A newbie question about Task

I'm learning concurrency and have a question about Task. Below is a simple example code.

import SwiftUI

struct ContentView: View {
    @State var data = "Hello"

    var body: some View {
        VStack(spacing: 16) {
            Button("Test") {
                Task {
                    data = await getData()
                }
            }.buttonStyle(.bordered)

            Text("Data: \(data)")
            // This is used to test if main thread is responsive
            TextField("Please Input", text: $data)
        }
    }
}

// This demonstrates a slow func.
private func getData() async -> String {
    // Consuming cpu cycles (this works in debug mode)
    for i in 1...20000000 {
        let x = 100 * 100
    }
    return "World"
}

From what I read on the net, Task inherits its actor context, which is MainActor in this case. That explains why it can access data directly. My questions are about the getData() call in the Task.

Q1) What actor context does getData() run in? Or is it non-isolated?

Q2) deleted (I have a summary of what I really wanted to ask in Q2 at the end of the thread)

Can I assume that getData() is executed on another CPU core as long as there is an idle one and hence won't block the main thread? I ask this because a) this is the main reason why people use Task in this way, but b) on the other hand, I can't find the information in the doc confirming it (perhaps because it's considered an implementation detail of the cooperative thread pool?)


UPDATE on Q2: On a second thought, I suspect Q2 is a wrong question? I guess the right question should be on the high level (not involved in threads), like: can an async func call blocks the caller? The answer is a definitive "no" because that's by design in Swift concurrency (unless the CPU has just one core). I was confused because I was trying to think about the underlying details. Anyway I'd like to know how the others think about it. Thanks.

1 Like

Q1) It's a little less intuitive than you might expect. The getData() func is non isolated which means that when it is called from the @MainActor isolated closure of your Task (Task closures inherit their caller actor context and View.body is @MainActor isolated) , it will be called on the default executor (the cooperative thread pool) as by default, unless annotated with currently private API, functions don't inherit their actor contexts.

You can check by putting a breakpoint in getData() and seeing which queue it reports as being on.

Q2) You can assume it won't block the main queue, yes.

3 Likes

It’s also worth noting that it’s possible to be async with respect to the caller while still being on the same underlying thread. “When you’re done with what you’re doing, do this” semantics.

But yeah, the important thing here (since it’s an async function) is where the function wants to run, rather than what the caller is doing. This is the reverse of the situation with dispatch queues.

2 Likes

Thanks. I have two further questions if you don't mind.

Q3) I changed Task to Task.detached and found the code still worked fine:

-               Task {
+               Task.detached {
                    data = await getData()
                }

I don't understand why it's OK to access data in Task.detached. According to Swift doc, Task.detached doesn't inherit the current actor. If so, how come it can access data directly?

To create an unstructured task that runs on the current actor, call the Task.init(priority:operation:) initializer. To create an unstructured task that’s not part of the current actor, known more specifically as a detached task, call the Task.detached(priority:operation:) class method.

Q4) BTW, do you know if main thread is in the cooperative thread pool or not? My guess is it isn't and the the cooperative thread pool is just a new name for the old worker thread pool (but with coroutine support). Is my understanding correct?

The main thread is not in the cooperative pool. It’s related to the regular libdispatch worker thread pool, but with different behavior when blocked, which avoids thread explosions (at the cost of reducing concurrency, potentially to the point of deadlock, if people do block it).

1 Like

Thank you. That's what I really wanted to asked in Q2. But I failed to articulate it in both versions of Q2. I'll try to summarize it.

TLDR: can an async func initiated from main thread run in main thread? (I believe the answer is "no".)

  1. I mentioned CPU core in my original questions. That's wrong, because threads (and processes) are abstractions on top of CPU cores. Even a single core CPU can achieve concurrency by using threads.

  2. If await getData() started a new thread, it would be certain it wouldn't block the main thread. However, async func is implemnted as coroutine and, according to the Apple engineer in WWDC 2021 "Behind the Scene" video, when a susended async frame is resumed, it can run in any thread. While I have no problem in understanding each of these information separately, putting them together confused me: if an async func can run in any thread, it might run in main thread too. If so, it might block main thread if the async func is a CPU intensive func.

That's the reason I asked Q2. Now I know where I got it wrong. I believe, when the Apple engineer said "any thread", she meant "any worker thread" (or "any thread in the cooperative thread pool"). Below is how I understand it now:

  • An async func can run in any worker thread (including the same worker thread, as you explained above)
  • Main thread isn't in the cooperative thread pool, so an async func initiated from main actor can never blocks main thread.

I'm not sure I understand what you meant. Could you elaborate it a bit?

In libdispatch, if you do

dispatch_async(dispatch_get_main_queue(), ^{
  someFunction();
});

then someFunction will run on the main thread. This is technically true in Swift Concurrency as well:

Task { @MainActor in 
  someFunction()
}

BUT, and a lot of people miss this, if someFunction is async, then it decides where it runs, not the Task it's called from.

func someAsyncFunction() async {}

@MainActor func someMainAsyncFunction() async {}
…
Task { @MainActor in 
  await someAsyncFunction() //does NOT run on the main thread
  await someMainAsyncFunction() //does run on the main thread, but NOT because of the Task
}

I would hazard a guess that at least half, and probably more like 75%, of times people type Task { @MainActor, what they really wanted was to call a function with @MainActor on it. The big advantage of this system is that it's much harder to misuse. You can't forget to call it on the right queue/thread!

3 Likes

Is it guaranteed by the language that the first call to someAsyncFunction will run on a non-main thread? I seem to recall a lot of churn about eliminating actor hops, and I don’t recall whether this particular question came up.

Yes, this was fixed in SE-338 swift-evolution/0338-clarify-execution-non-actor-async.md at main · apple/swift-evolution · GitHub

3 Likes

I figured out why. First, I added code to print queue label as @tcldr suggested. The output showed that Task.detached's closure runs in cooperative pool thread indeed. Then I added @MainActor before the entire ContentView. This time the compiler output error on data = await getData() line as expected. Conclusion: while SwiftUI secretly added @MainActor on View.body, it doesn't put that property wrapper on states or the entire view in this case. So data is non-isolated. That's the reason why the async func can access it directly.

1 Like

I'm not sure I entirely follow, but the reason your detached Task would emit a compiler error is because data, for reasons I'll explain later, is a MainActor isolated property.

If you were to write:

// don't do this, non tested, but useful for this example.
Task.detached {
  let data = await getData()
  Task { @MainActor in
    self.data = data
  }
}

You should find it compiles again.

The reason for this is that when a SwiftUI View has certain state maintaining property wrappers (@State, etc) added to it, the whole View type is implicitly marked as @MainActor by the compiler. So now it isn't just body that's @MainActor, it's the whole type. I'm not sure if this is documented anywhere.

I have to admit that I do find this behaviour non-intuitive: where an async function will run or which actor a property belongs isn't always obvious.

I would argue that the compiler should enforce implementations of SwiftUI's View.body method to be annotated as @MainActor to make it clear as to what's going on, but that still doesn't clarify when the compiler may implicitly mark the whole type as @MainActor if it uses certain property wrappers. Perhaps in these instances it should enforce the type is annotated @MainActor and provide the appropriate fix-it.

1 Like

Sorry for the confusion. In the original example code I used Task. Then I changed it to Task.detached. I expected it would fail to compile because I thought access to data should be through MainActor, but to my surprise it actually compiled and worked. That's why I asked Q3.

Below is the complete code.

import SwiftUI

struct ContentView: View {
    @State var data = "Hello"

    var body: some View {
        VStack(spacing: 16) {
            Button("Test") {
                Task.detached {
                    data = await getData()
                }
            }.buttonStyle(.bordered)

            Text("Data: \(data)")
        }
    }
}

private func getData() async -> String {
    return "World"
}

Then I figured out the reason. I believe it's because in above case SwiftUI only scretely apply the @MainActor to ContentView.body, but not to ContentView.data. To verify my hypothesis, I made the following change. This time the compiler reported error as expected.

- struct ContentView: View {
+ @MainActor struct ContentView: View {

This is different from what I observed. See above. It seems only body, not the entire view, is marked as @MainActor in my case.

I feel the same way.

Ahh, yes. My apologies, ignore my previous comment. This is more to do with view being a value type and @State having a non mutating setter.

When you annotate a property with @State, a bunch of things are happening in the background, but the crux of it is that you're actually dealing with a State<String> rather than a String. You can see this yourself as if you type in let x = _data (notice the underscore) within one of your View type members, you'll see this special underscored property has been created behind the scenes for you by the compiler. This type holds additional metadata needed by SwiftUI to track and manage your State property.

Therefore, when you modify data, behind the scenes you're really modifying _data, which behaves more like a reference type. You can see this when you option-click your data property and see its full definition as var data: String { get nonmutating set } which shows a non mutating set. However, a mutation is indeed taking place, it just takes place within the guts of the State<String> type described above.

My guess is that the State type is marked as @Sendable and ensures that the view-tree update is called on the @MainActor somewhere internal to its implementation.

2 Likes