Task on MainActor does not run on the main thread, why?

This code can be compiled as command line tool for macOS.

import Foundation

@main
struct App {

    static var counter = 0

    static func main() async throws {
        print("Thread: \(Thread.current)")
        let task1 = Task { @MainActor () -> Void in
            print("Task1 before await Task.yield(): \(Thread.current)")
            await Task.yield()
            print("Task1 before await increaseCounter(): \(Thread.current)")
            await increaseCounter()
            print("Task1 after await increaseCounter(): \(Thread.current)")
        }

        let task2 = Task { @MainActor () -> Void in
            print("Task2 before await Task.yield(): \(Thread.current)")
            await Task.yield()
            print("Task2 before await decreaseCounter(): \(Thread.current)")
            await decreaseCounter()
            print("Task2 after await decreaseCounter(): \(Thread.current)")
        }

        _ = await (task1.value, task2.value)
        print("Final counter value: \(counter)")
    }

    static func increaseCounter() async {
        for i in 0..<999 {
            counter += 1
            print("up step \(i), counter: \(counter), thread: \(Thread.current)")
            await Task.yield()
        }
    }

    static func decreaseCounter() async {
        for i in 0..<999 {
            counter -= 1
            print("down step \(i), counter: \(counter), thread: \(Thread.current)")
            await Task.yield()
        }
    }
}

My understanding is:

  • static func main() async throws inherits MainActor async context, and should always run on the main thread (and it really seems that it does so)
  • Task is initialized by the initializer, so it inherits the actor async context, so I would expect that will run on the main thread. Correct?
  • Moreover, the closure for Task is annotated by @MainActor, so I would even more expect it will run on the main thread.
  • I would expect that static func main() async throws inherits MainActor async context and will prevent data races, so the final counter value will always be zero. But it is not.
  • Both task1 and task2 really start running on the main thread, however the async functions increaseCounter() and decreaseCounter() run on other threads than the main thread, so the Task does not prevent data races, while I would expect it.
  • When I annotate increaseCounter() and decreaseCounter() by @MainActor then it works correctly, but this is what I do not want to do, I would expect that Task will do that.

Can anyone explain, why this works as it does, please?

Replies

The resolution to this the the last bullet:  increaseCounter() and decreaseCounter() functions must be annotated by @MainActor, because these touch the shared counter property. Task cannot guarantee this.

So, there are two parts to this:

  • Why does it behave the way it does?

  • Why doesn’t Swift catch the problem?


The answer to the second is that the current Swift compiler only has limited concurrency checking and, even if you enable all the checks, it’s not very good about checking global variables (which is what App.counter is because it’s static).

If you compile your code with Xcode 14.0b3 and enable the Strict Concurrency Checking build setting (set SWIFT_STRICT_CONCURRENCY to complete), you’ll get a bunch of concurrency warnings.


As to why it works the way that it does, the key thing to note is that an async function can run in any context unless it’s explicitly isolated to an actor. Your increaseCounter() and decreaseCounter() functions are static, and so are effectively the same as free functions. These are not isolated to an actor and thus there’s nothing stopping them from running in parallel.

If you want to force them to run on the main actor, you can add the @MainActor attribute (which you’ve already tested) or move them, and counter, to an actor, removing the static for all three, and then annotate that with @MainActor.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"