通过之前的例子,我们已经知道了,每当调用异步函数的时候,我们必须使用 await
把这个调用和同步函数调用区分开。为什么要这样呢?
为什么要明确把异步和同步函数的调用区分开
Suspension point
为了把这个问题说明白,我们要提前说一点关于 Swift 结构化并发模型实现方式的内容。提起并发,传统意义上我们想到的一定是多线程。让需要等待的线程挂起,并切换到其它可以执行任务的线程。这样,CPU 就可以一直处于忙碌状态,尽可能多的完成任务。在 Swift 5.5 以前,基于同步函数的并发方式,就是这样工作的:

但线程切换是有成本的,这个过程涉及到和线程有关的内核数据结构,执行环境以及应用层数据结构的保存和加载。一旦我们创建的线程数超过了系统实际可以真正并发执行的线程数量,那么线程的切换就会对程序的性能造成影响。创建的越多,实际上处于等待状态的线程就越多,需要的线程切换就越频繁。Apple 管这种现象叫做 thread explosion。
为了优化这个问题,在 Swift 5.5 的并发模型中,只会创建 CPU 真正可以并发执行的线程数量,并把要并发执行的工作封装成若干叫做 continuation SE-0300 的对象。当这些对象的执行需要挂起时,它们有能力放弃当前正在执行的线程,进入等待状态。然后,Swift 运行时会选择其它 continuation 对象在线程中继续执行。我们可以把这些对象理解成是异步函数执行时的上下文环境,而切换这些对象的成本,就像切换函数调用一样。这样一来,并发任务的切换不会造成线程切换,自然系统开销也更小。把这个过程用一张图表示,是这样的:

图中的虚线,就是切换异步函数执行的地方,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 的结构化并发模型。