Skip to content

Commit 5eeff6f

Browse files
committed
Introduce checkIsolated proposal
1 parent 9913d23 commit 5eeff6f

File tree

1 file changed

+188
-0
lines changed

1 file changed

+188
-0
lines changed
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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

Comments
 (0)