广告位 · 页头(后台-模块管理-新建模块后替换此区域)

Swift TaskGroup 中高优先级任务等待低优先级任务持锁为什么会死锁?

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

Swift TaskGroup 中高优先级任务等待低优先级任务持锁为什么会死锁?

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 隔离状态、实现异步锁、收缩锁粒度,并辅以超时取消,是避免这类死锁最实际的方案。

广告位 · 文章底部(后台-模块管理替换此区域)