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