iOS 的 AsyncSequence 和 AsyncStream 教程

2,199 阅读14分钟

在本教程中,您将:

  • 比较同步和异步读取非常大的文件时的速度和内存使用情况。
  • 创建和使用自定义AsyncSequence.
  • 创建和使用基于拉的和基于推的AsyncStreams。

注意:这是一个中级教程。你应该熟悉“传统”并发——GCD(Grand Central Dispatch)和URLSession——以及基本的 Swift 并发特性,比如SwiftUI和SwiftUI 中的async/await和结构化并发中呈现的那些特性。

Data Files

ActorSearch的目的是帮助您通过从IMDb 数据集中搜索name.basics.tsv.gz数据集来解决询问演员姓名的难题。该文件包含一个标题行来描述每个名称的信息:

  • nconst(字符串)– 姓名/人员的字母数字唯一标识符
  • primaryName(字符串)– 最常记入此人的姓名
  • birthYear– YYYY 格式
  • deathYear– 如果适用,采用 YYYY 格式,否则为 '\N'
  • primaryProfession(字符串数组)– 人的前三个职业
  • knownForTitles(tconsts 数组)– 此人的知名头衔

为了减少对您的网络的需求并使其易于逐行阅读,启动项目已经包含data.tsv:这是解压缩的name.basics.tsv.gz,已删除标题行。它是一个制表符分隔值 (TSV) 文件,格式为 UTF-8 字符集。

注意不要试图通过在项目导航器中选择它来查看data.tsv。打开需要很长时间,Xcode 变得无响应。

在本教程中,您将探索将文件内容读入值数组的不同方法Actordata.tsv包含 11,445,101 行并且需要很长时间才能读入,因此您将仅使用它来比较内存使用情况。您将在较小的文件data-100.tsvdata-1000.tsv上尝试大部分代码,它们分别包含前 100 和 1000 行。

注意:这些文件仅在启动项目中。如果要构建和运行该项目,请将它们复制到最终项目中。

Models

打开ActorAPI.swiftActor是一个超级简单的结构,只有两个属性:idname

在此文件中,您将实现读取数据文件的不同方法。ActorAPI初始化程序接受一个参数filename并创建. url它是一个ObservableObject发布Actor数组的。

starter 包含一个基本的同步方法:

func  readSync () throws {
   let start =  Date .now
   let contents =  try  String (contentsOf: url)
   var counter =  0 
  contents.enumerateLines { _ , _  in 
    counter +=  1
  }
  print ( " \(counter) lines" )
   print ( "持续时间: \(Date.now.timeIntervalSince(start)) " )
}

String这只是从contentsOf文件的中创建一个url,然后计算行数并打印该数字以及花费了多长时间。

enumerateLines(invoking:)是从StringProtocol方法桥接的NSString方法enumerateLines(_:)

View

打开ContentView.swiftContentView创建一个ActorAPI具有特定文件名的对象并显示该Actor数组,并带有一个搜索字段。

searchable(text:)首先,在闭包下面添加这个视图修饰符:

.onAppear {
  做{
    尝试model.readSync()
  }捕捉 让错误 {
    打印(error.localizedDescription)
  }
}

readSync()在视图出现时调用,捕获并打印任何readSync()抛出的错误。

现在,查看运行此应用程序时的内存使用情况。打开调试导航器,然后构建并运行。当仪表出现时,选择内存并观看:

image.png

在我的 Mac 上,读取这个 685MB 的文件需要 8.9 秒,并产生 1.9GB 的内存使用峰值。

接下来,您将尝试一种 Swift 并发方式来读取文件。您将遍历一个异步序列

AsyncSequence

Sequence一直在使用该协议:数组、字典、字符串、范围和Data都是序列。它们带有许多方便的方法,例如next()contains()filter()。对序列进行循环使用其内置的迭代器,并在迭代器返回时停止nil

AsyncSequence协议的工作原理类似于Sequence,但异步序列异步返回每个元素(呃!)。随着更多元素随着时间的推移变得可用,您可以异步迭代其元素。

  • await每个元素,所以序列可以在获取或计算下一个值时暂停。
  • 序列生成元素的速度可能比您的代码使用它们的速度更快:一种AsyncStream缓冲其值,因此您的应用程序可以在需要时读取它们。

AsyncSequence为异步处理数据集合提供语言支持。有内置的AsyncSequences like NotificationCenter.NotificationsURLSession.bytes(from:delegate:) 以及它的子序列linescharactersAsyncSequence您可以使用和AsyncIteratorProtocol或使用创建自己的自定义异步序列AsyncStream

注意:Apple 的AsyncSequence 文档页面列出了所有内置的异步序列。

Reading a File Asynchronously

为了直接从 URL 处理数据集,URL基础类提供了自己的AsyncSequencein实现URL.lines。这对于直接从 URL 创建异步行序列很有用。

打开ActorAPI.swift并将这个方法添加到ActorAPI

// 异步读取
func  readAsync () async  throws {
   let start =  Date .now

  var counter =  0 
  for  try  await  _  in url.lines {
    计数器+=  1
  }
  打印(“ \(计数器)行”)

  print ( "持续时间:\(Date.now.timeIntervalSince(start)) " )
}

您在异步序列上异步迭代,边走边计算行数。

这里有一些 Swift 并发魔法:url.lines有自己的异步迭代器,for循环调用它的next()方法,直到序列通过返回nil.

注意URLSession有一个获取异步字节序列和常用URLResponse对象的方法。您可以检查响应状态代码,然后调用lines此字节序列将其转换为异步行序列。

let (stream, response) =  try  await  URLSession .shared.bytes(from: url)
 guard (response as?  HTTPURLResponse ) ? .statusCode ==  200  else {
   throw  "服务器响应错误。"
}
for  try  await line in stream.lines { 
   // ...
}

Calling an Asynchronous Method From a View

要从 SwiftUI 视图调用异步方法,请使用task(priority:_:)视图修饰符。

ContentView中,注释掉onAppear(perform:)闭包并添加以下代码:

.task {
  做{
    尝试 等待model.readAsync()
  }捕捉 让错误 {
    打印(error.localizedDescription)
  }
}

打开调试导航器,然后构建并运行。当仪表出现时,选择内存并观看:

image.png

在我的 Mac 上,读取文件需要 3.7 秒,内存使用量稳定在 68MB。差别很大!

在循环的每次迭代中forlines序列都会从 URL 中读取更多数据。因为这是分块发生的,所以内存使用量保持不变。

Getting Actors

是时候填充actors数组了,这样应用就可以显示一些东西了。

将此方法添加到ActorAPI

func  getActors () async  throws {
   for  try  await line in url.lines {
     let name = line.components(separatedBy: " \t " )[ 1 ]
     await  MainActor .run {
      actor.append(演员(姓名: 姓名))
    }
  }
}

您无需计算行数,而是从每一行中提取名称,使用它来创建一个Actor实例,然后将其附加到actors. 因为actors是 SwiftUI 视图使用的已发布属性,所以修改它必须在主队列上进行。

现在,在ContentViewtask包中,替换try await model.readAsync()为:

尝试 等待model.getActors()

此外,model使用较小的数据文件之一更新声明,data-100.tsvdata-1000.tsv

@StateObject  private  var model =  ActorAPI (filename: "data-100" )

构建并运行。

image.png

该列表很快出现。下拉屏幕以查看搜索字段并尝试一些搜索。使用模拟器的软件键盘 ( Command-K ) 可以更轻松地取消搜索词的首字母大写。

Custom AsyncSequence

到目前为止,您一直在使用 URL API 中内置的异步序列。您还可以创建自己的自定义AsyncSequence,例如AsyncSequenceActor

要定义一个AsyncSequenceover 数据集,您需要遵循其协议并构造一个AsyncIterator返回集合中数据序列的下一个元素的 an。

AsyncSequence of Actors

你需要两个结构——一个符合,AsyncSequence另一个符合AsyncIteratorProtocol.

ActorAPI.swift的 outsideActorAPI中,添加这些最小结构:

struct  ActorSequence : AsyncSequence {
   // 1 
  typealias  Element  =  Actor 
  typealias  AsyncIterator  =  ActorIterator

  // 2 
  func  makeAsyncIterator () -> ActorIterator {
     return  ActorIterator ()
  }
}

struct  ActorIterator : AsyncIteratorProtocol {
   // 3 
  mutating  func  next () -> Actor ? {
    返回 零
  }
}

注意:如果您愿意,可以在结构内部定义迭代器结构AsyncSequence

以下是此代码的每个部分的作用:

  1. AsyncSequence生成一个Element序列。在这种情况下,ActorSequenceActors 的序列。AsyncSequence期望一个AsyncIterator,你typealiasActorIterator
  2. AsyncSequence协议需要一个makeAsyncIterator()方法,该方法返回一个ActorIterator. 此方法不能包含任何异步或抛出代码。像这样的代码进入ActorIterator.
  3. AsyncIteratorProtocol协议需要一个next()方法来返回下一个序列元素,或者nil, 来表示序列的结束。

现在,要填写结构,请将这些行添加到ActorSequence

让文件名:字符串
让网址:网址

init(文件名:字符串){
   self .filename =文件名
  self .url =  Bundle .main.url(forResource:文件名,withExtension:“tsv”)!
}

该序列需要文件名的参数和存储文件 URL 的属性。您在初始化程序中设置这些。

makeAsyncIterator()中,您将遍历url.lines

将这些行添加到ActorIterator

让url: URL 
var迭代器: AsyncLineSequence < URL . 异步字节>。异步迭代器

初始化(网址:网址){
   self .url = url
  迭代器= url.lines.makeAsyncIterator()
}

您显式地获取了异步迭代器,url.lines因此next()可以调用迭代器的next()方法。

现在,修复ActorIterator()调用makeAsyncIterator()

返回 ActorIterator (url: url)

接下来,替换next()为以下内容:

变异 func  next () async -> Actor?{
   do {
     if  let line =  try  await iterator.next(), ! line.isEmpty {
       let name = line.components(separatedBy: " \t " )[ 1 ]
       return  Actor (name: name)
    }
  }捕捉 让错误 {
    打印(error.localizedDescription)
  }
  返回 零
}

您将async关键字添加到签名中,因为此方法使用异步序列迭代器。只是为了改变,你在这里处理错误而不是抛出它们。

现在,在 中ActorAPI,修改getActors()以使用此自定义AsyncSequence

func  getActors () async {
   for  await  actor  in  ActorSequence ( filename : filename ) {
     await  MainActor .run {
      演员.追加(演员)
    }
  }
}

处理任何错误的next()方法,因此不会抛出,并且您不必处理.ActorIterator``getActors()``try await``ActorSequence

您迭代ActorSequence(filename:),它返回Actor值供您附加到actors.

最后,在 中ContentView,将task闭包替换为:

.task {
  等待模型.getActors()
}

代码要简单得多,现在getActors()不会抛出。

构建并运行。

image.png

一切都一样。

AsyncStream

自定义异步序列的唯一缺点是需要创建和命名结构,这会添加到应用程序的命名空间中。AsyncStream让您“即时”创建异步序列。

typealias您只需使用元素类型初始化您的,而不是使用 a ,AsyncStream然后在其尾随闭包中创建序列。

其实有两种AsyncStream。一个有unfolding闭包。像AsyncIterator,它提供next元素。它只在任务要求一个值时创建一系列值,一次一个。将其视为基于拉动或需求驱动的

AsyncStream: Pull-based

首先,您将创建基于拉取AsyncStreamActorAsyncSequence.

将此方法添加到ActorAPI

// AsyncStream: 基于拉取的
func  pullActors () async {
   // 1 
  var iterator = url.lines.makeAsyncIterator()
  
  // 2 
  let actorStream =  AsyncStream < Actor > {
     // 3 
    do {
       if  let line =  try  await iterator.next(), ! line.isEmpty {
         let name = line.components(separatedBy: " \t " )[ 1 ]
         return  Actor (name: name)
      }
    }捕捉 让错误 {
      打印(error.localizedDescription)
    }
    返回 零
  }

  // 4 
  for  await  actor  in  actorStream {
     await  MainActor .run {
      演员.追加(演员)
    }
  }
}

这是您使用此代码所做的事情:

  1. 您仍然创建一个AsyncIteratorfor url.lines
  2. 然后你创建一个AsyncStream,指定Element类型Actor
  3. next()并将方法的内容复制ActorIterator到闭包中。
  4. 现在,actorStream是一个异步序列,与 完全一样ActorSequence,因此您可以像在getActors().

ContentView中,调用pullActors()而不是getActors()

等待模型.pullActors()

构建并运行,然后检查它是否仍然可以正常工作。

image.png

AsyncStream: Push-based

另一种AsyncStreambuild闭包。它创建一系列值并缓冲它们,直到有人要求它们为止。将其视为基于推送或供应驱动的

将此方法添加到ActorAPI

// AsyncStream: 基于推送的
func  pushActors () async {
   // 1 
  let actorStream =  AsyncStream < Actor > { continuation in 
    // 2 
    Task {
       for  try  await line in url.lines {
         let name = line.components(separatedBy: " \t " )[ 1 ]
         // 3 
        continuation.yield( Actor (name: name))
      }
      // 4
      延续.finish()
    }
  }

  对于 actorStream中的等待 演员 {
     await MainActor .run {  
      演员.追加(演员)
    }
  }
}

这是您在此方法中所做的事情:

  1. 您不需要创建迭代器。相反,您会得到一个continuation.
  2. build闭包不是异步的,因此您必须在Task异步序列上创建一个 to 循环url.lines
  3. 对于每一行,您调用延续的yield(_:)方法将Actor值推入缓冲区。
  4. 当你到达末尾时url.lines,你调用延续的finish()方法。

注意:因为build闭包不是异步的,所以你可以使用这个版本AsyncStream来与非异步 API 交互,比如fread(_:_:_:_:).

ContentView中,调用pushActors()而不是pullActors()

等待模型.pushActors()

构建并运行并确认它可以工作。

Continuations

自 Apple 首次推出 Grand Central Dispatch 以来,它就向开发人员提供了如何避免线程爆炸危险的建议。

当线程多于 CPU 时,调度程序在线程之间分时共享 CPU,执行上下文切换以换出正在运行的线程并换入阻塞的线程。每个线程都有一个堆栈和相关的内核数据结构,因此上下文切换需要时间。

当应用程序创建大量线程时——例如,当它下载成百上千张图像时——CPU 会花费太多时间进行上下文切换,而没有足够的时间做有用的工作。

在 Swift 并发系统中,线程的数量最多只有 CPU 的数量。

当线程在 Swift 并发下执行工作时,系统使用一个称为continuation的轻量级对象来跟踪在何处恢复暂停任务的工作。在任务延续之间切换比执行线程上下文切换更便宜、更有效。

image.png

注意:这个带有延续的线程图像来自 WWDC21 Session 10254。

当任务挂起时,它会继续捕获其状态。它的线程可以恢复另一个任务的执行,从它挂起时创建的延续中重新创建它的状态。这样做的代价是函数调用。

async当您使用函数时,这一切都发生在幕后。

但是您也可以继续手动恢复执行。的缓冲形式AsyncStream使用延续来yield流式传输元素。

不同的延续 API 可帮助您重用现有代码,如完成处理程序和委托方法。要了解如何操作,请查看Swift 中的现代并发,第 5 章,“中间 async/await 和 CheckedContinuation”。

Push or Pull?

Push-based 就像工厂制造衣服并将它们存储在仓库或商店中,直到有人购买它们。Pull-based 就像从裁缝那里订购衣服。

在基于拉取和基于推送之间进行选择时,请考虑与您的用例的潜在不匹配:

  • 基于拉的(展开)AsyncStream:您的代码需要比异步序列更快的值。
  • 基于推送(缓冲)AsyncStream:异步序列生成元素的速度比您的代码读取它们的速度更快,或者以不规则或不可预测的时间间隔生成元素,例如来自后台监视器的更新——通知、位置、自定义监视器

下载大文件时,基于拉取的方式AsyncStream(仅在代码请求时才下载更多字节)可以让您更好地控制内存和网络使用。基于推送(AsyncStream不暂停下载整个文件)可能会导致内存或网络使用量激增。

要了解这两种 . 的另一个区别AsyncStream,请查看如果您的代码不使用actorStream.

在和ActorAPI中注释掉这段代码:pullActors()``pushActors()

对于 actorStream中的等待 演员 {
   await MainActor .run {  
    演员.追加(演员)
  }
}

接下来,在这两种方法中的这一行放置断点:

let name = line.components(separatedBy: " \t " )[ 1 ]

编辑两个断点以记录断点名称和命中计数,然后继续:

image.png 现在,在 中ContentView,设置task为调用pullActors()

.task {
  等待模型.pullActors()
}

构建并运行,然后打开调试控制台:

image.png

actorStream不会出现日志消息,因为当您的代码不要求其元素时,基于拉取的代码不会运行。除非您要求下一个元素,否则它不会从文件中读取。

现在,切换task到调用pushActors()

.task {
  等待模型.pushActors()
}

构建并运行,打开调试控制台:

image.png

actorStream即使您的代码不要求任何元素,基于推送的也会运行。它读取整个文件并缓冲序列元素。

Conclusion

注意:数据文件仅在启动项目中。如果要构建和运行该项目,请将它们复制到最终项目中。

在本教程中,您:

  • 比较了同步和异步读取非常大的文件时的速度和内存使用情况。
  • 创建并使用了自定义AsyncSequence.
  • 创建并使用了基于拉的和基于推的AsyncStreams。
  • 表明在AsyncStream代码请求序列元素之前,基于拉的程序什么都不做,而基于推送的程序AsyncStream无论代码是否请求序列元素都运行。

您可以使用AsyncSequenceAsyncStream从现有代码生成异步序列——您多次调用的任何闭包,以及只报告新值且不需要返回响应的委托方法。

下载项目资料与原文地址

这里也推荐一些面试相关的内容!