Skip to content

Commit e20baf2

Browse files
committed
Add a proposal to improve usability of global-actor-isolated types.
1 parent d778eb6 commit e20baf2

File tree

1 file changed

+267
-0
lines changed

1 file changed

+267
-0
lines changed
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
## Usability of global-actor-isolated types
2+
3+
* Proposal: [SE-NNNN](NNNN-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: TBD
6+
* Status: **Awaiting implementation**
7+
* Implementation: TBD
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 global-isolated-types.
17+
18+
First, let's consider rules for properties of global-isolated value types. The first limitation is that `var` properties of such value types cannot be declared `nonisolated`. This poses a number of problems, for example when implementing a protocol conformance. The current workaround is to use the `nonisolated(unsafe)` keyword on the property:
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+
The above code is perfectly safe and should not require an unsafe opt-out. Since `S` is a value type and `x` is of a `Sendable` type `Int`, it should not be unsafe to declare `x` non-isolated, because when accessing `x` from different concurrency domains, we will be operating on a copy of the value type, and the result type is `Sendable` so it's safe to return the same value across different isolation domains. Because access to `x` across concurrency domains is always safe, `nonisolated` should be implicit within the module, similar to actor-isolated `let` constants with `Sendable` type.
33+
34+
Next, under the current concurrency rules, globally isolated functions and closures do not implicitly conform to `Sendable`. This impacts usability, because these closures cannot themselves be captured by `@Sendable` closures, which makes them unusable with `Task`:
35+
36+
```swift
37+
func test() {
38+
let closure: @MainActor () -> Void = {
39+
print("hmmmm")
40+
}
41+
42+
Task {
43+
// error: capture of 'closure' with non-sendable type '@MainActor () -> Void' in a `@Sendable` closure
44+
await closure()
45+
}
46+
}
47+
```
48+
49+
In the above code, the closure is global-actor-isolated, so it cannot be called concurrently. The compiler should be able to infer the `@Sendable` attribute. Because of the same reason, globally isolated closures should be allowed to capture non-`Sendable` values.
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+
- `Sendable` properties of a global-actor-isolated value type would be treated `nonisolated` as inferred within the module.
99+
- `@Sendable` would be inferred for global-actor-isolated functions and closures. Additionally, globally isolated closures would be allowed to capture non-`Sendable` values.
100+
- The programmer would be able to suppress the automatic conformance inferred via the above rule using the new `@~Sendable` attribute. By analogy, introduce a new `~Sendable` protocol to indicate that a nominal type is not `Sendable`.
101+
- Require the global-actor-isolated subclass of a `nonisolated`, non-`Sendable` to be non-`Sendable`.
102+
103+
104+
## Detailed design
105+
106+
107+
### Inference of `nonisolated` for `var` properties of globally isolated value types
108+
109+
Let's look at the first problem with usability of a `var` property of a main-actor-isolated struct:
110+
111+
```swift
112+
@MainActor
113+
struct S {
114+
var x: Int = 0 // okay ('nonisolated' is inferred within the module)
115+
}
116+
117+
extension S: Equatable {
118+
static nonisolated func ==(lhs: S, rhs: S) -> Bool {
119+
return lhs.x == rhs.x // okay
120+
}
121+
}
122+
```
123+
124+
In the above code, `x` is implicitly `nonisolated` within the module. Under this proposal, `nonisolated` is inferred for within the module access of `Sendable` properties of a global-actor-isolated value type. This is data-race safe because the property belongs to a value type, meaning it will be copied every time it crosses an isolation boundary.
125+
126+
The programmer can still choose to mark the property `nonisolated` to allow synchronous access from outside the module. Requiring asynchronous access from outside the module preserves the ability for library authors to change a stored property to a computed property without breaking clients, and the library author may explicitly write `nonisolated` to opt-into synchronous access as part of the API contract.
127+
128+
### `@Sendable` inference for global-actor-isolated functions and closures
129+
130+
To improve usability of globally isolated functions and closures, under this proposal `@Sendable` is inferred:
131+
132+
```swift
133+
func test() {
134+
let closure: @MainActor () -> Void = {
135+
print("hmmmm")
136+
}
137+
138+
Task {
139+
await closure() // okay
140+
}
141+
}
142+
```
143+
144+
The closure in the above code is global-actor isolated via the `@MainActor`. Thus, it can never operate on the same reference concurrently at the same time, making it safe to be invoked from different isolation domains. This means that for such global-actor-isolated closures and functions, the `@Sendable` attribute is implicit.
145+
146+
#### Non-`Sendable` captures in isolated closures
147+
148+
Under this proposal, globally-isolated closures are allowed to capture non-`Sendable` values:
149+
150+
```swift
151+
class NonSendable {}
152+
153+
func test() {
154+
let ns = NonSendable()
155+
156+
let closure { @MainActor in
157+
print(ns)
158+
}
159+
160+
Task {
161+
await closure() // okay
162+
}
163+
}
164+
```
165+
166+
The above code is data-race safe, since a globally isolated closure will never operate on the same instance of `NonSendable` concurrently.
167+
168+
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.
169+
170+
171+
### `@~Sendable` and `~Sendable`
172+
173+
This proposal also adds a way to "opt-out" of the implicit `@Sendable` inference introduced by the above change by using the new `@~Sendable` attribute:
174+
175+
```swift
176+
func test() {
177+
let closure: @~Sendable @MainActor () -> Void = {
178+
print("hmmmm")
179+
}
180+
181+
Task {
182+
await closure() // error
183+
}
184+
}
185+
```
186+
187+
In the above code, we use `@~Sendable` to explicitly indicate that the closure is not `Sendable` and suppress the implicit `@Sendable`. This change will mostly help with possible ABI compatibility issues, but will not necessarily improve usability.
188+
189+
By analogy, this proposal inroduces the new `~Sendable` syntax for explicitly suppressing a conformance to `Sendable`:
190+
191+
```swift
192+
class C { ... }
193+
194+
@available(*, unavailable)
195+
extension C: @unchecked Sendable {}
196+
197+
// instead
198+
199+
class C: ~Sendable {}
200+
```
201+
202+
In the above code, we use `~Sendable` instead of an unavailable `Sendable` conformance to indicate that the type `C` is not `Sendable`. Suppressing a `Sendable` conformance in a superclass still allows a conformance to be added in subclasses:
203+
204+
```swift
205+
class Sub: C, @unchecked Sendable { ... }
206+
```
207+
208+
Previously, an unavailable `Sendable` conformance would prevent `@unchecked Sendable` conformances from being added to subclasses because types can only conform to protocols in one way, and the unavailable `Sendable` conformance was inherited in all subclasses.
209+
210+
### Global actor isolation and inheritance
211+
212+
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:
213+
214+
```swift
215+
class NonSendable {
216+
func test() {}
217+
}
218+
219+
@MainActor
220+
class IsolatedSubclass: NonSendable {
221+
func trySendableCapture() {
222+
Task.detached {
223+
self.test() // error: Capture of 'self' with non-sendable type 'IsolatedSubclass' in a `@Sendable` closure
224+
}
225+
}
226+
}
227+
```
228+
229+
Inherited and overridden methods still must respect the isolation of the superclass method:
230+
231+
```swift
232+
class NonSendable {
233+
func test() { ... }
234+
}
235+
236+
@MainActor
237+
class IsolatedSubclass: NonSendable {
238+
var mutable = 0
239+
override func test() {
240+
super.test()
241+
mutable += 0 // error: Main actor-isolated property 'isolated' can not be referenced from a non-isolated context
242+
}
243+
}
244+
```
245+
246+
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:
247+
248+
* Upcasting to the superclass type
249+
* Erasing to an existential type based on conformances of the superclass type
250+
* Passing the isolated subclass as a generic argument to a type parameter that requires a conformance implemented by the superclass
251+
252+
## Source compatibility
253+
254+
The introduced changes are additive, so the proposal does not impact source compatibility.
255+
256+
## ABI compatibility
257+
258+
This proposal should have no impact on ABI compatibility.
259+
260+
261+
## Implications on adoption
262+
263+
This feature can be freely adopted and un-adopted in source code with no deployment constraints and without affecting source or ABI compatibility.
264+
265+
## Acknowledgments
266+
267+
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)