-
Notifications
You must be signed in to change notification settings - Fork 2.4k
closure isolation #2322
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sophiapoirier
wants to merge
17
commits into
swiftlang:main
Choose a base branch
from
sophiapoirier:closure-isolation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
closure isolation #2322
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
16e9bf0
closure isolation control
sophiapoirier 7e4d540
merge Matt Massicotte's "Parameter Actor Inheritance" proposal
sophiapoirier 5903e0a
add Konrad Malawski's discussion about distributed actors
sophiapoirier af48c4b
move distributed actor details into "detailed design" section
sophiapoirier 36388e1
rename file to match title
sophiapoirier 34e8ac4
address ambiguity of a closure parameter named nonisolated
sophiapoirier 1f0d6fc
add name of experimental feature flag
sophiapoirier 0fb068e
@nonisolated attribute syntax
sophiapoirier 0d6fef5
revert to non-attribute-@ syntax for nonisolated but require parenthe…
sophiapoirier d359d1e
move contents of "Implications on adoption" section into "Source comp…
sophiapoirier 4d81353
Matt Massicotte's corrections and clarifications
sophiapoirier 4429af8
Some typos an a few small wording changes
mattmassicotte 54edf1d
weak isolated in future directions
sophiapoirier 7f25fe9
isolated capture in sync closure
sophiapoirier 442fa7b
Merge pull request #2 from mattmassicotte/fix/typos
sophiapoirier a0c3109
non-@Sendable local functions should inherit their enclosing isolation
sophiapoirier 25d8171
add missing Void return type on example closure parameters
sophiapoirier File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,274 @@ | ||
# Closure isolation control | ||
|
||
* Proposal: [SE-NNNN](nnnn-closure-isolation.md) | ||
* Authors: [Sophia Poirier](https://github.com/sophiapoirier), [Matt Massicotte](https://github.com/mattmassicotte), [Konrad Malawski](https://github.com/ktoso), [John McCall](https://github.com/rjmccall) | ||
* Review Manager: TBD | ||
* Status: **Awaiting review** | ||
* Implementation: On `main` gated behind `-enable-experimental-feature ClosureIsolation` | ||
* Previous Proposals: [SE-0313](0313-actor-isolation-control.md), [SE-0316](0316-global-actors.md) | ||
* Review: ([pitch](https://forums.swift.org/t/isolation-assumptions/69514)) | ||
|
||
## Introduction | ||
|
||
This proposal provides the ability to explicitly specify actor-isolation or non-isolation of a closure, as well as providing a parameter attribute to guarantee that a closure parameter inherits the isolation of the context. It makes the isolation inheritance rules more uniform, helps to better express intention at closure-creation time, and also makes integrating concurrency with non-Sendable types less restrictive. | ||
|
||
## Table of Contents | ||
|
||
* [Introduction](#introduction) | ||
* [Motivation](#motivation) | ||
* [Proposed solution](#proposed-solution) | ||
+ [Explicit closure isolation](#explicit-closure-isolation) | ||
+ [Isolation inheritance](#isolation-inheritance) | ||
* [Detailed design](#detailed-design) | ||
+ [Distributed actor isolation](#distributed-actor-isolation) | ||
* [Source compatibility](#source-compatibility) | ||
* [ABI compatibility](#abi-compatibility) | ||
* [Implications on adoption](#implications-on-adoption) | ||
* [Alternatives considered](#alternatives-considered) | ||
* [Future directions](#future-directions) | ||
* [Acknowledgments](#acknowledgments) | ||
|
||
## Motivation | ||
|
||
The formal isolation of a closure can be explicitly specified as global actor isolation: | ||
|
||
```swift | ||
Task { @MainActor in | ||
print("global actor isolation") | ||
} | ||
``` | ||
|
||
Without a global actor isolation annotation, actor-isolation or non-isolation of a closure is inferred but cannot be explicitly specified. This proposal enables closures to be fully explicit about all three types of formal isolation: | ||
* `nonisolated` | ||
* global actor | ||
* specific actor value | ||
|
||
Explicit annotation has the benefit of disabling inference rules and the potential that they lead to a formal isolation that is not preferred. For example, there are circumstances where it is beneficial to guarantee that a closure is `nonisolated` therefore knowing that its execution will hop off the current actor. Explicit annotation also offers the ability to identify a mismatch of intention, such as a case where the developer expected `nonisolated` but inference landed on actor-isolated, and the closure is mistakenly used in an isolated context. Using explicit annotation, the developer would receive a diagnostic about a `nonisolated` closure being used in an actor-isolated context which helpfully identifies this mismatch of intention. | ||
|
||
Additionally, there is a difference in how isolation inheritance behaves via the experimental attribute `@_inheritActorContext` (as used by `Task.init`) for isolated parameters vs actor isolation: global actor isolation is inherited by `Task`'s initializer closure argument, whereas an actor-isolated parameter is not inherited. This makes it challenging to build intuition around how isolation inheritance works. It also makes it impossible to allow a non-Sendable type to create a new Task that can access self. | ||
|
||
```swift | ||
class NonSendableType { | ||
@MainActor | ||
func globalActor() { | ||
Task { | ||
// accessing self okay | ||
} | ||
} | ||
|
||
func isolatedParameter(_ actor: isolated any Actor) { | ||
Task { | ||
// not okay to access self | ||
} | ||
} | ||
} | ||
``` | ||
|
||
## Proposed solution | ||
|
||
### Explicit closure isolation | ||
|
||
Enable explicit specification of non-isolation by allowing `nonisolated` to be a modifier on a closure: | ||
|
||
```swift | ||
Task { nonisolated in | ||
print("nonisolated") | ||
} | ||
``` | ||
|
||
Enable explicit specification of actor-isolation via an isolated parameter in a closure's capture list by using the `isolated` specifier: | ||
|
||
```swift | ||
actor A { | ||
nonisolated func isolate() { | ||
Task { [isolated self] in | ||
print("isolated to 'self'") | ||
} | ||
} | ||
} | ||
``` | ||
|
||
### Isolation inheritance | ||
|
||
Provide a formal replacement of the experimental parameter attribute `@_inheritActorContext` to resolve its ambiguity with closure isolation. Currently, `@_inheritActorContext` actual context capture behavior is conditional on whether you capture an isolated parameter or isolated capture or actor-isolated function, but unconditional if the context is isolated to a global actor or `nonisolated`. Its replacement `@inheritsIsolation` changes the behavior so that it unconditionally and implicitly captures the isolation context. | ||
|
||
```swift | ||
class Old { | ||
public init(@_inheritActorContext operation: () async -> Void) | ||
} | ||
|
||
class New { | ||
public init(@inheritsIsolation operation: () async -> Void) | ||
} | ||
|
||
class C { | ||
var value = 0 | ||
|
||
@MainActor | ||
func staticIsolation() { | ||
Old { | ||
value = 1 // closure is MainActor-isolated and therefore okay to access self | ||
} | ||
New { | ||
value = 2 // closure is MainActor-isolated and therefore okay to access self | ||
} | ||
} | ||
|
||
func dynamicIsolation(_ actor: isolated any Actor) { | ||
Old { | ||
// not isolated to actor without explicit capture | ||
} | ||
New { | ||
// isolated to actor through guaranteed implicit capture | ||
} | ||
} | ||
} | ||
``` | ||
|
||
## Detailed design | ||
|
||
An isolated parameter in a capture list must be of actor type, or conform to or imply an actor, potentially optional, and there can only be one isolated parameter captured, following the same rules described in [SE-0313](0313-actor-isolation-control.md#actor-isolated-parameters) for actor-isolated parameters. | ||
|
||
The contexts in which an isolated parameter is permitted in the capture list of a synchronous closure are when the closure is: | ||
|
||
* called immediately | ||
* converted to an `async` function type | ||
* converted to an `@isolated(any)` function type | ||
* converted to a non-Sendable function type and has the correct isolation for the context that does the conversion | ||
|
||
Due to the ambiguity between the `nonisolated` modifier and a type-inferred closure parameter, most notably disambiguating `{ nonisolated parameter in ... }` as a modifier followed by a single parameter vs both as a bound pair of tokens, the use of parentheses for a parameter list is required when `nonisolated` is specified. | ||
|
||
```swift | ||
{ nonisolated (parameter) in ... } | ||
``` | ||
|
||
Opting out of `@inheritsIsolation` can be achieved by explicitly annotating the closure argument as `nonisolated`. | ||
|
||
`@_inheritActorContext` is currently used by the `Task` initializer in the standard library which should be updated to use `@inheritsIsolation` instead. | ||
|
||
One further related clarification of isolation inheritence is that non-`@Sendable` local functions should always inherit their enclosing isolation (unless explicitly `nonisolated` or isolated some other way). | ||
|
||
### Distributed actor isolation | ||
|
||
`isolated` capture parameter works with distributed actors, however only statically "known to be local" distributed actors may be promoted to `isolated`. Currently, this is achieved only through an `isolated` distributed actor type, meaning that a task can only be made isolated to a distributed actor if the value already was isolated, like this: | ||
|
||
```swift | ||
import Distributed | ||
|
||
distributed actor D { | ||
func isolateSelf() { | ||
// 'self' is isolated | ||
Task { [isolated self] in print("OK") } // OK: self was isolated | ||
} | ||
|
||
nonisolated func bad() { | ||
// 'self' is not isolated | ||
Task { [isolated self] in print("BAD") } // error: self was not isolated, and may be remote | ||
} | ||
} | ||
|
||
func isolate(d: isolated D) { | ||
Task { [isolated d] in print("OK") } // OK: d was isolated, thus known-to-be-local | ||
} | ||
|
||
func isolate(d: D) { | ||
Task { [isolated d] in print("OK") } // error: d was not isolated, and may be remote | ||
} | ||
``` | ||
|
||
While it is technically possible to enqueue work on a remote distributed actor reference, the enqueue on such an actor will always immediately crash. Because of that, we err on the side of disallowing such illegal code. [Future directions](#future-directions) discusses how this can be made more powerful when it is known that an actor is local. It is also worth noting the `da.whenLocal { isolated da in ... }` API which allows dynamically recovering an isolated distributed actor reference after it has dynamically been checked for locality. | ||
|
||
## Source compatibility | ||
|
||
It is possible that existing code could have a closure that names a type-inferred parameter `nonisolated`: | ||
```swift | ||
{ nonisolated in print(nonisolated) } | ||
``` | ||
but with this proposed change, `nonisolated` in this case would instead be interpreted as the contextual keyword specifying the formal isolation of the closure. Such code would then result in a compilation error when trying to use a parameter named `nonisolated`. | ||
|
||
The change to `Task.init` in the standard library does have the potential to isolate some closures that previously were inferred to be `nonisolated`. Prior behavior in those cases could be restored, if desired, by explicitly declaring the closure as `nonisolated`. | ||
|
||
It is worth noting that this does not affect the isolation semantics for actor-isolated types that make use of isolated parameters. It is currently impossible to access self in these cases, and even with this new inheritance rule that remains true. | ||
|
||
```swift | ||
actor MyActor { | ||
var mutableState = 0 | ||
|
||
func isolatedParameter(_ actor: isolated any Actor) { | ||
self.mutableState += 1 // invalid | ||
|
||
Task { | ||
self.mutableState += 1 // invalid | ||
} | ||
} | ||
} | ||
|
||
@MainActor | ||
class MyClass { | ||
var mutableState = 0 | ||
|
||
func isolatedParameter(_ actor: isolated any Actor) { | ||
self.mutableState += 1 // invalid | ||
|
||
Task { | ||
self.mutableState += 1 // invalid | ||
} | ||
} | ||
} | ||
``` | ||
|
||
## ABI compatibility | ||
|
||
The language change does not add or affect ABI since formal isolation is already part of a closure's type regardless of whether it is explicitly specified. The `Task.init` change does not impact ABI since the function is annotated with `@_alwaysEmitIntoClient` and therefore has no ABI. | ||
|
||
## Implications on adoption | ||
|
||
none | ||
|
||
## Alternatives considered | ||
|
||
`@nonisolated` in attribute form was considered to avert the potential for source breakage, but requires an unintuitive inconsistency in the language for when `@` is required vs needs to be avoided. | ||
|
||
One alternative to `@inheritsIsolation` is to not use `Task` in combination with non-Sendable types in this way, restructuring the code to avoid needing to rely on isolation inheritance in the first place. | ||
|
||
```swift | ||
class NonSendableType { | ||
private var internalState = 0 | ||
|
||
func doSomeStuff(isolatedTo actor: isolated any Actor) async throws { | ||
try await Task.sleep(for: .seconds(1)) | ||
print(self.internalState) | ||
} | ||
} | ||
``` | ||
|
||
Despite this being a useful pattern, it does not address the underlying inheritance semantic differences. | ||
|
||
There has also been discussion about the ability to make synchronous methods on actors. The scope of such a change is much larger than what is covered here and would still not address the underlying differences. | ||
|
||
## Future directions | ||
|
||
### weak isolated | ||
|
||
Explore support for explicitly `isolated` closure captures to additionally be specified as `weak`. | ||
|
||
### "Known to be local" distributed actors and isolation | ||
|
||
Distributed actors have a property that is currently not exposed in the type system that is "known to be local". If a distributed actor is known to be local, code may become isolated to it. | ||
|
||
Once the locality of a type is expressed in the type system, the following would become possible: | ||
|
||
```swift | ||
let worker: local Worker | ||
|
||
// silly example, showcasing isolating on a known-to-be-local distributed actor | ||
func work(item: Item) async { | ||
await Task { [isolated worker] in | ||
worker.work(on: item) | ||
}.value | ||
} | ||
``` | ||
|
||
## Acknowledgments | ||
|
||
Thank you to Franz Busch and Aron Lindberg for looking at the underlying problem so closely and suggesting alternatives. Thank you to Holly Borla for helping to clarify the current behavior, as well as suggesting a path forward that resulted in a much simpler and less-invasive change. |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These two examples both have a tiny typo in them. They are missing a
-> Void
.