Swift 的异步编程生态非常丰富,除了现代主流的 async/await 之外,确实还有其他几种经典的异步编程方式。我们可以把它们分为**“现代响应式框架”、“传统并发框架”以及“底层桥接机制”三大类: 1、现代响应式框架:Combine. 2、传统并发框架:GCD 与 OperationQueue. 3、底层桥接机制:RunLoop 与 Continuation. 但是在现代 Swift 开发中,async/await 已经成为处理绝大多数异步任务的首选**,因为它代码直观且没有线程爆炸的风险.
在 Swift 中,async/await 是自 Swift 5.5(iOS 15+)引入的革命性特性,它彻底改变了异步编程的范式,让我们能用同步的写法处理异步操作,告别了传统的“回调地狱”。
⚙️ 核心原理:协程与状态机
Swift 的 async/await 是基于**协程(Coroutine)**实现的。理解以下几个核心概念,就能掌握它的运行机制:
- 挂起(Suspend)≠ 阻塞(Block):这是最重要的概念。当代码执行到
await时,当前线程不会被阻塞,而是被释放回全局的协作式线程池(Cooperative Thread Pool)去执行其他任务。系统会保存当前函数的执行状态(即 Continuation,包含局部变量、返回地址等),等异步操作完成后,再恢复执行。 - 状态机(State Machine):编译器在底层会将
async函数自动转换为一个状态机。每一个await都是一个潜在的挂起点,对应状态机的一个状态。这使得异步代码的调度开销极低,远优于传统的线程切换。 - 协作式线程池(CTP):Swift 并发系统内置了一个与 CPU 核心数匹配的固定数量线程池。任务挂起时立即回收线程,最大化 CPU 资源利用率,彻底避免了 GCD 中常见的“线程爆炸”问题。
💡 核心使用技巧与最佳实践
1. 告别回调地狱,实现线性书写
传统的嵌套回调不仅可读性差,还容易导致错误处理分散。async/await 让异步逻辑像同步代码一样清晰。
传统回调写法:
fetchUser { result in
switch result {
case .success(let user):
fetchAvatar(userId: user.id) { avatarResult in
// 嵌套切回主线程更新UI
DispatchQueue.main.async {
self.avatarImageView.image = avatarResult
}
}
case .failure(let error):
print("加载失败:\(error)")
}
}
async/await 现代写法(还有可优化的地方):
Task {
do {
let user = try await fetchUser()
let avatar = try await fetchAvatar(userId: user.id)
// 切换回主线程更新UI
await MainActor.run {
self.avatarImageView.image = avatar
}
} catch {
print("加载失败:\(error)")
}
}
2. 并行执行(async let)提升效率
如果多个异步任务之间没有依赖关系,千万不要串行等待,应该使用 async let 让它们并发执行,总耗时将等于耗时最长的那个任务。
// ❌ 错误:串行执行,总耗时 = 任务1 + 任务2
let user = try await fetchUser()
let articles = try await fetchArticles()
// ✅ 正确:并行执行,总耗时 = max(任务1, 任务2)
async let user = fetchUser()
async let articles = fetchArticles()
// 在这里统一等待结果
let (finalUser, finalArticles) = try await (user, articles)
3. 使用 Actor 保证线程安全
Swift 引入了 actor 模型来保护共享状态,避免多线程下的数据竞争(Data Race)。Actor 内部的状态在同一时间只能被一个任务访问。
actor BankAccount {
private var balance: Double = 0
func deposit(_ amount: Double) {
balance += amount
}
func withdraw(_ amount: Double) -> Bool {
guard balance >= amount else { return false }
balance -= amount
return true
}
}
// 调用时必须使用 await
let account = BankAccount()
Task {
await account.deposit(1000)
let success = await account.withdraw(500)
}
4. 结构化并发(Task & TaskGroup)
-
Task:是异步执行的容器。你可以用它来启动一个顶层的异步操作,或者在需要时手动取消任务(
task.cancel())。 -
TaskGroup:当你需要动态创建一组并发任务(例如批量下载图片)时,可以使用
withTaskGroup。
⚠️ 避坑指南与注意事项
1、关键字顺序:声明异步函数时,关键字的顺序是固定的。记住:参数 -> async -> throws -> 返回类型。
func fetchData() async throws -> Data { ... } // ✅ 正确
func fetchData() throws async -> Data { ... } // ❌ 错误
2、try 和 await 的顺序:调用一个既会抛出错误又会挂起的函数时,必须先写 try 再写 await。可以理解为:先尝试(try)这个操作,然后等待(await)它的完成。
let data = try await fetchData() // ✅ 正确
**3、**避免在主线程滥用同步阻塞:不要在 async 上下文中使用 DispatchSemaphore 等传统的阻塞手段,这会导致线程死锁。请始终使用 await 进行非阻塞等待。
4、版本适配:async/await 需要 iOS 15+ / macOS 12+。如果你的应用需要兼容旧系统,需要使用 @available(iOS 15, *) 进行版本检查,或者通过 withCheckedThrowingContinuation 将旧的回调 API 桥接为 async 函数。
延伸
🚀 为什么状态机(State Machine)能极大降低调度开销?
要理解这一点,首先要明白 async/await 在底层是如何工作的。
当你在 Swift 中写一个 async 函数时,编译器并不会把它当作普通的函数来处理,而是会自动将其转换为一个状态机(State Machine)。函数里的每一个 await 挂起点,都会被编译器标记为状态机的一个特定状态。
这种设计之所以开销极低,主要得益于以下三点:
- 零成本抽象与栈上分配:
状态机是由编译器在编译期静态生成的。大多数情况下,状态机的内存(包含当前的状态编号、局部变量等)可以直接分配在**栈(Stack)**上,而不需要像传统的闭包或对象那样在堆(Heap)上进行动态内存分配。省去了堆内存的malloc/free以及相关的引用计数操作,性能开销微乎其微。 - 避免了昂贵的线程上下文切换:
在传统的多线程模型中,线程的切换需要操作系统内核参与,需要保存和恢复大量的 CPU 寄存器、栈指针等上下文信息,这是一个非常“重”的操作。而状态机的切换仅仅是应用层代码中一个变量的状态变更(比如从state = 0变为state = 1),配合协程的挂起与恢复,完全绕过了操作系统的线程调度,因此速度极快。 - 极小的内存占用:
状态机只保存当前执行进度所必需的最少数据(即 Continuation,包含局部变量和返回地址)。相比于为每个任务分配一个完整的线程栈(通常是 MB 级别),状态机的内存占用通常只有几个字节到几十个字节的枚举大小。
简单来说,状态机把复杂的异步回调逻辑,转化成了极其高效的“状态跳转指令”,让开发者能用同步的写法,享受到接近裸写汇编的性能。
💥 GCD 中常见的“线程爆炸”(Thread Explosion)是什么问题?
“线程爆炸”是 GCD 在处理大量并发且包含阻塞操作的任务时,容易引发的一种严重性能恶化现象。
它的核心成因是:GCD 的线程池调度策略与阻塞任务之间的冲突。
GCD 的底层维护着一个全局的线程池。它的设计哲学是:只要 CPU 核心处于空闲状态,就尽可能创建新的工作线程来填补空缺,以保证 CPU 的利用率。
线程爆炸的恶性循环通常是这样发生的:
- 任务阻塞:假设你向 GCD 的全局并发队列中瞬间提交了成百上千个任务,而这些任务内部包含了大量的同步阻塞操作(比如同步读取磁盘文件、使用
DispatchSemaphore.wait()等待信号量、或者执行耗时的同步网络请求)。 - GCD 的误判:当这些工作线程因为 I/O 或锁等待而陷入阻塞(Block)时,CPU 核心实际上是在空转等待。GCD 的调度器检测到 CPU 没在干活,它会误以为“现在的线程不够用了”,于是继续创建更多的新线程来执行队列中剩余的任务。
- 资源耗尽:这个过程会不断重复。成百上千个线程被创建出来,但绝大多数都卡在阻塞等待上。
- 内存崩溃:每个线程都需要占用一定的栈内存(通常默认是 512KB 或 1MB)。创建几百上千个线程会瞬间耗尽应用的内存,进而触发系统的虚拟内存交换(Swap),导致程序极度卡顿甚至崩溃。
- CPU 空耗:即使线程没有把内存撑爆,操作系统为了调度这海量的线程,会陷入极其频繁的线程上下文切换。CPU 的大部分时间都花在了“切换线程”上,而不是真正执行业务逻辑,导致 CPU 使用率飙升但程序毫无响应。
举个形象的例子:
想象一个餐厅(CPU)只有 4 个厨师(线程核心),但突然来了 100 个需要等待发酵 1 小时的面团任务(阻塞任务)。GCD 的逻辑是:“哎呀,厨师们都在盯着面团发呆(阻塞),太浪费人力了,赶紧再雇佣 96 个厨师来帮忙!” 结果餐厅里挤满了 100 个厨师,大家不仅没地方站(内存爆炸),互相还撞来撞去(上下文切换),最后反而谁也干不了活。
如何避免?
现代 Swift 并发(async/await)通过**协作式线程池(Cooperative Thread Pool)**完美解决了这个问题。当任务遇到 await 挂起时,它会主动把线程“还给”线程池,让线程去执行其他任务,而不是傻傻地阻塞在那里。因此,Swift 的线程池只需要保持与 CPU 核心数相匹配的少量线程,就能轻松应对海量的并发任务,从根本上杜绝了线程爆炸。