|
| 1 | +# Advanced isolation checking for SerialExecutor |
| 2 | + |
| 3 | +* Proposal: [SE-NNNN](NNNN-advanced-isolation-checking-for-serialexecutor.md) |
| 4 | +* Author: [Konrad 'ktoso' Malawski](https://github.com/ktoso) |
| 5 | +* Review Manager: ??? |
| 6 | +* Status: **Work in Progress** |
| 7 | +* Implementation: [PR #71172](https://github.com/apple/swift/pull/71172) |
| 8 | +* Review: ??? |
| 9 | + |
| 10 | +## Introduction |
| 11 | + |
| 12 | +Swift introduced custom actor executors in [SE-0392: Custom Actor Executors](https://github.com/apple/swift-evolution/blob/main/proposals/0392-custom-actor-executors.md), and ever since allowed further customization of isolation and execution semantics of actors. |
| 13 | + |
| 14 | +This proposal also introduced a family of assertion and assumption APIs which are able to dynamically check the isolation of a currently executing task. These APIs are: |
| 15 | + |
| 16 | +- Asserting isolation context: |
| 17 | + - [`Actor/assertIsolated(_:file:line:)`](https://developer.apple.com/documentation/swift/actor/assertisolated(_:file:line:)) |
| 18 | + - [`Actor/preconditionIsolated(_:file:line:)`](https://developer.apple.com/documentation/swift/actor/preconditionisolated(_:file:line:)) |
| 19 | + - [`DistributedActor/assertIsolated(_:file:line:)`](https://developer.apple.com/documentation/distributed/distributedactor/preconditionisolated(_:file:line:)) |
| 20 | + - [`DistributedActor/preconditionIsolated(_:file:line:)`](https://developer.apple.com/documentation/distributed/distributedactor/preconditionisolated(_:file:line:)) |
| 21 | +- Assuming isolation context, and obtaining an `isolated actor` reference of the target actor |
| 22 | + - [`Actor/assumeIsolated(_:file:line:)`](https://developer.apple.com/documentation/swift/actor/assumeisolated(_:file:line:)) |
| 23 | + - [`DistributedActor/assumeIsolated(_:file:line:)`](https://developer.apple.com/documentation/distributed/distributedactor/assumeisolated(_:file:line:)) |
| 24 | + |
| 25 | +## Motivation |
| 26 | + |
| 27 | +All the above mentioned APIs rely on an internal capability of the Swift concurrency runtime to obtain the "current serial executor", and compare it against the expected executor. Additional comparison modes such as "complex equality" are also supported, which help executors that e.g. share a single thread across multiple executor instances to still be able to correctly answer the "are we on the same executor?" question when different executor *instances* are being compared, however in reality they utilize the same threading resource. |
| 28 | + |
| 29 | +The proposal did not account for the situation in which the Swift concurrency runtime has no notion of "current executor" though, causing the following situation to -- perhaps surprisingly -- result in runtime crashes reporting an isolation violation, while in reality, no such violation takes place in the following piece of code: |
| 30 | + |
| 31 | +```swift |
| 32 | +import Dispatch |
| 33 | + |
| 34 | +actor Caplin { |
| 35 | + let queue: DispatchSerialQueue(label: "CoolQueue") |
| 36 | + |
| 37 | + // use the queue as this actor's `SerialExecutor` |
| 38 | + nonisolated var unownedExecutor: UnownedSerialExecutor { |
| 39 | + queue.asUnownedSerialExecutor() |
| 40 | + } |
| 41 | + |
| 42 | + nonisolated func connect() { |
| 43 | + queue.async { |
| 44 | + // guaranteed to execute on `queue` |
| 45 | + // which is the same as self's serial executor |
| 46 | + queue.assertIsolated() // CRASH: Incorrect actor executor assumption |
| 47 | + self.assertIsolated() // CRASH: Incorrect actor executor assumption |
| 48 | + } |
| 49 | + } |
| 50 | +} |
| 51 | +``` |
| 52 | + |
| 53 | +One might assume that since we are specifically using the `queue` as this actor's executor... the assertions in the `connect()` function should NOT crash, however how the runtime handles this situation can be simplified to the following steps: |
| 54 | + |
| 55 | +- try to obtain the "current executor" |
| 56 | +- since the current block of code is not executing a swift concurrency task... there is no "current executor" set in the context of `queue.async { ... }` |
| 57 | +- compare current "no executor" to the "expected executor" (the `queue` in our example) |
| 58 | +- crash, as `nil` is not the same executor as the specific `queue` |
| 59 | + |
| 60 | +In other words, these APIs assume to be running "within Swift Concurrency", however there may be situations in which we are running on the exact serial executor, but outside of Swift Concurrency. Isolation-wise, these APIs should still be returning correctly and detecting this situation -- however they are unable to do so, without some form of cooperation with the expected `SerialExecutor`. |
| 61 | + |
| 62 | +## Proposed solution |
| 63 | + |
| 64 | +We propose to add an additional last-resort mechanism to executor comparison, to be used ty the above mentioned APIs. |
| 65 | + |
| 66 | +This will be done by providing a new `checkIsolation()` protocol requirement on `SerialExecutor`: |
| 67 | + |
| 68 | +```swift |
| 69 | +protocol SerialExecutor: Executor { |
| 70 | + // ... |
| 71 | + |
| 72 | + /// Invoked as last-resort when the swift concurrency runtime is performing an isolation |
| 73 | + /// assertion, and could not confirm that the current execution context belongs to this |
| 74 | + /// "expected" executor. |
| 75 | + /// |
| 76 | + /// This function MUST crash the program with a fatal error if it is unable |
| 77 | + /// to prove that the calling context can be safely assumed to be the same isolation |
| 78 | + /// context as represented by this ``SerialExecutor``. |
| 79 | + /// |
| 80 | + /// A default implementation is provided that unconditionally causes a fatal error. |
| 81 | + func checkIsolation() |
| 82 | +} |
| 83 | + |
| 84 | +extension SerialExecutor { |
| 85 | + public func checkIsolation() { |
| 86 | + fatalError("Incorrect actor executor assumption, expected: \(self)") |
| 87 | + } |
| 88 | +} |
| 89 | +``` |
| 90 | + |
| 91 | +## Detailed design |
| 92 | + |
| 93 | +This proposal adds another customization point to the Swift concurrency runtime that hooks into isolation context comparison mechanisms used by `assertIsolated`, `preconditionIsolated`, `assumeIsolated` as well as implicitly injected assertions used in `@preconcurrency` code. |
| 94 | + |
| 95 | +### Extended executor comparison mechanism |
| 96 | + |
| 97 | +With this proposal, the logic for checking if the "current" executor is the same as the "expected" executor changes becomes as follows: |
| 98 | + |
| 99 | +- obtain current executor |
| 100 | + - if no current executor exists, use heurystics to detect the "main actor" executor |
| 101 | + - These heurystics could be removed by using this proposal's `checkIsolation()` API, however we'll first need to expose the MainActor's SerialExecutor as a global property which this proposal does not cover. Please see **Future Directions** for more discussion of this topic. |
| 102 | + - if a current executor exists, perform basic object comparison between them |
| 103 | +- if unable to prove the executors are equal: |
| 104 | + - compare the executors using "complex equality" (see [SE-0392: Custom Actor Executors](https://github.com/apple/swift-evolution/blob/main/proposals/0392-custom-actor-executors.md) for a detailed description of complex exector equality) |
| 105 | +- if still unable to prove the executors are equal: |
| 106 | + - :arrow_right: call the expected executor's `checkIsolation()` method |
| 107 | + |
| 108 | +The last step of this used to be just to unconditionally fail the comparison, leaving no space for an executor to take over and use whatever it's own tracking -- usually expressed using thread-locals the executor sets as it creates its own worker thread -- to actually save the comparison from failing. |
| 109 | + |
| 110 | +Specific use-cases of this API include `DispatchSerialQueue`, which would be able to implement the requirement as follows: |
| 111 | + |
| 112 | +```swift |
| 113 | +// Dispatch |
| 114 | + |
| 115 | +extension DispatchSerialQueue { |
| 116 | + public func checkIsolated() { |
| 117 | + dispatchPrecondition(condition: .onQueue(self)) // existing Dispatch API |
| 118 | + } |
| 119 | +} |
| 120 | +``` |
| 121 | + |
| 122 | +Other executors would have the same capability, if they used some mechanisms to identify their own worker threads. |
| 123 | + |
| 124 | +### Impact on async code and isolation assumtions |
| 125 | + |
| 126 | +The `assumeIsolated(_:file:line:)` APIs purposefully only accept a **synchronous** closure. This is correct, and with the here proposed additions, it remains correct -- we may be executing NOT inside a Task, however we may be isolated and can safely access actor state. |
| 127 | + |
| 128 | +This means that the following code snippet, while a bit unusual remains correct isolation-wise: |
| 129 | + |
| 130 | +```swift |
| 131 | +actor Worker { |
| 132 | + var number: Int |
| 133 | + |
| 134 | + nonisolated func canOnlyCallMeWhileIsolatedOnThisInstance() -> Int { |
| 135 | + return self.assumeIsolated { // () throws -> Int |
| 136 | + // suspensions are not allowed in this closure. |
| 137 | + |
| 138 | + self.number // we are guaranteed to be isolated on this actor; read is safe |
| 139 | + } |
| 140 | + } |
| 141 | + |
| 142 | +``` |
| 143 | + |
| 144 | +As such, there is no negative impact on the correctness of these APIs. |
| 145 | + |
| 146 | +## Future directions |
| 147 | + |
| 148 | +### Introduce `globalMainExecutor` global property and utilize `checkIsolated` on it |
| 149 | + |
| 150 | +This proposal also paves the way to clean up this hard-coded aspect of the runtime, and it would be possible to change these heurystics to instead invoke the `checkIsolation()` method on a "main actor executor" SerialExecutor reference if it were available. |
| 151 | + |
| 152 | +This proposal does not introduce a `globalMainActorExecutor`, however, similar how how [SE-0417: Task ExecutorPreference](https://github.com/apple/swift-evolution/blob/main/proposals/0417-task-executor-preference.md) introduced a: |
| 153 | + |
| 154 | +```swift |
| 155 | +nonisolated(unsafe) |
| 156 | +public var globalConcurrentExecutor: any TaskExecutor { get } |
| 157 | +``` |
| 158 | + |
| 159 | +the same could be done to the MainActor's executor: |
| 160 | + |
| 161 | +```swift |
| 162 | +nonisolated(unsafe) |
| 163 | +public var globalMainExecutor: any SerialExecutor { get } |
| 164 | +``` |
| 165 | + |
| 166 | +The custom heurystics that are today part of the Swift Concurrency runtime to detect the "main thread" and "main actor executor", could instead be delegated to this global property, and function correctly even if the MainActor's executor is NOT using the main thread (which can happen on some platforms): |
| 167 | + |
| 168 | +```swift |
| 169 | +// concurrency runtime pseudo-code |
| 170 | +if expectedExecutor.isMainActor() { |
| 171 | + globalMainActor.checkIsolated() |
| 172 | +} |
| 173 | +``` |
| 174 | + |
| 175 | +This would allow the isolation model to support different kinds of main executor and properly assert their isolation, using custom logic, rather than hardcoding the main thread assumptions into the Swift runtime. |
| 176 | + |
| 177 | +## Alternatives considered |
| 178 | + |
| 179 | +### Do not provide customization points, and just hardcode DispatchQueue handling |
| 180 | + |
| 181 | +Alternatively, we could harcode detecting dispatch queues and triggering `dispatchPrecondition` from within the Swift runtime. |
| 182 | + |
| 183 | +This is not a good direction though, as our goal is to have the concurrency runtime be less attached to Dispatch and allow Swift to handle each and every execution environment equally well. As such, introducing necessary hooks as official and public API is the way to go here. |
| 184 | + |
| 185 | + |
| 186 | +## Revisions |
| 187 | +- 1.0 |
| 188 | + - initial revision |
0 commit comments