Skip to content

Task executor preference #2187

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Dec 13, 2023
Merged
Changes from 1 commit
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b4e3724
Initial revision of task executors proposal
ktoso Oct 16, 2023
714a76c
Cleanup and remove unclear pieces of design
ktoso Oct 16, 2023
d11cc51
add missing withTaskExecutor in snippet
ktoso Oct 17, 2023
41cc3d9
Update NNNN-task-executor-preference.md
ktoso Oct 18, 2023
f6f17e8
remove ordering promises; they are not strictly guaranteed
ktoso Oct 19, 2023
a023e7a
preference also affects default actors
ktoso Oct 25, 2023
1bef6aa
introduce the notion of TaskExecutor, in order to handle default actors
ktoso Nov 1, 2023
152d77d
change title
ktoso Nov 1, 2023
03759d7
add some warnings about when to use this proposal
ktoso Nov 1, 2023
2cbcac7
arrow typo in diagram
ktoso Nov 1, 2023
e2e8de6
Fix typo (#11)
Genaro-Chris Nov 1, 2023
4a6e0a5
Fixed typo. (#12)
wadetregaskis Nov 1, 2023
5ecbea1
Update missed section; SerialExecutors are NOT TaskExecutors by default
ktoso Nov 1, 2023
2e905b7
Explain Task(on actor) a bit more -- in future directions
ktoso Nov 1, 2023
428beb6
fix typo in async let example
ktoso Nov 7, 2023
594b747
add unownedTaskExecutor API
ktoso Nov 13, 2023
1287b64
minor rewording in sentence
ktoso Nov 13, 2023
045eeab
document that executor can be both serial and task executor
ktoso Nov 21, 2023
08d1dea
add minor notes to serial+task executor section
ktoso Nov 21, 2023
935ec5f
Apply suggestions from code review
ktoso Dec 5, 2023
454f0f0
Update NNNN-task-executor-preference.md
ktoso Dec 5, 2023
51439d5
Cleanup a comment
ktoso Dec 8, 2023
66f61de
Update proposals/NNNN-task-executor-preference.md
DougGregor Dec 13, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 170 additions & 32 deletions proposals/NNNN-task-executor-preference.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ We propose to introduce an additional layer of control over where a task can be

[ func / closure ] - /* where should it execute? */
|
+--------------+ +=========================+
+- no - | is isolated? | - yes -> | on the `isolated` actor |
| +--------------+ +=========================+
+--------------+ +==========================+
+- no - | is isolated? | - yes -> | default (actor) executor |
| +--------------+ +==========================+
|
| +==========================+
+-------------------------------> | on global conc. executor |
Expand All @@ -53,27 +53,48 @@ This proposal introduces a way to control hopping off to the global concurrent p

[ func / closure ] - /* where should it execute? */
|
+--------------+ +=========================+
+--- no - | is isolated? | - yes -> | on the `isolated` actor |
| +--------------+ +=========================+
|
v +==========================+
/* task executor preference? */ ------ no ------> | on global conc. executor |
| +==========================+
yes
|
v
+=================================+
| on specified preferred executor |
+=================================+
+--------------+ +===========================+
+--- no - | is isolated? | - yes -> | actor has unownedExecutor |
| +--------------+ +===========================+
| | |
| yes no
| | |
| v v
| +=======================+ /* task executor preference? */
| | on specified executor | | |
| +=======================+ yes no
| | |
| | v
| | +==========================+
| | | default (actor) executor |
| v +==========================+
v +==============================+
/* task executor preference? */ ---- yes ----> | on Task's preferred executor |
| +==============================+
yes
|
v
+===============================+
| on global concurrent executor |
+===============================+
```

In other words, this proposal introduces the ability to control where a **`nonisolated` function** should execute:
In other words, this proposal introduces the ability to control code may execute from a Task, and not just by using a custom actor executor.

* if no task preference is set, it is equivalent to current semantics, and will execute on the global concurrent executor,
* if a task preference is set, nonisolated functions will execute on the selected executor.
With this proposal a **`nonisolated` function** will execute, as follows:

This proposal does not change isolation semantics of nonisolated functions, and only applies to the runtime execution semantics of such functions.
* if task preference **is not** set:
* it is equivalent to current semantics, and will execute on the global concurrent executor,

* if a task preference **is** set,
* **(new)** nonisolated functions will execute on the selected executor.


The preferred executor also may influence where **actor-isolated code** may execute, specifically:

- if task preference **is** set:
- **(new)** default actors will use the task's preferred executor
- actors with a custom executor execute on that specified executor (i.e. "preference" has no effect), and are not influenced by the task's preference

The task executor preference can be specified either, at task creation time:

Expand Down Expand Up @@ -170,13 +191,17 @@ Task(on: preferredExecutor) {

### Task executor preference inheritance in Structured Concurrency

Task executor preference is inherited by child tasks and is *not* inherited by un-structured tasks. Specifically:
Task executor preference is inherited by child tasks and actors which do not declare an explicit executor (so-called "default actors"), and is *not* inherited by un-structured tasks.

Specifically:

* **Do** inherit task executor preference
* TaskGroup’s `addTask()`, unless overridden with explicit parameter
* `async let`
* methods on actors which which do not declare an explicit `unownedExecutor` requirement
* **Do not** inherit task executor preference
* Unstructured tasks: `Task {}` and `Task.detached {}`
* methods on actors which **do** declare an explicit `unownedExecutor` (including e.g. the `MainActor`)

This also means that an entire tree can be made to execute their nonisolated work on a specific executor, just by means of setting the preference on the top-level task.

Expand Down Expand Up @@ -349,25 +374,118 @@ extension (Discarding)(Throwing)TaskGroup {
}
```

### Task executor preference and global actors
#### Task executor preference and default actor isolated methods

Thanks to the improvements to treating @SomeGlobalActor isolation proposed in [SE-NNNN: Improved control over closure actor isolation](https://github.com/apple/swift-evolution/pull/2174) we are able to express that a Task may prefer to run on a specific global actor’s executor, and shall be isolated to that actor.
It is also worth explaining the interaction with actors which do not declare any executor requirement, like most actors.
Such actors are referred to as "default actors" and are the default way of how actors are declared:

Thanks to the equivalence between `SomeGlobalActor.shared` instance and `@SomeGlobalActor` annotation isolations (introduced in the linked proposal), this does not require a new API, but uses the previously described API that accepts an actor as parameter, to which we can pass a global actor’s `shared` instance.
```swift
actor RunsAnywhere { // a "default" actor == without an executor requirement
func hello() {
return "Hello"
}
}
```

Such actor has no requirement as to where it wants to execute. This means that if we were to call the `hello()` isolated
actor method from a task that has defined an executor preference -- the hello() method would still execute on a thread owned by that executor (!),
however isolation is still guaranteed by the actor's semantics:

```swift
@MainActor
var example: Int = 0
let anywhere = RunsAnywhere()
Task { await anywhere.hello() } // runs on "default executor", using a thread from the global pool

Task(on: MainActor.shared) {
example = 12 // not crossing actor-boundary
}
Task(on: myExecutor) { await anywhere.hello() } // runs on preferred executor, using a thread owned by that executor
```

It is more efficient to write `Task(on: MainActor.shared) {}` than it is to `Task { @MainActor in }` because the latter will first launch the task on the inferred context (either enclosing actor, or global concurrent executor), and then hop to the main actor. The `on MainActor` spelling allows Swift to immediately enqueue on the actor itself.
Methods which assert isolation, such as `Actor/assumeIsolated` and similar still function as expected.

## Execution semantics discussion

### Analysis of use-cases and the "sticky" preference semantics

The semantics explained in this proposal may at first seem tricky, however in reality the rule is quite strightfoward:

- when there is a strict requirement for code to run on some specific executor, *it will* (and therefore disegard the "preference"),
- when there is no requirement where asynchronous code should execute, this proposal allows to specify a preference and therefore avoid hopping and context switches, leading to more efficient programs.

It is worth discussing how user-control is retained with this proposal. Most notably, we believe this proposal follows Swift's core principle of progressive disclosure.

When developing an application at first one does not have to optimize for less context switches, however as applications grow performance analysis diagnoses context switching being a problem -- this proposal gives developers the tools to, selectively, in specific parts of a code-base introduce sticky task executor behavior.

### Separating blocking code off the global shared pools

This proposal gives control to developers who know that they'd like to isolate their code off from callers. For example, imagine an IO library which wraps blocking IO primitives like read/write system calls. You may not want to perform those on the width-limited default pool of Swift Concurrency, but instead wrap APIs which will be calling such APIs with the executor preference of some "`DedicatedIOExecutor`" (not part of this proposal):

```swift
// MyCoolIOLibrary.swift

func blockingRead() -> Bytes { ... }

public func callRead() async -> Bytes {
await withTaskExecutor(DedicatedIOExecutor.shared) { // sample executor
blockingRead() // OK, we're on our dedicated thread
}
}

public func callBulk() async -> Bytes {
// The same executor is used for both public functions
await withTaskExecutor(DedicatedIOExecutor.shared) { // sample executor
await callRead()
await callRead()
}
}
```

This way we won't be blocking threads inside the shared pool, and not risking thread starving of the entire application.

We can call `callRead` from inside `callBulk` and avoid un-necessary context switching as the same thread servicing the IO operation may be used for those asynchronous functions -- and no actual context switch may need to be performed when `callBulk` calls into `callRead` either.

For end-users of this library the API they don't need to worry about any of this, but the author of such library is in full control over where execution will happen -- be it using task executor preference, or custom actor executors.

This works also the other way around: when we're using a library and notice that it is doing blocking things and we'd rather separate it out onto a different executor. It may even have declared asynchronous methods -- but still is taking too slow to yield the thread for some reason, causing issues to the shared pool.

```swift
// SomeLibrary
nonisolated func slowSlow() async { ... } // causes us issues by blocking
```

In such situation, we, as users of given library can notice and work around this issue by wrapping it with an executor preference:

```swift
// our code
func caller() async {
// on shared global pool...
// let's make sure to run slowSlow on a dedicated IO thread:
await withTaskExecutor(DedicatedIOExecutor.shared) { // sample executor
await slowSlow() // will not hop to global pool, but stay on our IOExecutor
}
}
```

In other words, task executor preference gives control to developers at when and where care needs to be taken.

The default of hop-avoiding when a preference is set is also a good default because it optimizes for less context switching and can lead to better performance.

It is possible to disable a preference by setting the preference to `nil`. So if we want to make sure that some code would not be influenced by a caller's preference, we can defensively insert the following:

```swift
func function() async {
// make sure to ignore caller's task executor preference
await withTaskExecutor(nil) { ... }
}
```



#### What about the Main Actor?

While the `MainActor` is not really special under this model, and behaves just as any other actor _with_ an specific executor requirement.

It is worth reminding that using the main actor's executor as a preferred excecutor would have the same effect as with any other executor. While usually using the main actor as preferred executor is not recommended. After all, this is why the original proposal was made to make nonisolated async functions hop *off* from their calling context, in order to free the main actor to interleave other work while other asynchronous work is happening.

In some situations, where the called asynchronous function may be expected to actually never suspend directly but only sometimes call another actor, and otherwise just return immediately without ever suspending. This may be used as fine optimization to tune around specific well known calls.

### Task executor preference and `AsyncSequence`s

One use-case worth calling out is AsyncSequences, especially when used from actors.
Expand Down Expand Up @@ -443,6 +561,23 @@ Kotlin jobs also inherit the coroutine context from their parent, which is simil

## Future directions

### Task executor preference and global actors

Thanks to the improvements to treating @SomeGlobalActor isolation proposed in [SE-NNNN: Improved control over closure actor isolation](https://github.com/apple/swift-evolution/pull/2174) we would be able to that a Task may prefer to run on a specific global actor’s executor, and shall be isolated to that actor.

Thanks to the equivalence between `SomeGlobalActor.shared` instance and `@SomeGlobalActor` annotation isolations (introduced in the linked proposal), this does not require a new API, but uses the previously described API that accepts an actor as parameter, to which we can pass a global actor’s `shared` instance.

```swift
@MainActor
var example: Int = 0

Task(on: MainActor.shared) {
example = 12 // not crossing actor-boundary
}
```

It is more efficient to write `Task(on: MainActor.shared) {}` than it is to `Task { @MainActor in }` because the latter will first launch the task on the inferred context (either enclosing actor, or global concurrent executor), and then hop to the main actor. The `on MainActor` spelling allows Swift to immediately enqueue on the actor itself.

### Static closure isolation

When starting tasks on an actor's serial executor this proposal has to utilize the pattern of passing an isolated parameter to a task's operation closure in order to carry the isolation information to the closure, like this:
Expand Down Expand Up @@ -522,5 +657,8 @@ We considered if not introducing this feature could be beneficial and forcing de

## Revisions

- added future direction about simplifying the isolation of closures without explicit parameter passing
- removed ability to observe current executor preference of a task
- 1.2
- preference also has effect on default actors
- 1.1
- added future direction about simplifying the isolation of closures without explicit parameter passing
- removed ability to observe current executor preference of a task