在 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.isCancelledTask.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.sleep 和 Thread.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已经 popSwiftUI 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:页面级任务必须可管理
- 持有
Taskhandle - 生命周期结束时 cancel
- 不要随手
Task.detached
建议 2:长任务必须设计取消检查点
- CPU 密集任务:加
Task.checkCancellation() - IO/异步任务:利用可取消 await 点
- 不要假设
cancel()会自动生效
建议 3:区分"取消"和"失败"
CancellationError不是普通错误- 取消通常不应该走失败兜底 UI
- 应单独处理取消语义
建议 4:谨慎使用 try?
- 它会吞掉
CancellationError - 很容易让取消逻辑失真
建议 5:优先使用结构化并发
async letwithTaskGroup- 少用
Task.detached - 避免任务游离出生命周期体系
结语
Swift Concurrency 的取消机制,真正难的地方从来不是 API 本身,而是思维方式切换:
- 不是"我调了 cancel,为什么你还不停"
- 而是"这个任务有没有设计好响应取消的时机"
这也是我觉得 Swift Concurrency 很有意思的一点。
它逼着我们从"线程思维"切到"任务语义"和"结构化生命周期"思维。
而很多线上诡异 bug,恰恰就出在这一步没有切过来。