在 Swift 并发模型中,TaskGroup 用来聚合一组并发的子任务,父任务会等待所有子任务完成后才继续执行。很多人会以为 TaskGroup 只是把闭包扔到一个并行队列里,但实际上它的调度行为受任务优先级影响很大。问题是,当高优先级子任务长时间占用执行资源时,低优先级子任务可能迟迟得不到运行机会,形成优先级饥饿。要解决这个问题,需要先弄清楚优先级在 TaskGroup 中是如何传播的。

TaskGroup 的优先级传播规则
Swift 的 Task 在创建时默认继承当前任务的优先级,除非显式传入 TaskPriority 参数。TaskGroup.addTask 同样遵循这一规则。也就是说,如果父任务运行在 .high 优先级,并且调用 addTask 时没有指定 priority,那么子任务也会以 .high 优先级被创建。这个默认行为看起来合理,但在实际使用中容易让高优先级任务数量失控。
更关键的是,TaskGroup 还会参与优先级反转的临时调整。当一个高优先级任务等待一个低优先级任务的结果时,系统可能临时提升低优先级任务的优先级,以便让高优先级任务尽快解除等待。这种机制在简单依赖关系中很有用,但在 TaskGroup 内多个子任务互相竞争时,调整效果并不稳定。例如一个父任务创建了十个高优先级子任务和一个低优先级子任务,父任务最终需要等待所有子任务完成,那么低优先级子任务几乎得不到临时提升,因为高优先级子任务已经占据了大量线程。
下面这段代码展示了默认传播的情况,所有子任务都直接继承父任务的高优先级:
func loadAll(urls: [URL]) async -> [Data] {
await withTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask(priority: .high) {
await fetch(url)
}
}
var collected: [Data] = []
for await data in group {
collected.append(data)
}
return collected
}
}
这种方式在任务数量较少时没有问题,但当 urls 数量很大,或者 fetch 内部包含大量计算时,线程池会被高优先级任务占满,低优先级任务就容易被饿死。
饥饿是如何发生的:线程池竞争与协作式让出
Swift 并发运行时使用一个有限的协作式线程池来执行任务,线程数量通常与 CPU 核心数相关。调度器会优先让高优先级任务获得可用线程。如果一个高优先级任务内部只包含同步计算或者没有挂起点的 IO 操作,它会一直占用线程,直到任务结束或遇到显式的 await 挂起点。
TaskGroup 中的子任务之间并没有自动的时间片轮转。协作式调度意味着任务只有主动让出,其他任务才可能获得执行机会。如果高优先级子任务都是 CPU 密集型的,它们会持续占用线程,低优先级子任务虽然已经创建并进入调度队列,但调度器只能在高优先级任务释放线程后才能安排它们。这就形成了典型的优先级饥饿。
更隐蔽的情况是,高优先级任务在短时间内不断产生新的高优先级子任务。父任务可能因为等待子任务而处于挂起状态,但高优先级子任务的持续涌入会让线程池始终处于高优先级任务的竞争之中,低优先级任务被无限延后。即使运行时试图进行优先级提升,也很难改变整体资源分配的倾斜。
下面的示例中,高优先级子任务执行密集计算,且没有挂起点,低优先级子任务只能一直等待:
func mixedPriorityWork() async {
await withTaskGroup(of: Int.self) { group in
group.addTask(priority: .high) {
var sum = 0
for i in 0..<1_000_000_000 {
sum &+= i
}
return sum
}
group.addTask(priority: .low) {
await someAsyncWork()
return 0
}
for await value in group {
print(value)
}
}
}
这个例子中,如果线程池只有少数线程,高优先级任务内部几乎没有挂起点,someAsyncWork 可能很长时间都不会被调度执行。
解决方案一:拆分高优先级任务并插入挂起点
解决饥饿最直接的方法是让高优先级任务不要长时间独占线程。把大块计算拆分成多个小片段,并在每个片段之间调用 Task.yield() 或 Task.sleep(for:)。这样任务会主动让出执行上下文,调度器就有机会安排低优先级任务运行。
Task.yield() 是一个协作式挂起点,它不会真正把任务挂起较长时间,而是告诉调度器当前任务可以暂时让出线程。对于 CPU 密集型任务,通常每隔一定迭代次数调用一次,就能明显改善其他任务的响应性。Task.sleep 则会让任务真正挂起一段时间,适合需要降低执行频率的场景,但会引入额外延迟。
下面是对高优先级任务的改进,让它在计算过程中周期性让出线程:
func heavyComputeWithYield() async -> Int {
var sum = 0
for i in 0..<1_000_000_000 {
sum &+= i
if i % 50_000 == 0 {
await Task.yield()
}
}
return sum
}
这样修改后,高优先级任务不再连续占用线程,低优先级任务可以在挂起间隙被调度。需要注意的是,Task.yield() 只是提供机会,并不保证低优先级任务一定立即执行,因为调度器仍然会优先考虑高优先级任务。但它已经足够打破原先的持续占用状态。
另一个思路是降低高优先级任务的规模。如果一个任务确实需要处理大量数据,可以把它拆成多个较小的任务,让调度器有机会在任务之间插入其他优先级的执行。这样虽然增加了任务数量,但整体调度更加平衡。
解决方案二:显式指定子任务优先级与 Task.detached
如果饥饿问题的根源是某些子任务被错误地标成高优先级,那么最有效的办法是在 addTask 时显式指定合适的优先级。比如纯后台计算任务不应该使用 .high,而应使用 .utility 或 .background。这能减少高优先级任务对线程池的抢占,给低优先级任务留出空间。
有时我们希望某个子任务完全不受父任务优先级传播的影响。这时可以使用 Task.detached 创建一个独立任务,它会切断优先级继承和取消传播。在 TaskGroup 内部使用时,可以先添加一个子任务,然后在子任务内部再启动一个 detached 任务,并等待其结果。这样做可以避免高优先级父任务强制提升子任务的优先级。
下面的代码展示了如何在 TaskGroup 内部使用 Task.detached 来控制优先级:
await withTaskGroup(of: Void.self) { group in
group.addTask {
let handle = Task.detached(priority: .background) {
await heavyBackgroundWork()
}
await handle.value
}
}
这种方式虽然能有效隔离优先级,但有一个明显的代价:Task.detached 不会自动继承父任务的取消状态,也不会继承任务本地值。如果外部任务被取消,detached 任务不会自动停止,需要手动传递取消信号并检查 Task.isCancelled。因此只有在确实需要隔离优先级时才建议使用。
解决方案三:限制并发数量与分批提交
TaskGroup 本身没有提供直接的并发数限制,一次性添加大量子任务会加剧线程池竞争。通过分批提交子任务,可以让每一批的任务数量保持在合理范围内,避免高优先级任务在同一时间占满所有线程。
分批处理的基本思路是:维护一个批次大小,每次只创建固定数量的子任务,等待这些子任务全部完成后再创建下一批。这样即使某批任务都是高优先级,也不会同时占满线程池,其他优先级的任务可以在批次之间获得调度机会。
下面的代码展示了如何用 withTaskGroup 分批处理数据:
func processInBatches(items: [Item]) async {
let batchSize = 4
var index = 0
while index < items.count {
await withTaskGroup(of: Void.self) { group in
let end = min(index + batchSize, items.count)
for item in items[index..<end] {
group.addTask(priority: .medium) {
await process(item)
}
}
for await _ in group {}
}
index += batchSize
}
}
这种设计降低了高优先级任务对线程池的瞬时压力。根据实际任务类型,可以调整 batchSize 的大小。计算密集型任务可以设置较小的批次,IO 密集型任务可以设置较大的批次。虽然分批会损失一部分并行度,但换来了更稳定的调度表现。
如果需要更细粒度的并发控制,可以在 TaskGroup 外维护一个类似信号量的结构,或者使用 AsyncStream 与 TaskGroup 配合。不过这些方案实现复杂度较高,一般项目中使用分批提交已经足够解决常见的优先级饥饿问题。
结论
Swift TaskGroup 中的优先级饥饿问题,本质上是任务优先级传播、线程池资源有限以及协作式调度共同作用的结果。修复它并不需要抛弃 TaskGroup,而是要从任务拆分、优先级指定和并发数量控制几个方面入手。拆解长任务并提供挂起点,能打破高优先级任务的持续占用;显式指定优先级或使用 Task.detached,能减少不必要的优先级提升;分批提交子任务,则能防止线程池被瞬间占满。结合这些手段,可以让低优先级任务在合理时间内得到执行,并发结构也会更加公平。
Swift TaskGroup任务优先级优先级饥饿修改时间:2026-09-18 23:27:17