Swift 6: Sendable、@unchecked Sendable、@Sendable、sending and nonsending

发表于

Swift 的并发模型引入了众多关键字,其中一些在命名和用途上颇为相似,容易让开发者感到困惑。本文将对 Swift 并发中与跨隔离域传递相关的几个关键字:Sendable@unchecked Sendable@Sendablesendingnonsending 进行梳理,帮助大家理解它们各自的作用和使用场景。

隔离域(Isolation Domain)

在探讨这些关键字之前,我们需要先了解 Swift 并发模型中的隔离域概念。

数据竞争一直是并发编程中的棘手问题,稍有不慎就会导致数据错误甚至应用崩溃。为了从根本上解决这个问题,Swift 在构建新的并发模型时引入了隔离域的概念。开发者可以通过 actor@MainActor 或其他自定义的 Global Actor 来声明一个隔离域。每个隔离域实例都会通过其 Executor 来维护和管理一个串行队列,保证同一时刻只有一个任务在访问受保护的状态,从而将数据竞争从运行时错误转变为编译时错误。

正因为有了明确的隔离域边界,开发者就需要为数据类型或接收端添加特定的标注,让编译器能够检查数据是否可以在不同的隔离域之间安全传递。因此,一系列包含 “Send” 语义的关键字和协议应运而生。

Sendable

值类型在 Swift 中被广泛使用,其复制语义天然适合在不同的隔离域之间传递。但由于自定义类型中可能包含不能安全传递的成员,因此我们可以通过添加 Sendable 标注,让编译器帮助验证其安全性。虽然 Sendable 以协议形式出现,但本质上它是一个编译时契约,声明某个类型可以安全地跨隔离域传递,编译器会验证这个声明的真实性。

Swift
// Sendable 是一个标记协议(marker protocol)
protocol Sendable { }

// 它告诉编译器:"这个类型跨隔离域传递是安全的"

对于明显符合 Sendable 特性的类型,编译器会自动推断,即使不显式添加 Sendable 标注,编译器仍会确保其满足跨隔离域安全传递的要求:

Swift
func getSendable<T:Sendable>(value:T) {}

struct People { // 值类型
    var name:String // 属性都是 Sendable
    var age:Int
}

let people = People(name: "fat", age: 10)
getSendable(value: people) // ✅ 自动推断为 Sendable

需要注意的是,在 Swift 6 中,某些之前可以自动推断的场景不再被支持:

Swift
final class NoNeedMarkSendable {
    let name = "fat"
}

let a = NoNeedMarkSendable()
getSendable(value: a) // Swift 6 之前或没有开启严格并发检查 ✅
getSendable(value: a) // Swift 6 之后 ❌: Type 'NoNeedMarkSendable' does not conform to the 'Sendable' protocol

因此,为了确保代码的健壮性,建议开发者显式添加 Sendable 标注。

另外,如果一个类型具有明确的隔离域(如 actor 类型或标记为 @MainActor 的类),它本身就被视为 Sendable,因为其内部状态已由 Swift 并发模型提供隔离保障:

Swift
@MainActor
final class NoNeedMarkSendable {
    let name = "fat"
}

Task { @MainActor in
  let a = NoNeedMarkSendable()
  getSendable(value: a) // ✅ @MainActor 类型自动 Sendable
}

Sendable 通常作为泛型约束使用,在编译时确保只接受可安全传递的类型。

@unchecked Sendable

在某些场景中,特别是处理遗留代码时,我们可能已经通过其他机制(如锁、队列等)确保了线程安全,但编译器无法理解这些实现。此时,开发者可以通过 @unchecked Sendable 向编译器保证类型的安全性,从而绕过编译器的自动检查:

Swift
final class ThreadSafeCache: @unchecked Sendable {
    private var cache: [String: Sendable] = [:]
    // 虽然有可变状态,但通过队列保证了线程安全
    private let queue = DispatchQueue(label: "cache", attributes: .concurrent)
    
    func get(_ key: String) -> Sendable? {
        queue.sync {
            cache[key]
        }
    }
    
    func set(_ key: String, value: Sendable) {
        queue.async(flags: .barrier) {
            self.cache[key] = value
        }
    }
}

需要警惕的是,一些开发者为了让代码通过编译会滥用 @unchecked Sendable,这会使严格并发检查失去意义。

从 Swift 6 开始,Synchronization 框架提供的 Mutex 类型可以实现真正的 Sendable,无需再依赖 @unchecked

Swift
import Synchronization

final class ThreadSafeCache: Sendable {
    private let cache = Mutex<[String: Sendable]>([:])

    func get(_ key: String) -> Sendable? {
        cache.withLock {
            $0[key]
        }
    }

    func set(_ key: String, value: Sendable) {
        cache.withLock {
            $0[key] = value
        }
    }
}

@Sendable

由于 Sendable 协议只能用于类型声明,Swift 为闭包提供了专门的属性 @Sendable。它表示闭包可以安全地跨隔离域传递:

Swift
func getSendableClosure(perform: @escaping @Sendable () -> Void ) {}

编译器会在编译阶段检查闭包的安全性:

Swift
final class NoNeedMarkSendable {
    let name = "fat"
}

let a = NoNeedMarkSendable()
getSendableClosure(perform: { a }) // ❌ Capture of 'a' with non-Sendable type 'NoNeedMarkSendable' in a '@Sendable' closure

getSendableClosure(perform: {
   let a = NoNeedMarkSendable() // ✅ 在闭包内部创建,不存在跨隔离域传递
})

sending

@Sendable 闭包的限制有时过于严格。考虑以下场景:

Swift
Task {
    let nonSendable = NonSendableClass()
    getSendableClosure {
        nonSendable.value += 1 // Capture of 'nonSendable' with non-Sendable type 'NonSendableClass' in a '@Sendable' closure
    }
    // 实际上这里是安全的,因为 nonSendable 不会在其他地方被修改
}

Swift 6 引入了 sending 参数修饰符来解决这个问题:

Swift
// 原来的
func getSendableClosure(perform: @escaping @Sendable  () -> Void) {}

// 新的
func getSendableClosure(sending perform: @escaping  () -> Void) {}

Task {
    let nonSendable = NonSendableClass()
    getSendableClosure {
        nonSendable.value += 1 // ✅ 换成 `sending` 后
    }
}

sending 并非像 @unchecked Sendable 一样,会绕过 Swift 编译器的检查。如果出现了不安全的使用场景,编译器仍然会提醒我们:

Swift
actor MyActor {
    var storage: NonSendableClass?
    
    // 使用 sending 允许接收非 Sendable 参数
    func store(_ object: sending NonSendableClass) {
        storage = object 
    }
}

Task {
    let obj = NonSendableClass()
    let myActor = MyActor()
    await myActor.store(obj) // ✅ 所有权转移给 actor
    // obj.value += 1 // ❌ 'obj' used after being passed as a 'sending' parameter
}

这是因为,sending 的核心概念是“转移所有权”,尽管编译器不再对 Sendable 进行检查,但是会对所有权进行检查。在转移后,我们不能在其他地方再使用已转移的值!

因此,sending 更适合的场景是:

  • 迁移非 Sendable 的遗留代码
  • 实现构建者模式
  • 任务结果传递(Swift 6 中,Task 的实现已从 @Sendable 改为 sending

@Sendable 是类型安全地“声明线程安全”,而 sending 是所有权语义上的“保证单一使用权”,二者并非互斥,而是适用于不同上下文。

nonsending

nonsending 与前面的关键字在语义上有本质区别。它与 nonisolated 配合使用:nonisolated(nonsending),表示异步方法应该继承调用者的隔离域,而不是脱离当前隔离域运行。

这是 Swift 6.2 对 nonisolated 行为的重要调整。在 Swift 6.2 之前:

Swift
@MainActor
class RunInMainActor {    
    var name: String = "example"
    
    nonisolated func processData() async {
        // Swift 6.2 之前:总是脱离 MainActor 运行(非主线程)
    }
}

使用 nonisolated(nonsending) 后:

Swift
@MainActor
class RunInMainActor {    
    var name: String = "example"
    
    nonisolated(nonsending) func processData() async {
        // Swift 6.2:继承调用者的隔离域
        // 从 MainActor 调用时运行在主线程
        // 从其他 actor 调用时运行在对应的隔离域
    }
}

开发者还可以通过启用 NonisolatedNonsendingByDefault 标志使该行为成为默认设置。

关于 nonisolated(nonsending) 和 Swift 6.2 的更多变化,请参阅 Default Actor Isolation:好初衷带来的新问题

总结

🔑 关键字🧭 用途🎯 典型使用场景🛡️ 编译器行为 安全保证方式
Sendable类型标记跨隔离域传递类型✅ 编译器验证类型是否线程安全✅ 自动或显式声明
@unchecked Sendable跳过类型验证遗留代码、手动保障线程安全⚠️ 绕过检查,完全依赖开发者判断⚠️ 依赖开发者保证
@Sendable闭包属性标记跨隔离域传递闭包✅ 编译器检查捕获值是否 Sendable✅ 自动检查
sending参数修饰所有权转移、构建器模式🚫 不检查 Sendable,但禁止再次使用🚫 所有权语义(防止误用)
nonsending隔离继承修饰符保持调用者隔离域,适配异步调用✅ 控制运行时隔离上下文✅ 行为由调用者上下文决定

诚然,Swift 并发模型中众多的关键字增加了学习曲线的陡峭度。但理解这些概念对于编写安全的并发代码至关重要。即使某些关键字在日常开发中使用频率不高,掌握它们的含义也能帮助我们在遇到问题时快速定位和解决。

公告: 又到了每年休暑假 ⛱️ 的时候,接下来博客文章将会停更 4-5 周,期间《肘子的 Swift 周报》仍正常更新。

"加入我们的 Discord 社区,与超过 2000 名苹果生态的中文开发者一起交流!"

每周精选 Swift 与 SwiftUI 精华!