|
| 1 | +# Improved Custom SerialExecutor isolation checking for Concurrency Runtime |
| 2 | + |
| 3 | +* Proposal: [SE-NNNN](...) |
| 4 | +* Author: [Konrad 'ktoso' Malawski](https://github.com/ktoso) |
| 5 | +* Review Manager: TBD |
| 6 | +* Status: Implemented |
| 7 | + * https://github.com/swiftlang/swift/pull/79788 |
| 8 | + * https://github.com/swiftlang/swift/pull/79946 |
| 9 | + |
| 10 | +* Pitch: [[Pitch][SerialExecutor] Improved Custom SerialExecutor isolation checking](https://forums.swift.org/t/pitch-serialexecutor-improved-custom-serialexecutor-isolation-checking/78237/) |
| 11 | +* Review: TODO |
| 12 | + |
| 13 | +## Introduction |
| 14 | + |
| 15 | +In [SE-0424: Custom isolation checking for SerialExecutor](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0424-custom-isolation-checking-for-serialexecutor.md) we introduced a way for custom executors implementing the `SerialExecutor` protocol to assert and assume the static isolation if the dynamic check succeeded. This proposal extends these capabilities, allowing custom executors to not only "check and crash if assumption was wrong", but also check and act on the result of the check. |
| 16 | + |
| 17 | +## Motivation |
| 18 | + |
| 19 | +The previously ([SE-0424](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0424-custom-isolation-checking-for-serialexecutor.md)) introduced family of `Actor/assertIsolated()`, `Actor/preconditionIsolated()` and `Actor/assumeIsolated(operation:)` all rely on the `SerialExecutor/checkIsolated()` API which was introduced in the same proposal. |
| 20 | + |
| 21 | +These APIs all follow the "*pass or crash*" pattern. Where the crash is caused in order to prevent an incorrect assumption about isolation resulting in unsafe concurrent access to some isolated state. This is frequently used by methods which are "known to be called" on some specific actor to recover the dynamic (known at runtime) isolation information, into its static equivalent and therefore safely access some isolated state, like in this example: |
| 22 | + |
| 23 | +```swift |
| 24 | +@MainActor |
| 25 | +var counter: Int = num |
| 26 | + |
| 27 | +protocol P { |
| 28 | + // Always called by the main actor, |
| 29 | + // yet protocol author forgot to annotate the method or protocol using @MainActor |
| 30 | + func actuallyWeKnowForSureThisIsCalledFromTheMainActor() |
| 31 | +} |
| 32 | + |
| 33 | +struct Impl: P { |
| 34 | + func actuallyWeKnowForSureThisIsCalledFromTheMainActor() { |
| 35 | + MainActor.assumeIsolated { // we know this is safe here |
| 36 | + counter += 1 |
| 37 | + } |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +@MainActor |
| 42 | +func call(p: some P) { |
| 43 | + p.actuallyWeKnowForSureThisIsCalledFromTheMainActor() |
| 44 | +} |
| 45 | +``` |
| 46 | + |
| 47 | +This works fine for many situations, however some libraries may need to be more careful when rolling out strict concurrency checks like these, and instead of crashing may want to choose to issue warnings before they adopt a mode that enforces correct use. |
| 48 | + |
| 49 | +Currently all APIs available are using the `checkIsolated()` API which must crash if called from a context not managed by the serial executor it is invoked on. This method is often implemented using `dispatchPrecondition()` when the serial executor is backed using `Dispatch` or custom `fatalError()` messages otherwise: |
| 50 | + |
| 51 | +```swift |
| 52 | +final class ExampleExecutor: SerialExecutor { |
| 53 | + func checkIsolated() { |
| 54 | + dispatchPrecondition(condition: .onQueue(self.queue)) |
| 55 | + } |
| 56 | +} |
| 57 | +``` |
| 58 | + |
| 59 | +This approach is better than not being able to participate in the checks at all (i.e. this API allows for advanced thread/queue sharing between actor and non-actor code), but it has two severe limitations: |
| 60 | + |
| 61 | +- the crash **messages** offered by these `checkIsolated()` crashes **are often sub-optimal** and confusing |
| 62 | + - messages often don't include crucial information about which actor/executor the calling context was _actually_ executing on. Offering only "expected [...]" messages, leading to hard to debug crashes. |
| 63 | +- it is **impossible** for the Swift runtime to offer **isolation violation warnings** |
| 64 | + - because the Swift runtime _must_ call into a custom executor to verify its isolation, the "pass or crash" method will crash, rather than inform the runtime that a violation occurred and we should warn about it. |
| 65 | + |
| 66 | +Today, it is not possible for the Swift runtime to issue _warnings_ if something is detected to be on not the expected executor, but somehow we'd still like to continue without crashing the application. |
| 67 | + |
| 68 | +And for existing situations when `assumeIsolated()` is called and _should_ crash, by using this new API the Swift runtime will be able to provide _more informative_ messages, including all available context about the execution environment. |
| 69 | + |
| 70 | +## Proposed solution |
| 71 | + |
| 72 | +We propose to introduce a new `isIsolatingCurrentContext` protocol requirement to the `SerialExecutor` protocol: |
| 73 | + |
| 74 | +```swift |
| 75 | +protocol SerialExecutor { |
| 76 | + |
| 77 | + /// May be called by the Swift runtime before `checkIsolated()` |
| 78 | + /// in order to check for isolation violations at runtime, |
| 79 | + /// and potentially issue isolation warnings. |
| 80 | + /// |
| 81 | + /// [...] |
| 82 | + func isIsolatingCurrentContext() -> Bool |
| 83 | + |
| 84 | + // existing API, since SE-0424 |
| 85 | + @available(SwiftStdlib 6.0, *) |
| 86 | + func checkIsolated() // must crash if run on a context not managed by this serial executor |
| 87 | + |
| 88 | + // ... |
| 89 | +} |
| 90 | + |
| 91 | +extension SerialExecutor { |
| 92 | + /// Default implementation for backwards compatibility. |
| 93 | + func isIsolatingCurrentContext() -> Bool { false } |
| 94 | +} |
| 95 | +``` |
| 96 | + |
| 97 | +The Swift runtime is free to call the `isIsolated` function whenever it wants to verify if the current context is appropriately isolated by some serial executor. |
| 98 | + |
| 99 | +In most cases implementing this new API is preferable to implementing `checkIsolated()`, as the Swift runtime is able to offer more detailed error messages when an isolation failure detected by a call to `isIsolatingCurrentContext()` is detected. |
| 100 | + |
| 101 | +## Detailed design |
| 102 | + |
| 103 | +The newly proposed `isIsolatingCurrentContext()` function participates in the previously established runtime isolation checking flow, and happens _before_ any calls to `checkIsolated()` are attempted. The following diagram explains the order of calls issued by the runtime to dynamically verify an isolation when e.g. `assumeIsolated()` is called: |
| 104 | + |
| 105 | + |
| 106 | + |
| 107 | + |
| 108 | + |
| 109 | +There are a lot of conditions here and availability of certain features also impacts this decision flow, so it is best to refer to the diagram for detailed analysis of every situation. However the most typical situation involves executing on a task, which has a potentially different executor than the `expected` one. In such situation the runtime will: |
| 110 | + |
| 111 | +- check for the existence of a "current" task, |
| 112 | +- (fast path) if a task is present: |
| 113 | + - compare the current task's serial executor it is isolated to (if any) with the expected serial executor, |
| 114 | + |
| 115 | +- :new: if **`isIsolatingCurrentContext`** **is available** on the `expected` executor: |
| 116 | + - invoke `isIsolatingCurrentContext` |
| 117 | + - ✅ if it returned true: pass the check. |
| 118 | + - :x: if it returned false: fail the check. |
| 119 | + - The runtime will **not** proceed to call `checkIsolated` after `isIsolated` is invoked! |
| 120 | + |
| 121 | +- if **`isIsolatingCurrentContext`** is **not available** on the expected executor, but **`checkIsolated`** **is available**: |
| 122 | + - invoke `expected.checkIsolated` which will crash :x: or pass :white_check_mark: depending on its internal checking. |
| 123 | +- if neither `checkIsolated` or `isIsolatingCurrentContext` are available, |
| 124 | + - :x: crash with a best effort message. |
| 125 | + |
| 126 | + |
| 127 | +This proposal specifically adds the "if `isIsolatingCurrentContext` is available" branch into the existing logic for confirming the isolation expectation. |
| 128 | + |
| 129 | +If `isIsolatingCurrentContext` is available, effectively it replaces `checkIsolated` because it does offer a sub-par error message experience and is not able to offer a warning if Swift would be asked to check the isolation but not crash upon discovering a violation. |
| 130 | + |
| 131 | +### Detecting the `isIsolatingCurrentContext` checking mode |
| 132 | + |
| 133 | +The `isIsolatingCurrentContext` method effectively replaces the `checkIsolated` method, because it can answer the same question _if it is implemented_. |
| 134 | + |
| 135 | +Some runtimes may not be able to implement a the returning `isIsolatingCurrentContext`, and they are not required to implement the new protocol requirement. |
| 136 | + |
| 137 | +The general guidance about which method to implement is to implement `isIsolatingCurrentContext` whenever possible. This method can be used by the Swift runtime in "warning mode". When running a check in this mode, the `checkIsolated` method cannot and will not be used because it would cause an unexpected crash. A runtime may still want to implement the `checkIsolated` function if it truly is unable to return a true/false response to the isolation question, but can only assert on an illegal state. This function will not be used when the runtime does not expect a potential for a crash. |
| 138 | + |
| 139 | +The presence of a non-default implementation of the `isIsolatingCurrentContext` protocol witness is detected by the compiler and the runtime can detect this information in order to determine if the new function should be used for these checks. In other words, if there is an implementation of the requirement available _other than_ the default one provided in the concurrency library, the runtime will attempt to use this method _over_ the `checkIsolated` API. This allows for a smooth migration to the new API, and enables the use of this method in if the runtime would like issue a check that cannot cause a crash. |
| 140 | + |
| 141 | +### Compatibility strategy for custom SerialExecutor authors |
| 142 | + |
| 143 | +New executor implementations should prioritize implementing `isIsolatingCurrentContext` when available, using an appropriate `#if swift(>=...)` check to ensure compatibility. Otherwise, they should fall back to implementing the crashing version of this API: `checkIsolated()`. |
| 144 | + |
| 145 | +For authors of custom serial executors, adopting this feature is an incremental process and they can adopt it at their own pace, properly guarding the new feature with necessary availability guards. This feature requires a new version of the Swift concurrency runtime which is aware of this new mode and therefore able to call into the new checking function, therefore libraries should implement and adopt it, however it will only manifest itself when the code is used with a new enough concurrency runtime |
| 146 | + |
| 147 | +As a result, this change should cause little to no disruption to Swift concurrency users, while providing an improved error reporting experience if using executors which adopt this feature. |
| 148 | + |
| 149 | +## Source Compatibility |
| 150 | + |
| 151 | +This proposal is source compatible, a default implementation of the new protocol requirement is introduced along with it, allowing existing `SerialExecutors` to compile without changes. |
| 152 | + |
| 153 | +## Binary Compatibility |
| 154 | + |
| 155 | +This proposal is ABI additive, and does not cause any binary incompatibility risks. |
| 156 | + |
| 157 | +## Future directions |
| 158 | + |
| 159 | +### Expose `isIsolated` on (Distributed)Actor and MainActor? |
| 160 | + |
| 161 | +We are _not_ including new public API on Actor types because of concerns of this function being abused. |
| 162 | + |
| 163 | +If we determine that there are significant good use-cases for this method to be exposed, we might reconsider this position. |
| 164 | + |
| 165 | +## Alternatives considered |
| 166 | + |
| 167 | +### Somehow change `checkIsolated` to return a bool value |
| 168 | + |
| 169 | +This would be ideal, however also problematic since changing a protocol requirements signature would be ABI breaking. |
| 170 | + |
| 171 | +### Deprecate `checkIsolated`? |
| 172 | + |
| 173 | +In order to make adoption of this new mode less painful and not cause deprecation warnings to libraries which intend to support multiple versions of Swift, the `SerialExcecutor/checkIsolated` protocol requirement remains _not_ deprecated. It may eventually become deprecated in the future, but right now we have no plans of doing so. |
| 174 | + |
| 175 | +### Offer a tri-state return value rather than `Bool` |
| 176 | + |
| 177 | +We briefly considered offering a tri-state `enum DetectedSerialExecutorIsolation` as the return value of `isIsolatingCurrentContext`, however could not find many realistic use-cases for it. |
| 178 | + |
| 179 | +The return type could be defined as: |
| 180 | + |
| 181 | +```swift |
| 182 | +// not great name |
| 183 | +enum DetectedSerialExecutorIsolation { |
| 184 | + case isolated // returned when isolated by this executor |
| 185 | + case notIsolated // returned when definitely NOT isolated by this executor |
| 186 | + case unknown // when the isIsolatingCurrentContext could not determine if the caller is isolated or not |
| 187 | +} |
| 188 | +``` |
| 189 | + |
| 190 | +If we used the `.unknown` as default implementation of the new protocol requirement, this would allow for programatic detection if we called the default implementation, or an user provided implementation which could check a proper isolated/not-isolated state of the executing context. |
| 191 | + |
| 192 | +Technically there may exist new implementations which return the `.unknown` however it would have to be treated defensively as `.notIsolated` in any asserting APIs or other use-cases which rely on this check for runtime correctness. We are uncertain if introducing this tri-state is actually helpful in real situations and therefore the proposal currently proposes the use of a plain `Bool` value. |
| 193 | + |
| 194 | +## Changelog |
| 195 | + |
| 196 | +- removed the manual need to signal to the runtime that the specific executor supports the new checking mode. It is now detected by the compiler and runtime, checking for the presence of a non-default implementation of the protocol requirement. |
0 commit comments