项目跑了三年多里面躺着几处从第一版就写下的 GCD 并发代码。前两天线上又爆出一个偶发崩溃排查到最后问题竟然出在一个六层回调嵌套的状态标志位身上。我盯着那段代码想了很久终于决定把整个 Swift 并发模型翻新成 async/await 体系。这篇文章是这次重构的完整复盘包含迁移路径、坑点排查和一些踩过之后才知道的细节。如果你也在维护一个 callback 满天飞的旧项目或者自己动手把 DispatchQueue 换成 async/await 时总被编译器隔离检查拦下来这篇应该对你有用。这次改动不是简单换几个 API而是把整个并发子系统做了一次系统性重构。唯一不变的原则是不追求架构上的全面翻新只追求并发行为的等价迁移。在动手之前我会先把为什么必须要做这件事的逻辑链条完整梳理一遍。1. 为什么我决定把项目里的 GCD 并发代码翻新一遍1.1 回调嵌套的维护成本不是丑而是错误路径缺失很多人说代码里最怕回调地狱其实回调嵌套本身不是最致命的。致命的是每一层回调都需要自己处理成功、失败、超时、取消、页面是否还在这五件事而绝大多数老代码只处理了其中两件。我遇到的那个崩溃就很典型。图片下载完成后回调链依次经过网络层、缓存层、业务层最后回到 UI 层。每一层都有人顺手改了一个状态标志位isDownloading、hasShowed、needRefresh。这些 Bool 分散在不同类里互相之间没有约束。线上某次偶发崩溃停在检查完状态之后、使用状态之前的那几行代码之间——一个线程刚把标志位置为 false另一个线程已经通过检查并继续往下走了。这种竞态在单人调试时很难复现但是在用户真实网络环境下主线程和后台线程频繁切换偶发概率被放大。我后来整理这份代码时发现嵌套回调里的错误路径很多是缺失的比如一个下载失败的分支只是打了 log并没有把错误传给上层。这种问题在 GCD 时代不会暴露因为你要么在一个 block 里自己do/catch要么把 error 放进回调参数里继续传漏传了编译器完全不知道。1.2 取消与超时GCD 没有给开发者主动抓手GCD 的DispatchWorkItem有cancel()但它只能取消还没开始执行的 block。一旦 block 已经在跑cancel 只是一个标志位代码内部不会自动停下来。这导致一个很普遍的现象用户退出页面后后台任务依然在跑等它跑完了再回到主线程刷新一个已经销毁的视图。超时控制也一样。老代码里最常见的做法是DispatchSemaphore等待 DispatchQueue.asyncAfter手动唤醒要么就是用一个业务层的 Timer 自己在倒计时。每加一种等待机制就要多维护一条路径。多次实践下来我得到的教训是并发系统里路径越多状态丢失的概率越大。GCD 不是不能做取消和超时而是它把实现责任完全交给了开发者交出去的代码越多越容易在边角处漏掉一两种异常情况。1.3 线程模型全靠手写串行、并发、优先级一起碰运气GCD 里一个常见的做法是自定义一个串行队列来保护数据。这个思路本身没问题问题在于后续维护者不一定遵守 必须在这个队列里访问数据 的纪律。常有新来的同学为省事直接在主线程读了一个只应由串行队列保护的属性数据竞争就此埋下。并发队列 barrier 的写法稍微好一点但也需要人肉保证所有写操作走 barrier所有读操作走同步读。优先级更是没人敢乱动DispatchQueue.global(qos: .userInitiated)和.utility混用后偶尔会出现一个低优先级任务占着线程池里的线程高优先级任务却迟迟得不到执行的情况。这些现象解释起来很麻烦但排查起来更麻烦因为问题出现一次之后可能要隔很久才再次触发。2. 重构前必须想明白的几件事Task、Actor、async let 的地基认知2.1 先搞懂 async 函数与挂起点不是异步函数是可挂起的同步函数不管有多少年 Swift 经验动手迁移前我都建议先扭转一个认知async 函数本质上不是一段将在未来执行的异步逻辑而是一个允许在中间暂停的同步函数。普通函数一旦开始执行要么 return要么 throw。async 函数则多了一个能力执行到await时可以把当前任务挂起把线程还给调度器等被等待的异步操作完成后再从挂起点继续往下走。这种写法让业务逻辑的线性表达成为可能。关键点在于挂起的是任务不是线程。线程在任务 await 期间会去执行其他任务所以千万不要把await理解成线程阻塞等待结果。这个区别直接决定了后面很多 API 的选择尤其是为什么在新模型里不应该继续使用DispatchSemaphore.wait()。2.2 结构化并发父子任务关系直接决定清理成本结构化并发是这次重构里最被低估的概念但它决定了任务生命周期管理。用async let可以并发执行多个子任务编译器会在作用域执行到末尾时自动等待所有子任务完成或者取消尚未完成的子任务。TaskGroup可以在循环里动态添加一批子任务并且明确管理它们的关系。这种父子关系带来的最大好处是取消是自动向下传播的。父任务被取消子任务会收到取消信号整棵任务树一起收拾干净。这在 GCD 时代几乎不可能做到你要取消一个任务得手动把 cancel 标志分发到每一层回调里稍微漏一层就会有一个后台任务活到超时。另一种非结构化入口是Task {}它的生命周期不受当前作用域约束会一直执行到自身结束。UI 层调用 async 函数时经常要用它但任务多起来之后要小心不要再在同一处Task {}里堆一堆不会结束的循环否则这些任务也会像老代码里的后台残留一样堆积。2.3 Actor编译期接管的串行队列加锁GCD 里保护共享数据通常组合使用串行队列 锁。Swift 并发模型里这个组合的替代者叫 Actor。Actor 外部只能通过方法或属性访问其内部状态并且编译器会强制检查隔离规则。属于 Actor 自己的可变属性不允许被外部非隔离代码直接读写必须在 actor 的方法里操作。这相当于把以前需要团队靠纪律维护的事情变成了编译器的硬性检查。我用一个生活化类比来理解它Actor 像一个只开一个窗口的办事柜台柜员一次只接待一个人。外面的人不可能直接翻柜台里的文件只能递条子调用方法让柜员帮你查。柜员查完再通过返回值告诉你结果。因为是单窗口天然串行所以不需要额外加锁。2.4 一份可对照使用的 GCD 到 Swift 并发映射表迁移过程中我整理过一张映射关系表基本把旧代码里绝大多数写法对应到了新模型。这张表算是整个重构的搭桥蓝图先在这里给出后面章节会逐个展开。老代码写法新模型对应物说明DispatchQueue.main.asyncMainActor 标注 / Task { MainActor in }主线程隔离由编译器接管私有串行队列Actor串行执行 隔离检查并发队列 barrierActor 的方法切换barrier 本质就是锁completion handlerasync throws错误路径成为一等公民DispatchGroup.notifyasync let / TaskGroup多任务等待与结果聚合DispatchWorkItem.cancelTask.cancel()取消自动向下传播DispatchSemaphore.wait()await 等待 / AsyncStream避免阻塞协作线程池3. 并发模型替换实战把 DispatchQueue 和回调逐块翻译成 async/await 的迁移路径3.1 迁移顺序先迁底层再迁接口最后迁 UI如果一上来就跑去把首页的viewDidLoad改成 async你会立即陷入一个麻烦UI 层向下调用的数据层接口还是 completion handler 风格每个调用点都要包一层withCheckedContinuation写几个样板之后就非常想放弃。我采用的顺序是数据层/服务层先迁再迁它们暴露出来的接口最后再逐页迁 UI 层。原因是 async 函数可以被上层用 await 调用但一个回调式接口很难被新的 async 调用方直接复用。让底层先彻底转为 async/await上层每次调用都是自然 await而不是到处做桥接。在这个过程中老接口不马上删除。我会在兼容层保留一个 completion handler 入口内部走新的 async 实现这样 UI 层在还没改造的时候项目依然能正常编译运行。3.2 老接口的过渡方案Continuation 桥接 completion handler遇到第三方库或者底层的旧 service 还是回调风格时用withCheckedThrowingContinuation做一层桥接是最稳的方式。下面这个例子把回调接口变成 async throws 函数func fetchUser(id: Int) async throws - User { try await withCheckedThrowingContinuation { continuation in // 假设这是项目里已有的老接口 fetchUserWithCallback(id: id) { result in switch result { case .success(let user): // 注意resume 只能调用一次多线程环境下要保证每个分支只走一次 continuation.resume(returning: user) case .failure(let error): continuation.resume(throwing: error) } } } }这里几个细节值得留意必须保证所有路径都会调用continuation.resume一旦漏掉调用方会永远挂起。必须保证只调用一次重复 resume 会在运行时触发警告和日志输出。如果老回调可能在任意队列触发桥接函数内部不用额外切队列因为恢复任务时会回到原来的执行上下文。如果老回调不是单次回调而是连续事件流比如下载进度、定位更新就别用 Continuation直接转成AsyncStream更合适func watchDownloadProgress() - AsyncStreamDouble { AsyncStream { continuation in downloader.onProgress { progress in continuation.yield(progress) } downloader.onFinish { continuation.finish() } } }然后调用方用for await progress in watchDownloadProgress()消费即可。3.3 三种 DispatchQueue 场景的替换写法与代码示例我梳理了老代码里最常出现的三种 DispatchQueue 场景并给出对应的替换写法。第一种私有串行队列保护状态。老代码一般是private let queue DispatchQueue(label: order.worker) private var orderState: OrderState .idle func updateOrderState(_ state: OrderState) { queue.async { self.orderState state } }新模型直接用 Actor 替代连锁都省了actor OrderWorker { private var orderState: OrderState .idle func updateOrderState(_ state: OrderState) { orderState state } }调用方只需要await worker.updateOrderState(.ready)。以前需要担心的是否在串行队列中这类问题直接由编译器兜底。第二种并发队列 barrier 处理多读单写。这种能力的替代品也是 Actor因为 Actor 本身就保证了同一时刻只有一个任务能访问内部状态外部读操作也要通过方法返回。第三种切主线程刷新 UI。老代码是DispatchQueue.main.async { self.label.text ... }。新模型里视图控制器如果已经标了MainActor那这个方法内部根本不需要写切主线程的代码如果是在一个同步上下文里想启动一个主线程任务可以这样Task { MainActor in self.label.text updated }从主线程创建Task不意味着它自动运行在主线程所以我通常显式加MainActor。这个细节在排查 UI 更新错乱时经常救你一命。3.4 一个真实迁移案例首页数据加载从 DispatchGroup 换成 async let我拿项目里首页数据加载做了一次完整替换。旧实现是这样的同时请求用户资料和推荐列表两者都完成后合并刷新。let group DispatchGroup() var fetchedUser: User? var fetchedOrders: [Order] [] group.enter() fetchUser { user in fetchedUser user group.leave() } group.enter() fetchOrders { orders in fetchedOrders orders group.leave() } group.notify(queue: .main) { self.render(user: fetchedUser, orders: fetchedOrders) }这段代码有个隐患两个回调分别写两个局部变量iOS 13 系统在极端情况下回调顺序不定理论上两个group.leave()都执行完之前主线程不会收到通知所以一般能正确合并。但多个局部变量的状态一致性只能靠人肉保证。换到 async let 后async let user fetchUser() async let orders fetchOrders() let (user, orders) try await (user, orders) render(user: user, orders: orders)迭代后一眼能看出两个请求是并发执行的结果合并处就是await (user, orders)这一行。子任务的生命周期由编译器管理不会再出现一个请求先返回、另一个请求永远不返回导致通知丢了的场景。下载批量图片这类任务数量不固定的场景我会用TaskGrouptry await withThrowingTaskGroup(of: ImageData.self) { group in for url in urls { group.addTask { try await downloadImage(from: url) } } for try await image in group { imageCache.insert(image) } }3.5 验证与回归TSan 从打开到变干净的过程迁移期间我把 Thread SanitizerTSan从打开到跑出结果的过程当作验收标准。Xcode 的 Scheme 设置里打开 Thread Sanitizer 后测试用例在并发操作时会检测数据竞争并在发生竞争的位置精准停住。启动这项检测后团队很快抓到了几个旧代码里的隐患。其中一个很典型一个全局单例的属性被多个队列同时读虽然读取时机大多是在已经加锁的保护区内但总有几次读到未同步的中间状态。在 GCD 时代这类问题未必立刻崩溃可一旦内存布局变化就很容易演变成偶发闪退。TSan 的价值在于把这些问题暴露在开发阶段而不是线上。我还让测试 target 在开启 TSan 的状态下连续跑了几轮并发用例确保迁移后的 Actor 路径没有新增竞争。一开始告警栏没干净过等最后跑通整晚不再出现新竞争后我才放心推进下一步迁移。4. 迁移中容易翻车的四个并发安全点隔离检查、await 间隙、锁遗留和任务丢信号4.1 隔离检查报错常见编译期错误与正确的修法迁移到一半最容易遇到的一类编译错误是隔离检查报错。典型提示长这样Call to main actor-isolated instance method foo() in a synchronous nonisolated contextProperty xxx is not isolated to global actor MainActor看到这类错误第一反应别是给方法加nonisolated。nonisolated的意思是这个属性不参与隔离检查等于把可变状态直接暴露给了外部并发环境很容易让数据竞争重新回来。正确的处理思路按优先级来给相关方法标MainActor让它成为主线程隔离上下文的一部分。如果调用方不是主线程上下文但又必须更新 UI就包一层Task { MainActor in ... }。如果方法里只是希望“瞬间读一下某个 UI 属性”停一下为什么 UI 属性会暴露到业务层把要传给业务层的数据抽成普通值类型比在业务代码里穿过一堆 UI 依赖要干净得多。我用过一个很偏执的标准在传输过程中不传递 UIView 或 UIViewController只传值类型的数据模型。这样一来主线程隔离检查的报错会少很多因为多数代码根本不接触 UI 类型。4.2 await 间隙与 Actor 重入状态在挂起期间已经变了await会把真正的执行控制权交出去。在 Actor 场景下这意味着其他任务可以在这段时间进入同一个 Actor。举个真实的例子一个 Actor 方法先检查用户 token再 await 网络请求网络返回后更新用户信息。由于网络请求期间 Actor 会让出另一个任务也进来了可能把 token 换掉了。等第一个任务恢复后它更新用户信息用的还是旧 token 对应的状态。解决这类问题的通用做法是不要在 await 之前捕获一个快照然后用到底而是在 await 之后重新检查当前状态。我给项目里类似代码加了一个简单约定await 之后要对前置条件重新校验校验失败就丢弃本次结果。actor UserSession { private var token: String? func refreshUserInfo() async throws { guard let currentToken token else { return } let info try await fetchUserInfo(token: currentToken) // 关键await 之后重新验证 token 是否仍是 currentToken guard token currentToken else { return // 期间 token 已变化放弃本次写入 } userInfo info } }UI 层也一样。页面发起请求时记录了一个页面版本号await 返回后先比对版本号如果页面已经切换就什么都不做。这种方式比每次回调都去检查是否还在导航栈里要可靠得多。4.3 多余的老锁在新隔离边界里重新引入串行化和死锁迁移过程中很容易出现的一种情况是数据访问已经交给 Actor 隔离了但老代码留下的 NSLock 还没删。这些锁在新模型里不会立刻引发崩溃但会制造多余的串行点甚至埋下死锁隐患。有一次性能 trace 显示某个网络响应方法调用时长突然变长查了半天才发现Actor 方法内部还 hold 着一把老锁锁的持有时间跨过了await。这等于在线程切换的间隙里锁住了一个对另一个线程也可见的资源风险被放大了好几倍。所以我在每次完成某模块迁移后会做一次专项清理搜索NSLock、OSAllocatedUnfairLock、pthread_mutex等符号逐个判断它们保护的数据是否已经收进 Actor。如果已经收进 Actor锁直接删掉不需要再搞双保险。双保险在实践中不是双倍安全是双倍串行外加一份额外的死锁风险。4.4 DispatchSemaphore 潜伏在 Task 里的线程饥饿问题这是我在迁移中最想提醒的一个点。很多老代码里用DispatchSemaphore来等待一个异步操作完成再继续在 GCD 时代虽然也不完美但 GCD 的线程池平时会维持一个较大的线程数偶尔阻塞几个线程还能扛住。Swift 并发的协作式线程池则不一样线程数量大概与 CPU 核心数相关而且它有一个隐含假设线上任务应该是短促的、非阻塞的。一旦在Task里用一个 semaphore 去同步等待另一个任务完成就可能把一个协作线程真正阻塞住。如果多来几个这样的等待线程池被占满后续所有 Task 都无法获得执行机会整个 App 表现成所有并发操作全部卡死。我当时的修改方式是把所有semaphore.wait()从任务链路里清出去改成真正的await等待。比如用async let等待多个任务或者用withCheckedContinuation接收回调。记住一个原则Swift 并发环境里await 是用来表达等待的唯一正确工具任何阻塞线程的做法在这个模型里都是反向操作。4.5 一个小的排查速查表我自己把迁移期常见的异常症状整理成了一张表遇到问题时先对着查症状可能原因正确方向主线程卡死同步等待 async 结果比如 MainActor 里用 semaphore 阻塞删除同步等待把整个调用链改成 async所有后台任务停滞Task 内 semaphore / lock 阻塞协作线程池换成 await 等待清理阻塞型同步锁数据偶发错乱老锁未清理和 Actor 隔离重复叠加删掉多余锁只保留一层隔离编译期 MainActor 报错非隔离上下文触碰 UI 类型标 MainActor 或把数据抽象成值类型5. 模型重构之后的收益与遗留风险以及我的实测体会5.1 重构后能看到的直接收益重构完成后最大的感受不是代码变好看了而是很多问题提前在编译期暴露。以前并发安全靠人肉纪律评审时反复强调这里要加锁、那里要切队列现在隔离规则由编译器检查团队新同学改代码也不会再因为不熟悉历史约定而踩进数据竞争。性能层面也有两处可以量化的改善。一处是启动阶段原来首页需要等待缓存任务全部完成后才刷新顺序依赖错综复杂换成结构化并发后启动链路的等待时间少了大概五分之一。另一处是合并请求路径之前用 DispatchGroup 的通知机制加主线程回调每次合并都要经历两次队列切换现在 async let 直接在同一任务内聚合结果队列切换成本消失了。这两处都属于特定场景的优化不是整体 App 变快了多少但足够说明并发模型重构对性能路径的直接帮助。5.2 遗留风险与仍然需要小心的地方迁移完成并不代表所有并发风险清零有几类问题我建议不要忽略。第一第三方老库的桥接层仍然存在。只要底层还有 completion handlerContinuation 桥接点就还在。每个桥接点都要保证 resume 被调用且只调用一次我建议把这些桥接点集中封装在一个文件里不要散落在业务代码各处。第二最低系统版本限制。Swift 并发运行时在一些较老系统上需要依赖系统库的能力如果项目还支持特别老的版本迁移前先确认最小支持版本避免上线后才发现部分接口不可用。第三调试体验需要适应。挂起点在断点上的表现和以前不一样单步调试时你会经常看到任务在某个 await 处暂停下一步可能直接跳到另一个线程或另一个任务。刚开始不太习惯但用熟之后会发现它把异步边界展示得比回调时代清晰得多。5.3 动手重构前我会反复确认的三件事如果让我给准备做同样重构的人一个最短建议我会列出这三件事第一先写测试再动手改代码。每个模块的目标行为先用单测锁住让 TSan 把这些测试跑干净。重构时保持行为等价比架构好看重要得多。第二不要把新老两套同时散落在同一层。尽量按照数据层到 UI 层的顺序推进每推进一层就保留一层兼容壳确保整个工程随时可编译、可运行。第三做好回退开关。我迁移期间保留了一个运行时开关方便在发现问题时快速切回老实现。这个开关不是让你用来反复横跳的而是给你一个心理安全垫让团队敢于小步连续提交。这次重构最值回票价的地方不是某个 API 的升级而是把整套并发约束前置到编译器让团队不用再靠人肉纪律维持秩序。完工之后的代码评审里我反而会经常问一个问题这个锁为什么还存在这个 Semaphore 是不是可以删掉如果你也开始重构先别急着把 DispatchQueue 全部找出来替换先把 Task、Actor、async let 这三个概念在同一张桌子上想清楚改动会顺利很多。