It’s because it’s a preference. An actor’s isolation works by “forcing” isolated work on that executor. An executor preference works by preferring it over the global cincurrent executor.
Before Swift 6.2, nonisolated code would proactively hop back to the GCE. Therefore your assumption would be correct.
In 6.2, the code stays on the currently active executer until:
Isolated code forces a change
A suspension point happens, and the continuation is resumed later
In this case, there’s a natural continues flow from the task.value’s execution domain. So the current executor is kept, since it’s not in an isolated context. This is a performance win, as context switching costs.
You’d likely see all three lines run on A if the inner task is spawned using Task.immediate
I think it’s a bug. If you compare the two UnownedTaskExecutors, the result will be as you expect: 1 on “A”, 2 on “B”, 3 on “A”. Perhaps they are too similar, i.e., UnownedSerialExecutor has a notion of complex identity, while UnownedTaskExecutor lacks such a capability.
I'm not 100% sure but it feels like allowing access to nonSendable on both A and B could lead to improper isolation?
TaskExecutor is not about isolation; rather, it should be considered an execution resource provider. For example, the Global Concurrent Executor is a TaskExecutor (Kinda). The proposal succinctly describes it as:
In a way, one should think of the SerialExecutor of the actor and TaskExecutor both being tracked and providing different semantics. The SerialExecutor guarantees mutual exclusion, and the TaskExecutor provides a source of threads.
P.S. If one of the compiler folks sees this, why do we expose a Builtin.Executor initializer? Is it because of distributed actors?
this is kind of an odd situation because this implementation of TaskExecutor isn't actually providing execution resources – it's just co-opting whatever thread it's called on to run the jobs. this means that both executors essentially provide the same 'substrate' for executing async work, so i think in essence it's like calling a bunch of synchronous functions (though i think the stack frames get saved and swapped out by the concurrency runtime when switching between the executors, rather than just getting deeper).
i think what happens in this case is:
run on executor A up to the creation of the inner Task
the task body is enqueued on executor B
this is immediately run – the async stack frame for A is saved, and the current thread swaps to executing the new job for B
the job is synchronous, so no suspension points occur, thus no enqueuing to other executors
the job completes, the async stack frame is 'popped' and we continue execution of the outer Task
we await the result of the inner Task – this has already run to completion so there is no dynamic suspension point
the outer Task completes
if you add in another print you can see that the inner task does not run concurrently with the outer one (well, it helps convince you of that, but i suppose is not definitive proof):
if you provide an implementation of TaskExecutor that does provide its own execution resources, like an underlying DispatchQueue or Thread or whatever, then the behavior will be different because the two tasks will be able to execute concurrently.
as @NotTheNHK points out, the execution at the print("3") statement is actually happening on the 'A' task executor though – it's just that no additional enqueue was necessary between the inner task and that point.
+1. If you replace print("2.5") with await Task.yield(), print("3") is correctly shown as executing on A:
let task = Task(executorPreference: TestExecutor(name: "A")) {
print("1")
let task = Task(executorPreference: TestExecutor(name: "B")) {
print("2")
}
await Task.yield()
await task.value
print("3")
}
await task.value
/*
Running on A
1
Running on B
2
Running on A
3
*/