背景:Swift 6 的"并发安全"红线
在 Swift 5 时代,开启 Strict Concurrency 后,以下代码会报错:
swift
class MyClass { var count = 0 }
func foo() {
let obj = MyClass()
Task { // 🚨 Capture of 'obj' with non-sendable type 'MyClass'
obj.count += 1
}
}
Swift5时 Task的初始化方法定义
swift
public init(
priority: TaskPriority? = nil,
operation: @Sendable @escaping () async -> Success
)
原因:Task
的 operation
被标注为 @Sendable
,意味着闭包只能捕获 Sendable
的值。
Swift 6 变化:同样的代码,不再报错。因为 Task
的签名被悄悄改成了 sending
闭包。
Swift6之后的Task初始化定义
swift
public init(name: String? = nil,
priority: TaskPriority? = nil,
operation: sending @escaping @isolated(any) () async -> Success)
Sendable
与 sending
:一字之差,天壤之别
比较项 | Sendable | sending |
---|---|---|
特性 | 类型(struct / class / actor) | 值(实例、闭包) |
作用对象 | 类型(struct / class / actor) | 值(实例、闭包) |
关键词位置 | 类型声明处 | 参数/变量前 |
编译器要求 | "永远线程安全" | "转移后不再使用"即可 |
典型场景 | 全局共享、长期存活 | 一次性移交、临时捕获 |
示例 | final class Box: Sendable { ... } |
func f(_: sending () -> Void) |
一句话记忆:Sendable
是"终身荣誉",sending
是"一次性通行证"。
sending
解决了什么痛点?
痛点回顾
@Sendable
闭包要求所有捕获变量都线程安全,导致大量临时对象被迫做成 Sendable
,甚至强行加锁,过度设计。
sending
的思路
编译器不再要求对象本身线程安全,只保证:
- 该值移交给闭包后,原作用域不再访问;
- 从而不会出现数据竞争。
合法 vs 非法 示例
✅ 合法:一次性移交
swift
func foo() async {
let obj = MyClass()
Task { // obj 被"sending"进任务
obj.count += 1 // 只在任务内部使用
}
// 下面再访问 obj 会编译错误
}
❌ 非法:移交后仍访问
swift
func foo() async {
let obj = MyClass()
Task { // Sending value of non-Sendable type '() async -> ()' risks causing data races
obj.count += 1
}
print(obj.count)
}
编译器会精准指出:later accesses could race。
自定义 sending
函数------语法与注意点
swift
class ImageCache {
func warmUp() {
}
}
/// 自定义与 Task 相同能力的"发送"函数
func runLater(_ body: sending @escaping () async -> Void) {
Task { await body() }
}
// 使用
func demo() async {
let cache = ImageCache() // 非 Sendable
runLater {
await cache.warmUp() // 安全:cache 不再被外部持有
}
}
语法小结:
sending
写在参数类型最前面,与@escaping
位置相同。- 只能用于值(不能修饰类名)。
- 编译器会插入隐形转移检查(类似 Rust 的 move 语义)。
与 @Sendable
的对比实验
实验项 | @Sendable 闭包 |
sending 闭包 |
---|---|---|
捕获非 Sendable 对象 | ❌ 编译失败 | ✅ 允许 |
捕获后外部再访问 | ❌ 编译失败 | ❌ 编译失败 |
跨并发域传递 | ✅ 安全 | ✅ 安全(靠"一次性") |
长期全局共享 | ✅ 适合 | ❌ 不适合 |
结论:@Sendable
负责"终身安全",sending
负责"临时过户"。
实战场景:什么时候该自己写 sending
?
-
异步回调框架
你的框架提供
func deferWork(_: sending () async -> Void)
,让用户把任意对象塞进来,而无需强迫它们做成Sendable
。 -
并行 Map/Reduce 工具
把大块非线程安全数据切成临时片,通过
sending
交给子任务,主线程后续不再触碰。 -
与遗留代码的接缝
旧代码里大量
NSObject
子类无法改成Sendable
,用sending
包装一次性异步迁移,渐进式现代化。
注意事项 & 最佳实践
-
不要返回
sending
值目前
sending
只能用于参数,不能作为返回类型,防止"转移"语义被滥用。 -
不要捕获
sending
变量再逃逸编译器会阻止你把
sending
闭包再次存到全局变量或属性,确保"生命周期唯一"。 -
单元测试多线程场景
即使编译器通过,也要写压力测试:
swift
(0..<1000).concurrentForEach { _ in
await foo() // 确保无数据竞争
}
-
与 actor 搭配更香
把
sending
当作"进入 actor 世界的门票":- 主线程生成临时对象 →
sending
交给 actor → 后续所有访问都在 actor 内部,零锁代码。
- 主线程生成临时对象 →
一张图总结(建议保存)
Sendable
├─ 终身线程安全
├─ 可被任意并发环境长期持有
└─ 实现代价高(锁、不可变、snapshot)
sending
├─ 一次性"过户"
├─ 编译器保证"原主人不再碰"
└─ 实现代价低,适合临时对象
写在最后
-
sending
不是@Sendable
的替代品,而是互补品:前者解决"临时搬迁",后者解决"长期共处"。
-
在 Swift 6 的并发宇宙里,数据竞争被提前到编译期。
理解并善用
sending
,可以让你少写 80 % 的锁,少改 80 % 的旧代码,同时不牺牲线程安全。