A question about Task Executor Preference

I'm trying to understand how task executor preferences work, and have the following executor:

final class TestExecutor: TaskExecutor {

  init(name: String) {
    self.name = name
  }
  let name: String

  func enqueue(_ job: consuming ExecutorJob) {
    print("Running on \(name)")
    job.runSynchronously(on: asUnownedTaskExecutor())
  }

  func asUnownedTaskExecutor() -> UnownedTaskExecutor {
    UnownedTaskExecutor(ordinary: self)
  }

}

Then I execute the following code:

  let task = Task(executorPreference: TestExecutor(name: "A")) {
    print("1")
    let task = Task(executorPreference: TestExecutor(name: "B")) {
      print("2")
    }
    await task.value
    print("3")
  }
  await task.value

And this prints:

Running on A
1
Running on B
2
3

I would have expected to have print("3") run on A... why is this not the case?

I added a non-senable type and it had the same outcome:


final class NonSendable {
  var x: Int = 0
}

  let task = Task(executorPreference: TestExecutor(name: "A")) {
    let nonSendable = NonSendable()
    print("1")
    let task = Task(executorPreference: TestExecutor(name: "B")) {
      print("2")
    }
    await task.value
    nonSendable.x += 1
    print("3")
  }
  await task.value

I'm not 100% sure but it feels like allowing access to nonSendable on both A and B could lead to improper isolation?

Would love any guidance/insight!

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

See immediate(name:priority:executorPreference:operation:) | Apple Developer Documentation

3 Likes

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:

  1. run on executor A up to the creation of the inner Task
  2. the task body is enqueued on executor B
  3. this is immediately run – the async stack frame for A is saved, and the current thread swaps to executing the new job for B
  4. the job is synchronous, so no suspension points occur, thus no enqueuing to other executors
  5. the job completes, the async stack frame is 'popped' and we continue execution of the outer Task
  6. we await the result of the inner Task – this has already run to completion so there is no dynamic suspension point
  7. 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):

let task = Task(executorPreference: TestExecutor(name: "A")) {
    print("1")
    let task = Task(executorPreference: TestExecutor(name: "B")) {
      print("2")
    }
    print("2.5")
    await task.value
    print("3")
  }
  await task.value

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 Like

+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
*/