在错误处理篇和闭包篇中,我多次加过“进入async/await之后”这一前提。现在进入正文。
这是开启进阶系列的Swift Concurrency连载第1篇,从async/await究竟解决了什么、又是如何工作的开始。
语法本身一天就能学会:给函数加上async,给调用加上await。
难点在后面。“await会让线程停止吗?”“async函数在哪个线程上运行?”这类问题才是真正困难的部分。
如果这些问题没有明确答案,并发代码就会一直停留在背诵咒语的状态。
本文的目标就是准确回答这两个问题。
回调真正的问题——不是缩进,而是编译器失明
async/await之前的异步代码,就是闭包篇中介绍过的completion处理程序。
人们常把“回调地狱”的缩进当作问题,但那只是症状。根源在于编译器看不见流程。
func loadProfile(completion: @escaping (Result<Profile, Error>) -> Void) {
fetchUser { result in
switch result {
case .success(let user):
fetchAvatar(user.avatarURL) { avatarResult in
// completion 忘记调用怎么办?编译器不知道
}
case .failure(let error):
completion(.failure(error))
}
}
}
无论在哪条路径漏掉completion、调用两次,还是从错误的线程调用,编译器都不会说什么。
这是用调用闭包的惯例替代语言中“返回”这一基本概念的结果。语言原本保证的所有路径返回值、错误会传播,如今全都交给开发者自律。
async/await把异步代码重新带回语言的管辖范围。
func loadProfile() async throws -> Profile {
let user = try await fetchUser()
let avatar = try await fetchAvatar(user.avatarURL)
return Profile(user: user, avatar: avatar)
}
返回是return,错误是throws。编译器再次检查每条路径是否都产生值或抛出错误。
错误处理篇总结的do-catch和传播规则,在异步代码中同样有效。这不是语法糖,而是找回编译器失去的视力。
suspension的准确含义——停止的是函数,不是线程
第一个问题:await会让线程停止吗?
不会。停止的是函数,线程会被释放去做其他工作。
这是Swift Concurrency中最重要的一句话。
在await处,函数会把自己的进度状态(局部变量和执行位置)保存到堆中,而不是栈中,然后归还它占用的线程。
这叫作suspension(暂停)。等待的工作完成后,执行会接着保存的状态恢复(resume)。
恢复时不保证使用刚才的线程。同一个函数的前后代码可能在不同线程上执行。
得益于这种设计,Swift的并发运行时使用协作式线程池。它创建数量大致等于CPU核心数的线程,函数在每个await处互相让出线程。
这与GCD(Grand Central Dispatch)时代形成对比:当时会创建数百个线程并让它们休眠(blocking)。线程不会睡眠,因此线程爆炸和浪费的上下文切换都能从结构上减少。
由此可以直接得到一条实践规则:不能阻塞协作式线程池中的线程。
使用信号量等待或调用sleep,会让一个以让出线程为前提设计的线程池线程整体睡眠。若有4个核心、约4个线程,其中一个睡眠,就相当于整个系统的四分之一停止。
“await负责让出,block禁止使用”是协作式线程池的第一规则。
在哪个线程上运行——问隔离,而不是问线程
第二个问题:async函数会在哪个线程上运行?
Swift Concurrency的答案是“放弃这个问题”。线程是由运行时管理的资源,开发者指定的是隔离(isolation),也就是“这段代码属于哪个串行执行上下文”。
典型的隔离是MainActor。标记为@MainActor的函数和类型保证在主线程上运行。
这不同于过去凭感觉在UI更新代码中到处撒DispatchQueue.main.async。“只能在主线程运行”的要求会声明在类型系统中,并由编译器检查。
UIViewController和SwiftUI View已经用@MainActor声明。在视图代码中会自动获得主线程隔离。
相反,没有隔离标记的async函数不属于任何特定actor(nonisolated),会在协作式线程池的某处运行。
如果想把繁重计算移出主线程,不需要编写“发送到后台线程”的代码。Swift式的思路是声明该工作不属于MainActor隔离。
必要时,可以用Task开启新的异步上下文。(Task的结构化用法会在本系列第4篇介绍。)
语言也提供了连接现有回调API的桥梁。可以用withCheckedThrowingContinuation把回调API包装成async函数。
准确调用一次continuation的resume就是契约,“Checked”版本会在运行时检测违反契约的情况。面对遗留SDK时,这是最先应该熟练掌握的桥接工具。
顺序await的陷阱——并发不是免费的
上面的loadProfile代码其实有一个性能陷阱。如果另一个请求与fetchUser无关呢?
// 顺序:头像要等横幅结束后才能开始(总计 2秒)
let avatar = try await fetchAvatar() // 1秒
let banner = try await fetchBanner() // 1秒
await的意思是“在这里等待”,所以这样写会让两个请求排成串行。如果任务互不依赖,应该用async let同时启动。
// 并发:两个请求一起运行(总计 1秒)
async let avatar = fetchAvatar()
async let banner = fetchBanner()
let profile = try await Profile(avatar: avatar, banner: banner)
async/await不会自动并行化。选择顺序执行还是并发执行,仍然是设计者的工作。
不过,这个选择已经从回调组合变成了一行语法的差异,这就是进步。async let和TaskGroup的系统用法会在结构化并发篇中详细介绍。
总结
- 回调真正的问题不是缩进,而是编译器失明。async/await将返回和错误传播交还给语言,恢复编译器检查。
- await处停止的是函数,不是线程。函数状态保存在堆中,线程被归还,恢复时也不保证使用同一个线程。
- 运行时是协作式线程池。因此,禁止在其中阻塞(信号量、sleep)。
- 问的不是“哪个线程”,而是“哪种隔离”。UI通过@MainActor声明获得保证,回调API则用continuation包装。
- await不会自动并行化。独立任务要用async let同时启动。
下一篇是本系列的核心:actor。内容包括数据竞争究竟是什么、actor如何将它提升为编译时概念,以及臭名昭著的reentrancy陷阱。

![[Swift进阶 #1] async/await原理:停止的不是线程 封面图](/assets/images/posts/17c93232-c3fd-4d3b-867a-e93af7bc893f/swift-async-await-suspension-1.jpg)