Can someone explain how Task really works in terms of threads?

Today I finished a WWDC session with the swift team and they basically clarified what's really going on in my code. Unfortunately the session is only 30 min long so I didn't get to ask all the questions. Hoping that someone who is a swift guru can answer it here instead.

Code we discussed is like this:

func calledFromMainThread() {
    // Main
        Task {
            // Main
            await withTaskGroup(of: Void.self) { group in
                // Main
                group.addTask {
                    // Wont be on main
                    guard let result = await self.backgroundTask() else { return }
                    await MainActor.run {
                        //Main
                        withAnimation {
                            self.updateUI()
                        }
                    }
                }
}

So basically I had them clarify line by line which code will run on the main thread.

I found it really interesting that for example, withTaskGroup despite being await, runs on the main thread. Meanwhile addTask() does not. So first question, how can I discover things like this on my own without calling Apple? I don't see where the API docs specify these things. At least with Task() there's a paragraph somewhere that tells you it inherits the Mainactor.

Second question. To this day, because I'm so new that I still don't fully understand what Task does in terms of thread. I've read the WWDC videos and some swift books. Couldn't grasp it coming from other programming languages. According to the docs, Task creates a unit of work. But what does that mean?

For example if you have

print("1")
Task {
   print("2")
}
print("3")
  1. If it was originally called from the main thread (and thus inheriting mainactor), Is 2 expected to run immediately after 1? Can code run in between 2 and 1?
  2. If it was NOT on the main thread. Is it possible that 2 and 1 run on different threads?
  3. If it was originally called from the main thread (and thus inheriting mainactor), Is 3 expected to run immediately after 1? Can code run in between 3 and 1?
  4. If it was NOT on the main thread. Is it possible that 3 and 1 run on different threads?
3 Likes

Not a real answer (which I also would like to see), just a suggestion: every time you are not sure what thread the thing is running on use dispatchPrecondition:

dispatchPrecondition(.onQueue(.main))
or
dispatchPrecondition(.notOnQueue(.main))
or
dispatchPrecondition(.onQueue(specificQueue))

the last one if you happen to know somehow what queue it should be.

Treat those preconditions as a better version of those "main" / "not main" comments as they are the assertions checked by runtime.

2 Likes

The clarifying concept that you need to consider is that of the executor. That's the actual execution environment in which async code runs — not the actor, not the task.

Originally, there were only 2 executors: the MainActor executor (which runs things sequentially on the main dispatch queue), and the concurrent global executor (which runs things concurrently on non-sequential dispatch queues). Last year, a third executor was added — the non-actor executor — to run async code that is not isolated to an actor, and this year you can create additional custom executors as necessary.

This is not documented very extensively in API docs, because there isn't (or at least wasn't, before we got custom executors) explicit API relating to executors. They were something of an implementation detail — although a good understanding of how concurrency works has always required some knowledge of executors.

There is a fairly detailed explanation of how "modern" executors fit into the picture in SE-0338.

In regard to your specific question, most of the code in your fragment runs on the MainActor executor because async functions "stick" to the current executor unless there's a formal reason why they can't.

Tasks you add to a task group are formally required to run on the concurrent global executor. The documentation (TaskGroup | Apple Developer Documentation, see "Task execution order") says this without using the word "executor".

The other common example of a formal executor requirement is Task.detached, which is defined to run on the global concurrent executor, unlike Task.init.

Note that I haven't used the word "thread" in any of the above. That's because concurrency (on Apple platforms) runs on top of GCD, where code executes on dispatch queues, not on threads. Of course, code submitted to a dispatch queue eventually executes on some thread or other, but there's no simple mental model for that. In particular, it's surprising but possible that code submitted to the main queue doesn't have to run on the main thread, if GCD realizes it can safely avoid a thread switch.

In general, reasoning about threads, or even about how GCD relates to executors, isn't useful when reasoning about Swift concurrency.

4 Likes
  1. No and no yes. The main thread executor is a serial executor, and it can't change tasks other than at a suspension point (e.g. await, typically). There's no suspension point after 1, so 3 necessarily runs before 2.
  2. Yes. Both will run on the global concurrent executor, so they can run concurrently.
  3. Yes and no. Again, because there's no suspension point between 1 and 3.
  4. No. For the same reason.

Keep in mind that the unit of execution scheduling is not the Task, but a task fragment consisting of the synchronous code between two successive suspension points. Such a task fragment has had various names in the past, but is now called a "job". In your code fragment, the interior of the task closure (that is, 2) is one job, and everything else is another job.

I thought only the opposite could happen (for the code scheduled on a background queue running on the main thread). Could you show an example when the code submitted to the main queue runs on a different thread?

I'm not sure if I'm misunderstanding what you're saying here, but just to clarify: SE-0338 explicitly sets the semantics that non-actor-isolated async functions do not stick to to the current executor—they will always hop to a generic executor not associated with any specific actor:

async functions that are not actor-isolated should formally run on a generic executor associated with no actor. Such functions will formally switch executors exactly like an actor-isolated function would: on any entry to the function, including calls, returns from calls, and resumption from suspension, they will switch to a generic, non-actor executor. If they were previously running on some actor's executor, that executor will become free to execute other tasks.

There's an underscored attribute, @_unsafeInheritExecutor which disables this behavior change for functions it annotates, and attribute appears on the with*TaskGroup functions as a compatibility workaround (since they were made ABI before SE-0338 was adopted).

Also, Task.init does use the @_inheritActorContext attribute on its operation parameter and so will use the same actor context as its caller, but that only applies to the static context. So even if you're in a function that happens to be running on the main actor dynamically (e.g. a synchronous non-@MainActor-annotated function which was called from an @MainActor-annotated function), the body of Task { ... } won't necessarily run on the main actor:

func f() -> Task<Void, Never> {
    MainActor.assertIsolated("Outside Task.init") // ok
    return Task {
        MainActor.assertIsolated("Inside Task.init") // 💥
    }
}

@MainActor
func g() async {
    let task = f()
    await task.value
}

await g()

This should now be accomplished by the GlobalActor.assertIsolated() and actorInstance.assertIsolated() functions rather than the GCD APIs.

3 Likes

Just to be clear: stuff on the main queue (or main actor, or main runloop) always runs on the main thread, and stuff not on one of those never does. The "main" anything is entirely separate from the rest.

2 Likes

Sometimes it happens:

dispatchPrecondition(condition: .onQueue(.main))
precondition(Thread.isMainThread)
let queue = DispatchQueue(label: "other")
queue.sync {
    dispatchPrecondition(condition: .onQueue(queue)) // ✅
    dispatchPrecondition(condition: .notOnQueue(.main)) // 🛑
    precondition(!Thread.isMainThread) // 🛑
}
RunLoop.current.run(until: .distantFuture)

But indeed I don't know how that'd be possible for stuff scheduled on main queue to end up being on a secondary thread.

1 Like

Right, there's a little ambiguity about "on", thanks for pointing that out. I was referring to it in the sense of "if you run something on X, it will/won't be on thread Y", rather than "if X is somewhere in the current queue hierarchy, it will/won't be on thread Y".

1 Like

I realized afterwards that I over-simplified, because there are additional formal executor hops in the OP's original code fragment:

func calledFromMainThread() {
    // Main
        Task {
            // Main
            await withTaskGroup(of: Void.self) { group in
                // Main

withTaskGroup is non-actor-isolated, so it implies a hop to the non-actor executor, but its trailing closure (which adds child tasks) is defined in a scope that's implicitly MainActor isolated (because the Task closure is MainActor isolated because the function is on the main thread).

In this scenario, which is envisioned by SE-0338, the implied hop off MainActor and back onto it, with no "significant" work in between, means that both of those hops can be eliminated.

1 Like

FWIW, I put the mentioned dispatchPreconditions and two of those failed. Worth double checking in your example.

If we assume that calledOnMainThread or its enclosing type is marked as @MainActor then the conditions should be satisfied, otherwise (if calledOnMainThread just happens to be executing on the main thread the Task block won’t get the “inherit actor context” behavior for the main actor and things start to break down.

1 Like

Sorry I was away and did not get the chance to respond. But now that I've read everything I think I'm more confused than before although I do appreciate all the help so far.

As a new swift programmer, there's really only 2 objectives I need to figure out for concurrency.

  1. Am I on the main thread (and here maybe the term is misleading because I don't actually care about threads, but rather whether it is safe to make UI changes, so for the rest of this post I will refer to that as UI-Safe).
  2. Am I doing parallel execution

Unfortunately as swift is currently designed, neither is clear semantically and the documentation is sparse at best. So let's break it down.

(1) Am I UI-Safe?

Well, there's really no easy way to know other than to talk to Apple as I did. Seemingly one way to test is to use dispatchPrecondition(.onQueue(.main)) and btw GlobalActor.assertIsolated() does not compile. But it's really quite painful to have to litter this line of code all over the project.

So ideally I learn all the rules for when it's UI-Safe and when it's not. Here are the rules as I understood it

  • It is UI-Safe when called from SwiftUI/UIkit (i.e. a Button's closure)
  • It is UI-Safe inside @MainActor
  • It is UI-Safe inside Task called from inside UI-Safe code
  • It is NOT UI-Safe inside Task.detached
  • It is NOT UI-Safe inside group.addTask
  • It is NOT UI-Safe inside async code unless isolated
    let me know if there are other rules

(2) Are things running parallel? (background execution)
Once again, the way Apple designed this, it's very difficult to know. In most programming language (Java for example) new Thread().run() basically would just start executing a job in the background in parallel immediately.

The swift equivalent does not. For example:

Button {
            Task {
                async let _ = vm.run()
            }
            sleep(4)
            print("end")
        } label: {
            Text("run")
        }

vm.run() does not actually run until main thread is available again. The solution seems to be to use Task.detached by default. Or alternatively make vm.run() not async and call Task inside the non-async function instead.

So parallel execution seems to be heavily dependent on whether you are in a UI-Safe context or not, thus which is why I wanted to figure out (1).

Once Apple fully updates all of their frameworks to be concurrency aware and properly marked as @MainActor, you shouldn't need to care where you are at any given point. The compiler will either see you're already on the main actor and allow you to call main actor APIs directly, or see that you aren't and force you to await any interaction with the main actor.

As for parallelism, Swift concurrency offers no native, automatic solution here. You either need to explicitly break out of the concurrency environment using other APIs (like DispatchQueue.concurrentPerform) or use what concurrency gives you like Task.detached (which isn't really what you want for heavy parallelism anyway). But what you may really be asking here is how to easily hop off the current executor (whether main or not) to operate completely independently. You've already discovered how to do so.

Hopefully we see Swift's concurrency evolve to better cover these cases, especially parallel (and serial) execution and easy ways to operate off the default thread pool.

You need to specify a concrete global actor type or some actor instance. So the call would be MainActor.assertIsolated() to assert that you're on the main actor. Or myActor.assertIsolated() to check that you're running in the context of myActor.

Note that this API has been added in Swift 5.9 and has not been backported to previous OSes, so on Apple platforms it will only work on macOS 14/iOS 17 and up.

Edit: for more info on these APIs, see SE-0392

1 Like