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?

Accepted Reply

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"

  • Statement: "Swift makes no guarantee that the thread which executed the code before the await is the same thread which will pick up the continuation as well." Questions: Do I understand right that when the explicit async context is MainActor (Task closure is annotated by @MainActor), then it is guaranteed that the thread used for continuation (after await) will be the main thread? Is this guaranteed also for inherited MainActor async context?

Add a Comment

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"

  • Statement: "Swift makes no guarantee that the thread which executed the code before the await is the same thread which will pick up the continuation as well." Questions: Do I understand right that when the explicit async context is MainActor (Task closure is annotated by @MainActor), then it is guaranteed that the thread used for continuation (after await) will be the main thread? Is this guaranteed also for inherited MainActor async context?

Add a Comment

I respect statement "Swift makes no guarantee that the thread which executed the code before the await is the same thread which will pick up the continuation as well.".

I would like to fully understand, what exactly means "Task inherits async context". Does it keep the async (actor!) context only till the first await? Or longer?

I have modified the code above:

@main
struct App {

    static var counter = 0

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

        let task2 = Task.detached { () -> Void in
            print("Task2 before await decreaseCounter(): \(Thread.current)")
            await decreaseCounter()
            print("Task2 AFTER await decreaseCounter(): \(Thread.current)")
            await decreaseCounter()
            print("Task2 AFTER2 await decreaseCounter(): \(Thread.current)")
        }

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

    static func increaseCounter() async {
        print("increase before loop, thread: \(Thread.current)")
        for _ in 0..<999 {
            counter += 1
            await Task.yield()
        }
        print("increase after loop, thread: \(Thread.current)")
    }

    static func decreaseCounter() async {
        print("decrease before loop, thread: \(Thread.current)")
        for _ in 0..<999 {
            counter -= 1
            await Task.yield()
        }
        print("decrease after loop, thread: \(Thread.current)")
    }
}

The key question is related to the thread used for continuation in the tasks. The tasks now do not have explicit async context, however should inherit async context from the main() function, and I would expect that also continuation (after await) will run on the same MainActor async context, eg. on the main thread.

However, as seen below, the continuation in task2 run on other than main thread. What is wrong in my understanding, please?

Thread: <_NSMainThread: 0x10700aad0>{number = 1, name = main}
Task1 before await increaseCounter(): <_NSMainThread: 0x10700aad0>{number = 1, name = main}
Task2 before await decreaseCounter(): <NSThread: 0x107411040>{number = 2, name = (null)}
decrease before loop, thread: <NSThread: 0x107411040>{number = 2, name = (null)}
increase before loop, thread: <_NSMainThread: 0x10700aad0>{number = 1, name = main}
decrease after loop, thread: <NSThread: 0x1071451d0>{number = 3, name = (null)}
Task2 AFTER await decreaseCounter(): <NSThread: 0x1071451d0>{number = 3, name = (null)}
increase after loop, thread: <NSThread: 0x107045480>{number = 4, name = (null)}
decrease before loop, thread: <NSThread: 0x1071451d0>{number = 3, name = (null)}
Task1 AFTER await increaseCounter(): <_NSMainThread: 0x10700aad0>{number = 1, name = main}
increase before loop, thread: <_NSMainThread: 0x10700aad0>{number = 1, name = main}
decrease after loop, thread: <NSThread: 0x107005d70>{number = 5, name = (null)}
Task2 AFTER2 await decreaseCounter(): <NSThread: 0x107005d70>{number = 5, name = (null)}
increase after loop, thread: <NSThread: 0x107045480>{number = 4, name = (null)}
Task1 AFTER2 await increaseCounter(): <_NSMainThread: 0x10700aad0>{number = 1, name = main}
Final counter value: -3
Program ended with exit code: 0

The surprise is the line Task2 AFTER2 await decreaseCounter(): <NSThread: 0x107005d70>{number = 5, name = (null)}

Tested on XCode 13.4.1 (13F100)

  • task2 is created as detached, so does NOT inherit the main actor async context, so it is correct not to run on the main thread. The example above was misleading.

Add a Comment

Do I understand right that when the explicit async context is MainActor (Task closure is annotated by @MainActor), then it is guaranteed that the thread used for continuation (after await) will be the main thread?

Yes. An async function running on the main actor can call sync functions with the guarantee that those sync functions run on the main thread. For example, in this task:

Task { @MainActor in
    someSyncFunction()
    await someAsyncFunction()
    someSyncFunction()
    await someAsyncFunction()
    someSyncFunction()
}

all three calls to someSyncFunction() are run on the main thread.

Is this guaranteed also for inherited MainActor async context?

I’m not sure what you mean by “inherited MainActor async context” but I think it’s something to do with this…

Does it keep the async (actor!) context only till the first await?

I think you’re very confused about what tasks inherit. An actor is not an fundamental component of a task. In Doug’s talk, tasks are boats and actors are islands. A boat can visit an island, but the island isn’t a property of the boat.

Task do inherit some state from their parent task, assuming they are not detached. For example, QoS and task-specific variables and task-local values can be inherited. However, task don’t inherit their actor.

Another way to slice this is to think about how the compiler checks actor isolation. It needs to be able to do this at compile time, not runtime. So just by looking at the code you can tell which actor an async function is running on. In your example, increaseCounter() is a static function on App, and thus it runs as a standalone async function, not part of any actor.

Share and Enjoy

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

The XCode documentation for Task says:

"...the task created by Task.init(priority:operation:) inherits the priority and actor context of the caller, so the operation is treated more like an asynchronous extension to the synchronous operation."

You wrote "task don’t inherit their actor."

If task does NOT inherit caller actor context, then everything is clear to me. Please, confirm.... (and please update the documentation accordingly)

You wrote "task don’t inherit their actor."

Yeah, I was wrong there )-: More specifically, I missed an important subtlety. I’m out of time to explain that today, but I hope to post a correction soon.

Share and Enjoy

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

  • THANKS! Yes, it seems that the devil must be in some detail. The swift doc says: "To create an unstructured task that runs on the current actor, call the Task.init(priority:operation:) initializer." And I am really curious, why task created in main() with inferred @MainActor (I have read that this static function has @MainActor inferred, when it is in the @main struct) does not all run on the main thread. I am puzzled.

Add a Comment

Sorry about the misleading response above. Swift concurrency is cool but it’s also very subtle. I shouldn’t try to explain such things when I’m in a hurry.

A great reference for this stuff is WWDC 2021 Session 10134 Explore structured concurrency in Swift. Specifically, the chart at 26:22 is super useful. I watched this when it came out but I obviously need to watch it again (-:

Finally, the best way to understand what is and isn’t allowed is to get the Swift compiler to check it for you. The following is based on Xcode 14.0b3 with Strict Concurrency Checking set to Complete. Earlier versions of the compiler support concurrency checking to some degree, but Xcode 14 is the first version where I really trust it (-:


Consider this actor:

actor Counter {

    var count: Int = 0

    func increment() {
        count += 1
    }
    
    func increment1Then1() {
        count += 1
        Task {
            count += 1
        }
    }
}

The count property is isolated to the actor so it can only be accessed by a task that’s bound to the actor. In the analogy from this year’s talk, the task ‘ship’ must be docked at the actor ‘island’.

The increment() function compiles because it’s a standard actor-isolated method.

The increment1Then1() function compiles because the unstructured task (I’m using terms from WWDC 2021 Session 10134 here) starts bound to the actor. This is the actor inheritance discussed in the Task.init(priority:operation:) docs.

Contrast the above with this:

extension Counter {

    func increment1Then2() {
        count += 1
        Task.detached {
            count += 2
            ^ error: actor-isolated property 'count' can not be mutated from a Sendable closure
        }
    }
}

The increment1Then2() triggers an error because the task does not start bound to the actor.

Now consider this:

extension Counter {

    func increment1Then3() {
        count += 1
        Task { @MainActor in
            count += 3
            ^ error: actor-isolated property 'count' can not be mutated from the main actor
        }
    }
}

The presence of @MainActor overrides this binding, so the task now starts bound to the main actor, so it can’t access count.


Earlier I wrote:

So just by looking at the code you can tell which actor an async function is running on.

Consider this:

extension Counter {

    func increment1Then4() {
        count += 1
        Task {
            self.increment4()
        }
    }

    func increment4() {
        count += 4
    }

    func increment1Then5() {
        count += 1
        Task {
            Counter.increment5(counter: self)
        }
    }

    static func increment5(counter: Counter) {
        counter.count += 5
                ^ error: actor-isolated property 'count' can not be mutated from a non-isolated context
    }
}

increment1Then4() compiles while increment1Then5() does not. The reason is that concurrency correctness is check statically, just by looking at the code. increment4() is an actor-isolated method, so it knows that the task running it must be currently bound to the actor. In contrast, increment5(counter:) is a static method. It can be called by any task. The fact that it just happens be be called by a task that started out bound to the actor is irrelevant.


You wrote:

I have read that this static function has @MainActor inferred, when it is in the @main struct

That’s correct. Consider this snippet:

@MainActor var greeting: String = "Hello Cruel World!"

@main
struct Main {
    static func main() {
        print(greeting)
        Task {
            print(greeting)
        }
        Task.detached {
            print(greeting)
            ^ error: expression is 'async' but is not marked with 'await'
        }
    }
}

There are three calls to print:

  • The first works because, as you said, @main causes the main() static function to be bound to the main actor.

  • The second works because of the inherited actor context.

  • The third fails because tasks created with Task.detached(priority:operation:) do not inherit the actor context.

I am really curious, why task created in main() with inferred @MainActor … does not all run on the main thread. I am puzzled.

It’s really important that you try to get away from the concept of threads. Other than the relationship between the main actor and the main thread, threads are pretty much irrelevant to the Swift concurrency model. And thinking in terms of threads is actively misleading because in that model the fact that function A calls function B means that B runs on the same thread as A. That’s not the case in Swift concurrency, and deliberately so. In the threaded model you can’t tell, by looking at the code, anything about the concurrency context of that code. In contrast, one of the key goals of Swift concurrency is that a local static analysis of the code allows you to reason about its concurrency context.

So, with regards your original code, look at your increaseCounter() and decreaseCounter() static methods. Ignore the calling context and instead just look at the code as the compiler sees it. Is there anything that binds those methods to the main actor? No, and hence the problem you’re seeing at runtime, and the errors flagged by Xcode 14.0b3. And when you add the @MainActor attribute to those functions the problem goes away because now there is something binding them to the main actor.

Share and Enjoy

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

Thank you, exhausting enough, everything is clear. Just a final summary for all others reading this thread, the core of my interest (not needed to be further commented, if correct :-) )

  • Unstructured Task created through Task.init inherits actor async context. (Detached Task does NOT - detached tasks are not subject of the summary below)
  • The inherited actor async context has nothing to do with threads, after each await the thread may change, however the actor async context is kept throughout all the unstructured task (yes, may be on various threads, but this is important just for the executor).
  • If the inherited actor async context is MainActor, then the task runs on the main thread, from beginning till its end, because the actor context is MainActor. This has no impact on any subtasks. Unstructured subtasks may inherit the actor async context, where applicable. async let = tasks, and group tasks (and detached unstructured tasks) do NOT inherit actor context!
  • The bullet above is important if you plan to run some really parallel computation - make sure all the unstructured tasks do not run on the same thread.

Uff. I think that this thread can be closed. Thanks a lot!

Thanks for the great answer @eskimo. It's quite clear from your answer and the design of Swift async-await that if one were to force some code to run on the main thread, than it should be designed that way at compile time. Otherwise, the way code is executed is pretty much thread-blind.

I have one more question regarding this issue: if you were to have the same logic run sometimes on the main thread but at other times on any other thread, do you have to declare two methods and have one marked @MainActor?

P.S. I was hoping that Swift async-await would turn out to be something like Kotlin coroutines. I was so confused at first. We'll see if this indeed achieves the goal Swift is trying to solve.

P.P.S I'll post some code that helped me understand the behavior somewhat. Hopefully it will help others too.

class SomeClass {
    func runSomeWorkAnywhere(_ taskNumber: Int) async {
        print(#function, "#\(taskNumber)", Thread.current)
    }
    
    @MainActor
    func runOnMain() {
        print(#function, Thread.current)
        Task { @MainActor in
            print(#function, "task #1", Thread.current)
            await runSomeWorkAnywhere(1)
        }
        Task {
            print(#function, "task #2", Thread.current)
            await runSomeWorkAnywhere(2)
        }
        Task.detached { [unowned self] in
            print(#function, "task #3", Thread.current)
            await runSomeWorkAnywhere(3)
        }
    }
    
    func runRogue() async {
        print(#function, Thread.current)
        Task { @MainActor in print(#function, "task #1", Thread.current) }
        Task { print(#function, "task #2", Thread.current) }
        Task.detached { print(#function, "task #3", Thread.current) }
    }
    
}

let obj = SomeClass()
Task {
    print("top-level task", Thread.current)
    await obj.runOnMain()
    await obj.runRogue()
}
Add a Comment

I was hoping that Swift async-await would turn out to be something like Kotlin coroutines.

Swift concurrency is implemented using coroutines under the covers, but it goes well beyond the facilities of traditional coroutine mechanisms [1].

if you were to have the same logic run sometimes on the main thread but at other times on any other thread, do you have to declare two methods and have one marked @MainActor?

Sure, but you’d be better off tweaking your design to avoid that situation. For example:

  • You could put the core logic into an async function (or actor) and pass it another actor that encapsulates the might-need-to-be-on-the-main-thread stuff. You could then implement a standard actor for the standard case an an @MainActor actor for the main thread case.

  • Alternatively, you could implement these two functions but factor the core logic out into a sync function that you can call from either context.

Share and Enjoy

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

[1] Although I’m not familiar with Kotlin coroutines, so it may do that as well (-: