Skip to content

Commit 991e4c8

Browse files
ktosoFranzBuschjamieQJumhyn
authored
Task Priority Escalation APIs (#2685)
* Task Priority Escalation APIs * Update proposals/NNNN-task-priority-escalation-apis.md Co-authored-by: Franz Busch <[email protected]> * Add better example, thanks to @dnadoba * remove the section explaining triggering * Apply suggestions from code review Co-authored-by: Jamie <[email protected]> * mark as implemented * Apply suggestions from code review * Task priority escalation is SE-0462 --------- Co-authored-by: Franz Busch <[email protected]> Co-authored-by: Jamie <[email protected]> Co-authored-by: Frederick Kellison-Linn <[email protected]> Co-authored-by: Freddy Kellison-Linn <[email protected]>
1 parent e56820b commit 991e4c8

File tree

1 file changed

+237
-0
lines changed

1 file changed

+237
-0
lines changed

Diff for: proposals/0462-task-priority-escalation-apis.md

+237
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
# Task Priority Escalation APIs
2+
3+
* Proposal: [SE-0462](0462-task-priority-escalation-apis.md)
4+
* Authors: [Konrad 'ktoso' Malawski](https://github.com/ktoso)
5+
* Review Manager: [Freddy Kellison-Linn](https://github.com/jumhyn)
6+
* Status: **Active review (February 20...March 2, 2025)**
7+
* Implementation: https://github.com/swiftlang/swift/pull/78625
8+
* Review: ([pitch](https://forums.swift.org/t/pitch-task-priority-escalation-apis/77702))
9+
10+
## Introduction
11+
12+
A large part of Swift Concurrency is its Structured Concurrency model, in which tasks automatically form parent-child relationships, and inherit certain traits from their parent task. For example, a task started from a medium priority task, also starts on the medium priority, and not only that – if the parent task gets awaited on from a higher priority task, the parent's as well as all of its child tasks' task priority will be escalated in order to avoid priority inversion problems.
13+
14+
This feature is automatic and works transparently for any structured task hierarchy. This proposal will discuss exposing user-facing APIs which can be used to participate in task priority escalation.
15+
16+
## Motivation
17+
18+
Generally developers can and should rely on the automatic task priority escalation happening transparently–at least for as long as all tasks necessary to escalate are created using structured concurrency primitives (task groups and `async let`). However, sometimes it is not possible to entirely avoid creating an unstructured task.
19+
20+
One such example is the async sequence [`merge`](https://github.com/apple/swift-async-algorithms/blob/4c3ea81f81f0a25d0470188459c6d4bf20cf2f97/Sources/AsyncAlgorithms/AsyncAlgorithms.docc/Guides/Merge.md) operation from the [swift-async-algorithms](https://github.com/apple/swift-async-algorithms/) project where the implementation is forced to create an unstructured task for iterating the upstream sequences, which must outlive downstream calls. These libraries would like to participate in task priority escalation to boost the priority of the upstream consuming task, however today they lack the API to do so.
21+
22+
```swift
23+
// SIMPLIFIED EXAMPLE CODE
24+
// Complete source: https://github.com/apple/swift-async-algorithms/blob/main/Sources/AsyncAlgorithms/Merge/MergeStorage.swift
25+
26+
struct AsyncMergeSequenceIterator: AsyncIterator {
27+
struct State {
28+
var task: Task<Void, any Error>? // unstructured upstream consumer task
29+
var buffer: Deque<Element>
30+
var upstreamContinuations: [UnsafeContinuation<Void, Error>]
31+
var downstreamContinuation: UnsafeContinuation<Element?, Error>?
32+
}
33+
34+
let state = Mutex<State>(State())
35+
36+
func next() async throws {
37+
self.state.withLock { state in
38+
if state.task == nil {
39+
state.task = Task {
40+
// Consume from the base iterators
41+
// ...
42+
}
43+
}
44+
}
45+
46+
if let element = self.state.withLock { $0.buffer.popFirst() } {
47+
return element
48+
} else {
49+
// We are handling cancellation here and need to handle task escalation here as well
50+
try await withTaskCancellationHandler {
51+
// HERE: need to handle priority escalation and boost `state.task`
52+
try await withCheckedContinuation { cont in
53+
self.state.withLock { $0.consumerContinuation = cont }
54+
}
55+
} onCancel: {
56+
// trigger cancellation of tasks and fail continuations
57+
}
58+
}
59+
}
60+
}
61+
```
62+
63+
The above example showcases a common pattern: often a continuation is paired with a Task used to complete it. Around the suspension on the continuation, waiting for it to be resumed, developers often install a task cancellation handler in order to potentially break out of potentially unbounded waiting for a continuation to be resumed. Around the same suspension (marked with `HERE` in the snippet above), we might want to insert a task priority escalation handler in order to priority boost the task that is used to resume the continuation. This can be important for correctness and performance of such operations, so we should find a way to offer these libraries a mechanism to participate in task priority handling.
64+
65+
Another example of libraries which may want to reach for manual task priority escalation APIs are libraries which facilitate communication across process boundaries, and would like to react to priority escalation and propagate it to a different process. Relying on the built-in priority escalation mechanisms won't work, because they are necessarily in-process, so libraries like this need to be able to participate and be notified when priority escalation happens, and also be able to efficiently cause the escalation inside the other process.
66+
67+
## Proposed solution
68+
69+
In order to address the above use-cases, we propose to add a pair of APIs: to react to priority escalation happening within a block of code, and an API to _cause_ a priority escalation without resorting to trickery by creating new tasks whose only purpose is to escalate the priority of some other task:
70+
71+
```swift
72+
enum State {
73+
case initialized
74+
case task(Task<Void, Never>)
75+
case priority(TaskPriority)
76+
}
77+
let m: Mutex<State> = .init(.initialized)
78+
79+
await withTaskPriorityEscalationHandler {
80+
await withCheckedContinuation { cc in
81+
let task = Task { cc.resume() }
82+
83+
let newPriority: TaskPriority? = state.withLock { state -> TaskPriority? in
84+
defer { state = .task(task) }
85+
switch state {
86+
case .initialized:
87+
return nil
88+
case .task:
89+
preconditionFailure("unreachable")
90+
case .priority(let priority):
91+
return priority
92+
}
93+
}
94+
// priority was escalated just before we stored the task in the mutex
95+
if let newPriority {
96+
Task.escalatePriority(task, to: newPriority)
97+
}
98+
} onPriorityEscalated: { newPriority in
99+
state.withLock { state in
100+
switch state {
101+
case .initialized, .priority:
102+
// priority was escalated just before we managed to store the task in the mutex
103+
state = .priority(newPriority)
104+
case .task(let task):
105+
Task.escalatePriority(task, to: newPriority)
106+
}
107+
}
108+
}
109+
}
110+
```
111+
112+
The above snippet handles edge various ordering situations, including the task escalation happening after
113+
the time the handler is registered but _before_ we managed to create and store the task.
114+
115+
In general, task escalation remains a slightly racy affair, we could always observe an escalation "too late" for it to matter,
116+
and have any meaningful effect on the work's execution, however this API and associated patterns handle most situations which
117+
we care about in practice.
118+
119+
## Detailed design
120+
121+
We propose the addition of a task priority escalation handler, similar to task cancellation handlers already present in the concurrency library:
122+
123+
```swift
124+
public func withTaskPriorityEscalationHandler<T, E>(
125+
operation: () async throws(E) -> T,
126+
onPriorityEscalated handler: @Sendable (TaskPriority) -> Void,
127+
isolation: isolated (any Actor)? = #isolation
128+
) async throws(E) -> T
129+
```
130+
131+
The shape of this API is similar to the `withTaskCancellationHandler` API present since initial Swift Concurrency release, however–unlike a cancellation handler–the `onPriorityEscalated` callback may be triggered multiple times. The `TaskPriority` passed to the handler is the "new priority" the surrounding task was escalated to.
132+
133+
It is guaranteed that priority is ever only increasing, as Swift Concurrency does not allow for a task priority to ever be lowered after it has been escalated. If attempts are made to escalate the task priority from multiple other threads to the same priority, the handler will only trigger once. However if priority is escalated to a high and then even higher priority, the handler may be invoked twice.
134+
135+
Task escalation handlers are inherently racy, and may sometimes miss an escalation, for example if it happened immediately before the handler was installed, like this:
136+
137+
```swift
138+
// priority: low
139+
// priority: high!
140+
await withTaskPriorityEscalationHandler {
141+
await work()
142+
} onPriorityEscalated: { newPriority in // may not be triggered if ->high escalation happened before handler was installed
143+
// do something
144+
}
145+
```
146+
147+
This is inherent to the nature of priority escalation and even with this behavior, we believe handlers are a worthy addition. One could also check for the `Task.currentPriority` and match it against our expectations inside the `operation` wrapped by the `withTaskPriorityEscalationHandler` if that could be useful to then perform the operation at an already _immediately_ heightened priority.
148+
149+
Escalation handlers work with any existing task kind (child, unstructured, unstructured detached), and trigger at every level of the hierarchy in an "outside in" order:
150+
151+
```swift
152+
let t = Task {
153+
await withTaskPriorityEscalationHandler {
154+
await withTaskGroup { group in
155+
group.addTask {
156+
await withTaskPriorityEscalationHandler {
157+
try? await Task.sleep(for: .seconds(1))
158+
} onPriorityEscalated: { newPriority in print("inner: \(newPriority)") }
159+
}
160+
}
161+
} onPriorityEscalated: { newPriority in print("outer: \(newPriority)") }
162+
}
163+
164+
// escalate t -> high
165+
// "outer: high"
166+
// "inner: high"
167+
```
168+
169+
The API can also be freely composed with `withTaskCancellationHandler` or there may even be multiple task escalation handlers registered on the same task (but in different pieces of the code).
170+
171+
### Manually propagating priority escalation
172+
173+
While generally developers should not rely on manual task escalation handling, this API also does introduce a manual way to escalate a task's priority. Primarily this should be used in combination with a task escalation handler to _propagate_ an escalation to an _unstructured task_ which otherwise would miss reacting to the escalation.
174+
175+
The `escalatePriority` API is offered as a static method on `Task` in order to slightly hide it away from using it accidentally by stumbling upon it if it were directly declared as a member method of a Task.
176+
177+
```swift
178+
extension Task {
179+
public static func escalatePriority(of task: Task, to newPriority: TaskPriority)
180+
}
181+
182+
extension UnsafeCurrentTask {
183+
public static func escalatePriority(of task: UnsafeCurrentTask, to newPriority: TaskPriority)
184+
}
185+
```
186+
187+
It is possible to escalate both a `Task` and `UnsafeCurrentTask`, however great care must be taken to not attempt to escalate an unsafe task handle if the task has already been destroyed. The `Task` accepting API is always safe.
188+
189+
Currently it is not possible to escalate a specific child task (created by `async let` or a task group) because those do not return task handles. We are interested in exposing task handles to child tasks in the future, and this design could then be easily amended to gain API to support such child task handles as well.
190+
191+
## Source compatibility
192+
193+
This proposal is purely additive, and does not cause any source compatibility issues.
194+
195+
## ABI compatibility
196+
197+
This proposal is purely ABI additive.
198+
199+
## Alternatives considered
200+
201+
### New Continuation APIs
202+
203+
We did consider if offering a new kind of continuation might be easier to work with for developers. One shape this might take is:
204+
205+
```swift
206+
struct State {
207+
var cc = CheckedContinuation<Void, any Error>?
208+
var task: Task<Void, any Error>?
209+
}
210+
let C: Mutex<State>
211+
212+
await withCheckedContinuation2 { cc in
213+
// ...
214+
C.withLock { $0.cc = cc }
215+
216+
let t = Task {
217+
C.withLock {
218+
$0.cc?.resume() // maybe we'd need to add 'tryResume'
219+
}
220+
}
221+
C.withLock { $0.task = t }
222+
} onCancel: { cc in
223+
// remember the cc can only be resumed once; we'd need to offer 'tryResume'
224+
cc.resume(throwing: CancellationError())
225+
} onPriorityEscalated: { cc, newPriority in
226+
print("new priority: \(newPriority)")
227+
C.withLock { Task.escalatePriority($0.task, to: newPriority) }
228+
}
229+
```
230+
231+
While at first this looks promising, we did not really remove much of the complexity -- careful locking is still necessary, and passing the continuation into the closures only makes it more error prone than not since it has become easier to accidentally multi-resume a continuation. This also does not compose well, and would only be offered around continuations, even if not all use-cases must necessarily suspend on a continuation to benefit from the priority escalation handling.
232+
233+
Overall, this seems like a tightly knit API that changes current idioms of `with...Handler ` without really saving us from the inherent complexity of these handlers being invoked concurrently, and limiting the usefulness of those handlers to just "around a continuation" which may not always be the case.
234+
235+
### Acknowledgements
236+
237+
We'd like to thank John McCall, David Nadoba for their input on the APIs during early reviews.

0 commit comments

Comments
 (0)