【译】SE-0302 Sendable 和 @Sendable 闭包
- Proposal: SE-0302
- Authors: Chris Lattner, Doug Gregor
- Review Manager: John McCall
- Status: Accepted (2021-03-16)
- Implementation: apple/swift#35264
- Major Contributors: Dave Abrahams, Paul Cantrell, Matthew Johnson, John McCall
- Review: (first review) (revision announcement) (second review) (acceptance)
简介
Swift Concurrency 的其中一个关键目标就是“提供一种机制来隔离并发程序中的状态,以消除 data races”。这样的机制将会是通用编程语言的一次重大进步 – 大多数语言提供的并发编程抽象会使程序员面临范围宽广的 bug,包括 race conditions,死锁和其它问题。
这个提案里描述了一种方式,去解决这个领域面临的其中一个问题 – 如何对结构化并发和 Actor 消息传递的值进行类型检查。因此,这是一个统一的理论,它提供了一些基本的类型系统机制,使它们既保障安全又能很好地协同工作。
这种实现方式会提供一个名为 Sendable
的 marker 协议,以及一个可应用于函数的 @Sendable
注解。
背景故事
程序中的每个 actor 实例和结构化并发任务都代表着一个”单线程性的孤岛”(island of single threaded-ness),这使得它们成为一个自然的同步点,持有一系列可变的状态。这些任务与其他任务并行进行计算,但我们希望这样一个系统中的绝大多数代码都是非同步的 – 建立在 actor 的逻辑独立性之上,将其邮箱作为数据的同步点。
因此,一个关键问题是:”我们何时以及如何允许数据在并发域之间传输?” 例如,这种转移发生在 actor 方法调用的参数和返回值中,以及由结构化并发创建的任务中。
Swift Concurrency 的功能渴望建立一个安全而强大的编程模型。我们希望实现这三件事:
- 我们希望 Swift 程序员在试图 跨并发域传递 可能引入不受保护的共享可变状态 时得到一个静态的编译器错误。
- 我们希望高阶程序员能够实现包含复杂技术的库(例如 ConcurrentHashTable),并且能够让其他人以一种安全的方式去使用。
- 我们需要拥抱现有的世界,其中包含了很多在设计时没有考虑到 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 协议不能作为类型使用
is
或as?
进行检查(例如,x as? Sendable
是一个错误)。 - marker 协议不能用于非 marker 协议的 conditional conformance 约束中。
我们认为这是一个通用的特性,但认为在这一点上它应该是一个编译器内部的特性。因此,我们在下面对它进行了解释,并通过 @_marker
注解语法来表达这个概念。
Sendable
协议
这个提案的核心是 Swift 标准库中定义的一个 marker 协议,它具有特殊的 conformance 检查规则:
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 的元组有特定协议的硬编码 conformance,Sendable
也应该加入到这个规则里,当元组的元素都符合 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 { }
正如在编译器诊断信息里提到的,任何类型都可以通过用 @unchecked
对 Sendable
的 conformance 进行注解,来跳过这种检查行为。这表明该类型可以安全地跨并发域传递,但需要该类型的作者来确保这是安全的。
一个 struct
或 enum
只能在定义该类型的同一个源文件中实现 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
的遵循。
理由:来自
Hashable
、Equatable
和Codable
的现有先例是要求有显式的 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 类型提供它们自己的内部同步,所以它们隐式得遵循 Sendable
。actors 提案提供了更多细节。
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
函数类型的上下文中(例如parallelMap
或Task.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
。
标准库协议 Error
和 CodingKey
都添加 Sendable
协议的继承:
Error
继承Sendable
,以确保抛出的错误可以安全地跨并发域传递,如上一节所述。CodingKey
继承Sendable
,以便像EncodingError
和DecodingError
这样存储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( 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( 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 协议机制,成为自定义属性,如 propertyWrapper 和 resultBuilder。这样的改变对使用 @Sendable
现有的代码影响很小,只要用户不声明他们自己的 Sendable
类型,不与标准库中的类型同名。然而,它将使 @Sendable
不再特殊,并允许其他 marker 协议以类似的方式使用。
代码兼容性
这几乎与现有的代码库完全代码兼容。引入 Sendable
marker 协议和 @Sendable
函数注解是附加功能,不使用时没有影响,因此不会影响现有代码。
但这里有一些新的限制,在特殊情况下可能会导致代码兼容性破坏:
- 对 keypath 字面量下标的改变将破坏用非标准类型索引的外部 keypath。
Error
和CodingKey
将添加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.
- Renamed
- Changes from the first review
- Renamed
ConcurrentValue
toSendable
and@concurrent
to@sendable
. - Replaced
UnsafeConcurrentValue
with@unchecked Sendable
conformances. - Add implicit conformance to
Sendable
for non-public, non-frozenstruct
andenum
types.
- Renamed