在Swift并发编程中,编译器最大的价值之一就是把原本靠运行时运气发现的数据竞争,提前到编译期暴露出来。Sendable协议就是这个机制的核心:一个类型声明自己可以被安全地跨并发域传递。当我们在泛型算法中使用类型参数时,如果不对参数做任何约束,编译器无法确认传入的类型是否线程安全,此时要么报错,要么逼迫你用非泛型的方式重写。本文将围绕泛型类型参数上的Sendable约束,结合并行排序、并行搜索和并行图算法三个典型场景,完整讲解如何写出既通用又并发安全的算法实现。

Sendable协议的本质与泛型约束的意义
Sendable是一个空协议,本身没有任何方法要求。它的作用不是提供功能,而是提供证明:证明这个类型的实例在多个并发域之间传递时不会引发数据竞争。对于struct、enum这类值类型,只要所有存储属性都是Sendable的,编译器会自动推断其为Sendable;对于class这类引用类型,则必须显式声明遵守,并且通常要求内部状态有适当的同步保护,或者使用let常量存储不可变数据。
在泛型上下文中,Sendable约束的价值就体现出来了。考虑一个通用的并行处理函数,它接收任意类型的元素并分发到多个Task中处理。如果类型参数T没有Sendable约束,编译器会拒绝将T的值传入Task闭包,因为无法保证T内部没有引用类型共享状态。加上约束后,编译器就有了静态证据,代码可以顺利通过检查。
// 泛型参数加Sendable约束,元素才能安全地进入并发的Task
func parallelProcess<T: Sendable>(
_ items: [T],
workerCount: Int = 4,
transform: @Sendable (T) -> T
) async -> [T] {
guard !items.isEmpty else { return [] }
// 按worker数量切分数据块,每个块独立处理
let chunkSize = (items.count + workerCount - 1) / workerCount
let chunks = stride(from: 0, to: items.count, by: chunkSize)
.map { Array(items[$0 ..< min($0 + chunkSize, items.count)]) }
return await withTaskGroup(of: [T].self) { group in
for chunk in chunks {
group.addTask {
// chunk是值拷贝,transform被标记为@Sendable
chunk.map(transform)
}
}
var results: [T] = []
for await partial in group {
results.append(contentsOf: partial)
}
return results
}
}
注意上面代码中transform参数被标记为@Sendable。闭包的Sendable检查同样严格:如果闭包捕获了可变的引用类型变量,编译器会直接报错。这种约束看起来繁琐,但它把问题从线上事故变成了编辑器里的红色波浪线,修复成本完全不在一个量级。
并行排序:Sendable约束下的归并排序实现
并行归并排序是演示Sendable约束的经典场景。算法的核心是递归地切分数组,在TaskGroup中并行排序各段,最后归并结果。数组本身是值类型,切分时发生拷贝,天然没有共享状态;需要处理的难点在于比较函数——它必须跨Task传递,因此需要@Sendable标记,而元素类型也需要Sendable约束。
func parallelMergeSort<T: Sendable & Comparable>(
_ array: [T],
threshold: Int = 1024
) async -> [T] {
// 小数组直接用系统排序,避免任务调度开销超过收益
if array.count <= threshold {
return array.sorted()
}
let mid = array.count / 2
let leftHalf = Array(array[0 ..< mid])
let rightHalf = Array(array[mid...])
async let sortedLeft = parallelMergeSort(leftHalf, threshold: threshold)
async let sortedRight = parallelMergeSort(rightHalf, threshold: threshold)
let (left, right) = await (sortedLeft, sortedRight)
return merge(left, right)
}
private func merge<T: Comparable>(_ a: [T], _ b: [T]) -> [T] {
var result: [T] = []
result.reserveCapacity(a.count + b.count)
var i = 0, j = 0
while i < a.count && j < b.count {
if a[i] <= b[j] { result.append(a[i]); i += 1 }
else { result.append(b[j]); j += 1 }
}
result.append(contentsOf: a[i...])
result.append(contentsOf: b[j...])
return result
}
这里用async let启动两个子任务,它会隐式创建结构化并发任务,并且要求捕获的值满足Sendable。如果T没有Sendable约束,编译器会在async let这一行给出明确报错。阈值参数的设计也很重要:并行化不是免费的,任务创建和调度有开销,小数组串行排序反而更快,实际项目中通常把阈值设置在一千到几千个元素之间,可以通过性能测试调整。
另一个值得注意的点是错误处理。如果比较逻辑可能抛出异常,函数签名需要改成async throws,TaskGroup会自动在第一个子任务抛错时取消其余任务,这正是结构化并发配合Sendable保证数据一致性的体现——不会出现一半任务完成、一半失败导致的中间状态泄漏。
并行搜索与并行图算法中的数据一致性
并行搜索相对简单,典型做法是把大数组切成若干段,每段交给一个Task查找,谁先找到就整体取消。这里Sendable约束保证了两件事:一是待搜索的元素可以安全传入各Task;二是结果类型(比如找到的索引)可以安全地传回汇总。
func parallelFirstIndex<T: Sendable & Equatable>(
of target: T, in array: [T]
) async -> Int? {
let chunk = 4096
return await withTaskGroup(of: (Int, Int?).self) { group in
var start = 0
while start < array.count {
let segment = Array(array[start ..< min(start + chunk, array.count)])
let offset = start
group.addTask {
// 返回段内索引加偏移量,元组由两个Sendable类型构成,整体也是Sendable
(offset, segment.firstIndex(of: target))
}
start += chunk
}
var found: Int? = nil
for await (_, idx) in group where idx != nil {
found = idx!
group.cancelAll() // 找到后取消其余搜索任务
break
}
return found
}
}
图算法的挑战更大,因为图通常用邻接表表示,往往涉及引用类型或者需要跨Task共享的可变状态。策略有两条:一是把图转换为不可变的值类型结构(比如[Int: [Int]]字典加Sendable约束的节点数据),各Task只读不写,结果统一汇总;二是确实需要共享可变状态时,用actor封装。下面是一个并行广度优先搜索的骨架,访问标记集中在一个actor中管理:
actor VisitTracker {
private var visited: Set<Int> = []
func tryVisit(_ node: Int) -> Bool {
guard !visited.contains(node) else { return false }
visited.insert(node)
return true
}
}
func parallelBFS(graph: [Int: [Int]], start: Int) async -> [Int] {
let tracker = VisitTracker() // actor引用本身是Sendable
_ = await tracker.tryVisit(start)
var frontier = [start]
var order = [start]
while !frontier.isEmpty {
await withTaskGroup(of: [Int].self) { group in
for node in frontier {
group.addTask {
var nextLevel: [Int] = []
// graph是let值类型字典,跨Task只读访问安全
for neighbor in graph[node] ?? [] {
if await tracker.tryVisit(neighbor) {
nextLevel.append(neighbor)
}
}
return nextLevel
}
}
frontier = []
for await level in group {
frontier.append(contentsOf: level)
}
}
order.append(contentsOf: frontier)
}
return order
}
这段代码里有一个容易被忽略的细节:actor的引用是Sendable的,所以可以安全地传入各个Task;但调用actor方法必须await,这意味着热路径上会有同步开销。对于访问标记这种高频操作,可以把已确认的节点集合按层快照传递,减少actor调用次数。这种取舍在并行图算法中很常见——Sendable约束划定了安全边界,性能优化则要在边界内寻找空间。
常见报错与排查思路
实践中最常见的报错是“non-sendable type passed into task”。排查顺序建议是:先看类型本身,struct和enum会自动推断,除非内部有非Sendable的引用类型属性;再看class,是否需要改成不可变设计或者用actor替代;最后看闭包捕获,@Sendable闭包不能捕获可变局部变量,必要时把变量改成let或者在进入并发域前完成拷贝。
有一种逃生通道是@unchecked Sendable,它告诉编译器“我自己保证线程安全,别检查了”。这个能力应该谨慎使用,仅限于你确实实现了内部锁保护、且经过充分测试的类型,比如封装了NSLock或者DispatchQueue的遗留代码。滥用@unchecked Sendable等于把编译器的安全检查全部关闭,一旦内部实现有疏漏,数据竞争会重新回到运行时才暴露,得不偿失。
最后总结一下实践原则:优先用值类型和let不可变数据满足Sendable推断;泛型算法统一在类型参数上加Sendable约束,让调用方获得编译期保障;共享可变状态收敛到actor中;用性能测试决定并行度的阈值。这样写出的并发算法,安全性是编译器验证过的事实,而不是开发者口头承诺的假设。
Swift Sendable并发算法泛型约束修改时间:2026-09-10 18:29:00