Task execution order guarantees when targeting MainActor

Dispatching via DispatchQueue.main.async does guarantee the order of execution:

@MainActor func mustBeExecutedFirst() { /* ... */ } 
@MainActor func mustBeExecutedSecond() { /* ... */ } 

actor A {
    func foo() {
        DispatchQueue.main.async {
            MainActor.assumeIsolated(mustBeExecutedFirst)
        }
        DispatchQueue.main.async {
            MainActor.assumeIsolated(mustBeExecutedSecond)
        }
    }
}

To my surprise the following two approaches didn't break the order either in tests (Swift 6.2):

actor A {
    func foo() {
        Task { @MainActor in
            mustBeExecutedFirst()
        }
        Task { @MainActor in
            mustBeExecutedSecond()
        }
    }
}
actor A {
    func foo() {
        Task {
            await MainActor.run(body: mustBeExecutedFirst)
        }
        Task {
            await MainActor.run(body: mustBeExecutedSecond)
        }
    }
}

Is it by pure luck, or is there a stronger underlying reason / guarantee why the latter two are executed in order?

I believe in this particular case it's incidental. If you look at the SIL for the implementation of foo() without the explicit @MainActor annotations, you'll see that it "hops" to the generic executor, so there's no ordering guarantee there. Additionally, empirically it seems if you "clog up" the available threads with some extra work you'll be more likely to see the different orderings manifest. For example, if you run this compiler explorer example a few times you'll generally see some ordering changes Edit: see below for a better example.

SIL output:
// A.foo()
// Isolation: actor_instance. name: 'self'
sil hidden @$s6output1AC3fooyyF : $@convention(method) (@sil_isolated @guaranteed A) -> () {
// %0 "self"                                      // user: %1
bb0(%0 : $A):
  debug_value %0, let, name "self", argno 1       // id: %1
  %2 = metatype $@thin Task<(), Never>.Type       // user: %10
  %3 = enum $Optional<String>, #Optional.none!enumelt // user: %10
  %4 = alloc_stack $Optional<TaskPriority>        // users: %12, %10, %5
  inject_enum_addr %4, #Optional.none!enumelt     // id: %5
  // function_ref closure #1 in A.foo()
  %6 = function_ref @$s6output1AC3fooyyFyyYacfU_ : $@convention(thin) @async @substituted <τ_0_0> (@guaranteed Optional<any Actor>) -> @out τ_0_0 for <()> // user: %8
  %7 = enum $Optional<any Actor>, #Optional.none!enumelt // 👈‼️ user: %8
  %8 = partial_apply [callee_guaranteed] [isolated_any] %6(%7) : $@convention(thin) @async @substituted <τ_0_0> (@guaranteed Optional<any Actor>) -> @out τ_0_0 for <()> // user: %10
  // function_ref Task<>.init(name:priority:operation:)
  %9 = function_ref @$sScTss5NeverORs_rlE4name8priority9operationScTyxABGSSSg_ScPSgxyYaYAcntcfC : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Sendable, τ_0_1 == Never> (@owned Optional<String>, @in Optional<TaskPriority>, @sil_sending @owned @isolated(any) @async @callee_guaranteed @substituted <τ_0_0> () -> @out τ_0_0 for <τ_0_0>, @thin Task<τ_0_0, Never>.Type) -> @owned Task<τ_0_0, Never> // user: %10
  %10 = apply %9<(), Never>(%3, %4, %8, %2) : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Sendable, τ_0_1 == Never> (@owned Optional<String>, @in Optional<TaskPriority>, @sil_sending @owned @isolated(any) @async @callee_guaranteed @substituted <τ_0_0> () -> @out τ_0_0 for <τ_0_0>, @thin Task<τ_0_0, Never>.Type) -> @owned Task<τ_0_0, Never> // user: %11
  release_value %10                               // id: %11
  dealloc_stack %4                                // id: %12
  %13 = metatype $@thin Task<(), Never>.Type      // user: %21
  %14 = enum $Optional<String>, #Optional.none!enumelt // user: %21
  %15 = alloc_stack $Optional<TaskPriority>       // users: %23, %21, %16
  inject_enum_addr %15, #Optional.none!enumelt    // id: %16
  // function_ref closure #2 in A.foo()
  %17 = function_ref @$s6output1AC3fooyyFyyYacfU0_ : $@convention(thin) @async @substituted <τ_0_0> (@guaranteed Optional<any Actor>) -> @out τ_0_0 for <()> // user: %19
  %18 = enum $Optional<any Actor>, #Optional.none!enumelt // user: %19
  %19 = partial_apply [callee_guaranteed] [isolated_any] %17(%18) : $@convention(thin) @async @substituted <τ_0_0> (@guaranteed Optional<any Actor>) -> @out τ_0_0 for <()> // user: %21
  // function_ref Task<>.init(name:priority:operation:)
  %20 = function_ref @$sScTss5NeverORs_rlE4name8priority9operationScTyxABGSSSg_ScPSgxyYaYAcntcfC : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Sendable, τ_0_1 == Never> (@owned Optional<String>, @in Optional<TaskPriority>, @sil_sending @owned @isolated(any) @async @callee_guaranteed @substituted <τ_0_0> () -> @out τ_0_0 for <τ_0_0>, @thin Task<τ_0_0, Never>.Type) -> @owned Task<τ_0_0, Never> // user: %21
  %21 = apply %20<(), Never>(%14, %15, %19, %13) : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Sendable, τ_0_1 == Never> (@owned Optional<String>, @in Optional<TaskPriority>, @sil_sending @owned @isolated(any) @async @callee_guaranteed @substituted <τ_0_0> () -> @out τ_0_0 for <τ_0_0>, @thin Task<τ_0_0, Never>.Type) -> @owned Task<τ_0_0, Never> // user: %22
  release_value %21                               // id: %22
  dealloc_stack %15                               // id: %23
  %24 = tuple ()                                  // user: %25
  return %24                                      // id: %25
} // end sil function '$s6output1AC3fooyyF'
2 Likes

Actually, I just realized the example I came up with is flawed in its choice of synchronization mechanism for each "iteration" so might not actually demonstrate the ordering changes as I had originally thought. I think we could probably come up with an example that does however.


Edit: here's a better example (I think...): Compiler Explorer

Ohh that's actually really interesting. The reason the no-MainActor version jumps to the global executor is because the closures do not capture self. Since their isolation is conditional on captures, you end up with no isolation.

I used to think that there were no visible side effects of conditional isolation if you didn't capture any other local context. But this is a counter-example.

1 Like

I think what you're looking for is Task.immediate.

1 Like

Your first example (where the two closures are @MainActor) falls into the haven in SE-0431 where the initial jobs to start executing those tasks are guaranteed to be enqueued onto the target actor in order.

Your second example does not reliably work. The closures are formally @concurrent[1], so that does not apply), so the initial jobs to start executing their tasks will be enqueued onto the default task executor. While those enqueues are, in fact, processed in order, the default task executor is of course concurrent and does not guarantee that jobs will be run in the order they were enqueued.


  1. The closure does not have an explicit isolation, so one must be inferred. It is used in a type context that does not provide an isolation, either. It is passed to Task.init, so the inferred isolation is that of the lexically enclosing context, unless that is an isolated actor that is not captured by the closure. Since the enclosing isolation is an isolated actor that is not captured by the closure, that does not apply, so the closure is @concurrent. ↩︎

7 Likes

Although initial jobs of tasks are eunqueued in order, does the following statement in SE-0392 mean that they aren't necessarily executed in the same order?

If the executor is a serial executor, then the execution of all jobs must be totally ordered: for any two different jobs A and B submitted to the same executor with enqueue(_:), it must be true that either all events in A happen-before all events in B or all events in B happen-before all events in A.

  • Do note that this allows the executor to reorder A and B–for example, if one job had a higher priority than the other–however they each independently must run to completion before the other one is allowed to run.

Yes. Although, for the main actor specifically, jobs are guaranteed to execute in the order they were first enqueued. For every other actor, it is up to the implemented scheduling algorithm. For example, it could be priority-based instead of simple FIFO.

2 Likes

But the following code's result showed otherwise:

@MainActor
func test() async {
    // job A
    print("a")

    Task { @MainActor in
        // job B
        print("b")
    }

    // job C
    print("c")
}

await test()
try? await Task.sleep(for: .seconds(1))

// Output:
// a
// c
// b

Compiler explorer: Compiler Explorer

I think the actual behavior is consistent with what @ktoso said in another thread: Possibility of synchronous Task body execution - #10 by ktoso.

Jobs A and C are the same job; there are no dynamic suspension points (awaits that actually dynamically require the task to suspend) between them.

4 Likes

Thanks. But when running the same test code using a custom global executor, its log showed that enqueue was called three times, which I think indicated job A and C were enqueued separately. Just curious, why such a difference?

The following code was copied from another thread:

final class Exec: SerialExecutor, TaskExecutor {
  func enqueue(_ job: consuming ExecutorJob) {
    print("[enqueue] entering...")
    job.runSynchronously(
      isolatedTo: self.asUnownedSerialExecutor(),
      taskExecutor: self.asUnownedTaskExecutor()
    )
    print("[enqueue] exiting...")
  }
}

@globalActor
actor GA {
    static let shared = GA()

    let exec = Exec()

    nonisolated var unownedExecutor: UnownedSerialExecutor {
        exec.asUnownedSerialExecutor()
    }
}

@GA
func test() async {
    print("a")

    Task { @GA in
        print("b")
    }

    print("c")
}

await test()
try? await Task.sleep(for: .seconds(1))

// Output:
// [enqueue] entering...
// a
// [enqueue] entering...
// [enqueue] entering...
// b
// [enqueue] exiting...
// [enqueue] exiting...
// c
// [enqueue] exiting...

Compiler explorer: https://godbolt.org/z/vx1YnhEr5

AFAICs you're seeing an inefficiency how Task { @GA in } is enqueued; We enqueue it to the target executor thanks to @isolated(any) on the closure, but then the closure forces a hop to its isolation again effectively making a silly hop to the same actor again...

John's comment that A === C tasks is right, so there can't be (and is not) an enqueue there. It is just the Task{} being wasteful.

I have a PR laying around correcting such various hops behaviors which at runtime are not necessary but we don't catch them... I should look into it again.

2 Likes

But the AC task is enqueued first… how is it possible to get the output abc as in the above log?

Because Exec is ill-formed, it immediately executes the enqueued job without performing any actual synchronization.

2 Likes

Just note that this contradicts to: “jobs are guaranteed to execute in the order they were first enqueued”.

Thank you, @NotTheNHK.

But, for the benefit of learners, how do we make the Exec fit for purpose?

Can you elaborate on why you think they contradict each other?

A SerialExecutor must finish executing the first job (ac) before it starts executing the second job (b). In the case of Exec, the second job is executed from within the first job because this SerialExecutor neither distinguishes between enqueuing and executing a job nor performs any synchronization.

There are two relevant aspects for a well-formed SerialExecutor to consider:

  • What kind of scheduling policy the executor should follow, e.g., FIFO or priority-based scheduling.
  • Where enqueued jobs should be executed. This also informs the decision about what kind of synchronization is needed. Execution could occur on a worker thread owned by the executor, a "borrowed" thread from a thread pool (e.g. a TaskExecutor), or another concurrency abstraction. It should also be possible to "steal" the enqueuing thread and continue execution there if the executor is idle at that moment, although I have never seen this in Swift concurrency for a custom executor, and it is somewhat undesirable.

In the case of Exec, you could use a FIFO scheduling policy, where jobs are enqueued onto a synchronized queue and then executed on a separate thread.

1 Like

In addition to jobs overlapping, Calling job.runSynchronously in enqueue has a more serious issue. Since enqueue is nonisolated, enqueue and thus job.runSynchronously run in caller's isolation. This means they might run simultaneously in different isolations. An example to demonstrate the data race:

https://godbolt.org/z/b71YP9oKq

The setup:

  • A global actor uses a custom executor. The exeuctor calls job.runSynchronously directly in enqueue.
  • An integer value and a function that increases the value by 1. Both of them are isolated in the global actor.
  • Start two tasks, each increasing the integer 100000 times.
  • Print the integer value. Expect it should be 200000.

In my experiments the output wasn't always 200000, which indicates there were data races.

1 Like