【译】SE-0302 Sendable 和 @Sendable 闭包

原文链接:SE-0302 Sendable and @Sendable closures

简介

Swift Concurrency 的其中一个关键目标就是“提供一种机制来隔离并发程序中的状态,以消除 data races”。这样的机制将会是通用编程语言的一次重大进步 – 大多数语言提供的并发编程抽象会使程序员面临范围宽广的 bug,包括 race conditions,死锁和其它问题。

这个提案里描述了一种方式,去解决这个领域面临的其中一个问题 – 如何对结构化并发和 Actor 消息传递的值进行类型检查。因此,这是一个统一的理论,它提供了一些基本的类型系统机制,使它们既保障安全又能很好地协同工作。

这种实现方式会提供一个名为 Sendable 的 marker 协议,以及一个可应用于函数的 @Sendable 注解。

背景故事

程序中的每个 actor 实例和结构化并发任务都代表着一个”单线程性的孤岛”(island of single threaded-ness),这使得它们成为一个自然的同步点,持有一系列可变的状态。这些任务与其他任务并行进行计算,但我们希望这样一个系统中的绝大多数代码都是非同步的 – 建立在 actor 的逻辑独立性之上,将其邮箱作为数据的同步点。

因此,一个关键问题是:”我们何时以及如何允许数据在并发域之间传输?” 例如,这种转移发生在 actor 方法调用的参数和返回值中,以及由结构化并发创建的任务中。

Swift Concurrency 的功能渴望建立一个安全而强大的编程模型。我们希望实现这三件事:

  1. 我们希望 Swift 程序员在试图 跨并发域传递 可能引入不受保护的共享可变状态 时得到一个静态的编译器错误。
  2. 我们希望高阶程序员能够实现包含复杂技术的库(例如 ConcurrentHashTable),并且能够让其他人以一种安全的方式去使用。
  3. 我们需要拥抱现有的世界,其中包含了很多在设计时没有考虑到 Swift 并发模型的代码。我们需要一个平滑和渐进的迁移策略。

在我们进入提议的解决方案之前,先看一下我们希望能够建模的一些常见案例,以及每个案例中包含的改进机会和挑战。这将有助于推理出我们需要覆盖的设计空间。

💖 Swift + 值语义

我们需要支持的第一种类型是像 Integer 这样的简单值。这些类型可以简单地跨并发域传递,因为它们不包含指针。

除此之外,Swift 还非常强调具有值语义的类型,这些类型可以安全地跨并发边界传输。除了 class 之外,Swift 的类型组合机制在其元素提供值语义时也提供值语义。这包含了通用的结构,以及其核心集合:例如 Dictionary<Int, String> 就可以直接跨并发域共享。Swift 的 Copy on Write 机制意味着可以在不主动复制数据的情况下传输集合,这是一个非常强大的事实,我相信这将使 Swift 的并发模型在实践中比其他系统更加高效。

然而,这里描述的一切并不简单:当核心集合包含了一般的 class 引用,捕获可变状态的闭包以及其他非值类型时,它们不能安全地跨并发域传输。我们需要一种方法来区分那些 可以安全转移的情况 和 不能安全转移的情况。

值语义的组合

struct、enum 和 tuple 是 Swift 数值组合的主要模式。这些都可以安全地跨并发域传输 – 只要它们包含的数据本身可以安全地传输。

高阶函数编程

在 Swift 和其他具有函数式编程基础的语言中,使用高阶编程是很常见的,即把函数传递给其他函数。Swift 中的函数是引用类型,但许多函数是完全可以跨并发域传递的 – 例如,那些没有捕获变量的函数。

你会有很多很多合理的理由,想要在并发域之间以函数的形式发送计算过程 – 即使是像 parallelMap 这样的微不足道的算法也需要。这在更大规模的计算里也会发生 – 例如,考虑这样一个 actor 的例子:

actor MyContactList {
func filteredElements(_ fn: (ContactElement) -> Bool) async -> [ContactElement] { }
}

能够像这样使用:

// 没有捕获变量的闭包是没问题的!
list = await contactList.filteredElements { $0.firstName != "Max" }

// 捕获了一个 `searchName` 字符串变量的闭包也是可以的
// 因为 String 是可以跨并发域传递的
list = await contactList.filteredElements {
[searchName] in $0.firstName == searchName
}

我们觉得让函数跨并发域传递是很重要的,但是同时我们也有顾虑,我们不应该允许在这些函数中通过引用来捕获本地状态,也不应该允许通过值来捕获不安全的东西。这两者都会带来内存安全问题。

不可变 class

并发编程中一个常见的高效设计模式是建立不可变的数据结构 – 如果一个类中的状态永远不会发生变化,那么在不同的并发域中传输对该类的引用是完全安全的。这种设计模式非常高效(不需要 ARC 以外的同步),可以用来构建高级数据结构,这种模式被纯函数式语言社区广泛采用。

内部同步的引用类型

并发系统中另一种常见的设计模式就是让一个类提供一个“线程安全”的 API:它们用显式同步(mutexes、atomics 等)来保护它们的状态。因为该类的公开 API 可以从多个并发域中安全使用,所以对该类的引用可以安全地直接转移。

对 actor 实例本身的引用就是一个例子:通过传递指针,它们可以在并发域之间安全传递,因为 actor 内部的可变状态是由 actor 邮箱隐式保护的。

在并发域之间”传递”对象

并发系统中的一种相当常见的模式是,一个并发域建立了一个包含非同步的 可变状态的 数据结构,然后通过传输原始指针将其”移交”给另一个并发域使用。如果(也只有在)发送方停止使用已移交的数据时,这在没有同步的情况下也是正确的 – 结果是每次只有发送方或接收方动态地访问可变状态。

既有安全也有不安全的方式来实现这一点,例如,请看最后的其它备选方案部分中关于”奇异的类型系统功能“的讨论。

深拷贝的 Class

转移引用类型的一种安全方式是对数据结构进行深度复制,确保源并发域和目标并发域都有自己的可变状态副本。这对大型结构来说可能很昂贵,但在一些 Objective-C 框架中是或曾经是常用的。一般的共识是,这应该是显式的,而不是隐含在类型定义中的东西。

总结

这只是一些模式的范例,但我们可以看到,有很多不同的并发设计模式在广泛使用。Swift 的设计中心围绕着值类型和鼓励使用 struct 形成了一个非常强大和有利的起点,但是我们也需要能够推导出复杂的情况 – 这既是为了那些希望能够为特定领域编写高性能 API 的社区,也是因为我们需要处理那些 不可能在一夜之间被重写的遗留代码。

因此,允许库作者表达其类型意图的方法是很重要的,应用开发者能够与现有不兼容的库一起相处也是很重要的,而且我们不仅需要提供安全的方式,也需要不安全的方式,这个世界正处于过渡阶段,我们需要在这个不完美的世界里”把任务完成”。

最后,我们的目标是让 Swift(在一般情况下和在这个特定情况下)成为一个高度统一的系统,健全且易于使用。未来 20 年,许多新的库将会基于 Swift 及其最终的并发模型建立。这些库将围绕值语义类型建立,但也应该允许高级程序员部署最先进的技术,如无锁算法,使用不可变类型,或任何其它对他们的领域有意义的设计模式。我们希望这些 API 的用户不必关心它们在内部的实现方式。

解决方案 + 具体设计

这个提案的上层设计围绕着一个名为 Sendable 的 marker 协议,标准库类型将会全面采用 Sendable 协议,以及一个新的 @Sendable 函数注解。

除了基本的提案,在未来,有可能增加一组 Adapter 类型来处理遗留代码的兼容,以及对 Objective-C 框架的一级支持。这些将在下一节中讨论。

Marker 协议类型

本提案将引入 marker 协议的概念,这表明该协议具有某种语义属性,但完全是一个编译时的概念,在运行时没有任何影响。marker 协议有以下限制:

  • 它们不能有任何形式的实现要求。
  • 它们不能继承非 marker 协议。
  • marker 协议不能作为类型使用 isas? 进行检查(例如,x as? Sendable 是一个错误)。
  • marker 协议不能用于非 marker 协议的 conditional conformance 约束中。

我们认为这是一个通用的特性,但认为在这一点上它应该是一个编译器内部的特性。因此,我们在下面对它进行了解释,并通过 @_marker 注解语法来表达这个概念。

Sendable 协议

这个提案的核心是 Swift 标准库中定义的一个 marker 协议,它具有特殊的 conformance 检查规则:

@_marker
protocol Sendable {}

当一个类型,其所有的 public API 都被设计成可以安全地跨并发域使用时,让它遵循 Sendable 协议就是个正确的选择。例如,当没有 public mutator 时,或者 public mutator 是用 COW 实现的,亦或者它们是用内部锁或其他机制实现的。类型当然也可以有本地 mutation 的内部实现细节,只要将 lock 或 COW 作为其公共 API 的一部分。

编译器拒绝任何跨并发域传递数据的尝试,例如,actor 消息发送,结构化并发调用的参数或返回值不符合 Sendable 协议,这些情况都会遭到拒绝:

actor SomeActor {
// async functions are usable *within* the actor, so this
// is ok to declare.
// async 函数会在 actor 内部调用的,所以这是可以声明的
func doThing(string: NSMutableString) async {...}
}

// ... but they cannot be called by other code not protected
// by the actor's mailbox:
// ... 但如果它们从不被 actor 邮箱保护的地方被其它代码调用:
func f(a: SomeActor, myString: NSMutableString) async {
// error: 'NSMutableString' may not be passed across actors;
// it does not conform to 'Sendable'
await a.doThing(string: myString)
}

Sendable 协议用于建模那些 允许通过复制值在并发域中安全传递的类型。这包括值语义类型、对不可变引用类型的引用、内部同步的引用类型、@Sendable 闭包,以及未来可能的其他类型系统扩展的唯一所有权等。

请注意,对该协议不正确的 conformance 会在你的程序中引入错误(就像对 Hashable 的不正确实现会破坏不变性一样),这就是为什么编译器会检查 conformance(见下文)。

元组的 Sendable conformance

Swift 的元组有特定协议的硬编码 conformanceSendable 也应该加入到这个规则里,当元组的元素都符合 Sendable 时,元祖也遵循 Sendable

元类型的 Sendable conformance

元类型(如 Int.Type,由表达式 Int.self 产生的类型)总是遵循 Sendable,因为它们是不可改变的。

struct 和 enum 的 Sendable conformance 检测

Sendable 类型在 Swift 中极为常见,它们的聚合体也可以安全地跨并发域传输。因此,Swift 编译器允许作为其他 Sendable 类型组合出来的 struct 和 class 直接遵循 Sendable

struct MyPerson : Sendable { var name: String, age: Int }
struct MyNSPerson { var name: NSMutableString, age: Int }

actor SomeActor {
// struct 和元组可以发送和接收
public func doThing(x: MyPerson, y: (Int, Float)) async {..}

// 如果跨 actor 边界被调用就会出错:MyNSPerson 不遵循 Sendable!
public func doThing(x: MyNSPerson) async {..}
}

虽然这很方便,但我们希望,在需要更加深思熟虑的情况下,稍微增加遵循协议的阻力。因此,当 struct 和 enum 的某个成员(或相关值)本身不符合 Sendable 协议(或通过约束无法推导出是否符合 Sendable 协议)时,编译器会拒绝让其遵循 Sendable 协议。

// error: MyNSPerson cannot conform to Sendable due to NSMutableString member.
// note: add '@unchecked' if you know what you're doing.
struct MyNSPerson : Sendable {
var name: NSMutableString
var age: Int
}

// error: MyPair cannot conform to Sendable due to 'T' member which may not itself be a Sendable
// note: see below for use of conditional conformance to model this
struct MyPair<T> : Sendable {
var a, b: T
}

// 使用 conditional conformance 来建模泛型类型
struct MyCorrectPair<T> {
var a, b: T
}

extension MyCorrectPair: Sendable where T: Sendable { }

正如在编译器诊断信息里提到的,任何类型都可以通过用 @uncheckedSendable 的 conformance 进行注解,来跳过这种检查行为。这表明该类型可以安全地跨并发域传递,但需要该类型的作者来确保这是安全的。

一个 structenum 只能在定义该类型的同一个源文件中实现 Sendable 的 conformance。这确保了 struct 中的存储属性和 enum 中的关联值是可见的,这样就可以检查它们的类型是否符合 Sendable。比如说:

// MySneakyNSPerson.swift
struct MySneakyNSPerson {
private var name: NSMutableString
public var age: Int
}

// 在另一个源文件或者模块中...
// error: cannot declare conformance to Sendable outside of
// the source file defined MySneakyNSPerson
extension MySneakyNSPerson: Sendable { }

如果没有这个限制,另一个源文件或模块,无法看到私有的存储属性,就会得出结论,MySneakyNSPerson 可以遵循 Sendable。我们也可以将与 Sendable 的遵循声明为 @unchecked 来禁用这个检查:

// 在另一个源文件或者模块中...
// okay: 在另一个源文件的 unchecked 遵循是被允许的
extension MySneakyNSPerson: @unchecked Sendable { }

struct/enum 的 Sendable 隐式 conformance

许多 struct 和 enum 都满足 Sendable 的实现要求,如果需要为每个类型都明确写出 “: Sendable“会让人觉得是太啰嗦。

对于不属于 @usableFromInline 的非 public struct 和 enum,以及 frozen 的 public struct 和 enum,当 conformance 检查(在上一节中描述)成功时,将隐式得遵循 Sendable

struct MyPerson2 { // 隐式遵循 Sendable
var name: String, age: Int
}

class NotConcurrent { } // 不遵循 Sendable

struct MyPerson3 { // 不遵循 Sendable 因为 nc 不是 Sendable 的类型
var nc: NotConcurrent
}

public 的非 fronzen struct 和 enum 不会得到隐式的遵循,因为这样做会给 API resilience 带来问题:对 Sendable 的隐式的遵循会成为与 API 客户端约定的一部分,即使它不是有意的。此外,这种约定可以很容易地被破坏,因为增加 struct 或 enum 的存储属性可能会破坏 Sendable 的遵循。

理由:来自 HashableEquatableCodable 的现有先例是要求有显式的 conformance ,即使实现细节会被合成。我们为 Sendable 打破了这个先例,因为(1) Sendable 可能会更加普遍,(2) Sendable 对代码大小(或二进制)没有影响,与其他协议不同,(3) Sendable 除了允许跨并发域使用该类型外,没有引入任何额外的 API。

请注意,对 Sendable 的隐式遵循只适用于非泛型类型和实例数据保证为 Sendable 的泛型类型。例如:

struct X<T: Sendable> {  // 隐式遵循 Sendable
var value: T
}

struct Y<T> { // 无法隐式遵循 Sendable 因为 T 不遵循 Sendable
var value: T
}

Swift 将不会隐式地引入 conditional conformance。这有可能在未来的提案中被引入。

class 的 Sendable conformance 检测

任何 class 都可以被声明为符合 Sendable@unchecked 遵循,允许它们在 actor 之间传递而不需要语义检查。这适用于使用访问权限和内部同步来提供内存安全的类 – 这些机制一般无法通过编译器检查。

此外,一个 class 也许可以遵循 Sendable,并在特定的有限情况下通过编译器检查内存安全:当该 class 是一个 final class,只包含遵循 Sendable 的不可变存储属性:

final class MyClass : Sendable {
let state: String
}

这样的 class 不能继承于除 NSObject 以外的类(为了 Objective-C 的互操作性)。Sendable class 与 struct 和 enum 有相同的限制,要求 Sendable 的 conformance 写在同一个源文件中。

这种行为使得在 actor 之间安全地创建和传递不可变的共享状态成为可能。在未来,有几种方法可以将其泛化,但有一些不明显的情况需要确定下来。因此,本提案有意保持对 class 的安全检查,以确保我们可以在并发性设计的其他方面取得进展。

Actor 类型

Actor 类型提供它们自己的内部同步,所以它们隐式得遵循 Sendableactors 提案提供了更多细节。

Key path 字面量

Key path 本身符合 Sendable 协议。然而,为了确保共享 key path 的安全性,key path 字面量只能捕获符合 Sendable 协议的类型的值。这会影响 key path 下标的使用:

class SomeClass: Hashable {
var value: Int
}

class SomeContainer {
var dict: [SomeClass : String]
}

let sc = SomeClass(...)

// error: capture of 'sc' in key path requires 'SomeClass' to conform
// to 'Sendable'
let keyPath = \SomeContainer.dict[sc]

新的 @Sendable 函数注解

虽然 Sendable 协议直接针对值类型,并允许 class 选择性地参与并发系统,但函数类型也是重要的引用类型,目前无法遵循协议。Swift 中的函数有几种形式,包括全局 func 声明、嵌套函数、访问器(getters、setters、subscripts 等)和闭包。在可能的情况下,允许函数跨并发域传递,以允许 Swift 并发模型中的高阶函数式编程技术,例如允许定义 parallelMap 和其他明显的并发结构,这是非常实用和重要的。

我们建议在函数类型上定义一个名为 @Sendable 的新注解。一个 @Sendable 函数类型可以安全地跨并发域传输(因此,它隐式得遵循 Sendable 协议)。为了确保内存安全,编译器会对 @Sendable 函数类型的值(例如闭包和函数)进行若干检查:

函数类型的 @Sendable 注解与现有的 @escaping 注解是正交的,但其工作方式是一样的。@Sendable 函数总是非 @Sendable 函数的子类型,并在需要时隐式地进行转换。同样地,闭包表达式从上下文中推断出 @Sendable 位,就像 @escaping 闭包所做的那样。

我们可以重温一下前文提到的例子 – 它可以这样声明:

actor MyContactList {
func filteredElements(_ fn: @Sendable (ContactElement) -> Bool) async -> [ContactElement] { }
}

然后可以像这样使用:

// 没有捕获变量的闭包是没问题的!
list = await contactList.filteredElements { $0.firstName != "Max" }

// 捕获了一个 `searchName` 字符串也是没问题的,因为字符串遵循 Sendable
// searchName 是隐式的值捕获
list = await contactList.filteredElements { $0.firstName==searchName }

// @Sendable 是类型的一部分,所以传递一个兼容的函数声明也没问题!
list = await contactList.filteredElements(dynamicPredicate)

// Error: cannot capture NSMutableString in a @Sendable closure!
list = await contactList.filteredElements {
$0.firstName == nsMutableName
}

// Error: someLocalInt cannot be captured by reference in a
// @Sendable closure!
var someLocalInt = 1
list = await contactList.filteredElements {
someLocalInt += 1
return $0.firstName == searchName
}

@Sendable 闭包和 Sendable 类型的组合允许类型安全的并发,它是可扩展的库,同时仍然易于使用和理解。这两个概念都是关键的基础,actor 和结构化并发都建立在其之上。

闭包表达式的 @Sendable 推导

闭包表达式的 @Sendable 注解推导规则与闭包 @escaping 相似。在以下几种情况中,一个闭包表达式将被推导为 @Sendable

  • 它被用于期望有 @Sendable 函数类型的上下文中(例如 parallelMapTask.runDetached)。
  • @Sendable 在闭包的 in 前进行指定。

@escaping 的区别在于,无上下文的闭包默认为非 @Sendable 的,但默认为 @escaping

// defaults to @escaping but not @Sendable
let fn = { (x: Int, y: Int) -> Int in x+y }

嵌套函数也是一个重要的考虑因素,因为它们也可以像闭包表达式一样捕获值。嵌套函数声明中使用了 @Sendable 注解来选择加入并发检查:

func globalFunction(arr: [Int]) {
var state = 42

// Error, 'state' is captured immutably because closure is @Sendable.
arr.parallelForEach { state += $0 }

// Ok, function captures 'state' by reference.
func mutateLocalState1(value: Int) {
state += value
}

// Error: non-@Sendable function isn't convertible to @Sendable function type.
arr.parallelForEach(mutateLocalState1)

@Sendable
func mutateLocalState2(value: Int) {
// Error: 'state' is captured as a let because of @Sendable
state += value
}

// Ok, mutateLocalState2 is @Sendable.
arr.parallelForEach(mutateLocalState2)
}

这对结构化并发和 actor 来说都是干净的组合。

Thrown errors

一个 throws 的函数或闭包可以有效地返回一个符合 Error 协议的任何类型的值。如果该函数从不同的并发域被调用,抛出的值可以被传递到另一个作用域:

class MutableStorage {
var counter: Int
}
struct ProblematicError: Error {
var storage: MutableStorage
}

actor MyActor {
var storage: MutableStorage
func doSomethingRisky() throws -> String {
throw ProblematicError(storage: storage)
}
}

从另一个并发域调用 myActor.doSomethingRisky() 会抛出有问题的 error ,它捕获了 myActor 的部分可变状态,然后提供给另一个并发域,破坏了 actor 的隔离。因为 doSomethingRisky() 的签名中没有关于抛出的 Error 类型的信息,而且从 doSomethingRisky() 传播出来的 error 可能来自该函数调用的任何代码,所以我们没有地方可以检查被抛出的 error 是否遵循 Sendable

为了修复这个安全漏洞,我们改变了 Error 协议的定义,要求所有的 error 类型都遵循 Sendable

protocol Error: Sendable {  }

现在,ProblematicError 类型将被编译器拒绝并抛出错误,因为它尝试声明为 Sendable,但包含一个非 Sendable 类型 MutableStorage 的存储属性。

一般来说,在不破坏代码和二进制兼容性的情况下,我们不能在现有的协议上添加新的协议继承。然而,marker 协议对 ABI 没有影响,也没有要求,所以二进制兼容性可以保持。

然而,代码兼容性还是需要更加留意。ProblematicError 在今天的 Swift 中是没有问题的代码,但随着 Sendable 的引入将被拒绝。为了便于过渡,在 Swift < 6 中,通过 Error 获得 Sendable 符合性的类型的错误将被降级为警告。

标准库类型全面添加 Sendable 的 conformance

对于标准库类型来说,跨并发域传递是很重要的。绝大多数标准库类型提供了值语义,因此也应该遵循 Sendable,例如:

extension Int: Sendable {}
extension String: Sendable {}

只要所有泛型参数的类型可以安全地跨并发域传递,泛型的值语义类型就可以安全地跨并发域传递。这种先决条件可以通过 conditional conformance 来进行建模:

extension Optional: Sendable where Wrapped: Sendable {}
extension Array: Sendable where Element: Sendable {}
extension Dictionary: Sendable
where Key: Sendable, Value: Sendable {}

除了下面列出的情况,标准库中的所有 struct、enum 和 class 的类型都将添加 Sendable 的 conformance。当泛型类型的所有泛型参数都遵循 Sendable 时,它们将有条件地遵循 Sendable。这些规则的例外情况如下:

  • ManagedBuffer: 这个类的目的是为一个缓冲区提供可变的引用语义。它不应该遵循 Sendable(甚至不应该跳过检查)。
  • unsafe(Mutable)(Buffer)Pointer:这些泛型类型无条件地遵循 Sendable 协议。这意味着一个非并发值的 unsafe pointer 有可能被用来在并发域之间共享这些值。Unsafe 的 pointer 类型从根本上提供了对内存的不安全访问,必须相信程序员能够正确地使用它们;对它们的一个狭窄的维度强制执行严格的安全规则,否则完全不安全的使用似乎与该设计不一致。
  • Lazy 算法适配器类型:lazy 算法返回的类型(例如,作为 array.lazy.map { … } 的结果)从不符合 Sendable。许多这样的算法(如 lazy 的 map)采取非 @Sendable 的闭包,因此不能安全地符合Sendable

译者注:虽然上面提到 unsafePointer 家族的类型都无条件遵循 Sendable 协议,后续的提案 SE-0331 Remove Sendable conformance from unsafe pointer types 移除了这条规则,所以 unsafePointer 家族的类型现在并不遵循 Sendable

标准库协议 ErrorCodingKey 都添加 Sendable 协议的继承:

  • Error 继承 Sendable,以确保抛出的错误可以安全地跨并发域传递,如上一节所述。
  • CodingKey 继承 Sendable,以便像 EncodingErrorDecodingError 这样存储 CodingKey 实例的类型可以遵循 Sendable

支持导入 C / Objective-C 的 API

与 C 和 Objective-C 的互操作性是 Swift 的一个重要组成部分。由于 Swift 无法强制强制 C 语言 API 行为正确,因此 C 语言代码对于并发来说总是隐含着不安全的要素。然而,我们仍然通过为许多 C 类型提供隐式 Sendable 遵循来定义与并发模型的一些基本互动:

  • C 的 enum 类型总是遵循 Sendable 协议。
  • C 的 struct 类型遵循 Sendable 协议,如果它们所有的存储属性都遵循 Sendable
  • C 的函数指针遵循 Sendable 协议。这是很安全的,因为它们无法捕获值。

未来工作 / 后续项目

除了基本提案之外,还有几个后续的东西可以作为后续提案进行探讨。

Adaptor Types for Legacy Codebases

注意。本节不属于提案的一部分 – 包含它只是为了说明设计的各个方面。

上面的提案为组合和 Swift 类型提供了良好的支持,这些类型将被更新以支持并发。此外,Swift 对跳过协议遵循的支持,让用户可以使用尚未更新的遗留代码库。

然而,在与现有框架的兼容性方面,还有一个重要的问题需要面对:框架有时是围绕着具有特殊结构的可变对象密集图设计的。虽然最终能”重写整个世界”是件好事,但实际工作中 Swift 程序员需要得到支持,以便在这期间”将工作完成”。

举个例子,当 Swift 刚出来的时候,大多数 Objective-C 框架都没有对 nullability 进行审核。我们引入了 “ImplicitlyUnwrappedOptional“ 来处理过渡期的问题,随着时间的推移,它优雅地淡出了使用范围。

为了说明我们如何在 Swift Concurrency 中做到这一点,请考虑 Objective-C 框架中常见的一种模式:通过跨线程”转移”引用来传递一个对象图 – 这很有用,但不符合内存安全!程序员会希望能够在他们的应用程序中把这些东西作为 actor API 的一部分来表达。

这可以通过引入一个通用的 helper struct 来实现:

@propertyWrapper
struct UnsafeTransfer<Wrapped> : @unchecked Sendable {
var wrappedValue: Wrapped
init(wrappedValue: Wrapped) {
self.wrappedValue = wrappedValue
}
}

例如,NSMutableDictionary 在跨并发域传递时并不安全,所以它没办法安全得遵循 Sendable。上面的 struct 允许你(作为应用程序的程序员)像这样写一个 actor 的 API:

actor MyAppActor {
// The caller *promises* that it won't use the transferred object.
public func doStuff(dict: UnsafeTransfer<NSMutableDictionary>) async
}

虽然这不是特别优雅,但当你需要处理未经审计和不安全的代码时,它能有效地在调用者一方完成工作。这也可以使用最近提出的将 propertyWrapper 拓展到函数参数,变成一个参数属性,允许一种更优雅的声明和调用方语法:

actor MyAppActor {
// The caller *promises* that it won't use the transferred object.
public func doStuff(@UnsafeTransfer dict: NSMutableDictionary) async
}

Objective-C 框架支持

注意。本节不属于是提案的一部分 - 它只是为了说明设计的各个方面。

Objective-C 已经建立了一些模式,可以合理地融入到这个框架里,例如,NSCopying 协议是一个重要的、被广泛采用的协议,应该被纳入这个框架。

一般的共识是,在模型中明确复制是很重要的,所以我们可以像这样实现一个 NSCopied helper:

@propertyWrapper
struct NSCopied<Wrapped: NSCopying>: @unchecked Sendable {
let wrappedValue: Wrapped

init(wrappedValue: Wrapped) {
self.wrappedValue = wrappedValue.copy() as! Wrapped
}
}

这将允许 actor 方法的个别参数和结果选择使用一个单独的副本:

actor MyAppActor {
// The string is implicitly copied each time you invoke this.
public func lookup(@NSCopied name: NSString) -> Int async
}

一个粗略的解释:Objective-C 的静态类型系统在这里对我们的 immutability 帮助不大:静态类型的 NSString 由于可以被继承,所以实际上可能是动态的 NSMutableString。正因为如此,我们无法假设 NSString 类型的值是动态不可变的 – 它们应该在实现里调用 copy() 方法。

和 actor self 与 @Sendable 闭包的交互

Actor 是基于这个提案之上提出的,但了解 actor 的设计也很重,以确保本提案能满足其需求。如上所述,actor 方法跨越并发边界的发送,要求参数和结果遵循 Sendable,因此隐含着一个要求,跨越这种边界传递的闭包必须是 @Sendable 的。

还有一个需要解决的细节是”什么时候算是跨 actor 的调用?”。例如,我们希望这些调用是同步的,不需要 await:

extension SomeActor {
public func oneSyncFunction(x: Int) {... }
public func otherSyncFunction() {
// No await needed: stays in concurrency domain of self actor.
self.oneSyncFunction(x: 42)
oneSyncFunction(x: 7) // Implicit self is fine.
}
}

然而,我们也需要考虑当 self 被捕获到 actor 方法中的闭包时的情况。比如说:

extension SomeActor {
public func thing(arr: [Int]) {
// This should obviously be allowed!
arr.forEach { self.oneSyncFunction(x: $0) }

// Error: await required because it hops concurrency domains.
arr.parallelMap { self.oneSyncFunction(x: $0) }

// Is this ok?
someHigherOrderFunction {
self.oneSyncFunction(x: 7) // ok or not?
}
}
}

我们需要编译器知道是否有一个可能的并发域跳转 – 如果有,就需要一个 await。幸运的是,这可以通过上述基本类型系统规则的直接组合来实现。在 actor 方法的非 @Sendable 闭包中使用 actor self 是完全安全的,但是在 @Sendable 闭包中使用它将被视为来自不同的并发域,因此需要一个 await

Marker 协议作为一个自定义注解

marker 协议 Sendable 和函数注解 @Sendable 被故意赋予相同的名字。这里有一个潜在的未来方向,即 @Sendable 可以从一个被编译器识别的特殊注解(如这个提议),类似 Sendable 这样使用一个通用的 marker 协议机制,成为自定义属性,如 propertyWrapperresultBuilder。这样的改变对使用 @Sendable 现有的代码影响很小,只要用户不声明他们自己的 Sendable 类型,不与标准库中的类型同名。然而,它将使 @Sendable 不再特殊,并允许其他 marker 协议以类似的方式使用。

代码兼容性

这几乎与现有的代码库完全代码兼容。引入 Sendable marker 协议和 @Sendable 函数注解是附加功能,不使用时没有影响,因此不会影响现有代码。

但这里有一些新的限制,在特殊情况下可能会导致代码兼容性破坏:

  • 对 keypath 字面量下标的改变将破坏用非标准类型索引的外部 keypath。
  • ErrorCodingKey 将添加 Sendable 的继承,因此要求自定义 Error 和 CodingKey 符合 Sendable

由于这些修改,新的限制将只在 Swift 6 模式下执行,但对于 Swift 5 和更早的版本将是警告。

对 API resilience 的影响

这项建议对 API 的弹性没有任何影响!

其它备选方案

在讨论这个提案时,有几个备选方案也是很有意义的。在这里,我们尝试讨论一些较大的问题。

奇异的类型系统功能

Swift Concurrency Roadmap 提到,未来的功能集迭代可能会引入新的类型系统特性,如 “mutableIfUnique“ 类,而且很容易想象,move 语义和唯一所有权有一天会被引入 Swift。

虽然在不了解未来提案完整规范的情况下,很难理解详细的交互逻辑,但我们相信执行 Sendable 检查的检查机制是简单和可组合的。它应该适用于任何可以安全跨越并发边界的类型。

支持一种显式的 copy hook

本提案的第一次修订允许类型在跨并发域发送时自定义行为,通过实现 unsafeSend 协议要求。这增加了提案的复杂性,添加了不必要的功能(明确实现的复制行为),使递归聚合的情况消耗更多性能,并且会导致更大的代码二进制文件。

总结

这项提案引入了一种非常简单的方式,用于定义可以安全地跨并发域传输的类型。它需要的编译器/语言支持很少,与现有的 Swift 功能一致,可由用户扩展,与传统的代码库一起使用,并提供了一个简单的模型,即使在 20 年后我们也会觉得很棒。

因为该功能主要是建立在现有语言支持基础上的库功能,所以很容易定义包装类型,为特定领域的关注点进行扩展(按照上面 NSCopied 的例子),跳过遵循检查使用户很容易与尚未更新以了解 Swift 并发模型的旧库合作。

修订历史

  • Changes from the second review:
    • Renamed @sendable to @Sendable, per review feedback and Core Team decision.
    • Add a future direction on marker protocols as custom attributes.
    • Removed “Swift Concurrency 1.0” and “2.0” discussion in Alternatives Considered.
  • Changes from the first review
    • Renamed ConcurrentValue to Sendable and @concurrent to @sendable.
    • Replaced UnsafeConcurrentValue with @unchecked Sendable conformances.
    • Add implicit conformance to Sendable for non-public, non-frozen struct and enum types.