iOS · Swift 并发 · Flick

App 假死三次,同一个凶手:别阻塞 Swift 协作线程池

Flick 复盘:4 次 compute start、0 次 compute done——协作式调度的契约,以及违反它的三种姿势。

Flick 上线后修过三个表现完全不同的 bug:扫描时整个 App 卡死(v1.3.0)、iPad 上分析永远跑不完(v1.3.2)、纯 iCloud 相册进度卡在 0(v1.3.2)。三次的修复代码几乎长得一样,因为根因是同一个:阻塞了 Swift 并发的协作线程池

先讲清楚这个池子是什么

Swift 结构化并发的所有 async 任务,跑在一个协作式线程池(cooperative pool)上。它有两个和 GCD 截然不同的性质:

  • 宽度固定等于 CPU 核心数。6 核设备就是 6 条线程,永远不会为你多开一条——这是它避免线程爆炸的设计初衷;
  • 调度是协作式的。任务只在 await 处让出线程;一个任务如果同步阻塞(锁、信号量、同步 IO),那条线程就被整个扣押,运行时不会察觉、也不会补充线程。

推论很残酷:只要有「核心数」个任务同时阻塞,全 App 所有 async 任务——UI 的 .task、网络回调后续、别的模块的后台工作——集体断粮。不崩溃、不报错、CPU 占用几乎为零,就是所有异步的东西都不动了。这也是它难查的原因:崩溃日志是空的,因为没有任何东西崩溃。

第一次:PhotoKit 同步请求,6 条线程全 park

现象:点「重新扫描」后,滑动页图片加载不出、待删列表全是灰色占位图。

取图代码的形态是 withCheckedContinuation 包住 PhotoKit 请求,并且 isSynchronous = true。同步模式不是失误,是刻意的:PhotoKit 的异步模式配 .opportunistic 会对同一请求多次回调(先给低清、再给高清),而 checked continuation 恢复两次是运行时直接 crash。同步模式保证回调恰好一次,continuation 安全。

代价当时没算清:同步请求会阻塞调用线程直到图片返回——包括 iCloud 下载的全程。扫描期间,4 个分析 worker 加上每张可见缩略图的 .task,全在协作池上发起这种请求。6 条线程,秒空。

修复是一个值得记住的桥接模式:阻塞等待挪到一条专用 GCD 并发队列上(GCD 可以超配线程,阻塞它不伤协作池),用 continuation 桥回 async 世界:

private let blockingRequestQueue = DispatchQueue(
  label: "photo.blocking", qos: .userInitiated, attributes: .concurrent)

func requestImage(...) async -> UIImage? {
  await withCheckedContinuation { cont in
    blockingRequestQueue.async {
      let opts = PHImageRequestOptions()
      opts.isSynchronous = true          // 阻塞的是 GCD 线程,不是协作池
      manager.requestImage(...) { image, _ in cont.resume(returning: image) }
    }
  }
}

协作池上的任务从「阻塞在 PhotoKit 里」变成「挂起在 await 上」——后者会让出线程,这正是契约要求的。

第二次:Vision 同步计算,真机上教科书级死锁

现象:iPad 上分析永远跑不完。真机日志给出了我见过最干净的死锁证据:4 次 “compute start”,0 次 “compute done”

VNGenerateImageFeaturePrintRequestperform 是同步计算,第一版直接跑在 task group 的子任务上——又是协作线程。分析并发度设为 4,在核少的 iPad 上,4 条协作线程同时扎进 Vision 里出不来;而消费计算结果、推进流水线的任务,已经没有线程可调度。生产者占满池子等消费者腾地方,消费者等生产者让线程——环等成立,死锁坐实。

修复与第一次同构:专用 computeQueue 承接同步计算,协作池只做调度和收结果。我在 AnalysisCoordinator.swift 的注释里写了一句自嘲:「Same lesson as the PhotoKit blockingRequestQueue fix」。同一个坑,第一次穿着 IO 的衣服,第二次穿着 CPU 密集的衣服,我都认不出来。

第三次:iCloud 下载 stall,continuation 永不恢复

现象:开了「优化 iPhone 储存空间」的用户,分析卡在 0。

这次连「等久一点」都救不了:同步请求允许联网时,遇到卡住的 iCloud 下载,PhotoKit 的回调可能永远不来。continuation 永不 resume,线程永久扣押,扫描循环整体楔死。前两次是「慢性拥堵」,这次是「永久失踪」。

修复分策略和机制两层:

  • 策略:分析根本不需要原图——特征提取只用 256px。改用 .opportunistic 异步请求,拿到第一张可用的图就 resume:本地照片给全图,离线照片给本地缩略图,都是毫秒级。永不等下载;
  • 机制.opportunistic 会多次回调,于是配一个 ResumeOnce 守卫(原子标志位,第二次以后的回调直接丢弃),再加硬超时兜底「回调彻底不来」的病理情况——超时触发时 resume 一个 nil,宁可这张照片跳过,不可整条流水线陪葬。

附:一个近亲变体——取消的任务替新任务「收了尸」

同期还有个黑色幽默的 bug(v1.2.0):分组页封面全黑。原因是被取消的分析任务照常执行了收尾的 finalize()——它会把 isRunning 置 false、把 runTask 置 nil,而这两个字段此刻已经属于新启动的任务。旧任务给新任务收了尸,新的 start() 又 spawn 一条分析循环,两条循环并发抢 PhotoKit,IPC 风暴饿死了所有缩略图请求。

修复一行:if !Task.isCancelled { await finalize() }。教训归入同一个主题:结构化并发里,任务的生命周期钩子必须检查自己是否还是「现任」

规则清单

  • 协作线程池上永远不做阻塞等待——同步 API、锁、信号量、隐式的下载等待,全算;
  • 必须调同步 API 时,用「GCD 队列 + continuation」桥接,让协作池的任务停在 await 而不是停在别人的库里;
  • 对「可能多次回调」的 API,continuation 前面放单次恢复守卫;对「可能永不回调」的 API,配硬超时;
  • 被取消的任务不执行会改共享状态的收尾逻辑;
  • 排查这类问题的信号:全局异步停摆 + 零崩溃日志 + CPU 接近空闲。见到这三件套,先怀疑池子被抽干,去看有谁在协作线程上同步等待。

Swift 并发的固定宽度线程池是性能特性,也是一份契约:任务要么在跑,要么让出。三次事故的学费换一句话——契约不会因为你有苦衷(怕 double-resume、要同步语义)就网开一面。苦衷要用桥接去解决,不能用阻塞去凑合。