Understanding Task behavior in Swift

203 views Asked by At

I'm confused by these two seemingly contradictory statements about Swift's concurrency model:

At minute 58 of the WWDC session Swift Concurrency Code Along, the speaker says:

Remember this handler is running on the main thread, so when we create a Task that task will also be running on the main thread

At minute 23 of the WWDC session Meet Async/Await, the speaker says:

An async Task packages up the work in the closure and sends it to the system for immediate execution on the next available thread, like the async function on a global dispatch queue

I am having a hard time squaring these two statements. Is a Task similar to DispatchQueue.global().async or does it depend on the context?

3

There are 3 answers

5
Rob On BEST ANSWER

You asked:

Is a Task similar to DispatchQueue.global().async or does it depend on the context?

In short, Task {…} is not the same as DispatchQueue.global().async. That second quote is imprecise.

A DispatchQueue.global().async dispatches to the next available worker thread from the thread pool. A Task {…}, however, is isolated to the current actor, if any. Task.detached {…} is more akin to a dispatch to a GCD global queue than Task {…} is. (Note, with the explosion of async API, explicitly moving something off the current actor is not nearly as common as it was back in the day. Likewise, if you really need to get something off the actor because it is slow and synchronous, there are a ton of subtle concerns with Swift concurrency.)

But, as you suggested, context matters. Task {…} behaves much like Task.detached {…} if invoked from a nonisolated context. I would suggest, though, that if you must get it off the current actor, then it would be best to avoid Task {…} and instead use Task.detached {…}. The resulting behavior is not context-dependent and the developer’s intent is more obvious.

FWIW, while the compiler is excellent at verifying a type’s actor-isolation, there are edge cases where it is not remotely obvious to the casual reader. (E.g., add a @StateObject property to a nonisolated type and it becomes isolated!) It is best not to write code reliant upon assumptions about a type’s lack of actor-isolation. One can make an argument that if you must get it off the current actor, if any, it might be best to do so explicitly. Likewise, if you do not explicitly need it off the current actor, then do not do so unnecessarily.

As an aside, if one really need to get something off the current actor, one might reach for a task group, async let, a nonisolated async function (per SE-0338), or isolate it to a different actor. It is hard to generalize this. We should avoid unnecessary unstructured concurrency.


Let us look at the code referenced in that second quote. They await a call to the view model’s fetchImage:

struct ThumbnailView: View {
    @ObservedObject var viewModel: ViewModel
    var post: Post

    @State private var image: UIImage?

    var body: some View {
        Image(uiImage: image ?? placeholder)
            .onAppear {
                Task {
                    image = try? await viewModel.fetchThumbnail(for: post.id)
                }
            }
    }
}

Given the await, we are not too worried about blocking a thread in this case. The whole idea of a concurrency system is that the current thread is not blocked while it awaits fetchImage.

In fact, if you change that example to use Task.detached {…}, you will get an appropriate compile-time error (if you have “Strict concurrency checking” build setting of “Complete”).


FWIW, WWDC 2021 video Swift concurrency: Behind the scenes is a great introduction to the threading model underpinning Swift concurrency.


While I included the code snippet from the video, above, for the sake of completeness, I will take it to the next step and note that we would probably use a .task view modifier:

struct ThumbnailView: View {
    @ObservedObject var viewModel: ViewModel
    var post: Post

    @State private var image: UIImage?

    var body: some View {
        Image(uiImage: image ?? placeholder)
            .task {
                image = try? await viewModel.fetchThumbnail(for: post.id)
            }
    }
}

In that video, they were walking us through the transition from GCD to Swift concurrency, and that onAppear with a Task was just one step in the transition. But the .task view modifier takes it a step further, not only dropping us in an asynchronous context, but automatically canceling the task if the user leaves the view before the asynchronous work is done.

4
matt On

You're sort of comparing apples with oranges.

The first quote is about how the context may determine whether or not a task runs on the main thread. That has nothing to do with the second quote! Obviously DispatchQueue.global().async is not going run on the main thread; that's the whole point of specifying the global queue. So no one is saying that Task is like DispatchQueue.global().async in that sense!

The second quote, the one mentioning DispatchQueue.global().async, is about what a task is. The problem you're having here is that you're misunderstanding what "like the async function on a global dispatch queue" means and what a queue is:

  • A queue is not a thread. A queue is a way of mustering threads.
  • When you say DispatchQueue.global().async, you are not dictating what thread the queue will actually use; you are saying "If you find a (non-main) thread available, do the work on that."

And that is what a Task does as well. So they are certainly similar in that sense. The big difference (or one big difference anyway) is that if a Task waits in the middle, it might resume on a different thread — whereas a dispatch queue, having picked a thread, must stick to it until the code ends.

0
Alexander Volkov On

The second phrase was said without a context of the solved task:

An async Task packages up the work in the closure and sends it to the system for immediate execution on the next available thread, like the async function on a global dispatch queue

It was about any Task { in general.

In the context of that task (onAppear { Task {…} }) the only available thread is main because all Tasks launched from the main thread will be executed on the main thread (formally it's more complicated, but shortly it's so). So,

Is a Task similar to DispatchQueue.global().async or does it depend on the context?

Second - Task depends on the context.