|
| 1 | +# `transferring` isolation regions of parameter and result values |
| 2 | + |
| 3 | +* Proposal: [SE-0430](0430-transferring-parameters-and-results.md) |
| 4 | +* Authors: [Michael Gottesman](https://github.com/gottesmm), [Holly Borla](https://github.com/hborla), [John McCall](https://github.com/rjmccall) |
| 5 | +* Review Manager: [Becca Royal-Gordon](https://github.com/beccadax) |
| 6 | +* Status: **Active Review (March 21...April 1, 2024)** |
| 7 | +* Implementation: On `main` gated behind `-enable-experimental-feature TransferringArgsAndResults` |
| 8 | +* Previous Proposal: [SE-0414: Region-based isolation](/proposals/0414-region-based-isolation.md) |
| 9 | +* Review: ([pitch](https://forums.swift.org/t/pitch-transferring-isolation-regions-of-parameter-and-result-values/70240)) |
| 10 | + |
| 11 | + |
| 12 | +## Introduction |
| 13 | + |
| 14 | +This proposal extends region isolation to enable an explicit `transferring` |
| 15 | +annotation to denote when a parameter or result value is required to be in a |
| 16 | +disconnected region at the function boundary. This allows the callee or the |
| 17 | +caller, respectively, to transfer a non-`Sendable` parameter or result value |
| 18 | +over an isolation boundary or merge the value into an actor-isolated region. |
| 19 | + |
| 20 | +## Motivation |
| 21 | + |
| 22 | +SE-0414 introduced region isolation to enable safely transferring non-`Sendable` |
| 23 | +values over isolation boundaries. In most cases, function argument and result |
| 24 | +values are merged together into the same region for any given call. This means |
| 25 | +that non-`Sendable` parameter values can never be transferred: |
| 26 | + |
| 27 | +```swift |
| 28 | +// Compiled with -swift-version 6 |
| 29 | + |
| 30 | +class NonSendable {} |
| 31 | + |
| 32 | +@MainActor func main(ns: NonSendable) {} |
| 33 | + |
| 34 | +func tryTransfer(ns: NonSendable) async { |
| 35 | + // error: task isolated value of type 'NonSendable' transferred to |
| 36 | + // main actor-isolated context |
| 37 | + await main(ns: ns) |
| 38 | +} |
| 39 | +``` |
| 40 | + |
| 41 | +Actor initializers have a special rule that allows transferring its parameter |
| 42 | +values into the actor-isolated region. Actor initializers are `nonisolated`, so |
| 43 | +a call to an actor initializer does not cross an isolation boundary, meaning |
| 44 | +the argument values would be usable in the caller after the initializer returns |
| 45 | +under the standard region isolation rules. SE-0414 consider actor initializer |
| 46 | +parameters as being transferred into the actor's region to allow initializing |
| 47 | +actor-isolated state with those values: |
| 48 | + |
| 49 | +```swift |
| 50 | +class NonSendable {} |
| 51 | + |
| 52 | +actor MyActor { |
| 53 | + let ns: NonSendable |
| 54 | + init(ns: NonSendable) { |
| 55 | + self.ns = ns |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +func transfer() { |
| 60 | + let ns = NonSendable() |
| 61 | + let myActor = MyActor(ns: ns) // okay; 'ns' is transferred to 'myActor' region |
| 62 | +} |
| 63 | + |
| 64 | +func invalidTransfer() { |
| 65 | + let ns = NonSendable() |
| 66 | + |
| 67 | + // error: 'ns' is transferred from nonisolated caller to actor-isolated |
| 68 | + // init. Later uses in caller could race with uses on the actor |
| 69 | + let myActor = MyActor(ns: ns) |
| 70 | + |
| 71 | + print(ns) // note: note: access here could race |
| 72 | +} |
| 73 | +``` |
| 74 | + |
| 75 | +In the above code, if the `ns` local variable in the `transfer` function were |
| 76 | +instead a function parameter, it would be invalid to transfer `ns` into |
| 77 | +`myActor`'s region because the caller of `transfer()` may use the argument |
| 78 | +value after `transfer()` returns: |
| 79 | + |
| 80 | +```swift |
| 81 | +func transfer(ns: NonSendable) { |
| 82 | + // error: task isolated value of type 'NonSendable' transferred to |
| 83 | + // actor-isolated context; later accesses to value could race |
| 84 | + let myActor = MyActor(ns: ns) |
| 85 | +} |
| 86 | + |
| 87 | +func callTransfer() { |
| 88 | + let ns = NonSendable() |
| 89 | + transfer(ns: ns) |
| 90 | + print(ns) |
| 91 | +} |
| 92 | +``` |
| 93 | + |
| 94 | +The "transferred parameter" behavior of actor initializers is a generally |
| 95 | +useful concept, but it is not possible to explicitly specify that functions |
| 96 | +and methods can transfer away specific parameter values. Consider the following |
| 97 | +code that uses `CheckedContinuation`: |
| 98 | + |
| 99 | +```swift |
| 100 | +@MainActor var mainActorState: NonSendable? |
| 101 | + |
| 102 | +nonisolated func test() async { |
| 103 | + let ns = await withCheckedContinuation { continuation in |
| 104 | + Task { @MainActor in |
| 105 | + let ns = NonSendable() |
| 106 | + // Oh no! 'NonSendable' is passed from the main actor to a |
| 107 | + // nonisolated context here! |
| 108 | + continuation.resume(returning: ns) |
| 109 | + |
| 110 | + // Save 'ns' to main actor state for concurrent access later on |
| 111 | + mainActorState = ns |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + // 'ns' and 'mainActorState' are now the same non-Sendable value; |
| 116 | + // concurrent access is possible! |
| 117 | + ns.mutate() |
| 118 | +} |
| 119 | +``` |
| 120 | + |
| 121 | +In the above code, the closure argument to `withCheckedContinuation` crosses |
| 122 | +an isolation boundary to get onto the main actor, creates a non-`Sendable` |
| 123 | +value, then resumes the continuation with that non-`Sendable` value. The |
| 124 | +non-`Sendable` value is then returned to the original `nonisolated` context, |
| 125 | +thus crossing an isolation boundary. Because `resume(returning:)` does not |
| 126 | +impose a `Sendable` requirement on its argument, this code does not produce any |
| 127 | +data-race safety diagnostics, even under `-strict-concurrency=complete`. |
| 128 | + |
| 129 | +Requiring `Sendable` on the parameter type of `resume(returning:)` is a harsh |
| 130 | +restriction, and it's safe to pass a non-`Sendable` value as long as the value |
| 131 | +is in a disconnected region and all values in that disconnected region are not |
| 132 | +used again after the call to `resume(returning:)`. |
| 133 | + |
| 134 | +## Proposed solution |
| 135 | + |
| 136 | +This proposal enables explicitly specifying parameter and result values as |
| 137 | +being transferred over an isolation boundary using a contextual `transferring` |
| 138 | +modifier: |
| 139 | + |
| 140 | +```swift |
| 141 | +public struct CheckedContinuation<T, E: Error>: Sendable { |
| 142 | + public func resume(returning value: transferring T) |
| 143 | +} |
| 144 | + |
| 145 | +public func withCheckedContinuation<T>( |
| 146 | + function: String = #function, |
| 147 | + _ body: (CheckedContinuation<T, Never>) -> Void |
| 148 | +) async -> transferring T |
| 149 | +``` |
| 150 | + |
| 151 | +## Detailed design |
| 152 | + |
| 153 | +A `transferring` parameter requires the argument value to be in a disconnected |
| 154 | +region. At the point of the call, the disconnected region is transferred away |
| 155 | +and cannot be used in the caller's isolation domain after the transfer, |
| 156 | +allowing the callee to transfer the parameter value to a region that is opaque |
| 157 | +to the caller: |
| 158 | + |
| 159 | +```swift |
| 160 | +@MainActor |
| 161 | +func acceptTransfer(_: transferring NonSendable) {} |
| 162 | + |
| 163 | +func transferToMain() async { |
| 164 | + let ns = NonSendable() |
| 165 | + |
| 166 | + // error: value of non-Sendable type 'NonSendable' accessed after transfer to main actor |
| 167 | + await acceptTransfer(ns) |
| 168 | + |
| 169 | + // note: access here could race |
| 170 | + print(ns) |
| 171 | +} |
| 172 | +``` |
| 173 | + |
| 174 | +What the callee does with the argument value is opaque to the caller; the |
| 175 | +callee may transfer the value away, or it may merge the value to the isolation |
| 176 | +region of one of the other parameters. |
| 177 | + |
| 178 | +A `transferring` result requires the function implementation to return a value in |
| 179 | +a disconnected region: |
| 180 | + |
| 181 | +```swift |
| 182 | +@MainActor |
| 183 | +struct S { |
| 184 | + let ns: NonSendable |
| 185 | + |
| 186 | + func getNonSendableInvalid() -> transferring NonSendable { |
| 187 | + // error: value of non-Sendable type 'NonSendable' transferred out of main |
| 188 | + // actor |
| 189 | + return ns |
| 190 | + } |
| 191 | + |
| 192 | + func getNonSendable() -> transferring NonSendable { |
| 193 | + return NonSendable() // okay |
| 194 | + } |
| 195 | +} |
| 196 | +``` |
| 197 | + |
| 198 | +The caller of a function returning a `transferring` result can assume the value |
| 199 | +is in a disconnected region, enabling non-`Sendable` result values to cross an |
| 200 | +actor isolation boundary: |
| 201 | + |
| 202 | +```swift |
| 203 | +@MainActor func onMain(_: NonSendable) { ... } |
| 204 | + |
| 205 | +nonisolated func f(s: S) async { |
| 206 | + let ns = s.getNonSendable() // okay; 'ns' is in a disconnected region |
| 207 | + |
| 208 | + await onMain(ns) // 'ns' can be transferred away to the main actor |
| 209 | +} |
| 210 | +``` |
| 211 | + |
| 212 | +A `Sendable` value always satisfies the requirements of `transferring` because |
| 213 | +`Sendable` values are always safe to pass over isolation boundaries, and thus |
| 214 | +not included in region analysis. |
| 215 | + |
| 216 | +### Function subtyping |
| 217 | + |
| 218 | +For a given type `T`, `transferring T` is a subtype of `T`. `transferring` is |
| 219 | +contravariant in parameter position; if a function type is expecting a regular |
| 220 | +parameter of type `T`, it's perfectly valid to pass a `transferring T` value |
| 221 | +that is known to be in a disconnected region. If a function is expecting a |
| 222 | +parameter of type `transferring T`, it is not valid to pass a value that is not |
| 223 | +in a disconnected region: |
| 224 | + |
| 225 | +```swift |
| 226 | +func transferringParameterConversions( |
| 227 | + f1: (transferring NonSendable) -> Void, |
| 228 | + f2: (NonSendable) -> Void |
| 229 | +) { |
| 230 | + let _: (transferring NonSendable) -> Void = f1 // okay |
| 231 | + let _: (transferring NonSendable) -> Void = f2 // okay |
| 232 | + let _: (NonSendable) -> Void = f1 // error |
| 233 | +} |
| 234 | +``` |
| 235 | + |
| 236 | +`transferring` is covariant in result position. If a function returns a value |
| 237 | +of type `transferring T`, it's valid to instead treat the result as if it were |
| 238 | +merged with the other parameters. If a function returns a regular value of type |
| 239 | +`T`, it is not valid to assume the value is in a disconnected region: |
| 240 | + |
| 241 | +```swift |
| 242 | +func transferringResultConversions( |
| 243 | + f1: () -> transferring NonSendable, |
| 244 | + f2: () -> NonSendable |
| 245 | +) { |
| 246 | + let _: () -> transferring NonSendable = f1 // okay |
| 247 | + let _: () -> transferring NonSendable = f2 // error |
| 248 | + let _: () -> NonSendable = f1 // okay |
| 249 | +} |
| 250 | +``` |
| 251 | + |
| 252 | +### Protocol conformances |
| 253 | + |
| 254 | +A protocol requirement may include `transferring` parameter or result annotations: |
| 255 | + |
| 256 | +```swift |
| 257 | +protocol P1 { |
| 258 | + func requirement(_: transferring NonSendable) |
| 259 | +} |
| 260 | + |
| 261 | +protocol P2 { |
| 262 | + func requirement() -> transferring NonSendable |
| 263 | +} |
| 264 | +``` |
| 265 | + |
| 266 | +Following the function subtyping rules in the previous section, a protocol |
| 267 | +requirement with a `transferring` parameter may be witnessed by a function |
| 268 | +with a non-transferring parameter: |
| 269 | + |
| 270 | +```swift |
| 271 | +struct X1: P1 { |
| 272 | + func requirement(_: transferring NonSendable) {} |
| 273 | +} |
| 274 | + |
| 275 | +struct X2: P1 { |
| 276 | + func requirement(_: NonSendable) {} |
| 277 | +} |
| 278 | +``` |
| 279 | + |
| 280 | +A protocol requirement with a `transferring` result must be witnessed by a |
| 281 | +function with a `transferring` result, and a requirement with a plain result |
| 282 | +of type `T` may be witnessed by a function returning a `transferring T`: |
| 283 | + |
| 284 | +```swift |
| 285 | +struct Y1: P1 { |
| 286 | + func requirement() -> transferring NonSendable { |
| 287 | + return NonSendable() |
| 288 | + } |
| 289 | +} |
| 290 | + |
| 291 | +struct Y2: P1 { |
| 292 | + let ns: NonSendable |
| 293 | + func requirement() -> NonSendable { // error |
| 294 | + return ns |
| 295 | + } |
| 296 | +} |
| 297 | +``` |
| 298 | + |
| 299 | +### `transferring inout` parameters |
| 300 | + |
| 301 | +A `transferring` parameter can also be marked as `inout`, meaning that the |
| 302 | +argument value must be in a disconnected region when passed to the function, |
| 303 | +and the parameter value must be in a disconnected region when the function |
| 304 | +returns. Inside the function, the `transferring inout` parameter can be merged |
| 305 | +with actor-isolated callees or further transferred as long as the parameter is |
| 306 | +re-assigned a value in a disconnected region upon function exit. |
| 307 | + |
| 308 | +### Ownership convention for `transferring` parameters |
| 309 | + |
| 310 | +When a call passes an argument to a `transferring` parameter, the caller cannot |
| 311 | +use the argument value again after the callee returns. By default `transferring` |
| 312 | +on a function parameter implies that the callee consumes the parameter. Like |
| 313 | +`consuming` parameters, a `transferring` parameter can be re-assigned inside |
| 314 | +the callee. Unlike `consuming` parameters, `transferring` parameters do not |
| 315 | +have no-implicit-copying semantics. |
| 316 | + |
| 317 | +To opt into no-implicit-copying semantics or to change the default ownership |
| 318 | +convention, `transferring` may also be used with an explicit `consuming` or |
| 319 | +`borrowing` ownership modifier. Note that an explicit `borrowing` annotation |
| 320 | +always implies no-implicit-copying, so there is no way to change the default |
| 321 | +ownership convention of a `transferring` parameter without also opting into |
| 322 | +no-implicit-copying semantics. |
| 323 | + |
| 324 | +### Adoption in the Concurrency library |
| 325 | + |
| 326 | +There are several APIs in the concurrency library that transfer a parameter |
| 327 | +across isolation boundaries and don't need the full guarnatees of `Sendable`. |
| 328 | +These APIs will instead adopt `transferring` parameters: |
| 329 | + |
| 330 | +* `CheckedContinuation.resume(returning:)` |
| 331 | +* `Async{Throwing}Stream.Continuation.yield(_:)` |
| 332 | +* `Async{Throwing}Stream.Continuation.yield(with:)` |
| 333 | +* The `Task` creation APIs |
| 334 | + |
| 335 | +Note that this list does not include `UnsafeContinuation.resume(returning:)`, |
| 336 | +because `UnsafeContinuation` deliberately opts out of correctness checking. |
| 337 | + |
| 338 | +## Source compatibility |
| 339 | + |
| 340 | +In the Swift 5 language mode, `transferring` diagnostics are suppressed under |
| 341 | +minimal concurrency checking, and diagnosed as warnings under strict |
| 342 | +concurrency checking. The diagnostics are errors in the Swift 6 language |
| 343 | +mode, as shown in the code examples in this proposal. This diagnostic behavior |
| 344 | +based on language mode allows `transferring` to be adopted in existing |
| 345 | +Concurrency APIs including `CheckedContinuation`. |
| 346 | + |
| 347 | +## ABI compatibility |
| 348 | + |
| 349 | +This proposal does not change how any existing code is compiled. |
| 350 | + |
| 351 | +## Implications on adoption |
| 352 | + |
| 353 | +Adding `transferring` to a parameter is more restrictive at the caller, and |
| 354 | +more expressive in the callee. Adding `transferring` to a result type is more |
| 355 | +restrictive in the callee, and more expressive in the caller. |
| 356 | + |
| 357 | +For libraries with library evolution, `transferring` changes name mangling, so |
| 358 | +any adoption must preserve the mangling using `@_silgen_name`. Adoping |
| 359 | +`transferring` must preserve the ownership convention of parameters; no |
| 360 | +additional annotation is necessary if the parameter is already (implicitly or |
| 361 | +explicitly) `consuming`. |
| 362 | + |
| 363 | +## Future directions |
| 364 | + |
| 365 | +### `Disconnected` types |
| 366 | + |
| 367 | +`transferring` requires parameter and result values to be in a disconnected |
| 368 | +region at the function boundary, but there is no way to preserve that a value |
| 369 | +is in a disconnected region through stored properties, collections, function |
| 370 | +calls, etc. To preserve that a value is in a disconnected region through the |
| 371 | +type system, we could introduce a `Disconnected` type into the Concurrency |
| 372 | +library. The `Disconnected` type would suppress copying via `~Copyable`, it |
| 373 | +would conform to `Sendable`, constructing a `Disconnected` instance would |
| 374 | +require the value it wraps to be in a disconnected region, and a value of type |
| 375 | +`Disconnected` can never be merged into another isolation region. |
| 376 | + |
| 377 | +This would enable important patterns that take a `transferring T` parameter, |
| 378 | +store the value in a collection of `Disconnected<T>`, and later remove values |
| 379 | +from the collection and return them as `transferring T` results. This would |
| 380 | +allow some `AsyncSequence` types to return non-`Sendable` buffered elements as |
| 381 | +`transferring` without resorting to unsafe opt-outs in the implementation. |
0 commit comments