|
| 1 | +# `transferring` parameter and result types |
| 2 | + |
| 3 | +* Proposal: [SE-NNNN](NNNN-transferring-parameteres-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: TBD |
| 6 | +* Status: **Awaiting review** |
| 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/...)) |
| 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, allowing the callee or the caller, respectively, to |
| 17 | +transfer a non-`Sendable` parameter or result value over an isolation boundary. |
| 18 | + |
| 19 | +## Motivation |
| 20 | + |
| 21 | +SE-0414 introduced region isolation to enable safely transferring non-`Sendable` |
| 22 | +values over isolation boundaries. In most cases, function argument and result |
| 23 | +values are merged together into the same region for any given call. This means |
| 24 | +that non-`Sendable` parameter values can never be transferred: |
| 25 | + |
| 26 | +```swift |
| 27 | +// Compiled with -swift-version 6 |
| 28 | + |
| 29 | +class NonSendable {} |
| 30 | + |
| 31 | +@MainActor func main(ns: NonSendable) {} |
| 32 | + |
| 33 | +func tryTransfer(ns: NonSendable) async { |
| 34 | + // error: task isolated value of type 'NonSendable' transferred to |
| 35 | + // main actor-isolated context |
| 36 | + await main(ns: ns) |
| 37 | +} |
| 38 | +``` |
| 39 | + |
| 40 | +However, for actor initializers, parameters are always considered to be |
| 41 | +transferred into the actor's region, because initializer parameters are |
| 42 | +typically used to initialize actor-isolated state: |
| 43 | + |
| 44 | +```swift |
| 45 | +class NonSendable {} |
| 46 | + |
| 47 | +actor MyActor { |
| 48 | + let ns: NonSendable |
| 49 | + init(ns: NonSendable) { |
| 50 | + self.ns = ns |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +func transfer() { |
| 55 | + let ns = NonSendable() |
| 56 | + let myActor = MyActor(ns: ns) // okay; 'ns' is transferred to 'myActor' region |
| 57 | +} |
| 58 | + |
| 59 | +func invalidTransfer() { |
| 60 | + let ns = NonSendable() |
| 61 | + |
| 62 | + // error: 'ns' is transferred from nonisolated caller to actor-isolated |
| 63 | + // init. Later uses in caller could race with uses on the actor |
| 64 | + let myActor = MyActor(ns: ns) |
| 65 | + |
| 66 | + print(ns) // note: note: access here could race |
| 67 | +} |
| 68 | +``` |
| 69 | + |
| 70 | +In the above code, if the `ns` local variable in the `transfer` function were |
| 71 | +instead a function parameter, it would be invalid to transfer `ns` into |
| 72 | +`myActor`'s region because the caller of `transfer()` may use the argument |
| 73 | +value after `transfer()` returns: |
| 74 | + |
| 75 | +```swift |
| 76 | +func transfer(ns: NonSendable) { |
| 77 | + // error: task isolated value of type 'NonSendable' transferred to |
| 78 | + // actor-isolated context; later accesses to value could race |
| 79 | + let myActor = MyActor(ns: ns) |
| 80 | +} |
| 81 | + |
| 82 | +func callTransfer() { |
| 83 | + let ns = NonSendable() |
| 84 | + transfer(ns: ns) |
| 85 | + print(ns) |
| 86 | +} |
| 87 | +``` |
| 88 | + |
| 89 | +The "transferred parameter" behavior of actor initializers is a generally |
| 90 | +useful concept, but it is not possible to explicitly specify that functions |
| 91 | +and methods can transfer away specific parameter values. Consider the following |
| 92 | +code that uses `CheckedContinuation`: |
| 93 | + |
| 94 | +```swift |
| 95 | +@MainActor var mainActorState: NonSendable? |
| 96 | + |
| 97 | +nonisolated func test() async { |
| 98 | + let ns = await withCheckedContinuation { continuation in |
| 99 | + Task { @MainActor in |
| 100 | + let ns = NonSendable() |
| 101 | + // Oh no! 'NonSendable' is passed from the main actor to a |
| 102 | + // nonisolated context here! |
| 103 | + continuation.resume(returning: ns) |
| 104 | + |
| 105 | + // Save 'ns' to main actor state for concurrent access later on |
| 106 | + mainActorState = ns |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + // 'ns' and 'mainActorState' are now the same non-Sendable value; |
| 111 | + // concurrent access is possible! |
| 112 | + ns.mutate() |
| 113 | +} |
| 114 | +``` |
| 115 | + |
| 116 | +In the above code, the closure argument to `withCheckedContinuation` crosses |
| 117 | +an isolation boundary to get onto the main actor, creates a non-`Sendable` |
| 118 | +value, then resumes the continuation with that non-`Sendable` value. The |
| 119 | +non-`Sendable` value is then returned to the original `nonisolated` context, |
| 120 | +thus crossing an isolation boundary. Because `resume(returning:)` does not |
| 121 | +impose a `Sendable` requirement on its argument, this code does not produce any |
| 122 | +data-race safety diagnostics, even under `-strict-concurrency=complete`. |
| 123 | + |
| 124 | +Requiring `Sendable` on the parameter type of `resume(returning:)` is a harsh |
| 125 | +restriction, and it's safe to pass a non-`Sendable` value as long as the value |
| 126 | +is in a disconnected region. |
| 127 | + |
| 128 | +## Proposed solution |
| 129 | + |
| 130 | +This proposal enables explicitly specifying parameter and result values as |
| 131 | +being transferred over an isolation boundary using a modifier: |
| 132 | + |
| 133 | +```swift |
| 134 | +public struct CheckedContinuation<T, E: Error>: Sendable { |
| 135 | + public func resume(returning value: transferring T) |
| 136 | +} |
| 137 | + |
| 138 | +public func withCheckedContinuation<T>( |
| 139 | + function: String = #function, |
| 140 | + _ body: (CheckedContinuation<T, Never>) -> Void |
| 141 | +) async -> transferring T |
| 142 | +``` |
| 143 | + |
| 144 | +## Detailed design |
| 145 | + |
| 146 | +A `transferring` parameter requires the argument value to be in a disconnected |
| 147 | +region. At the point of the call, the disconnected region is transferred to |
| 148 | +the isolation domain of the callee, and cannot be used in the caller's isolation |
| 149 | +domain after the transfer: |
| 150 | + |
| 151 | +```swift |
| 152 | +@MainActor |
| 153 | +func acceptTransfer(_: transferring NonSendable) {} |
| 154 | + |
| 155 | +func transferToMain() async { |
| 156 | + let ns = NonSendable() |
| 157 | + |
| 158 | + // error: value of non-Sendable type 'NonSendable' accessed after transfer to main actor |
| 159 | + await acceptTransfer(ns) |
| 160 | + |
| 161 | + // note: access here could race |
| 162 | + print(ns) |
| 163 | +} |
| 164 | +``` |
| 165 | + |
| 166 | +What the callee does with the argument value is opaque to the caller; the |
| 167 | +callee may transfer the value away, or it may merge the value to the isolation |
| 168 | +region of one of the other parameters. |
| 169 | + |
| 170 | +A `transferring` result requires the function implementation to return a value in |
| 171 | +a disconnected region: |
| 172 | + |
| 173 | +```swift |
| 174 | +@MainActor |
| 175 | +struct S { |
| 176 | + let ns: NonSendable |
| 177 | + |
| 178 | + func getNonSendableInvalid() -> transferring NonSendable { |
| 179 | + // error: value of non-Sendable type 'NonSendable' transferred out of main |
| 180 | + // actor |
| 181 | + return ns |
| 182 | + } |
| 183 | + |
| 184 | + func getNonSendable() -> transferring NonSendable { |
| 185 | + return NonSendable() // okay |
| 186 | + } |
| 187 | +} |
| 188 | +``` |
| 189 | + |
| 190 | +The caller of a function returning a `transferring` result can assume the value |
| 191 | +is in a disconnected region, enabling non-`Sendable` result values to cross an |
| 192 | +actor isolation boundary: |
| 193 | + |
| 194 | +```swift |
| 195 | +nonisolated func f(s: S) async { |
| 196 | + let ns = s.getNonSendable() // okay; 'ns' is in a disconnected region |
| 197 | +} |
| 198 | +``` |
| 199 | + |
| 200 | +### Function subtyping |
| 201 | + |
| 202 | +For a given type `T`, `transferring T` is a subtype of `T`. `transferring` is |
| 203 | +contravariant in parameter position; if a function type is expecting a regular |
| 204 | +parameter of type `T`, it's perfectly valid to pass a `transferring T` value |
| 205 | +that is known to be in a disconnected region. If a function is expecting a |
| 206 | +parameter of type `transferring T`, it is not valid to pass a value that is not |
| 207 | +in a disconnected region: |
| 208 | + |
| 209 | +```swift |
| 210 | +func transferringParameterConversions( |
| 211 | + f1: (transferring NonSendable) -> Void, |
| 212 | + f2: (NonSendable) -> Void |
| 213 | +) { |
| 214 | + let _: (transferring NonSendable) -> Void = f1 // okay |
| 215 | + let _: (transferring NonSendable) -> Void = f2 // okay |
| 216 | + let _: (NonSendable) -> Void = f1 // error |
| 217 | +} |
| 218 | +``` |
| 219 | + |
| 220 | +`transferring` is covariant in result position. If a function returns a value |
| 221 | +of type `transferring T`, it's valid to instead treat the result as if it were |
| 222 | +merged with the other parameters. If a function returns a regular value of type |
| 223 | +`T`, it is not valid to assume the value is in a disconnected region: |
| 224 | + |
| 225 | +```swift |
| 226 | +func transferringResultConversions( |
| 227 | + f1: () -> transferring NonSendable, |
| 228 | + f2: () -> NonSendable |
| 229 | +) { |
| 230 | + let _: () -> transferring NonSendable = f1 // okay |
| 231 | + let _: () -> transferring NonSendable = f2 // error |
| 232 | + let _: () -> NonSendable = f1 // okay |
| 233 | +} |
| 234 | +``` |
| 235 | + |
| 236 | +### Protocol conformances |
| 237 | + |
| 238 | +A protocol requirement may include `transferring` parameter or result annotations: |
| 239 | + |
| 240 | +```swift |
| 241 | +protocol P1 { |
| 242 | + func requirement(_: transferring NonSendable) |
| 243 | +} |
| 244 | + |
| 245 | +protocol P2 { |
| 246 | + func requirement() -> transferring NonSendable |
| 247 | +} |
| 248 | +``` |
| 249 | + |
| 250 | +Following the function subtyping rules in the previous section, a protocol |
| 251 | +requirement with a `transferring` parameter may be witnessed by a function |
| 252 | +with a non-transferring parameter: |
| 253 | + |
| 254 | +```swift |
| 255 | +struct X1: P1 { |
| 256 | + func requirement(_: transferring NonSendable) {} |
| 257 | +} |
| 258 | + |
| 259 | +struct X2: P1 { |
| 260 | + func requirement(_: NonSendable) {} |
| 261 | +} |
| 262 | +``` |
| 263 | + |
| 264 | +A protocol requirement with a `transferring` result must be witnessed by a |
| 265 | +function with a `transferring` result, and a requirement with a plain result |
| 266 | +of type `T` may be witnessed by a function returning a `transferring T`: |
| 267 | + |
| 268 | +```swift |
| 269 | +struct Y1: P1 { |
| 270 | + func requirement() -> transferring NonSendable { |
| 271 | + return NonSendable() |
| 272 | + } |
| 273 | +} |
| 274 | + |
| 275 | +struct Y2: P1 { |
| 276 | + let ns: NonSendable |
| 277 | + func requirement() -> NonSendable { // error |
| 278 | + return ns |
| 279 | + } |
| 280 | +} |
| 281 | +``` |
| 282 | + |
| 283 | +### Ownership convention for `transferring` parameters |
| 284 | + |
| 285 | +When a call passes an argument to a `transferring` parameter, the caller cannot |
| 286 | +use the argument value again after the callee returns. By default `transferring` |
| 287 | +on a function parameter implies that the callee consumes the parameter, but it does |
| 288 | +not imply no implicit copying semantics. `transferring` may also be used with an |
| 289 | +explicit `consuming` or `borrowing` ownership modifier. |
| 290 | + |
| 291 | +## Source compatibility |
| 292 | + |
| 293 | +In the Swift 5 language mode, `transferring` diagnostics are suppressed under |
| 294 | +minimal concurrency checking, and diagnosed as warnings under strict |
| 295 | +concurrency checking. The diagnostics are errors in the Swift 6 language |
| 296 | +mode, as shown in the code examples in this proposal. This diagnostic behavior |
| 297 | +based on language mode allows `transferring` to be adopted in existing |
| 298 | +Concurrency APIs including `CheckedContinuation`. |
| 299 | + |
| 300 | +## ABI compatibility |
| 301 | + |
| 302 | +This proposal does not change how any existing code is compiled. |
| 303 | + |
| 304 | +## Implications on adoption |
| 305 | + |
| 306 | +Adding `transferring` to a parameter is more restrictive at the caller, and |
| 307 | +more expressive in the callee. Adding `transferring` to a result type is more |
| 308 | +restrictive in the callee, and more expressive in the caller. |
| 309 | + |
| 310 | +For libraries with library evolution, `transferring` changes name mangling, so |
| 311 | +any adoption must preserve the mangling using `@_silgen_name`. Adoping |
| 312 | +`transferring` must preserve the ownership convention of parameters; no |
| 313 | +additional annotation is necessary if the parameter is already (implicitly or |
| 314 | +explicitly) `consuming`. |
| 315 | + |
| 316 | +## Future directions |
| 317 | + |
| 318 | +### `disconnected` types |
| 319 | + |
| 320 | +TODO |
0 commit comments