Swift 结构化并发里,TaskGroup 是并行拆解任务的重要工具。它能让父任务同时创建多个子任务,并在统一的出口处汇总结果。这种设计直观、安全,但优先级传播和阻塞锁叠加之后,会出现一种让人头疼的问题:高优先级任务通过 TaskGroup 等待子任务,子任务又在等待低优先级任务持有的锁,而低优先级任务因为调度权重不够或者本身在等外部信号,迟迟无法释放锁,最终整个分支卡死。要理解这个死锁,需要先弄清 TaskGroup 的优先级规则,以及 Swift 并发运行时对锁和 actor 的不同处理方式。

TaskGroup 如何把优先级传给子任务
Swift 的每个 Task 都携带优先级,负责影响任务在协作线程池中的调度顺序。TaskGroup 是结构化并发的一部分,父任务通过 withTaskGroup 进入一个任务组,在闭包里调用 group.addTask 创建子任务。默认情况下,子任务会继承父任务当前的优先级,也可以在 addTask 的 priority 参数中显式指定。比如一个高优先级父任务创建的子任务,通常也会以高优先级进入调度队列,从而更快获得执行机会。
这里有一个容易忽略的地方:优先级不是只影响单个任务,而是会沿着任务树传播。如果父任务在等待子任务完成,而子任务又触发了更底层的任务,高优先级会向整个依赖链传播。对于 actor 这类协作式挂起模型,Swift 运行时能够识别“谁在等待谁”,并在必要时提升被等待任务的优先级。也就是说,高优先级任务等待低优先级 actor 方法时,运行时可以让低优先级 actor 的任务临时提高调度权重,尽快跑完临界区,缓解优先级反转。
下面这段代码展示了 TaskGroup 子任务继承父任务优先级的基本行为:
func runTaskGroup() async {
await withTaskGroup(of: Int.self) { group in
group.addTask(priority: .high) {
// 子任务继承 .high 优先级
return await compute()
}
for await value in group {
print(value)
}
}
}
这里的 compute 可以是一个异步任务,子任务会在高优先级下被调度。优先级继承本身是合理设计,它能让高优先级的工作不被低优先级任务长期拖慢。但前提是等待关系发生在 Swift 运行时能观察到的协作式挂起点上,例如 await。一旦等待关系被转移到了 NSLock、信号量这类阻塞式原语内部,Swift 调度器就看不见了,问题便从这里开始。
一个可复现的死锁场景
为了看清问题,可以构造一个非常小的死锁模型。它包含三样东西:一把 NSLock、一个 DispatchSemaphore、一个低优先级任务和一个通过 TaskGroup 等待的高优先级父任务。低优先级任务先拿到锁,在锁的临界区内等待信号量;高优先级父任务通过 TaskGroup 创建多个子任务,这些子任务尝试获取同一把锁;父任务则等待所有子任务完成。由于父任务没有机会发出信号量,低优先级任务永远持锁,子任务永远拿不到锁。
import Foundation
final class DeadlockScene {
private let lock = NSLock()
private let semaphore = DispatchSemaphore(value: 0)
func lowPriorityWork() {
lock.lock()
// 模拟锁内需要等待外部协作信号,否则不会主动释放锁
semaphore.wait()
lock.unlock()
}
func highPriorityWork() async {
await withTaskGroup(of: Int.self) { group in
for _ in 0...3 {
group.addTask(priority: .high) {
self.lock.lock()
defer { self.lock.unlock() }
return 1
}
}
for await _ in group { }
}
}
func releaseLowPriority() {
semaphore.signal()
}
}
如果启动流程是:先让低优先级任务执行 lowPriorityWork,确保它已经拿到锁并阻塞在 semaphore.wait();接着调用 highPriorityWork。此时高优先级父任务创建的子任务都会阻塞在 lock.lock(),父任务则在 for await 处等待所有子任务完成。由于父任务不会主动调用 releaseLowPriority,低优先级任务也不会从信号量等待中醒来,锁自然不会被释放。子任务拿不到锁,父任务等不到子任务,这就是一个完整的循环等待。
这个例子说明,它不只是短暂的优先级反转。优先级反转通常是低优先级任务最终会执行完毕并释放资源,只是高优先级任务会多等一会儿。而这里的低优先级任务在锁内等待一个永远不会到来的信号,资源永远无法被释放。这正是死锁和普通优先级反转最本质的区别。
为什么优先级继承在这个场景下失效
Swift 的优先级继承主要作用在 actor 的协作式等待上。当一个高优先级任务 await 某个 actor 方法时,运行时会提升被等待 actor 对应的任务优先级,让它更快完成临界区。下面的代码展示了 actor 上的优先级提升思路:
actor Counter {
private var value = 0
func increment() {
value += 1
}
}
func updateFromHighPriority() async {
let counter = Counter()
let lowTask = Task(priority: .low) {
await counter.increment()
}
// 高优先级任务等待同一个 actor 时,运行时可以提升 lowTask 的调度优先级
await counter.increment()
_ = await lowTask.value
}
但 NSLock 和 DispatchSemaphore 是完全阻塞的原语。调用 lock() 或 wait() 时,线程会进入内核等待,Swift 协作式调度器没有机会介入,也不知道哪个任务持有锁、哪个任务在抢锁。高优先级子任务越多,占用的协作线程就越多;如果底层线程池被这些阻塞的线程占满,低优先级持锁任务可能根本得不到线程继续执行。这种情况下,任务优先级再高也无济于事,因为线程已经被阻塞调用占住,而不是被调度器分配给低优先级任务。
还需要注意,即使 Swift 运行时成功提升低优先级任务的调度优先级,只要低优先级任务仍然在锁内等待一个外部信号,死锁依旧存在。优先级继承只能缓解“低优先级占用资源导致高优先级等待”的延迟问题,不能解决“锁内等待外部事件”的资源循环依赖。因此,永久死锁的根因往往不是优先级本身,而是临界区内出现了跨任务的等待。把等待关系隐藏在阻塞锁下面,调度器就很难帮你兜底。
从阻塞锁转向 actor 隔离和异步锁
如果只是保护一段内存状态,最直接的方案是把 NSLock 替换成 actor。actor 内部串行执行,访问共享状态时无需手动加锁。只要不在 actor 方法内部引入 await 挂起点,就不会出现可重入导致的竞态问题。改造后的代码会简洁很多,而且 Swift 运行时可以感知到调用者与 actor 任务之间的等待关系,从而在需要时提升优先级。
actor SafeCounter {
private var value = 0
func increment() {
value += 1
}
func currentValue() -> Int {
value
}
}
func update() async {
let counter = SafeCounter()
await counter.increment()
let value = await counter.currentValue()
print(value)
}
如果某些场景必须保留锁语义,例如外部 C 库要求显式加锁,也可以把阻塞锁封装成异步锁。异步锁的核心思路是:获取不到锁时不阻塞线程,而是通过 withCheckedContinuation 把任务挂起,让出当前线程。这样既避免了线程池被占满,又让 Swift 调度器能够感知等待关系。下面是一个基于 actor 实现的简单异步锁:
actor AsyncLock {
private var isLocked = false
private var waiters: [CheckedContinuation<Void, Never>] = []
func lock() async {
if !isLocked {
isLocked = true
} else {
await withCheckedContinuation { continuation in
waiters.append(continuation)
}
}
}
func unlock() {
if waiters.isEmpty {
isLocked = false
} else {
waiters.removeFirst().resume()
}
}
}
使用时只需要在临界区前后调用 await asyncLock.lock() 和 await asyncLock.unlock()。获取不到锁的任务会挂起而不是阻塞线程,其他任务可以继续使用协作线程。这样高优先级任务等待锁时,不会把整个线程池拖垮,也为后续的取消和超时处理提供了空间。
let asyncLock = AsyncLock()
func asyncCriticalSection() async {
await asyncLock.lock()
// 直接操作受保护的状态
await asyncLock.unlock()
}
另一个重要原则是缩小锁粒度,并且绝不在锁内等待任何外部信号、信号量、异步调用或 TaskGroup 完成。锁只应该保护不可压缩的短内存写入。任何涉及 IO、网络、信号量或跨任务协同的操作,都要移到锁外执行。即便高优先级任务出现等待,也不会形成永久循环。这条规则听上去简单,但在维护旧代码时经常被破坏,因为锁的边界可能随着业务逻辑扩展而变模糊。
任务取消与超时保护
并不是所有阻塞锁都能从代码结构上完全消除,尤其是遗留代码或底层同步原语已经写死的情况。为了避免单个死锁拖垮整个功能,可以在 TaskGroup 外层加上超时和取消机制。当高优先级父任务等待超过阈值,就取消所有子任务,并释放相关资源。下面是一个带超时的 TaskGroup 例子:
func runWithTimeout() async throws {
try await withThrowingTaskGroup(of: Int.self) { group in
group.addTask {
return try await performCriticalSection()
}
try await Task.sleep(nanoseconds: 2_000_000_000)
group.cancelAll()
// 注意:如果子任务阻塞在 NSLock.lock() 中,cancelAll 无法中断它
}
}
这里必须强调,cancelAll() 只能取消尚未进入阻塞状态的协作式任务。如果子任务已经阻塞在 NSLock.lock() 或 semaphore.wait() 中,取消标志不会自动中断阻塞调用。因此,超时保护只是最后一道防线,不能替代锁结构改造。更稳妥的做法是使用带超时的锁获取方法,例如 NSLock 的 lock(before:):
let acquired = lock.lock(before: Date().addingTimeInterval(1.0))
if acquired {
defer { lock.unlock() }
// 临界区
} else {
print("获取锁超时")
}
这样即使锁被长期持有,子任务也不会无限等待,可以在超时后返回错误或执行降级逻辑。配合 TaskGroup 的取消检查,整个高优先级分支就有了退出路径。
Swift 的结构化并发在很多场景下能让异步代码更安全,但一旦把阻塞锁、信号量这类非协作式同步原语混入 TaskGroup,优先级传播就可能变成双刃剑。高优先级任务可能会大量占用线程,低优先级持锁任务得不到调度,或者锁内等待外部信号形成循环等待。理解这些边界之后,优先使用 actor 隔离状态、实现异步锁、收缩锁粒度,并辅以超时取消,是避免这类死锁最实际的方案。

