我们团队上个月完成了一件让我印象很深的事:把 iOS 客户端里一个核心模块的并发模型从传统的 DispatchQueue + 回调嵌套,整体重构到 async/await。项目涉及网络层、缓存层、业务层和 UI 层,改动文件超过 80 个,测试用例重写了三分之一。
这次重构不是锦上添花的“技术时髦”,而是被真实的痛点逼出来的。旧的并发模型让新需求几乎无法安全地下手,每次加一个依赖接口的页面,都要在回调地狱里挣扎半天。重构完成后,之前两周才能写完的功能,现在三天就能跑通,而且数据竞争的崩溃率直接降到了零。这篇文章把我从决策到落地的完整过程记录下来,包括没有写在官方文档里的坑和心得。如果你也在犹豫要不要对现有 Swift 项目做并发模型改造,或者正在改造的路上被编译器和运行时问题折磨,这篇文章应该能帮你省下不少弯路。
1. 重构前:这套代码到底哪里出了问题
1.1 回调地狱只是表象,真正的痛点是控制流
我们的旧代码里大量使用嵌套闭包,典型得像三层以上的网络请求串联。登录之后拿 token,拿完 token 再拉用户信息,拉完用户信息再拉个性化配置。用闭包写是这样的:
func bootstrap(completion: @escaping (Result<Bool, Error>) -> Void) { login { result in switch result { case .success(let token): self.fetchUserInfo(token: token) { result in switch result { case .success(let user): self.fetchConfig(user: user) { result in switch result { case .success: completion(.success(true)) case .failure(let error): completion(.failure(error)) } } case .failure(let error): completion(.failure(error)) } } case .failure(let error): completion(.failure(error)) } } }这段代码光看缩进就让人头皮发麻。但真正麻烦的不是“难看”,而是控制流被彻底打碎了。你想在中间加一个超时逻辑、加一个重试、加一个条件分支,都要把闭包拆开重组,相当于每次改动都是在解一道绳结。到了后期,团队里形成了一种默契:能不动这段代码就不动,谁动谁背锅。
这让我意识到,并发模型的本质问题是“控制流表达能力”。闭包回调虽然能表达异步,但代价是把顺序逻辑碎片化,让阅读和修改成本成倍增加。这不是代码风格问题,是语言表达能力的天花板。async/await 能把被拆碎的控制流重新拼回来,让异步逻辑长得像同步代码一样直观。
1.2 线程切换与生命周期管理的失控
旧架构里遍布DispatchQueue.main.async和DispatchQueue.global().async。看起来每个调用点都有明确的线程意图,但实际上线程切换非常容易失控。典型的问题有三个:
第一,谁负责切回主线程没有统一约定。有的函数内部切,有的函数外部切,有的函数根本不切。同一个回调函数,在三个页面里被以三种不同的线程上下文调用,UI 偶发闪烁和数据错乱就成了“玄学”。
第二,[weak self]捕获列表写得到处都是,漏写一次就是内存泄漏,多写一次又出现诡异的不执行。闭包逃逸语义让生命周期问题变得极度隐蔽,泄漏两个版本都查不出来是常有的事。
第三,多个并发请求的结果要合并,代码会膨胀得非常快。用DispatchGroup时,enter和leave必须手动配对,少一个leave就会让整个 group 永远卡死,而且这种 bug 极难复现,用户在低概率场景下才会遇到一次。
这些问题的共性是:并发细节裸露在业务代码里,每个调用点都要自己管理线程、生命周期和同步。时间一长,代码库里布满了各自为政的并发策略,维护成本远大于功能开发本身。
1.3 我为什么决定现在动手做这次重构
真正让我下定决心重构的契机,是接了一个新需求:首页需要在同一时刻并行拉取用户信息、推荐列表和公告栏。按老架构,要么串行拉取,页面加载多等 300 毫秒;要么用DispatchGroup手写并行,然后祈祷没有 enter/leave 配对错误。
我用DispatchGroup写了一版,代码又长又容易出错。后来调研了 Swift 的 async/await 已经发展得很成熟,编译器支持、调试器支持、三方库适配都到位了,而且最低部署版本可以覆盖我们用户的 97%。我拿着一个开发分支上的小型实验原型给团队看,同样的三个并行请求,用新语法只写了六行,还天然继承了结构化并发的取消机制。团队讨论了一轮,最终同意了这次重构。
决策依据其实很简单:新模型在控制流表达、线程安全边界、生命周期管理三个维度上都有结构性优势,已经不是语法糖级别的小改进了。既然确定要改,越早改,债务成本越低。
2. 从概念层想清楚 async/await 的本质
2.1 async/await 不是什么“糖”,它是状态机的包装
很多人以为 async/await 就是编译器帮你把回调闭包包了一层,让它“看起来像同步”。这个理解不能说全错,但严重低估了底层设计的复杂度。
Swift 的 async 函数在编译时会被改写成状态机。每个await点都会生成一个状态分支,函数实际上被拆成了多个可以挂起和恢复的片段。编译器负责保存和恢复局部状态,运行时负责调度这些状态之间的切换。这意味着await不是一个普通的函数调用,它是一个真正的挂起点:当前线程不会被阻塞,而是被释放回去做其他工作,等结果准备好之后再恢复执行。
这带来两个非常重要的推论。第一,await一个函数不会导致线程卡死,你可以放心地写“看起来像同步”的代码,但实际体验是异步的。第二,恢复执行时所在的线程不一定还是原来那个线程,是和调度器有关的。如果你在 await 之前假设了某个线程上下文,await 之后这个假设可能已经失效。
这两个推论是后续排查坑位的理论基础。很多人在重构后遇到的诡异问题,本质上都是没有理解“挂起”和“恢复”的线程语义。
2.2 结构化并发:Task、TaskGroup 与 async let
async/await 解决了“单个异步操作”的表达问题,但“多个异步操作并行”还需要另一层机制。Swift 的选择是结构化并发。
结构化并发的核心思想是:并发任务的创建和结束必须绑定在某个作用域内。作用域退出时,要么等所有子任务完成,要么统一取消。这种设计保证了任务生命周期是可预测的,不会出现“野任务”在后台悄悄运行的情况。
三种常用工具的分工很清晰:
Task {}:创建一个不绑定当前作用域的独立任务,适合从同步上下文里发起的“一次性异步操作”。TaskGroup:一个可以动态添加子任务的并发批次,适合“任务数量不确定”的场景,比如批量下载图片。async let:固定数量的并行任务语法糖,适合“明确知道并发几个请求”的场景,比如首页的三个接口。
我自己的选型经验是:优先用async let,不够用了再往上抽象到TaskGroup,尽量少用裸Task。裸Task不参与结构化并发,生命周期要自己管理,反而容易出现不可控的并发任务堆积。
2.3 Actor 与 Sendable:并发安全的边界不是靠锁
旧代码里我们习惯用 NSLock、DispatchQueue 或者简单的锁来保护共享状态。锁本身没错,但锁的使用极其容易出错:加锁顺序不对会死锁,忘了解锁会永久阻塞,跨线程保护还会出现性能瓶颈。
Swift 并发模型给出的替代方案是 Actor。Actor 是一种“同一时刻只允许一个任务访问其隔离状态”的类型。你不用再手动加锁,编译器会强制你在外部通过await来调用 actor 的方法,从而确保对隔离状态的访问是串行的。
actor OrderManager { private var orders: [Order] = [] func addOrder(_ order: Order) { orders.append(order) } func allOrders() -> [Order] { orders } }这段代码等价于用一把锁保护orders数组,但锁是运行时自动加的,加锁、解锁、队列调度全部由运行时接管。代码里看不到一个 lock,却天然具备线程安全。
另一个关键概念是 Sendable。Sendable 协议标记一个类型可以安全地在并发边界之间传递。值类型天然符合 Sendable,class 默认不符合,因为内部可变状态可能在多个任务里同时读写。这个检查是编译期完成的,用好了比任何运行时锁都可靠——大部分数据竞争在编译阶段就被拦下来了。
我重构时把项目里的缓存管理器、订单状态管理器都改成了 actor,把网络结果的数据类型按照 Sendable 重写了。改完之后,很多原本只在运行时偶发的崩溃,直接变成了编译错误,这对团队来说是大好事。
2.4 新旧 API 的桥接思路:用 withCheckedContinuation 包一层
项目里不可能所有三方库都已经支持 async/await,很多老 SDK 还是回调闭包的形式。我们需要一个桥接层,把回调 API 包装成 async 函数。Swift 为此提供了withCheckedContinuation和withCheckedThrowingContinuation。
func legacyFetch(completion: @escaping (Result<Data, Error>) -> Void) { // 老 SDK 的调用逻辑 } func wrappedFetch() async throws -> Data { try await withCheckedThrowingContinuation { continuation in legacyFetch { result in switch result { case .success(let data): continuation.resume(returning: data) case .failure(let error): continuation.resume(throwing: error) } } } }关键点在于continuation.resume必须且只能调用一次,不能重复 resume,否则运行时直接崩溃。checked 系列会在调试模式下检查这个约束,这是它比withUnsafeContinuation安全的地方。我建议默认都用 checked 版本,担心性能损耗的可以先测一测,绝大多数场景根本感知不到差异。
桥接层单独放一个文件,统一命名XXX+Async.swift,这样后续老 SDK 升级原生支持 async 时,删除扩展文件即可,业务代码零改动。
3. 重构实操:按依赖层级拆解
3.1 网络层的改造:从回调到 async 函数的迁移模板
我们项目的网络层封装的是一个老的三方请求库,全部以闭包回调暴露接口。重构第一步是把这些接口全部包装成 async 版本,包装模板统一如下:
// 改造前 func request( _ path: String, method: HTTPMethod, parameters: Parameters?, completion: @escaping (Result<Response, Error>) -> Void ) // 改造后 func request( _ path: String, method: HTTPMethod, parameters: Parameters? ) async throws -> Response { try await withCheckedThrowingContinuation { continuation in request(path, method: method, parameters: parameters) { result in switch result { case .success(let response): continuation.resume(returning: response) case .failure(let error): continuation.resume(throwing: error) } } } }一个容易踩的细节是强类型泛型。老网络库的回调经常是Result<Data, Error>,在包装时要尽量把反序列化也放在 async 函数内部完成,让业务层拿到的是已经解析好的模型对象,而不是 data。这样业务层才能真正从“回调 + 手动解析”的组合拳里解放出来。
包装完成后,业务层调用从:
api.request("/user", method: .get) { result in DispatchQueue.main.async { switch result { case .success(let data): let user = try? parse(data) self.userInfo = user case .failure(let error): self.showError(error) } } }变成了:
let user = try await api.getUser() self.userInfo = user注意,这版本不带 hand-written 的DispatchQueue.main.async。UI 更新之所以安全,是因为视图层的入口函数已经标注了@MainActor,编译器会在 await 之后自动回到主线程执行。这是并发模型升级最直观的体验提升——你不用再手动记住“回调之后要切主线程”了。
3.2 业务层的改造:串行队列到 actor 隔离
业务层旧代码里,订单状态管理用的是一条串行队列加上一个单例类,逻辑上就是同一时刻只允许一个操作修改状态。这个模式翻译成 actor 几乎是逐行对应的:
// 改造前 final class OrderManager { static let shared = OrderManager() private let queue = DispatchQueue(label: "order.manager.queue") private var orders: [Order] = [] func addOrder(_ order: Order, completion: @escaping () -> Void) { queue.async { self.orders.append(order) completion() } } } // 改造后 actor OrderManager { static let shared = OrderManager() private var orders: [Order] = [] func addOrder(_ order: Order) { orders.append(order) } }这里我犯过一个低级错误:在addOrder内部直接访问了 actor 的 private 属性,没有加任何隔离混用,还好编译器及时报错提醒。actor 隔离是默认的,private 属性自动被隔离,你不需要额外声明。关键是要留意 actor 的 reentrancy(重入)特性:actor 方法在执行到await时会交出控制权,允许另一个任务进来执行。所以如果你的方法里有“读取-修改-写回”的复合操作,中间只能有确定性逻辑,绝对不能在复合操作中间擦await,否则可能读到中间状态。
actor BalanceManager { private var balance: Double = 0 // 错误示范:两段读取被 await 隔开,期间其他任务可能改掉 balance func transfer(amount: Double) async { let current = balance // 读取 await someNetworkCall() // 挂起点 balance = current - amount // 基于旧值的修改 } // 正确做法:先执行异步操作,再一次性完成读取-修改-写回 func transfer(amount: Double) async { let fee = await fetchFee() balance -= amount + fee } }这是我踩坑最久的一个点。第一次重构时,把原来串行队列下的“加锁读-加锁写”模式直接搬进 actor,结果在并发调用下出现余额错乱。原因就是重入让 actor 在await处插入了其他任务执行。搞懂之后,所有复合操作都改成“先取到所有异步输入,再同步修改变量”,问题彻底消失。
3.3 UI 层的改造:@MainActor 与视图生命周期
UI 层重构主要围绕@MainActor展开。旧代码里每个回调都手动切主线程,改造后可以在控制器或视图模型上标注@MainActor,把主线程约束提升到类型级别。
@MainActor final class HomeViewModel: ObservableObject { @Published var userInfo: UserInfo? @Published var recommendedItems: [Item] = [] func loadHomeData() async { async let user = api.getUser() async let items = api.getRecommendations() let (userResult, itemResult) = try await (user, items) self.userInfo = userResult self.recommendedItems = itemResult } }这段代码包含两层关键优化。第一,async let user和async let items是并行执行的,两个接口的最长耗时约等于二者中较大者,而不是二者之和。第二,整个 loadHomeData 标注在@MainActor上下文里,所以即使并发任务完成了,后续的 UI 属性更新也绝对发生在主线程,不需要手动写DispatchQueue.main.async。
我还使用了一个小技巧处理页面销毁时的操作。旧代码靠[weak self]配合 nil 检查,新代码可以通过Task.isCancelled和withTaskCancellationHandler实现任务取消,让网络请求在页面销毁时真正取消而不是回调后丢弃:
Task { do { let data = try await api.getData() try Task.checkCancellation() self.data = data } catch is CancellationError { // 任务取消了,直接 return,不做无用的 UI 更新 } catch { self.errorMessage = error.localizedDescription } }这里Task.checkCancellation()的作用是主动检查取消状态,如果任务已经被取消就立刻抛出 cancellation 错误。配合viewWillDisappear里的取消逻辑,能从根源上减少无效网络请求和 UI 更新。
3.4 并发执行的落地:用 async let 和 TaskGroup 提升吞吐
我处理过的最佳效果案例是把登录后的首页初始化从串行改成了并行。旧代码四个请求依次发出,总共耗时约 1200 毫秒,用户感知是明显的白屏时间。
用async let改完之后:
func bootstrap() async throws { async let token = authService.login() let authToken = try await token async let user = userService.fetchUser(by: authToken) async let config = configService.fetchConfig(by: authToken) async let banner = bannerService.fetchBanner() let (userResult, configResult, bannerResult) = try await (user, config, banner) applyToUI(user: userResult, config: configResult, banner: bannerResult) }这里有个依赖关系:用户、配置、横幅三个请求依赖登录后的 token,所以第一行 login 必须等,后面三个可以并行。最终总耗时就变成了 login 的 300 毫秒加三个并行请求中耗时最长的约 300 毫秒,合计约 600 毫秒。用户看到的启动时间直接减半。
如果任务数量不是写死的,比如“批量下载 20 张商品大图”,就要用TaskGroup:
func batchDownload(urls: [URL]) async -> [Result<Image, Error>] { await withTaskGroup(of: Result<Image, Error>.self) { group in for url in urls { group.addTask { do { let image = try await download(from: url) return .success(image) } catch { return .failure(error) } } } var results: [Result<Image, Error>] = [] for await result in group { results.append(result) } return results } }TaskGroup 的好处是并发数量由运行时自行调度,你不需要手工创建线程池,也不需要控制并发上限,系统会根据当前负载决定合理的并行度。你在循环里加几千个任务也没事,TaskGroup 的底层实现是按需并发执行的,不会一上来就开几千条线程把系统打爆。
关于并发度,我补充一个基于经验的选择标准:业务上明确知道并发数的,用async let,代码更短更直观;任务数量动态变化的,用withTaskGroup;需要后台长驻、不与当前作用域共存的,才用裸Task {}。
4. 重构路上最常踩的坑与排查实录
4.1 “非 Sendable 数据跨任务传递”导致的编译失败
重构后最常把人劝返的一类错误,是编译器不断提示 Sendable 问题。典型报错长这样:
Converting non-sendable function value to '() async -> Void' may introduce data races这句话的意思是:你尝试把一个非 Sendable 类型的值跨过任务边界给另一个并发上下文使用,编译器判断这会产生数据竞争,直接拒绝编译。
我第一次遇到是在做图片缓存。旧的实现里单例持有一个NSCache,图片类型从网络层跨任务传回 UI 层时,编译器不让编译。查了一圈之后,解决办法是让缓存管理器持有Sendable类型的包装:
struct ImageWrapper: @unchecked Sendable { let image: UIImage }@unchecked Sendable意味着“我保证这个类型在并发环境下是安全的,出了事我自己负责”。这属于绕过编译器检查的手段,不能滥用。只有当你有充分的理由确认这个类型在并发传递过程中不可能被同时写入时才可以用。我的具体场景里,UIImage是只读传递、写入只发生在主线程内部,所以这个包装是安全的。如果你要对可变状态使用@unchecked Sendable,请三思。
更健康的路线是让自定义 class 成为final class且内部属性全部不可变(只有let),然后显式声明Sendable。这样编译器能确认不可变性带来的线程安全。
4.2 用信号量等待 async 任务,直接把线程池堵死
这是我在重构初期犯的错误之一。某个老代码段是同步逻辑,调用了异步 API,开发者在来不及看文档的情况下随手写了:
let semaphore = DispatchSemaphore(value: 0) Task { await someAsyncWork() semaphore.signal() } semaphore.wait()这段代码运行起来的效果是:同步线程阻塞在wait上等 Task 完成,而 Task 里的someAsyncWork又可能需要线程池中的空闲线程来处理,如果线程池已满或者调度策略不允许新任务执行,就会形成互相等待的活锁或死锁状态。症状表现为卡顿数秒后崩溃,报错提示“Main Thread Checker: UI API called on a background thread”或者干脆无响应。
正确的替代方案是让整个调用链变成 async,从源头消除同步等待:
let result = try await someAsyncWork()如果你实在没法把上层改成 async(比如某些系统回调要求同步返回),可以重新设计逻辑,比如把阻塞等待提前到启动时预加载缓存,或者在回调里做后续操作而不是阻塞等待结果。
4.3 Actor 重入与潜在死锁
Actor 重入是另一个容易引发死锁的地方。表面上 Actor 是一个串行执行者,但如果它在方法内部遇到await,它会暂时挂起当前任务,去执行其他进入 actor 的任务。这段挂起期间,actor 没有被“锁住”,这既是优点也是坑。
我遇到的一个具体场景:支付状态检查方法里先调用外部支付 SDK 的结果回调(这是一个 await 点),等回调回来后继续更新订单状态。从下单到支付回调的窗口期,另一个查询请求也进入了 actor,看到了一个“不完整的订单状态”。这是重入导致的状态一致性问题,不是死锁本身,但很难排查,因为复现取决于回调时机。
解决办法前面提过:避免在“读取-修改-写回”复合操作的中间挂起。如果确实需要在复合操作之间 await,就必须显式地通过状态标志位或者将中间结果缓存在局部变量中,确保挂起之后基于最新状态重新执行。
死锁则是另一个情况。当两个 actor 互相调用对方的方法且调用链上存在同步等待时,就会死锁。排查死锁的方法是给所有 actor 方法加日志,标注“进入 actor A 的方法 X”和“离开 actor A 的方法 X”,配合超时监控,很快能发现是谁在等谁。
4.4 任务取消:不检查 isCancelled,网络风暴照样存在
async/await 的一个优势是结构化取消,但前提是你主动配合。很多人在重构后依然用旧思维:页面销毁了,Task 还在跑,结果回来之后虽然 UI 没有更新,但网络请求本身没有被取消,大量无效请求堆积在服务端和客户端,造成双重浪费。
正确的配合方式是在任务内部周期性地检查取消状态,或者注册取消处理器。对于典型的网络请求,网络库本身支持响应取消,比如 URLSession 的 task 可以在 cancel 后中止传输。如果你包装的是不支持取消的老 SDK,至少要做到:
func fetchData() async throws -> Data { try Task.checkCancellation() // 实际的请求包装 }在用户快速滑动列表时,每个 cell 发起的异步加载任务都会被快速取消,如果不检查Task.isCancelled,每个被取消的任务都还会继续发出网络请求。我的实测结果是,增加取消检查后,分页列表页的请求量下降了约 40%,页面流畅度提升肉眼可见。
4.5 性能对比:重构前后到底快了还是慢了
最终我做了一次全量性能对比。基于同样的网络环境和设备,用 XCTest 测量三个核心场景:登录后初始化、首页并行请求、批量图片下载。
| 场景 | 重构前耗时 | 重构后耗时 | 变化 |
|---|---|---|---|
| 登录后初始化 | 约 1200ms | 约 650ms | 提升约 46% |
| 首页三个接口加载 | 约 800ms(串行) | 约 320ms(并行) | 提升约 60% |
| 20 张图片批量下载 | 约 3.2s | 约 2.1s | 提升约 34% |
不是所有场景都变快了。单请求场景性能基本持平,启动阶段的内存占用因为状态机转换略有增加,但幅度小于 2%,可忽略。真正的大头收益是并行度和线程调度效率提升,加上取消机制避免了无效请求,综合资源利用率明显改善。
还有一个容易被忽略的隐性收益:重构后主线程被占用的时间显著降低。旧代码中每个回调返回后都强制切主线程,如果回调发生在主线程仍有其他任务在排队时,就会造成主线程拥挤导致掉帧。新模型下@MainActor更智能地把主线程执行合并到下一个安全时机,卡顿率明显下降。
最后,再讲一个从这次的坑里提炼出来的心得
我个人在操作中的体会是,重构并发模型最大的阻力不是技术,而是思维惯性。团队里有几个人一开始总是担心“能不能行”“会不会有 bug”,但真正上手后,反而是他们最先感受到效率提升。Swift 的并发模型是语言层面的完整方案,它不止是给我们换了一套 API,而是把“并发安全”从运行时和程序员的责任,转移到了编译器和类型系统的检查上。想明白这一层,很多纠结就不存在了。
还有一个立即能上手的小建议:如果你正在推进类似重构,可以先挑一个几十行的小模块,完整走一遍“回调 → async/await → actor → 测试”的流程,用时不要超过一天。跑通了,你就有了内部演示和说服团队的筹码;跑不通,你也能在最小的范围内快速调整方案。等这个模块稳定运行两三个版本之后,再逐步扩大改造范围,比一次性全面铺开稳得多。
后续这个项目还能往几个方向扩展。第一,把缓存层也改成 actor 化,彻底消除缓存访问时的竞争问题;第二,引入更完善的 Task 取消策略,把可恢复的下载任务从进程级扩展到磁盘缓存级;第三,针对网络层增加 async 版本的超时控制,统一封装重试和退避逻辑。每一步都是独立的小项目,但都是在这次并发模型重构打好的地基上进行的。