Swift 并发采纳指南
本文旨在为服务器端 Swift 库的作者提供一套指导方针。具体来说,这里的大部分讨论都围绕着如何处理现有 API 和库,这些 API 和库广泛使用 Swift NIO 的 EventLoopFuture
和相关类型。
Swift 并发是一项多年的努力。对于服务器社区来说,参与这项多年来对并发特性的采纳,一个接一个地进行,并在这样做时提供反馈是非常有价值的。因此,我们不应推迟到 Swift 6 才采纳并发特性,因为我们可能会错失改进并发模型的宝贵机会。
在 2021 年,我们看到了结构化并发和 Actor 随着 Swift 5.5 的到来。现在是使用这些原语提供 API 的绝佳时机。未来我们将看到完全检查的 Swift 并发。这将带来重大更改。因此,采纳新的并发特性可以分为两个阶段。
您现在可以做什么
API 设计
首先,现有库应努力在其面向用户的“表面”API 中尽可能添加 async
函数,并在可能的情况下添加到现有的基于 *Future
的 API。这些附加 API 可以根据 Swift 版本进行门控,并且可以在不破坏现有用户代码的情况下添加,例如像这样
extension Worker {
func work() -> EventLoopFuture<Value> { ... }
#if compiler(>=5.5) && canImport(_Concurrency)
@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *)
func work() async throws -> Value { ... }
#endif
}
如果一个函数不会失败,但之前使用了 Future,则不应在其新的化身中包含 throws
关键字。
这种采纳可以立即开始,并且不应给现有库的现有用户带来任何问题。
SwiftNIO 辅助函数
为了方便过渡到异步代码,SwiftNIO 在 EventLoopFuture
和 -Promise
上提供了许多辅助方法。
在每个 EventLoopFuture
上,您可以调用 .get()
将 Future 转换为可 await
的调用。如果您想将 async/await 调用转换为 EventLoopFuture
,我们建议使用以下模式
#if compiler(>=5.5) && canImport(_Concurrency)
func yourAsyncFunctionConvertedToAFuture(on eventLoop: EventLoop)
-> EventLoopFuture<Result> {
let promise = context.eventLoop.makePromise(of: Out.self)
promise.completeWithTask {
try await yourMethod(yourInputs)
}
return promise.futureResult
}
#endif
还存在用于 EventLoopGroup
、Channel
、ChannelOutboundInvoker
和 ChannelPipeline
的更多辅助工具。
#if
防护使用并发的代码
为了使使用并发的代码与不使用并发的代码共存,您可能需要使用 #if
保护某些代码段。正确的方法如下
#if compiler(>=5.5) && canImport(_Concurrency)
...
#endif
请注意,您不需要导入 _Concurrency
,如果它存在,则会自动导入。
#if compiler(>=5.5) && canImport(_Concurrency)
// DO NOT DO THIS.
// Instead don't do any import and it'll import automatically when possible.
import _Concurrency
#endif
Sendable 检查
SE-0302 引入了
Sendable
协议,该协议用于指示哪些类型的值可以安全地跨 Actor 复制,或者更普遍地复制到任何可能与原始值并发使用其副本的上下文中。统一应用于所有 Swift 代码,Sendable
检查消除了由共享可变状态引起的大量数据竞争。– 来自 Staging in Sendable checking,其中概述了 Swift 6 的
Sendable
采纳计划。
未来我们将看到完全检查的 Swift 并发。支持这一点的语言特性是 Sendable
协议和用于闭包的 @Sendable
关键字。由于 Sendable 检查会破坏现有的 Swift 代码,因此需要一个新的 Swift 主要版本。
为了简化向完全检查的 Swift 代码的过渡,现在可以使用 Sendable
协议注释您的 API。
您可以通过传递 -warn-concurrency
标志,在 Swift 5.5 中开始采纳 Sendable 并获得适当的警告,您可以在 SwiftPM 中为整个项目这样做,如下所示
swift build -Xswiftc -Xfrontend -Xswiftc -warn-concurrency
今天的 Sendable 检查
Sendable 检查目前在 Swift 5.5(.0) 中被禁用,因为它导致了一些棘手的情况,而我们缺乏解决这些情况的工具。
这些问题中的大多数已在今天的编译器 main
分支上得到解决,预计将在下一个 Swift 5.5 版本中发布。可能值得等到 5.5.0 之后的下一个版本再采纳。
例如,其中一项功能是允许 Sendable
类型的元组也符合 Sendable
。我们建议推迟 Sendable
的采纳,直到此补丁在 Swift 5.5 中发布(应该很快)。通过此更改,启用 -warn-concurrency
的 Swift 5.5 与 Swift 6 模式之间的差异应该非常小,并且可以在具体情况下进行管理。
声明和“检查”过的 Swift 并发的向后兼容性
采纳 Swift 并发将逐步导致更多警告,并在 Swift 6 中最终导致编译时错误,当违反 Sendability 检查时,会标记潜在的不安全代码。
对于库来说,维护一个既兼容 Swift 6 之前版本,又完全接受新的并发检查的版本可能很困难。例如,可能有必要将泛型类型标记为 Sendable
,如下所示
struct Container<Value: Sendable>: Sendable { ... }
在这里,Value
类型必须标记为 Sendable
,以便 Swift 6 的并发检查能够正确处理此类容器。但是,由于 Sendable
类型在 Swift 5.5 之前的版本中不存在,因此维护一个同时支持 Swift 5.4+ 和 Swift 6 的库将很困难。
在这种情况下,使用以下技巧可能有助于能够在库的两个 Swift 版本之间共享相同的 Container
声明
#if swift(>=5.5) && canImport(_Concurrency)
public typealias MYPREFIX_Sendable = Swift.Sendable
#else
public typealias MYPREFIX_Sendable = Any
#endif
注意: 是的,我们在这里使用
swift(>=5.5)
,而我们使用compiler(>=5.5)
来保护使用并发特性的特定 API。
当作为泛型约束应用时,Any
别名实际上是一个空操作,因此可以通过这种方式使相同的 Container<Value>
声明跨 Swift 版本工作。
任务本地值和日志记录
新引入的任务本地值 API (SE-0311) 允许隐式地携带元数据以及 Task
执行。它非常适合跟踪和携带元数据进行任务执行,例如将其包含在日志消息中。
我们正在努力调整 SwiftLog,使其功能足够强大,能够自动拾取和记录特定的任务本地值。此更改将以源代码兼容的方式引入。
目前,库应继续使用记录器元数据,但我们预计,在未来,许多手动传递给每个日志语句的元数据的情况可以用设置任务本地值来代替。
为截止日期的概念做准备
截止日期是另一个与 Swift 并发密切相关的功能,最初是在结构化并发提案的早期版本中提出的,后来从中移出。Swift 团队仍然有兴趣将截止日期概念引入语言,并且已经并发运行时内部为此进行了一些准备。然而,目前 Swift 并发中尚不支持截止日期,继续使用 NIODeadline
或类似机制在一段时间后取消任务是可行的。
一旦 Swift 并发获得截止日期支持,它们将表现为能够在截止日期(时间点)过后取消任务(及其子任务)。为了使 API “为截止日期做好准备”,它们不必执行任何特殊操作,只需准备好能够处理 Task
及其取消即可。
协作处理任务取消
Task
取消今天已存在于 Swift 并发中,库可能已经处理了它。在实践中,这意味着任何异步函数(或预期从 Task
中调用的函数)都可以使用 Task.isCancelled
或 try Task.checkCancellation()
API 来检查其正在执行的任务是否已取消,如果是,则可以协作中止其当前正在执行的任何操作。
取消在长时间运行的操作中或在启动一些昂贵的操作之前可能很有用。例如,HTTP 客户端可以在发送请求之前检查是否取消 - 如果已知等待它的任务不再关心结果,那么发送请求可能没有意义!
通常,取消可以理解为“等待此任务结果的人不再对其感兴趣”,并且通常最好在遇到取消时抛出“已取消”错误。但是,在某些情况下,返回“部分”结果也可能是合适的(例如,如果一个任务正在收集许多结果,它可能会返回到目前为止设法收集到的结果,而不是返回任何结果或忽略取消并收集所有剩余结果)。
对 Swift 6 的期望
Sendable:全局变量 & 导入的代码
目前,Swift 5.5 尚不处理其并发检查模型中的全局变量。这种情况很快会改变,但确切的语义尚未确定。一般来说,尽可能避免使用全局属性和变量,以避免将来遇到问题。如果可以,请考虑弃用全局变量。
一些全局变量具有特殊的属性,例如 errno
,它包含系统调用的错误代码。它是一个线程局部变量,因此可以安全地从任何线程/Task
读取。我们期望改进导入器,以便使用某种“已知安全”的注解来标记这些全局变量,这样即使在完全检查的并发模式下,使用它的 Swift 代码也不会报错。话虽如此,在 Swift 并发中使用 errno
和其他“线程局部” API 非常容易出错,因为线程跳转可能发生在任何挂起点,因此以下代码片段很可能是不正确的。
sys_call(...)
await ...
let err = errno // BAD, we are most likely on a different thread here (!)
请注意在 Swift 并发中与任何线程局部 API 交互时要小心。如果你的库之前使用过线程本地存储,你可能需要将它们迁移到使用 task-local values,因为它们可以与 Swift 的结构化并发任务正确地协同工作。
另一种棘手的情况是导入的 C 代码。可能没有好的方法将导入的类型标记为 Sendable (或者手动操作会非常麻烦)。Swift 可能会改进对导入代码的支持,并可能允许忽略对导入代码的一些并发安全检查。
这些针对导入代码的宽松语义尚未实现,但在你从 Swift 中使用 C API 并尝试采用 -warn-concurrency
模式时,请记住这一点。请在 bugs.swift.org 上提交你遇到的任何问题,以便我们可以根据你遇到的实际问题来指导这些检查启发式方法的开发。
自定义执行器
我们期望 Swift 并发在未来会允许自定义执行器。自定义执行器将允许在这样的执行器“上”运行 actor / 任务。EventLoop
有可能成为这样的执行器,但是自定义执行器的提案尚未提出。
虽然我们期望通过使用“在同一事件循环上”的自定义执行器来避免在调用不同 actor 之间进行异步跳转,从而获得潜在的性能提升,但它们的引入不会从根本上改变 NIO 库的结构。
此处的指南将随着 Swift Evolution 关于自定义执行器的提案的提出而发展,但在自定义执行器“落地”之前,不要推迟采用 Swift 并发 - 尽早开始采用非常重要。对于大多数代码,我们认为采用 Swift 并发带来的收益远远超过 actor 跳转可能引起的轻微性能成本。
减少使用 SwiftNIO Futures 作为“并发库”
SwiftNIO 目前为 Swift on Server 生态系统提供了许多并发类型。最值得注意的是 EventLoopFuture
和 EventLoopPromise
,它们被广泛用于异步结果。虽然 SSWG 过去建议在 API 级别使用这些类型,以便更容易地实现服务器库之间的互操作,但我们建议在 Swift 6 发布后弃用或删除这些 API。swift-server 生态系统应该全力投入到语言提供的结构化并发特性中。因此,至关重要的是今天就提供 async/await API,以便让你的库用户有时间来采用新的 API。
然而,一些 NIO 类型将保留在 Swift on server 库的公共接口中。我们期望网络客户端和服务器继续使用 EventLoopGroup
进行初始化。底层的传输机制 (NIOPosix
和 NIOTransportServices
) 应该成为实现细节,而不应该暴露给库的采用者。
SwiftNIO 3
虽然可能会有变动,但 SwiftNIO 很可能在 Swift 6.0 发布后的几个月内发布 3.0 版本,届时 Swift 将启用“完整”的 Sendable
检查。
你不应该期望 NIO 突然变得“更异步”,NIO 的固有设计原则是在事件循环上执行小任务,并为任何异步操作使用 Futures。NIO 的设计预计不会改变。通道管道预计不会在 Swift 并发的意义上变成“异步”。这是因为 SwiftNIO 本质上是一个 IO 系统,这对 Swift Concurrency 使用的协作式、共享的线程池提出了挑战。这个线程池绝不能被任何操作阻塞,因为这样做会使线程池资源耗尽,并阻止其他异步任务的进一步进行。
然而,I/O 系统必须在某个时候阻塞一个线程,等待更多的 I/O 事件,无论是在 I/O 系统调用中还是在像 epoll_wait 这样的操作中。这就是 NIO 的工作方式:每个事件循环线程最终都会阻塞在 epoll_wait 上。我们不能在协作式线程池内部这样做,因为这样做会使其资源耗尽,无法处理其他异步任务,所以我们必须在不同的线程上这样做。因此,SwiftNIO 不应该 *在* 协作式线程池上使用,而应该拥有并完全控制其线程——因为它是一个 I/O 系统。
可以将所有 NIO 工作都放在协作式线程池上进行,并在每个 I/O 操作和将其分派到 async/await 线程池之间进行线程跳转,但这对于高性能 I/O 来说是不可接受的:*每个 I/O 操作* 的上下文切换成本太高。因此,SwiftNIO 不计划仅仅为了它带来的易用性而采用 Swift 并发,因为在其特定上下文中,上下文切换不是一个可以接受的权衡。然而,随着语言运行时中“自定义执行器”的出现,SwiftNIO 可能会与 Swift 并发进行合作,但这尚未完全提出,因此我们不会对此进行过多猜测。
然而,NIO 团队将利用这个机会删除已弃用的 API 并改进一些 API。更改的范围应与 NIO1 → NIO2 版本升级相当。如果你的 SwiftNIO 代码今天编译时没有警告,那么它很可能在 NIO3 中继续工作而无需修改。
在 NIO3 发布之后,NIO2 将只进行错误修复。
最终用户代码破坏
预计 Swift 6 将会破坏一些代码。如前所述,SwiftNIO 3 也将在 Swift 6 发布前后发布。考虑到这一点,将主要版本发布与 Swift 6 和 NIO 3 的版本要求更新同步可能是一个好主意。
Swift 和 SwiftNIO 都不计划进行“大量更改”,因此采用应该是可能的,而不会带来太大的痛苦。
给库用户的指南
一旦 Swift 6 发布,我们建议使用最新的 Swift 6 工具链,即使使用 Swift 5.5.n 语言模式 (这可能只会产生警告,而不是在 Sendability 检查失败时直接报错)。与仅使用 5.5 工具链相比,这将产生更好的警告和编译器提示。