Skip to content

Commit 702112f

Browse files
authored
Merge pull request #2372 from simanerush/global-actor-isolated-types-usability
Add a proposal to improve usability of global-actor-isolated types.
2 parents 9e3da0f + 3295208 commit 702112f

File tree

1 file changed

+253
-0
lines changed

1 file changed

+253
-0
lines changed
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
## Usability of global-actor-isolated types
2+
3+
* Proposal: [SE-0434](0434-global-actor-isolated-types-usability.md)
4+
* Authors: [Sima Nerush](https://github.com/simanerush), [Matt Massicotte](https://github.com/mattmassicotte), [Holly Borla](https://github.com/hborla)
5+
* Review Manager: [John McCall](https://github.com/rjmccall)
6+
* Status: **Active review (April 10th...22nd, 2024)**
7+
* Implementation: On `main` gated behind `-enable-experimental-feature GlobalActorIsolatedTypesUsability`
8+
* Review: ([pitch](https://forums.swift.org/t/pitch-usability-of-global-actor-isolated-types/70799))
9+
10+
## Introduction
11+
12+
This proposal encompasses a collection of changes to concurrency rules concerning global-actor-isolated types to improve their usability.
13+
14+
## Motivation
15+
16+
Currently, there exist limitations in the concurrency model around types that are isolated to global actors.
17+
18+
First, let's consider the stored properties of `struct`s isolated to global actors. `let` properties of such types are implicitly treated as `nonisolated` within the current module if they have `Sendable` type, but `var` properties are not. This poses a number of problems, such as when implementing a protocol conformance. Currently, the only solution is to declare the property `nonisolated(unsafe)`:
19+
20+
```swift
21+
@MainActor struct S {
22+
nonisolated(unsafe) var x: Int = 0
23+
}
24+
25+
extension S: Equatable {
26+
static nonisolated func ==(lhs: S, rhs: S) -> Bool {
27+
return lhs.x == rhs.x
28+
}
29+
}
30+
```
31+
32+
However, there is nothing unsafe about treating `x` as `nonisolated`. The general rule is that concurrency is safe as long as there aren't data races. The type of `x` conforms to `Sendable`, and using a value of `Sendable` type from multiple concurrent contexts shouldn't ever introduce a data race, so any data race involved with an access to `x` would have to be on memory in which `x` is stored. But `x` is part of a value type, which means any access to it is always also an access to the containing `S` value. As long as Swift is properly preventing data races on that larger access, it's always safe to access the `x` part of it. So, first off, there's no reason for Swift to require `(unsafe)` when marking `x` `nonisolated`.
33+
34+
We can do better than that, though. It should be possible to treat a `var` stored property of a global-actor-isolated value type as *implicitly* `nonisolated` under the same conditions that a `let` property can be. A stored property from a different module can be changed to a computed property in the future, and those future computed accessors may need to be isolated to the global actor, so allowing access across module boundaries would not be okay for source or binary compatibility without an explicit `nonisolated` annotation. But within the module that defines the property, we know that hasn't happened, so it's fine to use a more relaxed rule.
35+
36+
Next, under the current concurrency rules, it is possible for a function type to be both isolated to a global actor and yet not required to be `Sendable`:
37+
38+
```swift
39+
func test(globallyIsolated: @escaping @MainActor () -> Void) {
40+
Task {
41+
// error: capture of 'globallyIsolated' with non-sendable type '@MainActor () -> Void' in a `@Sendable` closure
42+
await globallyIsolated()
43+
}
44+
}
45+
```
46+
47+
This is not a useful combination: such a function can only be used if the current context is isolated to the global actor, and in that case the global actor annotation is unnecessary because *all* non-`Sendable` functions will run with global actor isolation. It would be better for a global actor attribute to always imply `@Sendable`.
48+
49+
Because a globally-isolated closure cannot be called concurrently, it's safe for it to capture non-`Sendable` values even if it's implicitly `@Sendable`. Such values just need to be transferred to the global actor's region (if they aren't there already). The same logic also applies to closures that are isolated to a specific actor reference, although it isn't currently possible to write such a closure in a context that isn't isolated to that actor.
50+
51+
52+
Finally, the current diagnostic for a global-actor-isolated subclass of a non-isolated superclass is too restrictive:
53+
54+
```swift
55+
class NotSendable {}
56+
57+
58+
@MainActor
59+
class Subclass: NotSendable {} // error: main actor-isolated class 'Subclass' has different actor isolation from nonisolated superclass 'NotSendable'
60+
```
61+
62+
Because global actor isolation on a class implies a `Sendable` conformance, adding isolation to a subclass of a non-`Sendable` superclass can circumvent `Sendable` checking:
63+
64+
```swift
65+
func computeCount() async -> Int { ... }
66+
67+
class NotSendable {
68+
var mutableState = 0
69+
func mutate() async {
70+
let count = await computeCount()
71+
mutableState += count
72+
}
73+
}
74+
75+
@MainActor
76+
class Subclass: NotSendable {}
77+
78+
func test() async {
79+
let c = Subclass()
80+
await withDiscardingTaskGroup { group in
81+
group.addTask {
82+
await c.mutate()
83+
}
84+
85+
group.addTask { @MainActor in
86+
await c.mutate()
87+
}
88+
}
89+
}
90+
```
91+
92+
In the above code, an instance of `Subclass` can be passed across isolation boundaries because `@MainActor` implies that the type is `Sendable`. However, `Subclass` inherits non-isolated, mutable state from the superclass, so this `Sendable` conformance allows smuggling unprotected shared mutable state across isolation boundaries to create potential for concurrent access. For this reason, the warning about adding isolation to a subclass was added in Swift 5.10, but this restriction could be lifted by instead preventing the subclass from being `Sendable`.
93+
94+
## Proposed solution
95+
96+
We propose that:
97+
98+
- Stored properties of `Sendable` type in a global-actor-isolated value type can be declared as `nonisolated` without using `(unsafe)`.
99+
- Stored properties of `Sendable` type in a global-actor-isolated value type are treated as `nonisolated` when used within the module that defines the property.
100+
- `@Sendable` is inferred for global-actor-isolated functions and closures.
101+
- Global-actor-isolated closures are allowed to capture non-`Sendable` values despite being `@Sendable`.
102+
- A global-actor-isolated subclass of a non-isolated, non-`Sendable` class is allowed, but it must be non-`Sendable`.
103+
104+
105+
## Detailed design
106+
107+
108+
### Inference of `nonisolated` for `var` properties of globally isolated value types
109+
110+
Let's look at the first problem with usability of a `var` property of a main-actor-isolated struct:
111+
112+
```swift
113+
@MainActor
114+
struct S {
115+
var x: Int = 0 // okay ('nonisolated' is inferred within the module)
116+
}
117+
118+
extension S: Equatable {
119+
static nonisolated func ==(lhs: S, rhs: S) -> Bool {
120+
return lhs.x == rhs.x // okay
121+
}
122+
}
123+
```
124+
125+
In the above code, `x` is implicitly `nonisolated` within the module. Under this proposal, `nonisolated` is inferred for in-module access to `Sendable` properties of a global-actor-isolated value type. A `var` with `Sendable` type within a value type can also have an explicit `nonisolated` modifier to allow synchronous access from outside the module. Once added, `nonisolated` cannot later be removed without potentially breaking clients. The programmer can still convert the property to a computed property, but it has to be a `nonisolated` computed property.
126+
127+
Because `nonisolated` access only applies to stored properties, wrapped properties and `lazy`-initialized properties with `Sendable` type still must be isolated because they are computed properties:
128+
129+
```swift
130+
@propertyWrapper
131+
struct MyWrapper<T> { ... }
132+
133+
@MainActor
134+
struct S {
135+
@MyWrapper var x: Int = 0
136+
}
137+
138+
extension S: Equatable {
139+
static nonisolated func ==(lhs: S, rhs: S) -> Bool {
140+
return lhs.x == rhs.x // error
141+
}
142+
}
143+
```
144+
145+
### `@Sendable` inference for global-actor-isolated functions and closures
146+
147+
To improve usability of globally-isolated functions and closures, under this proposal `@Sendable` is inferred:
148+
149+
```swift
150+
func test(globallyIsolated: @escaping @MainActor () -> Void) {
151+
Task {
152+
await globallyIsolated() //okay
153+
}
154+
}
155+
```
156+
157+
The `globallyIsolated` closure in the above code is global-actor isolated because it has the `@MainActor` attribute. Because it will always run isolated, it's fine for it to capture and use values that are isolated the same way. It's also safe to share it with other isolation domains because the captured values are never directly exposed to those isolation domains. This means that there's no reason not to always treat these functions as `@Sendable`.
158+
159+
#### Non-`Sendable` captures in isolated closures
160+
161+
Under this proposal, globally-isolated closures are allowed to capture non-`Sendable` values:
162+
163+
```swift
164+
class NonSendable {}
165+
166+
func test() {
167+
let ns = NonSendable()
168+
169+
let closure { @MainActor in
170+
print(ns)
171+
}
172+
173+
Task {
174+
await closure() // okay
175+
}
176+
}
177+
```
178+
179+
The above code is data-race safe, since a globally-isolated closure will never operate on the same instance of `NonSendable` concurrently.
180+
181+
Note that under region isolation in SE-0414, capturing a non-`Sendable` value in an actor-isolated closure will transfer the region into the actor, so it is impossible to have concurrent access on non-`Sendable` captures even if the isolated closure is formed outside the actor:
182+
183+
```swift
184+
class NonSendable {}
185+
186+
func test(ns: NonSendable) async {
187+
let closure { @MainActor in
188+
print(ns) // error: task-isolated value 'ns' can't become isolated to the main actor
189+
}
190+
191+
await closure()
192+
}
193+
```
194+
195+
### Global actor isolation and inheritance
196+
197+
Subclasses may add global actor isolation when inheriting from a nonisolated, non-`Sendable` superclass. In this case, an implicit conformance to `Sendable` will not be added, and explicitly specifying a `Sendable` conformance is an error:
198+
199+
```swift
200+
class NonSendable {
201+
func test() {}
202+
}
203+
204+
@MainActor
205+
class IsolatedSubclass: NonSendable {
206+
func trySendableCapture() {
207+
Task.detached {
208+
self.test() // error: Capture of 'self' with non-sendable type 'IsolatedSubclass' in a `@Sendable` closure
209+
}
210+
}
211+
}
212+
```
213+
214+
Inherited and overridden methods still must respect the isolation of the superclass method:
215+
216+
```swift
217+
class NonSendable {
218+
func test() { ... }
219+
}
220+
221+
@MainActor
222+
class IsolatedSubclass: NonSendable {
223+
var mutable = 0
224+
override func test() {
225+
super.test()
226+
mutable += 0 // error: Main actor-isolated property 'mutable' can not be referenced from a non-isolated context
227+
}
228+
}
229+
```
230+
231+
Matching the isolation of the superclass method is necessary because the superclass method implementation may internally rely on the static isolation, such as when hopping back to the isolation after any asynchronous calls, and because there are a variety of ways to call the subclass method that don't preserve its isolation, including:
232+
233+
* Upcasting to the superclass type
234+
* Erasing to an existential type based on conformances of the superclass type
235+
* Passing the isolated subclass as a generic argument to a type parameter that requires a conformance implemented by the superclass
236+
237+
## Source compatibility
238+
239+
This proposal changes the interpretation of existing code that uses global-actor-isolated function types that are not already marked with `@Sendable`. This can cause minor changes in type inference and overload resolution. However, the proposal authors have not encountered any such issues in source compatibility testing, so this proposal does not gate the inference change behind an upcoming feature flag.
240+
241+
An alternative choice would be to introduce an upcoming feature flag that's enabled by default in the Swift 6 language mode, but this flag could not be enabled by default under `-strict-concurrency=complete` without risk of changing behavior in existing projects that adopt complete concurrency checking. Gating the `@Sendable` inference change behind a separate upcoming feature flag may lead to more code churn than necessary when migrating to complete concurrency checking unless the programmer knows to enable the flags in a specific order.
242+
243+
## ABI compatibility
244+
245+
`@Sendable` is included in name mangling, so treating global-actor-isolated function types as implicitly `@Sendable` changes mangling. This change only impacts resilient libraries that use global-actor-isolated-but-not-`Sendable` function types in effectively-public APIs. However, as noted in this proposal, such a function type is not useful, and the proposal authors expect that any API that uses a global-actor-isolated function type either already has `@Sendable`, or should add `@Sendable`. Because the only ABI impact of `@Sendable` is mangling, `@_silgen_name` can be used to preserve ABI in cases where `@Sendable` should be added, and the API is not already `@preconcurrency` (in which case the mangling will strip both the global actor and `@Sendable`).
246+
247+
## Implications on adoption
248+
249+
The existing adoption implications of `@Sendable` and global actor isolation adoption apply when making use of the rules in this proposal. For example, `@Sendable` and `@MainActor` can be staged into existing APIs using `@preconcurrency`. See [SE-0337: Incremental migration to concurrency checking](/proposals/0337-support-incremental-migration-to-concurrency-checking.md) for more information.
250+
251+
## Acknowledgments
252+
253+
Thank you to Frederick Kellison-Linn for surfacing the problem with global-actor-isolated function types, and to Kabir Oberai for exploring the implications more deeply.

0 commit comments

Comments
 (0)