通过之前的例子,我们已经知道了,每当调用异步函数的时候,我们必须使用 await 把这个调用和同步函数调用区分开。为什么要这样呢?

为什么要明确把异步和同步函数的调用区分开

Suspension point

为了把这个问题说明白,我们要提前说一点关于 Swift 结构化并发模型实现方式的内容。提起并发,传统意义上我们想到的一定是多线程。让需要等待的线程挂起,并切换到其它可以执行任务的线程。这样,CPU 就可以一直处于忙碌状态,尽可能多的完成任务。在 Swift 5.5 以前,基于同步函数的并发方式,就是这样工作的:

4-await-and-suspension-point-1

但线程切换是有成本的,这个过程涉及到和线程有关的内核数据结构,执行环境以及应用层数据结构的保存和加载。一旦我们创建的线程数超过了系统实际可以真正并发执行的线程数量,那么线程的切换就会对程序的性能造成影响。创建的越多,实际上处于等待状态的线程就越多,需要的线程切换就越频繁。Apple 管这种现象叫做 thread explosion。

为了优化这个问题,在 Swift 5.5 的并发模型中,只会创建 CPU 真正可以并发执行的线程数量,并把要并发执行的工作封装成若干叫做 continuation SE-0300 的对象。当这些对象的执行需要挂起时,它们有能力放弃当前正在执行的线程,进入等待状态。然后,Swift 运行时会选择其它 continuation 对象在线程中继续执行。我们可以把这些对象理解成是异步函数执行时的上下文环境,而切换这些对象的成本,就像切换函数调用一样。这样一来,并发任务的切换不会造成线程切换,自然系统开销也更小。把这个过程用一张图表示,是这样的:

4-await-and-suspension-point-2

图中的虚线,就是切换异步函数执行的地方,Swift 还给这些地方起了一个专门的名字,叫做 suspension point

一个简单的例子

为了观察 Swift 5.5 中的并发模型是否真的是这样,我们可以写一个简单的例子。假设有一个表示论坛的类 Forum 和一个表示帖子的 struct Post

struct Post: Codable {
  let id: Int
  let userId: Int
  let title: String
  let body: String
  
  static let empty = Post(id: 0, userId: 0, title: "", body: "")
}

class Forum {}

现在,我们要给 Forum 添加一个根据 userId 更新帖子的方法,传统基于 URLSession 的实现方式是这样的:

func update(userIds: Array<Int>) {
  let urlSession = URLSession.shared
  
  for userId in userIds {
    let url = URL(string: "https://jsonplaceholder.typicode.com/posts/\(userId)")!
    let dataTask = urlSession.dataTask(with: url) { 
      data, response, error in
      guard let data = data,
            let post = try? JSONDecoder().decode(Post.self, from: data)
      else { return }
      
      print("Decode post ID: \(post.id) @Thread: \(Thread.current)")
    }
    
    dataTask.resume()
  }
}

当我们像这样执行的时候:

@main
struct MyApp {
  static func main() {
    Forum().update(userIds: Array(0..<100))
    Forum().update(userIds: Array(0..<100))
    Forum().update(userIds: Array(0..<100))
    Forum().update(userIds: Array(0..<100))
    Forum().update(userIds: Array(0..<100))
    RunLoop.main.run()
  }
}

这里,为了尽可能明显的观察到结果,我尝试了 500 个请求,在我的电脑上,多执行几次之后,可以看到平均会创建 30 个线程,现在我的电脑无法同时运行这么多线程。现在把这个例子,用 Swift concurrency API 来写,是这样的:

func updateAsync(userIds: Array<Int>) async {
  await withThrowingTaskGroup(of: Post.self) { group in
    for userId in userIds {
      let url = URL(string: "https://jsonplaceholder.typicode.com/posts/\(userId)")!
      group.async {
        let (data, _ /*response*/) = try await URLSession.shared.data(from: url)
        guard let post = try? JSONDecoder().decode(Post.self, from: data) else {
          return Post.empty
        }

        print("Decode post ID: \(post.id) @Thread: \(Thread.current)")
        
        return post
      }
    }
  }
}

这次,我们要使用异步的 main 函数来执行:

@main
struct MyApp {
  static func main() async {    
    await withTaskGroup(of: Void.self) { group in
      for _ in 1...5 {
        group.async {
          await Forum().updateAsync(userIds: Array(0..<100))
        }
      }
    }
  }
}

同样 500 个请求,使用的线程数在 12 - 15 个之间。虽然并不像 Apple 描述的那样正好创建了和 CPU 核数相当的线程,但至少我们能观察到,线程的复用率的确是提高了。这多少可能从侧面印证到并发模型的工作方式。

不过,看到这你可能会想,一个任务是如何放弃当前线程的呢?新的任务又是怎么换进来的呢?这就涉及到异步函数执行过程中的细节了,我们等专门聊到 Swift 结构化并发模型的时候再来探究。

现在,回到我们一开始的问题,为什么 Swift 要求我们明确使用 await 标记异步函数调用呢?为什么 async 函数不能在同步函数中调用呢?做了上面这些尝试之后,至少现在我们应该知道了,Swift 中异步和同步函数的执行机制是有差别的,异步函数的调用相比普通函数也会带来额外的开销(这个开销只是比线程切换要轻量很多而已)。因此:

  • 从语法上说,Swift 编译器强制要求我们必须在每一个 suspension point 位置明确使用 await,以时刻提醒自己:“喔,这个地方是会和普通函数有点点不同的,这个调用是有额外开销的”;
  • 从运行时上说,Swift 运行时在切换异步函数时,需要使用异步函数的上下文环境,同步函数没有这个环境,因此不能实现异步和同步函数的相互调用和返回;

await 表达式的用法

说完了 await 背后的故事,我们再来专门说说 await 的用法。用一句话表达,就是 await 必须出现在每一个 suspension point 的位置。落实到具体的应用,有这么几点:

  • 首先,await 没有等待任何东西,就像 async 函数并没有真正实现并发一样,它只是 Swift 语法中的一个标记,用来表达代码中有可能发生挂起的位置;
  • 其次,await 最基础的用法,就是放在 async 函数调用前面;
  • 第三,当异步函数有可能抛出错误时,try 必须写在 await 前面
  • 第四,await 可以标记一个表达式中所有潜在的 suspension point,因此,不必在一个表达式的每一个异步函数调用前使用 await

例如,我们给 Forum 添加一个获取用户 ID 集合的异步函数:

func fetchUserIds() async -> [Int] {
  return Array(1...100)
}

那么,之前的 updateAsync 按照要求,应该是这样的:

await forum.updateAsync(userIds: await forum.fetchUserIds())

但就像刚才说过的,我们可以用开始的 await 表示整个表达式存在潜在的 suspension point,进而省略掉第二个 await

await forum.updateAsync(userIds: forum.fetchUserIds())
  • 第五,await 不能用于等待同步函数,当你理解了 await 的作用时,理解这个决定也就没什么问题了;
  • 第六,await 还可以用于等待 async let 定义的变量,以及在遍历 AsyncSequence 时,等待其中的每一个元素。这两部分的用法等我们专门提到它们的时候再和大家介绍,现在暂且有个印象就好了;

What's next?

说到这里,关于 Swift 中 await / async 这两个关键字的设计思路和对 Swift 语言自身的影响,我们应该整理的差不多了。就像这个系列一开始说的,它们只是帮助我们描述代码执行过程的工具,async 并没有真的让函数异步,await 也没有真正等待什么

Swift 的并发机制,是通过 SE-0304 这份 proposal 实现的。所以,接下来的内容,我们就花点时间仔细来聊聊 Swift 的结构化并发模型。

所有订阅均支持 12 期免息分期

¥ 59

按月订阅

一个月,观看并下载所有视频内容。初来泊学,这可能是个最好的开始。

开始订阅

¥ 512

按年订阅

一年的时间,让我们一起疯狂地狩猎知识吧。比按月订阅优惠 28%

开始订阅

¥ 1280

泊学终身会员

永久观看和下载所有泊学网站视频,并赠送 100 元商店优惠券。

我要加入
如需帮助,欢迎通过以下方式联系我们