|
| 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