Swift结构化并发的TaskGroup适合并发执行一批相互独立且可以汇总的异步任务。addTask在提交闭包时会强制做Sendable检查,目的是保证闭包捕获的每个值都能安全跨越并发边界。这个机制在接收具体类型时很好用,例如Int、String、以及由值类型组成的结构体都能自动通过检查;可一旦项目里用协议做抽象,接收any Worker这样的存在类型,编译器就没法判断底层实现到底是不是可变class,任务提交就很容易被并发安全检查拦截。要解决这个问题,不能简单关闭检查,而应该把Sendable也写进协议组合里,让类型系统同时确认业务能力和并发安全性。

一、Sendable与协议组合:把并发安全写进类型约束
Sendable是Swift并发模型中的标记协议,本身不要求实现任何方法。它的作用是告诉编译器:遵循该协议的值可以在不同并发域之间传递,而不会引发数据竞争。Int、Double、String以及字段全部都是Sendable的结构体会自动满足Sendable。class类型的判定则严苛得多:只有final class且内部属性不可变,或者使用锁保护可变状态并手动声明@unchecked Sendable时,编译器才会认为它具备并发安全条件。
协议本身并不自动继承Sendable。定义一个Worker协议时,默认情况下任何类型都可以遵循它,包括带共享可变状态的class。因此一个对象的静态类型如果只是any Worker,编译器看到的信息只有“这个对象能做process操作”,却不知道它跨线程传递是否安全。协议组合恰好可以补上这一层约束。Swift 5.7引入any关键字后,存在类型可以写成any Worker & Sendable,意思是:对象既要满足Worker的能力,又要满足Sendable的并发安全要求。这个&在协议组合里表示多个约束同时生效。
import Foundation
protocol Worker {
func process(_ input: Int) async -> Int
}
struct AddWorker: Worker, Sendable {
let offset: Int
func process(_ input: Int) async -> Int {
try? await Task.sleep(nanoseconds: 100_000_000)
return input + offset
}
}
class MutableWorker: Worker {
var history: [Int] = []
func process(_ input: Int) async -> Int {
history.append(input)
return input * 2
}
}
上面两个类型中,AddWorker是值类型且只读,天然满足Sendable,因此可用于任何带Sendable约束的协议组合;MutableWorker是class且内部有可变数组history,没有遵循Sendable,也只能出现在无并发安全要求的any Worker场景中。当TaskGroup出现时,这个差异会直接决定代码能否通过编译。
二、TaskGroup的捕获检查:为什么any Worker不够安全
withTaskGroup会创建一个任务组,内部通过group.addTask不断添加子任务。addTask的闭包被标记为@Sendable,表示闭包体会在独立任务中执行。Swift编译器会对@Sendable闭包捕获的所有外部变量做Sendable检查。如果捕获到非Sendable类型,在Swift 5语言模式下通常会产生并发安全警告,在Swift 6模式或开启strict-concurrency=complete后,会直接升级为错误。
下面这段代码把参数声明成[any Worker],并试图在任务组里捕获worker。直觉上看,worker只是执行process方法,似乎没有共享可变状态,但编译器不这么认为:any Worker没有Sendable约束,底层完全可能是前面定义的MutableWorker。于是addTask会提示捕获了非Sendable类型的worker。
func runWithoutSendable(workers: [any Worker], values: [Int]) async -> [Int] {
await withTaskGroup(of: Int.self) { group in
for (index, worker) in workers.enumerated() {
group.addTask {
return await worker.process(values[index])
}
}
var results: [Int] = []
for await result in group {
results.append(result)
}
return results
}
}
编译器提示:capture of 'worker' with non-sendable type 'any Worker' in a '@Sendable' closure。
这段代码里values是[Int],Int和Array都是Sendable,所以捕获values没有问题。唯一出问题的是worker。修复思路有两种:一是放弃协议抽象,改成具体类型[AddWorker],但这样会牺牲扩展性;二是给协议类型追加Sendable约束,让编译器在进入TaskGroup之前就完成并发安全过滤。第二种方式既保留协议抽象,又不会丢失类型安全,是更适合实际工程的选择。
三、使用any Worker & Sendable提交任务并保持类型安全
把参数类型改成[any Worker & Sendable]之后,数组本身不仅约束了元素具备Worker能力,还要求每个元素都满足Sendable。这个检查发生在函数调用处,而不是等到addTask闭包内部才报错。也就是说,如果你试图把MutableWorker实例放进这个数组,编译器会直接拒绝,因为它没有遵循Sendable。这样问题被提前暴露在API边界。
func runWithSendable(workers: [any Worker & Sendable], values: [Int]) async -> [Int] {
await withTaskGroup(of: Int.self) { group in
for (index, worker) in workers.enumerated() {
group.addTask {
return await worker.process(values[index])
}
}
var results: [Int] = []
for await result in group {
results.append(result)
}
return results
}
}
这样修改后,addTask闭包捕获的worker静态类型是any Worker & Sendable,编译器能确认它满足@Sendable闭包的捕获要求,警告或错误会消失。调用方可以继续传入AddWorker,也可以传入其他同时遵循Worker和Sendable的类型;而MutableWorker如果不做并发安全改造,就无法通过参数校验。这不是限制灵活性,而是把并发风险挡在任务组之外。
TaskGroup返回结果的顺序并不是固定的,因为每个子任务执行耗时可能不同。如果业务需要按原始输入顺序汇总,可以在子任务中同时返回索引,例如把addTask的结果类型改成(Int, Int),收集完成后再根据索引排序。错误处理则可以使用withThrowingTaskGroup,并在子任务中throws以及使用try await消费结果。无论哪种变体,只要参数位置保持any Worker & Sendable这类协议组合,Sendable检查都会持续生效。
需要特别留意的是,@unchecked Sendable并不是一个通用的解决方案。它会显式告诉编译器“这个类型可以安全传递”,从而绕过所有检查,如果内部仍然存在共享可变状态,反而会制造更难定位的数据竞争。正确顺序是先通过值类型或不可变引用来满足Sendable,再在必要的位置用协议组合固化约束。这样TaskGroup中提交的每个任务参数,都能同时获得类型安全和并发安全两重保障。
Swift SendableTaskGroup协议组合修改时间:2026-09-22 19:13:24