Swift Concurrency 语法都对了,为什么取消语义还是经常失效
真正难收的不是 async/await 迁移,而是取消信号能不能穿过 Task、桥接层和副作用边界,别让旧结果在页面上回灌
项目把回调改成 async/await 之后,很多人会有一种错觉:并发问题已经收住了。
函数签名变干净了,调用链也能顺着 await 看下去,连 Xcode 里的 warning 都少了。但线上最烦的一类问题,往往正是在这个阶段开始冒头:页面已经离开了,请求还没停;搜索词已经变了,旧结果又刷回来了;用户手动取消上传,底层任务还在继续跑。
这类问题最容易被归因成“某个接口太慢”或者“主线程刷新时机不对”,可真把链路拆开看,核心通常不是语法,而是取消信号根本没有沿着任务树、桥接层和副作用边界传到底。
我的判断是:Swift Concurrency 迁移完成之后,最常见的并发 bug,不是 async/await 写错,而是大家以为“父任务 cancel 了,下面自然都会停”。现实里只要中间经过了一层未受控的 Task、一段桥接旧 API 的包装,或者一个不会检查取消状态的副作用,取消语义就会在那一层断掉。最后页面看上去像偶发抖动,本质上却是状态真相已经分叉了。
这类问题通常不是在崩溃里暴露,而是在状态回灌里暴露
我第一次系统性把这个问题收出来,不是因为崩溃,而是因为一个搜索页总有人反馈“结果会自己跳回去”。
页面逻辑并不复杂:
- 用户输入关键字;
- ViewModel 发起搜索;
- 新关键字到来时取消上一次任务;
- 请求返回后刷新列表。
表面上这套流程完全符合 Swift Concurrency 的推荐写法。问题是线上录屏里能看到一个很怪的现象:
- 用户先搜
swift; - 紧接着改成
swift concurrency; - 界面先出现新结果;
- 隔半秒,旧结果又把列表覆盖回去了。
这不是简单的“请求乱序”四个字能解释完的。因为代码里明明已经有 searchTask?.cancel(),日志里也能看到 cancel 被调到了。
真正的问题出在:上层任务被取消了,但底层并没有把“取消”当成必须立刻收口的状态变化。
只要系统里还有一层继续把旧结果往上送,UI 就会把它当成合法结果接住。
很多取消失效,断在了看起来最无害的一层桥接代码里
最常见的断点,是把旧回调 API 包成 async 函数时,只做了“等结果回来”,没做“结果不该再回来时怎么办”。
比如很多人会这样包一层网络请求:
func loadUser(id: String) async throws -> User {
try await withCheckedThrowingContinuation { continuation in
apiClient.loadUser(id: id) { result in
continuation.resume(with: result)
}
}
}
语法没问题,功能也能跑。但这段代码有两个很致命的默认前提:
- 外层任务就算取消了,底层请求也会自己停;
- 就算底层没停,回调晚点回来也不会再影响当前状态。
这两个前提在真实项目里经常都不成立。
如果 apiClient 下面还是 URLSessionDataTask、第三方 SDK,或者你们自己留着的回调式仓储层,那么外层 Task 的取消并不会自动传过去。上面这个 async 包装只是让调用方式变成了 await,并没有让底层获得取消语义。
桥接层真正该补的是“把外层取消翻译成底层可执行的取消动作”。类似这样:
func loadUser(id: String) async throws -> User {
var request: Cancellable?
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
request = apiClient.loadUser(id: id) { result in
continuation.resume(with: result)
}
}
} onCancel: {
request?.cancel()
}
}
这段代码才开始接近“取消真的能传下去”。
但写到这里还不够,因为它只解决了“尽量别继续跑”,还没解决“已经晚到的结果怎么收口”。如果底层 SDK 的 cancel() 不是强语义取消,而只是尽量终止,回调依然可能在 race condition 里回来。那上层在接结果前还得继续做一次取消检查。
真正把页面写乱的,不是任务没取消,而是旧结果仍然被当成有效结果
很多团队看到 Task.isCancelled 就放心了,但它只能回答“当前任务有没有被标记取消”,回答不了“这个结果还应不应该落到当前页面上”。
搜索、联想、详情切换这类场景里,真正需要守住的是结果归属权。
下面这种 ViewModel 写法很常见:
final class SearchViewModel: ObservableObject {
@Published private(set) var items: [Item] = []
private var searchTask: Task<Void, Never>?
func search(keyword: String) {
searchTask?.cancel()
searchTask = Task {
do {
let items = try await repository.search(keyword: keyword)
self.items = items
} catch {
self.items = []
}
}
}
}
问题看起来只差一个取消调用,但真正缺的是两层保护:
- 成功返回后要确认当前任务还有效;
- 失败时不能把取消当成普通错误处理。
更稳一点的写法会是这样:
final class SearchViewModel: ObservableObject {
@MainActor @Published private(set) var items: [Item] = []
private var searchTask: Task<Void, Never>?
func search(keyword: String) {
searchTask?.cancel()
searchTask = Task { [weak self] in
guard let self else { return }
do {
let items = try await repository.search(keyword: keyword)
try Task.checkCancellation()
await MainActor.run {
self.items = items
}
} catch is CancellationError {
// 取消不是失败,不清空 UI,不弹错误
} catch {
await MainActor.run {
self.items = []
}
}
}
}
}
这里真正重要的不是 Task.checkCancellation() 这一行本身,而是它背后的态度:取消是一种正常控制流,不是异常事故。
很多页面抖动,就是因为代码把“用户已经切走了”处理成了“请求失败了,所以把 UI 清空一下”。结果新任务还没渲出来,旧任务的错误分支先把页面打回空态,视觉上就像随机闪烁。
另一类更隐蔽的问题,是任务树早就断了,大家还以为自己在结构化并发里
Swift Concurrency 的好处之一,是结构化并发让父子任务的生命周期关系清楚很多。但项目里最容易把这层关系弄丢的,恰恰是大家为了“图省事”随手起的那些 Task {}。
比如一个列表页进入时拉详情、拉推荐、打曝光点,很多代码会拆成这样:
func refresh() async {
Task {
async let detail = repository.loadDetail()
async let recommendation = repository.loadRecommendation()
let result = try await (detail, recommendation)
render(result)
}
}
看起来已经是 async/await 了,但这段代码最关键的问题是:refresh() 自己和里面那层 Task {} 已经没有结构化父子关系了。
也就是说:
- 调用
refresh()的上层即使结束; - 页面即使销毁;
- 外层任务即使被取消;
里面这层新开的 Task 还是可以继续跑。
这就是很多页面“明明退出了还在打请求”的根源。不是 Swift Concurrency 不工作,而是代码主动绕开了结构化并发。
这类场景如果只是为了并行拿结果,直接在当前 async 上下文里写就够了:
func refresh() async throws -> ScreenData {
async let detail = repository.loadDetail()
async let recommendation = repository.loadRecommendation()
return try await ScreenData(
detail: detail,
recommendation: recommendation
)
}
这样取消语义才会跟着调用链一起收。谁发起,谁负责;谁取消,下面一起停。
副作用边界如果不检查取消,最难解释的脏状态就会冒出来
请求没停掉还只是资源浪费,副作用没停掉才是真正会把状态写脏的地方。
我后来专门查过一类很难复现的问题:用户快速切换账号后,缓存里偶尔会出现上一个账号的数据。最后收敛下来,并不是鉴权串了,而是取消语义停在了“拿到数据”之前,没有延续到“写入副作用”这一步。
类似这样的代码很危险:
func refreshProfile() async throws {
let profile = try await repository.fetchProfile()
cache.save(profile)
analytics.trackProfileLoaded(profile.id)
state = .loaded(profile)
}
如果 fetchProfile() 返回时任务已经被取消,但这里没有做取消检查,那么后面的缓存写入、埋点和状态更新仍然会继续发生。
这时候你在 UI 上看到的可能只是一次偶发回跳,可在系统内部,脏数据已经落盘了,排查成本会一下子抬高很多。
更稳妥的写法通常是在副作用边界前再做一次显式检查:
func refreshProfile() async throws {
let profile = try await repository.fetchProfile()
try Task.checkCancellation()
cache.save(profile)
analytics.trackProfileLoaded(profile.id)
state = .loaded(profile)
}
这一步看上去有点机械,但它解决的是很现实的问题:取消不是只取消“等待”,还要取消“提交”。
真正需要保护的,往往不是那次网络耗时,而是后面那几个会把旧世界重新写回来的动作。
失败案例里最常见的误区,是把所有 error 都统一处理
很多并发迁移之所以留下长尾,就是因为团队喜欢把错误收口写成统一模板:
do {
let data = try await service.load()
state = .loaded(data)
} catch {
state = .error(error)
}
这在普通失败场景没什么问题,但一旦放到高频切换页面、搜索联想、输入防抖、可取消上传这些场景里,CancellationError 跟真正的业务失败根本不是一回事。
两者混在一起至少会带来三个后果:
- 用户主动离开页面,却被记成了一次失败;
- 埋点里错误率被虚高,误导稳定性判断;
- UI 因为统一走错误兜底,出现本不该出现的 toast、空态或重试按钮。
项目里只要把取消当失败展示过一次,后面就会出现一堆看上去互不相关的奇怪反馈:
- 搜索时列表反复清空;
- 下拉刷新结束后偶发弹错;
- 页面返回上一层时,loading 还会闪一下失败态。
这些现象都很碎,但根上是同一个问题:控制流取消,被误当成业务异常。
适用边界:不是每个 async 函数都要塞满取消检查
取消语义很重要,但也不是每一层都要机械地写 Task.checkCancellation()。
我现在更看重的是三个位置:
- 桥接旧 API 的入口:这里要负责把外层取消翻译到底层能力;
- 长耗时链路的阶段切换点:比如拿完网络、准备解码、准备写缓存;
- 副作用提交前:凡是会改状态、落缓存、发埋点、写数据库的地方,都值得再检查一次。
反过来说,如果一个函数只是纯计算、没有 suspend point、也没有副作用,那专门塞取消检查意义不大。因为取消真正要解决的,从来不是“代码风格正确”,而是“旧世界别再继续写下去”。
结尾
Swift Concurrency 最容易制造的错觉,是代码已经从 callback 迁到了 await,系统就自然进入了更可靠的并发时代。
但真实项目不会因为语法变新就自动获得取消语义。
父任务是不是还能管住子任务,桥接层能不能把 cancel 往下传,副作用提交前会不会拦住旧结果,这三件事只要漏一件,页面上看到的就不是“偶发抖动”,而是一套已经分叉的状态系统。
所以这类问题真正该审的,不是“有没有用 Swift Concurrency”,而是取消到底停在了哪一层。只要这个问题没答清楚,语法越新,越容易让人误以为自己已经把并发写对了。