swiftuiswift-concurrency

Unstructured Task not inheriting execution context


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.

enter image description here

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)
    }

enter image description here

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().


Solution

  • A few observations:

    1. 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.

    2. 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 is isolated to the main actor, asyncLongTask 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 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.

    3. 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. The asynchronous context of asyncLongTask is defined by how it was declared, not by who called it.

    4. 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.

    5. 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 both runLongTaskOnMainThread 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.

    6. 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:

      enter image description here

      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.

    7. 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.