Skip to content

Commit 02a2e83

Browse files
committed
Various proposal clarifications.
1 parent 7c2b980 commit 02a2e83

1 file changed

Lines changed: 123 additions & 24 deletions

File tree

proposals/NNNN-async-function-isolation.md

Lines changed: 123 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111

1212
Swift's general philosophy is to prioritize safety and ease-of-use over
1313
performance, while still providing tools to write more efficient code. The
14-
current behavior of nonisolated async functions prioritizes performance at the
15-
expense of usability.
14+
current behavior of nonisolated async functions prioritizes main actor
15+
responsiveness at the expense of usability.
1616

1717
This proposal changes the behavior of nonisolated async functions to inherit
1818
the isolation of the caller, and introduces an explicit way to state that an
@@ -39,6 +39,7 @@ manner.
3939
- [Implications on adoption](#implications-on-adoption)
4040
- [Alternatives considered](#alternatives-considered)
4141
- [Different spelling for `@concurrent`](#different-spelling-for-concurrent)
42+
- [Use `nonisolated` instead of a separate `@concurrent` attribute](#use-nonisolated-instead-of-a-separate-concurrent-attribute)
4243
- [Don't introduce a type attribute for `@concurrent`](#dont-introduce-a-type-attribute-for-concurrent)
4344

4445
## Motivation
@@ -53,7 +54,7 @@ preventing unexpected overhang on the main actor.
5354

5455
This decision has a number of unfortunate consequences.
5556

56-
**`nonisolated` is difficult to understand.** There is a semantic difference
57+
**`nonisolated` is difficult to understand.** There is a semantic difference
5758
between the isolation behavior of nonisolated synchronous and asynchronous
5859
functions; nonisolated synchronous functions always stay in the isolation
5960
domain of the caller, while nonisolated async functions always switch off of
@@ -117,7 +118,7 @@ only manifests when calling the API from an actor.
117118

118119
The concurrency library itself has made this mistake, and many of the async
119120
APIs in the concurrency library have since transitioned to inheriting the
120-
isolation of the caller using isolated parameters; see
121+
isolation of the caller using isolated parameters; see
121122
[SE-0421](/proposals/0421-generalize-async-sequence.md) for an example.
122123

123124
**It's difficult to write higher-order async APIs.** Consider the following
@@ -165,8 +166,30 @@ complicated.
165166
I propose changing nonisolated async functions to inherit the isolation of the
166167
caller by default. This means that nonisolated functions always have the same
167168
isolation rules, regardless of whether the function is synchronous or
168-
asynchronous. The `@concurrent` declaration attribute can be used to opt into
169-
async functions always running concurrently with actors.
169+
asynchronous. This makes the following example from the motivation section
170+
valid, because the call to `x.performAsync()` does not cross an isolation
171+
boundary:
172+
173+
```swift
174+
class NotSendable {
175+
func performSync() { ... }
176+
func performAsync() async { ... }
177+
}
178+
179+
actor MyActor {
180+
let x: NotSendable
181+
182+
func call() async {
183+
x.performSync() // okay
184+
185+
await x.performAsync() // okay
186+
}
187+
}
188+
```
189+
190+
This proposal also introduces the `@concurrent` declaration attribute to opt
191+
out of isolation inheritance, so that the function always switches off of an
192+
actor to run.
170193

171194
## Detailed design
172195

@@ -204,7 +227,7 @@ The implicit parameter is not preserved when using a nonisolated async function
204227
as a value. When referencing a nonisolated async function unapplied in a
205228
context that expects a nonisolated `@Sendable` or `sending` function type, the
206229
function will switch off of the caller's actor when the function value is
207-
called.
230+
called, and sendable checking will be applied to argument and result values.
208231

209232
> Note: It is not feasible to implicitly add parameters to function values
210233
> without widespread ABI impact. It's possible to stage in an ABI change for
@@ -214,11 +237,18 @@ called.
214237
For example:
215238

216239
```swift
217-
func useAsValue() async {}
240+
class NotSendable { ... }
241+
242+
func useAsValue(_ ns: NotSendable) async { ... }
243+
244+
@MainActor let global: NotSendable = .init()
218245

219246
@MainActor
220-
func callSendableClosure(closure: @Sendable () async -> Void) {
221-
await closure()
247+
func callSendableClosure(closure: @Sendable (NotSendable) async -> Void) {
248+
let ns = NotSendable()
249+
await closure(ns) // okay
250+
251+
await closure(global) // error
222252
}
223253

224254
callSendableClosure(useAsValue)
@@ -270,7 +300,8 @@ actor MyActor {
270300

271301
It is an error to use `@concurrent` together with another form of isolation,
272302
including global actors, isolated parameters, `nonisolated`, and
273-
`@isolated(any)`.
303+
`@isolated(any)`. `@concurrent` can be used together with `@Sendable` or
304+
`sending`.
274305

275306
### Task isolation inheritance
276307

@@ -386,30 +417,71 @@ isolation for a closure depends on two factors:
386417
2. Whether the contextual type of the closure is `@Sendable` or `sending`.
387418

388419
If the contextual type of the closure is neither `@Sendable` nor `sending`, the
389-
inferred isolation of the closure is the same as the enclosing context. If
390-
either the type of the closure is `@Sendable` or the closure is passed to a
391-
`sending` parameter, the closure is inferred to be nonisolated:
420+
inferred isolation of the closure is the same as the enclosing context:
392421

393422
```swift
394423
class NotSendable { ... }
395424

396425
@MainActor
397-
func closureOnMain(ns: NotSendable) {
398-
let nonSendableClosure: () -> Void = {
426+
func closureOnMain(ns: NotSendable) async {
427+
let syncClosure: () -> Void = {
399428
// inferred to be @MainActor-isolated
400429

401430
// capturing main-actor state is okay
402431
print(ns)
403432
}
404433

405-
let sendableClosure: @Sendable () -> Void = {
434+
// runs on the main actor
435+
syncClosure()
436+
437+
let asyncClosure: (NotSendable) async -> Void = {
438+
// inferred to be @MainActor-isolated
439+
440+
print($0)
441+
}
442+
443+
// runs on the main actor;
444+
// passing main-actor state is okay
445+
await asyncClosure(ns)
446+
}
447+
```
448+
449+
If either the type of the closure is `@Sendable` or the closure is passed to a
450+
`sending` parameter, the closure is inferred to be `nonisolated`. If the
451+
closure is `async`, the closure will switch off of the calling actor to run:
452+
453+
```swift
454+
class NotSendable { ... }
455+
456+
@MainActor
457+
func closureOnMain(ns: NotSendable) {
458+
let syncClosure: @Sendable () -> Void = {
406459
// inferred to be nonisolated
407460

408461
print(ns) // error
409462
}
463+
464+
let asyncClosure: @Sendable (NotSendable) async -> Void = {
465+
// inferred to be nonisolated and runs off of the actor
466+
467+
print($0)
468+
}
469+
470+
await asyncClosure(ns) // error
410471
}
411472
```
412473

474+
**Open question.** The current compiler implementation does not implicitly
475+
capture the isolation of the enclosing context for async closures formed in a
476+
method with an isolated parameter; the closure is only isolated to the actor if
477+
the actor value is explicitly captured. This is done to avoid implicitly
478+
capturing values that are invisible to the programmer, because this can lead to
479+
reference cycles. This behavior is surprising with respect to data-race safety,
480+
but I'm concerned about changes to this behavior causing new memory leaks. One
481+
potential compromise is to keep the current isolation inference behavior, and
482+
offer fix-its to capture the actor if there are any data-race safety errors
483+
from capturing state in the actor's region.
484+
413485
### Function conversions
414486

415487
A function conversion that changes the isolation of a function value applies
@@ -659,9 +731,13 @@ class NotSendable { ... }
659731
### Executor switching
660732

661733
Async functions switch executors in the implementation when entering the
662-
function, and after any calls to async functions. Isolated functions switch to
663-
the isolated parameter or global actor's executor, and `@concurrent` functions
664-
switch to the generic executor:
734+
function, and after any calls to other async functions. Note that synchronous
735+
functions do not have the ability to switch executors, and if a call to a
736+
synchronous function crosses an isolation boundary, the call must happen in an
737+
async context and the executor switch happens at the caller.
738+
739+
`@concurrent` async functions switch to the generic executor, and all other
740+
async functions switch to the isolated actor's executor.
665741

666742
```swift
667743
@MainActor func runOnMainExecutor() async {
@@ -675,23 +751,46 @@ switch to the generic executor:
675751
@concurrent func runOnGenericExecutor() async {
676752
// switch to generic executor
677753

678-
await Task { @MainActor in ... }.value
754+
await Task { @MainActor in
755+
// switch to main actor executor
756+
757+
...
758+
}.value
679759

680760
// switch to generic executor
681761
}
682762
```
683763

684-
Executor switching behaves the same way in nonisolated async functions:
764+
Under this proposal, by default, nonisolated async functions will switch to
765+
the executor of the implicit isolated parameter instead of switching to the
766+
generic executor:
685767

686768
```swift
687-
@MainActor func runOnMainExecutor() { ... }
769+
@MainActor func runOnMainExecutor() async {
770+
// switch to main actor executor
771+
...
772+
}
773+
774+
class NotSendable {
775+
var value = 0
776+
}
688777

689-
nonisolated func inheritIsolation() async {
778+
actor MyActor {
779+
let ns: NotSendable = .init()
780+
781+
func callNonisolatedFunction() async {
782+
await inheritIsolation(ns)
783+
}
784+
}
785+
786+
nonisolated func inheritIsolation(_ ns: NotSendable) async {
690787
// switch to isolated parameter's executor
691788

692789
await runOnMainExecutor()
693790

694791
// switch to isolated parameter's executor
792+
793+
ns.value += 1
695794
}
696795
```
697796

0 commit comments

Comments
 (0)