Skip to content

Commit ade3d8c

Browse files
authored
Merge pull request #2339 from hborla/transferring-parameters-and-results
Add draft proposal for transferring parameters and results.
2 parents d778eb6 + b23e263 commit ade3d8c

File tree

1 file changed

+381
-0
lines changed

1 file changed

+381
-0
lines changed
Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
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

Comments
 (0)