返回文章列表

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)
        }
    }
}

语法没问题,功能也能跑。但这段代码有两个很致命的默认前提:

  1. 外层任务就算取消了,底层请求也会自己停;
  2. 就算底层没停,回调晚点回来也不会再影响当前状态。

这两个前提在真实项目里经常都不成立。

如果 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 = []
            }
        }
    }
}

问题看起来只差一个取消调用,但真正缺的是两层保护:

  1. 成功返回后要确认当前任务还有效;
  2. 失败时不能把取消当成普通错误处理。

更稳一点的写法会是这样:

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()

我现在更看重的是三个位置:

  1. 桥接旧 API 的入口:这里要负责把外层取消翻译到底层能力;
  2. 长耗时链路的阶段切换点:比如拿完网络、准备解码、准备写缓存;
  3. 副作用提交前:凡是会改状态、落缓存、发埋点、写数据库的地方,都值得再检查一次。

反过来说,如果一个函数只是纯计算、没有 suspend point、也没有副作用,那专门塞取消检查意义不大。因为取消真正要解决的,从来不是“代码风格正确”,而是“旧世界别再继续写下去”。

结尾

Swift Concurrency 最容易制造的错觉,是代码已经从 callback 迁到了 await,系统就自然进入了更可靠的并发时代。

但真实项目不会因为语法变新就自动获得取消语义。

父任务是不是还能管住子任务,桥接层能不能把 cancel 往下传,副作用提交前会不会拦住旧结果,这三件事只要漏一件,页面上看到的就不是“偶发抖动”,而是一套已经分叉的状态系统。

所以这类问题真正该审的,不是“有没有用 Swift Concurrency”,而是取消到底停在了哪一层。只要这个问题没答清楚,语法越新,越容易让人误以为自己已经把并发写对了。