I'll start by saying that are are many similar but different posts about this, mainly hard to follow because of the examples they provide.
So, I'll provide easier-to-reason sample code.
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
Button("Test") {
viewModel.runLongTaskOnMainThread()
}
}
}
class ViewModel: ObservableObject {
@MainActor
func runLongTaskOnMainThread() {
Task {
await asyncLongTask()
}
}
private func asyncLongTask() async {
try? await Task.sleep(for: .seconds(5))
print(#function)
}
}
The issue I have is that I expected that upon tapping the button, the method asyncLongTask()
would block the main thread but it did not.
I inspected where it is being executed and found it was in a background thread.
I thought that by marking runLongTaskOnMainThread()
with @MainActor
, that guaranteed that anything run in it would run in the main thread.
I then realized that it is true but it must mean that the creation of the Task happens in the main thread, but not the execution of its operation (note that Task(operation: () -> Success)
.
So, I thought that redefining the function to
@MainActor
func runLongTaskOnMainThread() {
Task { @MainActor in
await asyncLongTask()
}
}
would then block the main thread, but again it did not because asyncLongTask()
is executed in a background thread.
Then I found that the only way I can guarantee that it runs in the main thread is by marking it with @MainActor:
@MainActor
private func asyncLongTask() async {
try? await Task.sleep(for: .seconds(5))
print(#function)
}
What is going on here? Clearly I am misunderstanding how an unstructured Task inherits its caller's execution context.
One hypothesis I had was that because there is a suspension point inside the Task (the await asyncLongTask()
), that means it can resume execution on a different thread. Meaning, if there were synchronous code, they would be executed in the main thread but not the asyncLongTask()
.
A few observations:
You said:
I expected that upon tapping the button, the method
asyncLongTask()
would block the main thread but it did not.
No threads will be blocked, because, as the sleep(for:)
documentation says:
This function doesn’t block the underlying thread.
Unlike legacy sleep
API, the Task.sleep
methods do not block threads.
So whether asyncLongTask
was isolated to the main actor or not, calling Task.sleep
will never block the current thread.
In your first rendition, the asyncLongTask
is not isolated to any particular actor. That means this is a nonisolated
async
function. As such, it will not run on the main actor, by virtue of SE-0338.
Note, just because runLongTaskOnMainThread
(or its Task
) is isolated to the main actor, asyncLongTask
still is not.
For those coming from GCD, this may feel alien. In GCD, the thread used by a function was often defined by the caller’s context. But in Swift concurrency, it is the actor-isolation (or absence thereof) of the called async
function (the asyncLongTask
in this case) that will dictate its Swift concurrency context. In your original example, the ViewModel
is not isolated to any particular actor, nor is asyncLongTask
, and, therefore, asyncLongTask
is nonisolated
.
You subsequently tried isolating the Task
to the main actor:
@MainActor
func runLongTaskOnMainThread() {
Task { @MainActor in
await asyncLongTask()
}
}
The @MainActor
qualifier in the Task {…}
is redundant in this case. The runLongTaskOnMainThread
is already isolated to the main actor, so its Task {…}
will be, too (even in the absence of the redundant @MainActor
). Unlike Task.detached {…}
, Task {…}
will always create “a new top-level task on behalf of the current actor” (if any). Or, using your terminology, Task {…}
does “inherit the execution context.”
But, again, constraining runLongTaskOnMainThread
or its Task {…}
has no bearing on the context of asyncLongTask
. Because asyncLongTask
is an async
function, its asynchronous context is defined by how it was declared, not by who called it.
Your final excerpt (below) will isolate asyncLongTask
to the main actor:
@MainActor
private func asyncLongTask() async {
try? await Task.sleep(for: .seconds(5))
print(#function)
}
As you noted, this is now isolated to the main actor.
For the sake of completeness, the other alternative is to isolate the whole ViewModel
to the main actor:
@MainActor
class ViewModel: ObservableObject {
func runLongTaskOnMainThread() {
Task {
…
}
}
private func asyncLongTask() async {
…
}
}
This isolates runLongTaskOnMainThread
, its Task
, and asyncLongTask
to the main actor (plus any properties that might be added to this class).
This practice, of isolating the entire ObservableObject
to the main actor, is actually Apple’s recommendation, discussed in WWDC videos Discover concurrency in SwiftUI and Swift concurrency: Update a sample app. Just make sure that if you subsequently call some blocking API, get that off of the main actor. But if you just await
other asynchronous API, then the main actor isolation is not a problem.
But, as I mentioned in point 1, now that asyncLongTask
is isolated to the main actor, the Task.sleep
will still not block the main thread because Task.sleep
is, itself, nonblocking.
A word of caution: I would hesitate to rely upon “what thread am I on” in Swift concurrency: Swift concurrency does all sorts of clever optimizations to reduce unnecessary context switches. For example, if you have some function isolated to the main actor, and then check to see what thread the “continuation” (the partial task after an await
inside this function) is running, there are edge-cases where it might not be on the main thread even though we are on the main actor:
Rest assured; you really are on the main actor. (Note, recent compiler versions reduced the amount of times that they employ this optimization, but it can happen, as shown above.) In fact, if your partial task is (a) isolated to the main actor; and (b) does something that requires the main actor, then it will be on the main thread. But if the partial task is not doing anything that requires actor-isolation, the compiler may optimize it to run on another thread.
In lieu of checking thread information, if you want to confirm that you are on the main actor, then use preconditionIsolated
:
MainActor.preconditionIsolated()
Now, in practice, this is of less utility than GCD’s dispatchPrecondition(.isOnQueue(.main))
. In GCD a function might not “know” on which queue it is running, so dispatchPrecondition
was a good defensive-programming technique. However, in Swift concurrency, given that the asynchronous context is baked into how the method was declared, preconditionIsolated
is not needed nearly as much. But if you really want to verify your assumption, this can be a useful diagnostic tool.
But the “take home” message is that one should hesitate to rely on thread information to verify on which actor you are currently isolated. One is best advised to stop thinking about “threads” and focus on actor-isolation.
I might suggest watching Swift concurrency: Behind the scenes, which outlines the threading model underpinning Swift concurrency. The Meet async/await in Swift is a good primer, too. And both of these videos include links to other great videos.
For example, the Swift concurrency: Update a sample app is a great, practical, introduction for users coming from GCD, as it walks through the steps of converting GCD code to async
-await
.