Skip to content

Commit a39c3fb

Browse files
committed
Add draft proposal for transferring parameters and results.
1 parent edde1f3 commit a39c3fb

File tree

1 file changed

+320
-0
lines changed

1 file changed

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

Comments
 (0)