Swift Concurrency 取消机制踩坑复盘:为什么很多人会误以为 Task 根本取消不了?

在 Swift Concurrency 面试里,我经常听到一句话:

Swift Task 无法取消。

这句话听起来很绝对,但其实并不准确。

更精确地说,Swift Task 不是"不能取消",而是取消是协作式(cooperative cancellation),不是"强杀式(preemptive cancellation)"。

这篇文章,我想结合一组真实 demo 复盘,把 Swift Concurrency 里的取消机制、常见误区和工程踩坑一次讲清楚。


一、先说结论:Task 可以取消,但取消不是"立刻停"

Swift 里的 task.cancel() 做的事情,不是粗暴终止线程,而是:

  • 给当前任务打一个"已取消"的标记
  • 等任务后续运行到"取消检查点"时,再决定是否退出

也就是说,取消信号已经发出,不代表任务已经结束

所以很多业务里看到"明明调用了 cancel(),任务还在跑",本质不是取消失效,而是:

  • 任务没有检查取消
  • 或者任务没有经过可取消的挂起点
  • 或者它本身就不是结构化并发里的 child task

一句话总结:

Swift Task 的取消是"发信号",不是"砍线程"。


二、为什么这个点这么容易踩坑?

因为很多人天然把 cancel() 理解成了:

  • Java/Kotlin 协程里的"自动停"
  • 或者操作系统层面的"线程被中止"
  • 或者 GCD block 被强制打断

但 Swift Concurrency 不是这么设计的。

它强调的是:

  • 结构化并发
  • 任务边界清晰
  • 任务自己在合适的时机响应取消

这也是为什么 Swift 官方一直强调:

  • Task.isCancelled
  • Task.checkCancellation()
  • 可取消的 await 挂起点

这几个点,才是理解取消语义的关键。


三、最核心的模型:Task 取消依赖"取消检查点"

在工程里,一个 Task 想真正停下来,通常要满足两类条件之一:

1. 显式取消检查点

比如:

swift 复制代码
try Task.checkCancellation()

或者:

swift 复制代码
if Task.isCancelled { return }

这类写法适合:

  • 纯计算任务
  • 没有 await 的循环
  • CPU 密集型逻辑

2. 隐式取消检查点

也就是可取消的 await 挂起点,例如:

swift 复制代码
try await Task.sleep(nanoseconds: 300_000_000)

这类写法适合:

  • 网络请求
  • sleep
  • 一些系统 async API
  • IO 型任务

所以你可以把 Swift Task 的取消理解成下面这句话:

不是"任务一取消就停",而是"任务运行到取消检查点时,才会感知并退出"。


四、一个最容易误导人的坑:你以为任务取消不了,其实只是它已经跑完了

我自己在做 demo 时,专门验证过一个很典型的场景。

先看这个协作式取消的任务:

swift 复制代码
static func cooperative(
    tag: String,
    steps: Int = 10,
    delayNanoseconds: UInt64 = 100_000_000
) async {
    do {
        for i in 1...steps {
            try Task.checkCancellation()
            try await Task.sleep(nanoseconds: delayNanoseconds)
            print("✅ coop[\(tag)] step=\(i) isCancelled=\(Task.isCancelled)")
        }
        print("✅ coop[\(tag)] finished")
    } catch is CancellationError {
        print("🛑 coop[\(tag)] cancelled isCancelled=\(Task.isCancelled)")
    } catch {
        print("❌ coop[\(tag)] error=\(error)")
    }
}

如果在外部这样调用:

swift 复制代码
let task = Task.detached {
    await cooperative(tag: "detached-coop")
}

try? await Task.sleep(nanoseconds: 350_000_000)
task.cancel()
_ = await task.value

你大概率会看到类似日志:

text 复制代码
✅ coop[detached-coop] step=1 isCancelled=false
🛑 coop[detached-coop] cancelled isCancelled=true

这说明:

  • 任务已经开始执行
  • 外部 cancel() 生效
  • 任务在下一次取消检查点退出

但这里很容易踩一个误区。

如果我把 Task.sleep(...) 注释掉会怎样?

比如改成这样:

swift 复制代码
for i in 1...steps {
    try Task.checkCancellation()
    print("✅ coop step=\(i)")
}

然后外部还是 350ms 后 cancel。

你可能会看到:

text 复制代码
✅ coop step=1
✅ coop step=2
...
✅ coop step=10
✅ coop finished
>>> cancel

很多人这时候会下结论:

你看,没有 Task.sleep,Task 就取消不了了。

这个结论是错的。

真正原因是:

  • 没有 Task.sleep 之后,这个循环跑得太快了
  • 350ms 后你再 cancel,任务早就执行完了
  • 所以不是"取消不了",而是"你取消得太晚了"

也就是说:

Task.sleep 不是取消生效的必要条件,它只是让任务执行变慢,从而更容易观察到取消效果。


五、再看一个更迷惑人的现象:我把 Task.checkCancellation() 注释掉,为什么还是能 cancel?

我们继续改代码:

swift 复制代码
for i in 1...steps {
    // try Task.checkCancellation()
    try await Task.sleep(nanoseconds: delayNanoseconds)
    print("✅ coop step=\(i)")
}

你会发现,即使不写 Task.checkCancellation(),任务仍然可能输出:

text 复制代码
🛑 coop[detached-coop] cancelled isCancelled=true

为什么?

因为:

swift 复制代码
try await Task.sleep(...)

本身就是可取消的 await 挂起点

一旦任务在 sleep 期间收到取消信号,Task.sleep 会直接抛出 CancellationError

所以这里的关键结论是:

Task.checkCancellation() 不是让取消生效的唯一方式;可取消的 await 挂起点本身就能响应取消。


六、真正"看起来取消不了"的场景:同步阻塞

再看另一段代码:

swift 复制代码
static func nonCooperativeBlocking(
    tag: String,
    steps: Int = 6,
    sleepSeconds: TimeInterval = 0.2
) async {
    print("🚧 nonCoop[\(tag)] begin isCancelled=\(Task.isCancelled)")
    for i in 1...steps {
        await blockingSleep(seconds: sleepSeconds)
        print("🚧 nonCoop[\(tag)] step=\(i) isCancelled=\(Task.isCancelled)")
    }
    print("🚧 nonCoop[\(tag)] finished isCancelled=\(Task.isCancelled)")
}

其中 blockingSleep 内部本质做的是同步阻塞:

swift 复制代码
private static func blockingSleep(seconds: TimeInterval) async {
    await withCheckedContinuation { continuation in
        DispatchQueue.global().async {
            Thread.sleep(forTimeInterval: seconds)
            continuation.resume()
        }
    }
}

然后外部这样 cancel:

swift 复制代码
let nonCoop = Task.detached {
    await nonCooperativeBlocking(tag: "detached-nonCoop")
}

try? await Task.sleep(nanoseconds: 350_000_000)
nonCoop.cancel()
_ = await nonCoop.value

日志会很典型:

text 复制代码
🚧 nonCoop[detached-nonCoop] begin isCancelled=false
>>> cancel
🚧 nonCoop[detached-nonCoop] step=1 isCancelled=true
🚧 nonCoop[detached-nonCoop] step=2 isCancelled=true
🚧 nonCoop[detached-nonCoop] step=3 isCancelled=true
...
🚧 nonCoop[detached-nonCoop] finished isCancelled=true

这才是业务里最容易让人误解的一类情况。

为什么会这样?

因为:

  • cancel() 已经把任务标记成 isCancelled = true
  • 但任务内部并没有在合适位置退出
  • 它只是继续执行同步阻塞逻辑

所以"取消没生效"的真正意思其实是:

取消标记已经到了,但任务不配合退出。


七、Task.sleepThread.sleep 最大区别是什么?

Task.sleep

swift 复制代码
try await Task.sleep(nanoseconds: ...)

它的本质是:

  • 挂起当前 async task
  • 把执行权交回 Swift Concurrency runtime
  • 是一个可取消的 await 点
  • 不阻塞底层线程

Thread.sleep

swift 复制代码
Thread.sleep(forTimeInterval: ...)

它的本质是:

  • 直接阻塞当前线程
  • 不属于 Swift Concurrency 的 await 点
  • 不会自动响应 Task 取消
  • 还会影响线程调度效率

所以一句话区分:

Task.sleep 是挂起任务,Thread.sleep 是阻塞线程。

这也是为什么 Swift 6 在异步上下文里直接报错:

Class method 'sleep' is unavailable from asynchronous contexts

因为 Swift 6 更明确地禁止你在 async 世界里直接塞同步阻塞。


八、另一个高频坑:为什么 Task.detached 经常让人感觉"取消失效"?

因为 Task.detached非结构化并发

这意味着它:

  • 不自动继承父任务取消
  • 不自动继承 Task Local
  • 不自动继承当前上下文

来看一个典型对比。

结构化并发

swift 复制代码
await withTaskGroup(of: Void.self) { group in
    group.addTask { await work1() }
    group.addTask { await work2() }
    group.addTask { await work3() }
}

这几个 task 是父任务的 child task。

如果父任务被取消,取消会向下传播,子任务也会收到取消信号。

非结构化并发

swift 复制代码
let detached = Task.detached {
    await work()
}

这个任务相当于"自己出去单干了"。

你取消父任务,并不会自动影响它。

这也是为什么很多页面退出后,你明明已经取消页面任务了,后台 detached 还在继续打印日志、甚至继续回写 UI 或状态。

一句话总结:

Task.detached 最大的坑,不是能不能跑,而是你很容易忘记它已经脱离了结构化并发体系。


九、业务里最常见的几个取消踩坑

1. 页面退出了,但异步任务还在更新 UI

典型表现:

  • UIViewController 已经 pop
  • SwiftUI View 已经消失
  • 但任务还在继续回调

根因通常是:

  • 没有持有 Task handle
  • 没有在页面销毁时 cancel
  • 或者用了 Task.detached

建议:

  • 页面级任务要有明确 owner
  • 生命周期结束时主动 cancel
  • UI 状态更新尽量收敛到 @MainActor

2. 以为调用了 cancel() 就代表任务结束了

这是最普遍的误区。

正确理解是:

  • cancel() 只是发信号
  • 真正确认任务结束,要 await task.value
  • 或者在 withTaskGroup 里等子任务收敛结束

否则你很可能看到这种现象:

text 复制代码
>>> cancel
--- done ---
🛑 task cancelled

这不是 Swift 有问题,而是你打印 "done" 时并没有真正等待任务结束。


3. 任务太快,导致你观察不到取消

比如:

swift 复制代码
for i in 1...10 {
    try Task.checkCancellation()
    print(i)
}

然后 300ms 后再 cancel。

这类逻辑常常早结束了,所以你根本看不到取消效果。

这时候不要误判成"取消失效",而应该意识到:

  • 任务执行太快
  • 取消时机太晚
  • demo 不具备观察价值

4. 用 try? 把取消吞掉了

例如:

swift 复制代码
try? await Task.sleep(nanoseconds: 300_000_000)

这类写法会把 CancellationError 一起吞掉。

结果就是:

  • 任务被 cancel 了
  • 但你没有感知到异常
  • 后续代码可能还继续执行

这类问题在真实业务里特别危险。

因为它会导致:

  • 页面都销毁了,任务还继续跑
  • 网络请求逻辑不再区分"失败"和"取消"
  • 状态机进入半终止状态

工程建议是:

  • 对"取消"要么显式 catch
  • 要么显式 return
  • 不要无脑 try?

十、我对 Swift Task 取消机制的最终理解

如果要把这套机制压缩成一句最准确的话,我会这么说:

Swift Task 取消不是抢占式中断,而是协作式取消;cancel() 只设置取消标记,任务是否终止取决于它是否运行到显式检查点或可取消的 await 挂起点。

再展开一点,就是三句话:

  • Task.checkCancellation():显式检查点
  • Task.sleep / 某些 async API:隐式可取消挂起点
  • 两者都没有:任务只会带着 isCancelled = true 继续跑

十一、最后给一份工程实践建议清单

建议 1:页面级任务必须可管理

  • 持有 Task handle
  • 生命周期结束时 cancel
  • 不要随手 Task.detached

建议 2:长任务必须设计取消检查点

  • CPU 密集任务:加 Task.checkCancellation()
  • IO/异步任务:利用可取消 await 点
  • 不要假设 cancel() 会自动生效

建议 3:区分"取消"和"失败"

  • CancellationError 不是普通错误
  • 取消通常不应该走失败兜底 UI
  • 应单独处理取消语义

建议 4:谨慎使用 try?

  • 它会吞掉 CancellationError
  • 很容易让取消逻辑失真

建议 5:优先使用结构化并发

  • async let
  • withTaskGroup
  • 少用 Task.detached
  • 避免任务游离出生命周期体系

结语

Swift Concurrency 的取消机制,真正难的地方从来不是 API 本身,而是思维方式切换:

  • 不是"我调了 cancel,为什么你还不停"
  • 而是"这个任务有没有设计好响应取消的时机"

这也是我觉得 Swift Concurrency 很有意思的一点。

它逼着我们从"线程思维"切到"任务语义"和"结构化生命周期"思维。

而很多线上诡异 bug,恰恰就出在这一步没有切过来。


相关推荐
软泡芙4 天前
【IOS】 Swift Package Manager (SPM) 指南
网络·ios·swift
东坡肘子4 天前
不是模型变慢了,是任务变大了 -- 肘子的 Swift 周报 #146
人工智能·swiftui·swift
大龄秃头程序员5 天前
Sendable 不是“能跑就行”:Swift Concurrency 下的实战踩坑与架构落地指南
swift
大熊猫侯佩5 天前
WWDC26 全新 SwiftData 的 .codable 宏选项:它不是泛型专用胶水
数据库·swift·apple
大龄秃头程序员6 天前
Swift 并发实战:从 Task 到 Actor 重入性,我掉进了“逻辑不一致”的坑
swift
LinXunFeng6 天前
给 Flutter 插件加上 Swift Package Manager 支持
flutter·swift
大龄秃头程序员6 天前
Swift 面试反直觉:Property Wrapper 真的拿不到宿主 self 吗?
swift
FeliksLv6 天前
从 +load 到 Swift Macros:自动注册的演进与实践
ios·objective-c·swift