- Proposal: SE-NNNN
- Author: Konrad 'ktoso' Malawski, John McCall, Franz Busch
- Review Manager: TBD
- Status: Partially implemented PR #68793
- Implementation: TBD
- Review: TBD
- Discussion threads:
Swift Concurrency uses tasks and actors to model concurrency and primarily relies on actor isolation to determine where a specific piece of code shall execute.
The recent introduction of custom actor executors in SE-0392 allows customizing on what specific SerialExecutor
implementation code should be running on while isolated to a specific actor. This allows developers to gain some control over exact threading semantics of actors, by e.g. making sure all work made by a specific actor is made on a dedicated queue or thread.
Today, the same flexibility is not available to tasks in general, and nonisolated asynchronous functions are always executed on the default global concurrent thread pool managed by Swift concurrency.
Custom actor executors allow developers to customize where execution of a task “on” an actor must happen (e.g. on a specific queue or thread, represented by a SerialExecutor
), the same capability is currently missing for code that is not isolated to an actor.
Notably, since Swift 5.7’s SE-0338: Clarify the Execution of Non-Actor-Isolated Async Functions, functions which are not isolated to an actor will always hop to the default global concurrent executor, which is great for correctness and understanding the code and avoids “hanging onto” actors longer than necessary. This is also a desirable semantic for code running on the MainActor
calling into nonisolated
functions, since it allows the main actor to be quickly freed up to proceed with other work, however it has a detrimental effect on applications which want to avoid hops in order to maximize request processing throughput. This is especially common with event-loop based systems, such as network servers or other kinds of tight request handling loops.
As Swift concurrency is getting adopted in a wider variety of performance sensitive codebases, it has become clear that the lack of control over where nonisolated functions execute is a noticeable problem. At the same time, the defensive "hop-off" semantics introduced by SE-0338 are still valuable, but sometimes too restrictive and some use-cases might even say that the exact opposite behavior might be desirable instead.
This proposal acknowledges the different needs of various use-cases, and provides a new flexible mechanism for developers to tune their their applications and avoid potentially un-necessary context switching when possible.
We propose to introduce an additional layer of control over where a task can be executed, and have this executor setting be “sticky” to the task.
Currently the decision where an async function or closure is going to execute is binary:
// `func` execution semantics before this proposal
[ func / closure ] - /* where should it execute? */
|
+--------------+ +==========================+
+- no - | is isolated? | - yes -> | default (actor) executor |
| +--------------+ +==========================+
|
| +==========================+
+-------------------------------> | on global conc. executor |
+==========================+
This proposal introduces a way to control hopping off to the global concurrent pool for nonisolated
functions and closures. This is expressed as task executor preference and is sticky to the task and entire structured task hierarchy created from a task with a specified preference. This changes the current decision diagram to the following:
// `func` execution semantics with this proposal
[ func / closure ] - /* where should it execute? */
|
+--------------+ +===========================+
+-------- | 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 |
| +==============================+
no
|
v
+===============================+
| on global concurrent executor |
+===============================+
In other words, this proposal introduces the ability to specify where code may execute from a Task, and not just by using a custom actor executor, and even influence the thread use of default actors.
With this proposal a nonisolated
function will execute, as follows:
-
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:
Task(on: executor) {
// starts and runs on the 'executor'
await nonisolatedAsyncFunc()
}
Task.detached(on: executor) {
// starts and runs on the 'executor'
await nonisolatedAsyncFunc()
}
await withDiscardingTaskGroup { group in
group.addTask(on: executor) {
// starts and runs on the 'executor'
await nonisolatedAsyncFunc()
}
}
func nonisolatedAsyncFunc() async -> Int {
// if the Task has a specific executor preference,
// runs on that 'executor' rather than on the default global concurrent executor
return 42
}
or, for a specific scope using the withTaskExecutor
method:
await withTaskExecutor(executor) {
// if not already running on specified 'executor'
// the withTaskExecutor would hop to it, and run this closure on it.
// task groups
await withDiscardingTaskGroup { group in
group.addTask {
// starts and runs on the 'executor'
await nonisolatedAsyncFunc()
}
}
// async let
async let number = nonisolatedAsyncFunc() // starts and runs on 'executor'
await number
}
Notably, the task executor preference is in effect for the entire structured task hierarchy while running in a task or scope where a task executor preference is set. For example, the following snippet illustrates child tasks created inside of a withTaskExecutor
.
If a task with such executor preference encounters code which is isolated
to some specific actor, it would adhere to that requirement and hop to it as expected:
let capy: Capybara = Capybara()
actor Capybara { func eat() {} }
Task(on: executor) {
// starts on 'executor', however...
try await capy.eat() // still executes actor isolated code on the actor's executor, as expected
}
A new concept of task executor preference is added to Swift Concurrency tasks. This preference is stored in a task and propagated throughout child tasks (such as ones created by TaskGroups and async let).
The preference can be set using various APIs that will be discussed in detail in their respective sections. The first of those APIs is withTaskExecutor
which can be called inside an asynchronous context to both ensure we’re executing on the expected executor, as well as set the task executor preference for the duration of the operation closure:
await withTaskExecutor(someExecutor) {
// guaranteed to be executing on someExecutor
}
Once set, the effect of an executor preference is such that a nonisolated func instead of immediately hopping to the global pool, it may hop to the preferred executor, e.g.:
nonisolated func tryMe() async {
// ...
}
let preferredExecutor: SomeConcreteTaskExecutor = ...
Task(on: preferredExecutor) {
// executes on 'preferredExecutor'
await tryMe() // tryMe body would execute on 'preferredExecutor'
}
await tryMe() // tryMe body would execute on 'default global concurrent executor'
In order to fulfil the requirement that we'd like default actors to run on a task executor, if it was set, we need to introduce a new kind of executor.
This stems from the fact that SerialExecutor
and how a default actor effectively acts as an executor "for itself" in Swift.
A default actor (so an actor which does not use a custom executor), has a "default" serial executor that is created by the Swift runtime and uses the actor is the executor's identity.
This means that the runtime executor tracking necessarily needs to track that some code is executing on a specific serial executor in order for things like assumeIsolated
or the built-in runtime thread-safety checks can utilize them.
The new protocol mirrors Executor
and SerialExecutor
in API, however it provides different semantics, and is tracked using a different mechanism at runtime -- by obtaining it from a task's executor preference record.
The TaskExecutor
is defined as:
public protocol TaskExecutor: Executor {
func enqueue(_ job: consuming ExecutorJob)
func asUnownedTaskExecutor() -> UnownedTaskExecutor
}
As an intuitive way to think about TaskExecutor
and SerialExecutor
, one can think of the prior as being a "source of threads" to execute work on,
and the latter being something that "provides serial isolation" and is a crucial part of Swift actors. The two share similarities, however the task executor has a more varied application space.
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 default actors (actors which do not use a custom executor)
- TaskGroup’s
- Do not inherit task executor preference
- Unstructured tasks:
Task {}
andTask.detached {}
- methods on actors which do use a custom executor (including e.g. the
MainActor
)
- Unstructured tasks:
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.
Since async let
are the simplest form of structured concurrency, they do not offer much in the way of customization.
An async let currently always executes on the global concurrent executor, and with the inclusion of this proposal, it does take into account task executor preference. In other words, if an executor preference is set, it will be used by async let to enqueue its underlying task:
func test() async -> Int {
return 42
}
await withTaskExecutor(someExecutor) {
async let value = test() // async let's "body" and target function execute on 'someExecutor'
// ...
await value
}
A TaskGroup
and its various friends (ThrowingTaskGroup
, DiscardingTaskGroup
, ...) are the most powerful, but also most explicit and verbose API for structured concurrency. A group allows creating multiple child tasks using the addTask
method, and always awaits all child tasks to complete before returning.
This proposal adds overloads to the addTask
method, which changes the executor the child tasks will be enqueued on:
extension (Discarding)(Throwing)TaskGroup {
mutating func addTask(
on executor: (any TaskExecutor)?, // 'nil' means the global pool
priority: TaskPriority? = nil,
operation: @Sendable @escaping () async (throws) -> Void
)
}
Which allows users to require child tasks be enqueued and run on specific executors:
Task(on: specialExecutor) {
_ = await withTaskGroup(of: Int.self) { group in
group.addTask() {
specialExecutor.assertIsolated()
return 12
}
group.addTask(on: differentExecutor) {
differentExecutor.assertIsolated()
return 42
}
group.addTask(on: nil) {
// guaranteed to run on the default global concurrent executor;
// this is equivalent to the only behavior a group exhibits today.
return 84
}
return await group.next()!
}
This gives developers explicit control over where a task group child task shall be executed. Notably, this gives callers of libraries more control over where work should be performed. Do note that code executing on an actor will always hop to that actor; and task executor preference has no impact on code which requires to be running in some specific isolation.
If a library really wants to ensure that hops to the global concurrent executor are made by e.g. such task group, they should use group.addTask(on: nil)
to override the inherited task executor preference.
We propose adding new APIs and necessary runtime changes to allow a Task to be enqueued directly on a specific Executor
, by using a new Task(on:)
initializer:
extension Task where Failure == Never {
@discardableResult
public init(
on executor: (any TaskExecutor)?,
priority: TaskPriority? = nil,
operation: @Sendable @escaping () async -> Success
)
@discardableResult
static func detached(
on executor: (any TaskExecutor)?,
priority: TaskPriority? = nil,
operation: @Sendable @escaping () async -> Success
)
}
extension Task where Failure == Error {
@discardableResult
public init(
on executor: (any TaskExecutor)?,
priority: TaskPriority? = nil,
operation: @Sendable @escaping () async throws -> Success
)
@discardableResult
static func detached(
on executor: (any TaskExecutor)?,
priority: TaskPriority? = nil,
operation: @Sendable @escaping () async throws -> Success
)
}
Tasks created this way are immediately enqueued on given executor.
By default, serial executors are not task executors, and therefore cannot be directly used with these APIs. This is because it would cause confusion in the runtime about having two "mutual exclusion" contexts at the same time, which could result in difficult to understand behaviors.
It is possible however to write a custom SerialExecutor
and conform to the TaskExecutor
protocol at the same time, if indeed one intended to use it for both purposes.
The serial executor conformance can be used for purposes of isolation (including the asserting and "assuming" of isolation), and the task executor conformance allows
using a type to provide a hint where tasks should execute although cannot be used to fulfil isolation requirements.
It is also worth explaining the interaction with actors which do not use a custom executor -- which is the majority of actors usually defined in a typical codebase. Such actors are referred to as "default actors" and are the default way of how actors are declared:
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:
let anywhere = RunsAnywhere()
Task { await anywhere.hello() } // runs on "default executor", using a thread from the global pool
Task(on: myExecutor) { await anywhere.hello() } // runs on preferred executor, using a thread owned by that executor
Methods which assert isolation, such as Actor/assumeIsolated
and similar still function as expected.
The task executor can be seen as a "source of threads" for the execution, while the actor's serial executor is used to ensure the serial and isolated execution of the code.
It is possible to inspect the current preferred task executor of a task, however because doing so is inherently unsafe -- due to lack of guarantees surrounding the lifetime of an executor referred to using an UnownedTaskExecutor
,
this operation is only exposed on the UnsafeCurrentTask
.
Furthermore, the API purposefully does not expose an any TaskExecutor
because this would risk incurring atomic ref-counting on an executor object that may have been already deallocated.
This API is intended only for fine-tuning and checking if we are executing on the "expected" task executor, and therefore the UnownedTaskExecutor
also implements the Equatable
protocol,
and implements it using pointer equality. This comparison is not strictly safe, in case if an executor was deallocated, and a new executor was allocated in the same memory location,
however for purposes of executors -- especially long-lived ones, we believe this is not going to prove to be a problem in practical uses of task executors.
An example use of this API might be something like this:
struct MyEventLoopTaskExecutor: TaskExecutor {}
func test(expected eventLoop: MyEventLoopTaskExecutor) {
withUnsafeCurrentTask { task in
guard let task else {
fatalError("Missing task?")
}
guard let currentTaskExecutor = task.unownedTaskExecutor else {
fatalError("Expected to have task executor")
}
precondition(currentTaskExecutor == eventLoop.asUnownedTaskExecutor())
// perform action that is required to run on the expected executor
}
}
This may be useful in synchronous functions; however should be used sparingly, and with caution. Asynchronous functions, or functions on actors should instead rely on the usual ways to statically ensure to be running on an expected executor: by providing the right annotations or custom executors to their enclosing actors.
Instead, functions which have strict execution requirements may be better served as declaring them inside of an actor
that has the required specific executor specified (by using custom actor executors), or by using an asynchronous function
and wrapping the code that is required to run on a specific executor in an withTaskExecutor(eventLoop) { ... }
block.
Nevertheless, because we understand there may be situations where synchronous code may want to compare task executors, this capability is exposed for advanced use cases.
Another use case may be carrying the same task executor into an un-structured Task -- although this should only be done with extreme caution, because it breaks structured concurrency lifetime expectations of executors. For example, the following code is correct under structured concurrency's default and automatic behavior surrounding task executors:
func computeThings() async {
let eventLoop: any TaskExecutor = MyCoolEventLoop()
defer { eventLoop.shutdown() }
let computed = withTaskExecutor(eventLoop) {
async let first = computation(1)
async let second = computation(2)
return await first + second
}
return computed // event loop will be shutdown and the executor destroyed(!)
}
func computation(_ int: Int) -> Int { return int * 2 }
The above code is structurally correct and we guarantee the lifetime of MyCoolEventLoop
throughout all of its uses
by structured concurrency tasks in this snippet.
The following snippet is not safe, which is why task executors are not inherited to un-structured tasks:
// !!! POTENTIALLY UNSAFE !!!
// Do not do this, unless you can guarantee the lifetime of TaskExecutor
// exceeds all potential for any task to be running on it (!)
func computeThings() async {
let eventLoop: any TaskExecutor = MyCoolEventLoop()
defer { eventLoop.shutdown() }
let computed = withTaskExecutor(eventLoop) {
async let first = computation(1)
async let second = computation(2)
return await first + second
}
return computed // event loop will be shutdown and the executor destroyed(!)
}
// DANGEROUS; MUST ENSURE THE EXECUTOR REMAINS ALIVE FOR AS LONG AS ANY TASK MAY BE RUNNING ON IT
func computation(_ int: Int) -> Int {
withUnsafeCurrentTask { task in
let unownedExecutor: UnownedTaskExecutor? = task?.unownedTaskExecutor
let eventLoop: MyCoolEventLoop? = EventLoops.find(unownedExecutor)
// Dangerous because there is no structured guarantee that eventLoop will be kept alive
// long for as long as there is any of its child tasks and functions running on it
Task(on: eventLoop) { ... }
}
}
It is possible to declare a single executor type and have it conform to both the SerialExecutor
(introduced in the custom actor executors proposal),
as well as the TaskExecutor
(introduce in this proposal).
If declaring an executor that adheres to both protocols like that, it truly must adhere to the SerialExecutor
semantics of not running work concurrently, as it may be used as an isolation context by an actor.
// naive executor for illustration purposes; we'll assert on the dispatch queue and isolation.
final class NaiveQueueExecutor: TaskExecutor, SerialExecutor {
let queue: DispatchQueue
init(_ queue: DispatchQueue) {
self.queue = queue
}
public func enqueue(_ _job: consuming ExecutorJob) {
let job = UnownedJob(_job)
queue.async {
job.runSynchronously(
isolatedOn: self.asUnownedSerialExecutor(),
taskExecutor: self.asUnownedTaskExecutor())
}
}
@inlinable
public func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
@inlinable
public func asUnownedTaskExecutor() -> UnownedTaskExecutor {
UnownedTaskExecutor(ordinary: self)
}
}
Since the enqueue method shares the same signature between the two protocols it is possible to just implement it once.
It is of crucial importance to run the job using the new runSynchronously(isolatedOn:taskExecutor:)
overload
of the runSynchronously
method. because it will set up all the required thread-local state for both isolation assertions
and task-executor preference semantics to be handled properly.
Given such executor, we are able to have it both be used by an actor (thanks to being a SerialExecutor
), as well as have
any structured tasks or nonisolated async functions keep executing on this executor (thanks to it being a TaskExecutor
):
nonisolated func nonisolatedFunc(expectedExecutor: NaiveQueueExecutor) async {
dispatchPrecondition(condition: .onQueue(expectedExecutor.queue))
expectedExecutor.assertIsolated()
}
actor Worker {
let executor: NaiveQueueExecutor
init(on executor: NaiveQueueExecutor) {
self.executor = executor
}
func test(_ expectedExecutor: NaiveQueueExecutor) async {
// we are isolated to the serial-executor (!)
dispatchPrecondition(condition: .onQueue(expectedExecutor.queue))
expectedExecutor.preconditionIsolated()
// the nonisolated async func properly executes on the task-executor
await nonisolatedFunc(expectedExecutor: expectedExecutor)
/// the task-executor preference is inherited properly:
async let val = {
dispatchPrecondition(condition: .onQueue(expectedExecutor.queue))
expectedExecutor.preconditionIsolated()
return 12
}()
_ = await val
// as expected not-inheriting
_ = await Task.detached {
dispatchPrecondition(condition: .notOnQueue(expectedExecutor.queue))
}.value
// we properly came back to the serial executor, just to make sure
dispatchPrecondition(condition: .onQueue(expectedExecutor.queue))
expectedExecutor.preconditionIsolated()
}
}
As with many new capabilities in libraries and languages, one may be tempted to use task executors to solve various problems.
We advise care when doing so with task executors, because while they do minimize the "hopping off" from executors and the associated context switching,
this is also a behavior that may be entirely undesirable in some situations. For example, over-hanging on the MainActor's executor is one of the main reasons
earlier Swift versions moved to make nonisolated
asynchronous functions always hop off their calling execution context; and this proposal brings back this behavior
for specific executors.
Applying task executors to solve a performance problem should be done after thoroughly understanding the problem an application is facing, and only then determining the right "sticky"-ness behavior and specific pieces of code which might benefit from it.
Examples of good candidates for task executor usage would be systems which utilize some form of "specific thread" to minimize synchronization overhead, like for example event-loop based systems (often, network applications), or IO systems which willingly perform blocking operations and need to perform them off the global concurrency pool.
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.
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):
// 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 long to yield the thread for some reason, causing issues to the shared pool.
// 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:
// 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:
func function() async {
// make sure to ignore caller's task executor preference
await withTaskExecutor(nil) { ... }
}
While the MainActor
is not really special under this model, and behaves just as any other actor with a specific executor requirement.
It is worth reminding that using the main actor's executor as a preferred executor 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.
One use-case worth calling out is AsyncSequences, especially when used from actors.
The following snippet illustrates a common performance pitfall today:
actor Looper {
func consumeSequence() async {
for await value in getAsyncSequence() {
// 1. hop-to global pool: await next()
// 2. hop-to actor: execute for-loop body
print("got: \(value)")
}
}
}
Because an async sequence's iterator has a nonisolated next()
method (declared like this func next() async -> Element?
),
under the current execution semantics, the execution will always hop to the global concurrent executor to execute the next()
method,
and only then let it run, potentially produce a value without even suspending (!), and hop back to the actor to process the body of the for-loop.
This is unfortunate and can cause a lot of back-and forth hopping that may not necessarily be desirable. Especially with async sequences which employ
some form of buffering, such that e.g. the sequence has a number of elements "ready" and will return them immediately without having to suspend or synchronize with other isolated
code.
With the use of task executor preference, we are able to circumvent the hops to the global concurrent executor, by preferring the actor's own executor, like this:
actor Looper {
func consumeSequence() async {
withTaskExecutor(self) {
for await value in getAsyncSequence() {
// 1.a. 'next()' can execute on Looper's executor
// 1.b. if next() needs to call some other isolated code, we would hop there
// but only when necessary.
// 2.a. Following the fast path where next() executed directly on Looper.executor,
// the "hop back to actor" is efficient because it is a hop to the same executor which is a no-op.
// 2.b. Following the slow path where next() had to call some `isolated` code,
// the hop back to the Looper is the same as it would be normally.
print("got: \(value)")
}
}
}
}
Async sequences are expected to undergo further evolution in order to express isolation more efficiently in the actor case. Task executors are expected to fit well into this model, and offer an additional layer of "fine tuning" of developers encounter the need to do so.
It is worth comparing with other concurrency runtimes with similar concepts to make sure if there are some common ideas or something different other projects have researched.
For example, in Kotlin, a launch
which equivalent to Swift’s creation of a new task, takes a coroutine context which can contain an executor preference. The official documentation showcases the following example:
launch { // context of the parent, main runBlocking coroutine
println("main runBlocking : I'm working in thread ${Thread.currentThread().name}")
}
launch(Dispatchers.Unconfined) { // not confined -- will work with main thread
println("Unconfined : I'm working in thread ${Thread.currentThread().name}")
}
launch(Dispatchers.Default) { // will get dispatched to DefaultDispatcher
println("Default : I'm working in thread ${Thread.currentThread().name}")
}
launch(newSingleThreadContext("MyOwnThread")) { // will get its own new thread
println("newSingleThreadContext: I'm working in thread ${Thread.currentThread().name}")
}
Which is similar to the here proposed semantics of passing a specific executor preference. Notably though, because Swift has the concept of actor isolation
the executor semantics introduced in this proposal are only a preference and will never override the executor requirements of actually strongly isolated
code.
Kotlin jobs also inherit the coroutine context from their parent, which is similar to the here proposed executor inheritance works.
Thanks to the improvements to treating @SomeGlobalActor isolation proposed in SE-NNNN: Improved control over closure actor isolation 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.
@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.
It would be interesting to allow starting a task on a specific actor's executor, and have this infer the specific isolation.
Today the proposal does not allow using serial executors, which are strictly associated with actors to start a Task "on" such executor.
We could consider adding some form of such ability, and then be able to infer that the closure of a Task is isolated to the actor passed to Task(on: some Actor)
.
The upcoming SE-NNNN: Improved control over closure actor isolation proposal includes a future direction which would allow isolating a closure to a known other value.
This could be utilized to spell the Task
initializer like this:
extension Task where ... {
init<TargetActor>(
on target: TargetActor,
// ...,
operation: @isolated(target) () async -> ()
) where TargetActor: Actor
}
This would allow us to cut down on the noise of passing the isolated-on parameter explicitly and avoid a hop to the global executor before the task eventually hops back to the intended actor.
Today, if we were to allow a default actor's executor to be used as TaskExecutor
, a similar API could be made that would look like this:
actor Worker { func work() {} }
let worker: Worker = Worker()
Task(on: worker) { worker in // noisy parameter; though required for isolation purposes
worker.work()
}
However, it would be noisy in the sense of having to repeat the worker
parameter for purposes of isolation.
Expressing the “run isolated to this distributed actor” APIs is tricky until distributed actors gain the ability to express the local
-ness of a specific instance. For that reason we currently do not introduce the above APIs for distributed actors.
For reference, the semantics we are after would be something like the following:
extension Task where Failure == Never {
@discardableResult
public init<TargetActor>(
on executor: local TargetActor,
priority: TaskPriority? = nil,
operation: @Sendable @escaping (isolated TargetActor) async -> Success
) where TargetActor: DistributedActor
}
Which would be used like this:
let definitelyLocal = Worker(actorSystem: system)
// : local Worker
Task(on: definitelyLocal) { definitelyLocal in
definitelyLocal.hello()
}
Which can work only with a local
distributed actor because a local can always be safely upgraded to an isolated
reference by performing an actor hop to its executor. The same is not possible for an “unknown if local or remote” reference.
We considered if not introducing this feature could be beneficial and forcing developers to always pass explicit isolated
parameters instead. We worry that this becomes a) very tedious and b) impossibly ties threading semantics with public API and ABI of methods. We are concerned that the lack of executor “preference” which only affects the nonisolated functions in a task hierarchy would cause developers to defensively and proactively create multiple versions of APIs. It would also only allow passing actors as the executors, because isolation is an actor concept, and therefore we’d only be able to isolate using serial executors, while we may want to isolate using general purpose Executor
types.
- 1.5
- document that an executor may be both SerialExecutor and TaskExecutor at th same time
- 1.4
- added
unownedTaskExecutor
to UnsafeCurrentTask
- added
- 1.3
- introduce TaskExecutor in order to be able to implement actor isolation properly and still use a different thread for running default actors
- wording cleanups
- removal of the
Task(on: Actor)
APIs; we could perhaps revisit this if we made default actors' executors somehow aware of being a thread source as well etc.
- 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