Swift Evolution SE-0538 解读:`Disconnected` 类型如何在存储边界上保存「断开区域」属性,安全传输非 `Sendable` 值
2026/9/23 17:26:45 网站建设 项目流程
  • 文档

【免费下载链接】swift-evolution

This maintains proposals for changes and user-visible enhancements to the Swift Programming Language.

项目地址:https://gitcode.com/gh_mirrors/sw/swift-evolution
点击查看免费下载

导读

SE-0538(Disconnected类型)为 Swift 并发模型引入了一个新的标准库包装类型:它能把"值处于断开区域(disconnected region)"这一性质穿透存储边界(泛型容器、存储属性、队列等)保存下来,使非Sendable值无需经过sending标注即可在隔离区域之间安全传输。读完本文,你将理解 region-based isolation 与sending的边界局限、Disconnected的完整 API 设计与安全不变量(init/consume()/exchange/withValue)、它为何能无条件符合Sendable,以及各备选方案(borrow 访问器、协议化、sending泛型参数等)被否决的深层原因。

背景:从 Region-based Isolation 到sending

SE-0538 建立在此前两个提案之上,理解它们是理解Disconnected的前提。

SE-0414:隔离区域(Isolation Region)

SE-0414 region-based isolation(已实现于 Swift 6.0)引入了控制流敏感的诊断:编译器把程序运行期存在的值划分成若干隔离区域——两个值 $x$、$y$ 在同一程序点 $p$ 属于同一隔离区域,当且仅当 $x$ 可能别名 $y$,或 $x$ 可能通过 $y$ 的属性链访问被 $y$ 引用。处于不同隔离区域的非Sendable值可以并发使用,因为任何使用 $x$ 的代码都不可能影响 $y$。

一个值处于断开区域(disconnected region)时,没有任何引用从其他区域到达它的存储、也没有引用从它的存储伸出到其他区域。这样的值可以安全转移到另一个隔离区域(actor、Task、其他并发上下文),因为转移过程不可能制造数据竞争。SE-0414 允许在"值处于断开区域且转移后不再被使用"的前提下,把非Sendable值跨隔离边界传递,从而避免大量依赖@unchecked Sendable逃生舱的写法。

SE-0430:sending参数与结果标注

SE-0430 transferring parameters and results 引入了sending标注,在函数边界上显式表达"此值必须处于断开区域":

public func withCheckedContinuation<T>( function: String = #function, _ body: (CheckedContinuation<T, Never>) -> Void ) async -> sending T
  • sending参数:要求调用方传入的值处于断开区域;调用点之后该断开区域不再属于调用方的隔离域,被调方可以把它发送到调用方无法感知的区域。
  • sending结果:要求函数实现返回一个处于断开区域的值;调用方可以假定结果在断开区域,从而让非Sendable类型的结果跨过 actor 隔离边界。
  • 子类型规则sending TT的子类型;sending在参数位置逆变、在结果位置协变。

sending描述的是值在函数边界上的属性(一次转移事件),而不是类型本身的稳定属性。关键局限随之而来。

Motivation:sending无法穿透存储

考虑一个跨隔离边界处理元素的队列实现——提案中的假设类型UniqueDeque

struct UniqueDeque<Element: ~Copyable>: ~Copyable { func append(_ element: consuming Element) { ... } func popFirst() -> Element? { ... } }

一个典型用法是把非Sendable的断开值 append 进队列,弹出后发送到另一个隔离区域:

var deque = UniqueDeque<NonSendable>() deque.append(NonSendable()) guard let element = deque.popFirst() else { return } Task { print(element) // Error: Element is assumed to be in the same isolation region as uniqueDeque }

上面会报错,因为Element被视为与uniqueDeque处于同一隔离区域。要让代码通过,需要让appendsending消费元素、让popFirstsending返回元素——但这会严重限制该类型其他同样重要的用途:用户可能想存储非Sendable并非断开的元素。

根本局限在于:sending是函数边界的属性,不是类型的属性。泛型类型(如UniqueDeque)无法根据条件决定存储的元素是否应保持断开状态;若把append/popFirst都标成sendingUniqueDeque就只能存放断开值了。这正是 SE-0430 的 Future directions 部分 所预告的缺口:sending要求值在函数边界处于断开区域,但没有任何办法把"值处于断开区域"这个信息保存下来,穿过存储属性、集合、函数调用等结构。

Proposed solution:用Disconnected包装类型保存断开属性

SE-0538 引入Disconnected类型来建模"断开的值",让泛型容器无需关心sending效应即可安全转移非Sendable值:

var deque = UniqueDeque<Disconnected<NonSendable>>() deque.append(Disconnected(NonSendable())) guard var disconnected = deque.popFirst() else { return } let element = disconnected.consume() Task { print(element) }

Disconnected包装一个值,确保它始终留在断开区域内;consume()方法消费包装器,以sending返回内部值,使其能够跨越隔离边界。队列本身不再需要任何sending标注——断开信息由包装类型携带,而非依赖函数签名。

Detailed design:Disconnected的完整 API

Disconnected是一个@frozen的结构体,位于标准库的Synchronization模块(与MutexAtomic等跨隔离边界的原语并列),完整签名如下:

@frozen public struct Disconnected<Value: ~Copyable>: ~Copyable, Sendable { public init(_ value: consuming sending Value) public consuming func consume() -> sending Value @discardableResult public mutating func exchange( newValue: consuming sending Value ) -> sending Value public mutating func withValue<Return: ~Copyable, Failure>( body: (inout sending Value) throws(Failure) -> Return ) throws(Failure) -> Return }

什么是断开区域

Region-based isolation 依据哪些引用可达哪些存储,把程序执行中任意时刻的值划分为隔离区域。值处于断开区域时,没有引用从任何其他区域伸入或伸出它的存储;把这样的值转移到另一个隔离区域不会产生数据竞争。实践中断开区域通常来自:

  • 新建的值,其初始化参数本身处于断开区域;
  • 刚从另一个断开容器中移除的值;
  • 函数边界的sending参数(被调方在断开区域中接收);
  • 函数返回的sending结果(调用方在断开区域中接收)。

断开属性通常只在sending边界被跟踪Disconnected<Value>让它可以穿过泛型容器、存储属性、队列等本会丢失区域信息的存储边界。

init(_:):值进入包装器的唯一入口

init要求参数为consuming sending Value,即调用点的实参必须处于断开区域。新构造、无别名的值天然满足该要求:

final class Resource: ~Sendable {} let wrapper = Disconnected(Resource())

包装器接管值的所有权(consuming),并在整个存储期间维持其断开属性。

consume():取出并转移

consume()消费包装器本身(consuming),返回sending Value——返回值处于断开区域,可以在同一表达式中跨隔离边界转移,也可以先存储再转移:

final class Resource: ~Sendable {} // `wrapper` 从持有 `Disconnected<Resource>` 的队列或其他容器中弹出, // 因此它包裹的资源已被确认与周围上下文断开。 func process(wrapper: consuming Disconnected<Resource>) async { let resource = wrapper.consume() await Task.detached { use(resource) // OK: `resource` 处于断开区域。 }.value }

若没有结果上的断开保证,被捕获的resource会被视为调用方区域的一部分,上述 detached task 中的捕获将不被允许。consume()返回后包装器已被消费,无法再执行任何操作。

exchange(newValue:):原地置换

exchange一步完成"放入新值、取回旧值":newValue必须处于断开区域,返回的旧值同样处于断开区域:

final class Resource: ~Sendable {} func exchangeResources(in wrapper: inout Disconnected<Resource>) async { let old = wrapper.exchange(newValue: Resource()) await Task.detached { dispose(old) // OK: `old` 处于断开区域。 }.value }

置换的两个方向都跨越断开区域边界:新值进入时必须断开,旧值出来时已知断开。

withValue(body:):原地可变访问

需要临时可变访问而不取出值时使用withValue。闭包以inout sending Value接收值,withValue返回闭包的返回值:

var wrapper = Disconnected([Int]()) wrapper.withValue { array in array.append(42) }

inout sending参数形态比普通inout含义更强:在闭包内部,值可以转移到另一个隔离区域,只要闭包返回时包装器仍持有一个断开值。典型用法是原地修改;更宽松的形态使withValue能与"想把值发送到其他隔离区域"的代码组合。若body抛错,包装器保留闭包最后留在存储中的值,错误传播给调用方。

为什么Disconnected无条件符合Sendable

Disconnected保证其包裹值处于断开区域;断开区域可以安全地跨隔离边界转移,因此无论T是否符合SendableDisconnected<T>都可以安全共享。此外,Disconnected的所有方法要么是consuming要么是mutating,编译器会强制执行静态与动态排他性检查(exclusivity checking),禁止重叠与并发访问——这是Sendable一致性成立的关键一环。

API 刻意被限制为sending边界上的原子转移init消费一个sending值;consume消费包装器并返回sending值;exchange用另一个sending值置换;withValue在闭包存活期内以inout sending出借值。不存在任何不消费、不置换就暴露包裹值的访问器——正如 Alternatives 一节所述,borrow 访问器在无条件Sendable一致性下是不健全(unsound)的。

兼容性与采纳影响

  • Source compatibility:本提案向标准库新增一个类型,不修改现有代码,无源兼容性影响。
  • ABI compatibility:新增@frozen类型,Disconnected的布局 ABI 稳定,不影响既有 ABI。
  • Implications on adoption:需要新版本的 Swift 标准库与运行时才能使用。

Alternatives considered:为什么其他方案被否决

备选命名

NonisolatedDisconnectedRegion曾被考虑,最终Disconnected最贴切,且"断开区域"概念由 SE-0414 先行引入。基于sending/Sendable词汇的Sent<Value>Sending<Value>也被提议,但被否决:sending描述的是值在函数边界上的属性(一次转移事件),而非稳定的区域状态;一个仅仅持有"生活在断开区域的值"的包装器并不处于转移中途,用转移事件命名会误导读者。SE-0414 引入的断开区域概念才是包装器真正维持的不变量,沿用该名称保持词汇与既有隔离模型一致。

在泛型参数上使用sending标注

与其引入包装类型,也可以尝试让泛型类型参数化"元素是否为sending"。但这需要大量语言改动来支持基于泛型约束的条件式sending应用,并使泛型签名复杂化。包装类型方案无需任何语言改动,仅靠库新增即提供等价功能。

Disconnected做成协议

协议方案可以应用于既有类型,但需要证明符合类型的所有值都处于断开区域——这对可变类型无法强制执行。包装类型方案通过构造提供更强保证。

暴露包裹值的 borrow 访问器

添加borrow访问器(如public var value: Value { borrow })会很符合人体工程学,也能与 SE-0519Ref/MutableRef类型 的借用访问器自然组合(持有Disconnected<Value>元素的容器会产出Ref<Disconnected<Value>>投影)。但任何此类访问器在无条件Sendable一致性下都是不健全的Sendable一致性告诉类型检查器包装器可以在隔离区域间转移而无需区域跟踪;从包装器中复制出携带引用的非Sendable值,会创建编译器无法关联回包装器的别名。提案给出了竞争示例:

final class Box { var state = 0 } struct Foo { let box: Box } actor A { func test() { let disconnected = Disconnected(Foo(box: Box())) let escaped = disconnected.value.box // 把类引用复制进 actor A 的区域 Task.detached { var d = consume disconnected // Sendable,因此被允许 d.consume().box.state += 1 // detached task 触碰 Box } escaped.state += 1 // actor A 触碰同一个 Box // race } }

编译器允许把disconnected转移进 detached task(类型是Sendable),却没有意识到escaped别名了包装器内部的存储。基于 SE-0519 的var ref: Ref<Value> { borrow }投影存在同样的漏洞;var mutableRef: MutableRef<Value> { mutate }投影甚至更糟——MutableRef.value的 setter 接受当前区域内任意Value而无sending约束,会允许通过赋值直接合并区域。

提案 API 通过只允许sending边界上的原子转移避开了整类问题:每个操作要么消费包装器,要么用另一个sending值置换,因此任何指向包装器存储的别名都无法比一次转移活得更久。健全的 borrow/mutate 式 API 要么需要让Disconnected条件性Sendable(这违背其目的),要么需要新的语言支持来跟踪从无条件Sendable包装器投影出的值的区域。

Value: Sendable时的只读访问器

Value自身符合Sendable时,读取访问器的健全性论证不适用:读取Sendable包裹值暴露的引用已经活在可安全共享的区域,投影出来不会产生数据竞争。因此曾考虑条件扩展:

extension Disconnected where Value: ~Copyable & Sendable { public var sendableValue: Value { borrow } }

该访问器最终未纳入:目前没有出现令人信服的用例——对Value: Sendable泛型的代码已经可以通过consume()exchange(newValue:)解包并直接操作;对具体Disconnected<T>T: Sendable)的代码可以直接使用T,无需经过包装器。按一致性拆分 API 表面还会迫使调用方记忆哪些操作可用、迫使泛型代码在约束变化时迁移。提案保持 API 对所有Value类型统一。若未来出现直接读取Sendable载荷的具体用例,可在不破坏源/ABI 兼容性的前提下于后续版本添加。

要求withValue的闭包返回sending

SE-0433MutexwithLock声明闭包参数为(inout sending Value) throws(E) -> sending Result并把sending Result传播到外层返回。为withValue考虑过平行签名:

public mutating func withValue<Return: ~Copyable, Failure>( body: (inout sending Value) throws(Failure) -> sending Return ) throws(Failure) -> sending Return

该方案被否决,因为非sendingReturn严格更具表达力。需要支持两种闭包形态:返回从包裹值派生、用于跨隔离边界转移的值;返回与调用方区域绑定的值(例如与包裹值一起使用的、被捕获的非Sendable值)。第一种形态在任一签名下都可表达(非sendingReturn通过从闭包返回Disconnected<T>来编码);第二种形态只能在非sendingReturn下表达——已在调用方区域的值无法在sending边界返回。

健全性担忧已由inout sending Value参数覆盖:返回别名进入包裹存储的值的闭包,会在退出时让该存储留下跨区域引用,违反参数上的sending不变量并产生编译期错误,与返回声明方式无关。与Mutex.withLock的不对称反映了用途差异:Mutex保护共享状态,withLock的主流模式是从加锁状态中提取值供临界区外使用,sending Result自然契合;Disconnected是活在调用方区域的自有包装器withValue需要支持混合包裹值状态与调用方状态的闭包。

支持~Escapable

当前设计把Disconnected限制在可逃逸(escapable)类型上。断开区域属性概念上独立于生命周期依赖,放宽Value约束、让Disconnected条件性Escapable很诱人:

struct Disconnected<Value: ~Copyable & ~Escapable>: ~Copyable, ~Escapable, Sendable { ... } extension Disconnected: Escapable where Value: Escapable {}

但该泛化实践中无益。SE-0446 引入的不可逃逸类型 是对某源存储有生命周期依赖的非自有视图(例如MutableSpan<Element>借用自Array<Element>),带来两个问题:

  1. 源头不存在sending形态:视图类型由返回"依赖self生命周期"值的借用访问器产生,没有可消费的sending访问器,Disconnected(array.mutableSpan)根本无法构造。
  2. 生命周期源头不会随包装器移动:即使能产生sending视图,视图仍携带指向他处存储的引用。把Disconnected<MutableSpan<Int>>转移到另一个隔离区域会让底层的Array留在原地,构造性地违反断开区域属性——泛型包装器无从得知生命周期源头是什么、也无从携带它。

结语:Disconnected在 Swift 并发工具箱中的位置

Disconnected是对 SE-0430 未来方向的落地:它让"断开"从函数边界的瞬时属性升格为类型层面的稳定不变量,使AsyncSequence等类型可以把非Sendable类型的缓冲元素作为sending返回,而无需在实现中诉诸不安全的 opt-out。它与Synchronization模块中的MutexAtomic并肩,为"跨隔离边界传输非Sendable值"这一需求提供了类型安全的正规通道。完整的标准库实现位于 swiftlang/swift#89597,状态为Accepted

  • 文档

【免费下载链接】swift-evolution

This maintains proposals for changes and user-visible enhancements to the Swift Programming Language.

项目地址:https://gitcode.com/gh_mirrors/sw/swift-evolution
点击查看免费下载
上一篇:Laguna-S-2.1-oQ2e与原生模型对比:MMLUPro 70.3%/MathQA 84.0%精度测试报告
下一篇:doc_wei/erp-pro:小程序开发技巧深度解析

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询