Are Tasks queued on the current actor even if their closure is marked with @MainActor?

If I create a task like this:

actor SomeActor {
    func test() { 
        Task { @MainActor in /* … */ }
    }
}

Would this Task be first scheduled on the current actor (SomeActor) and then when it executes be enqueued onto the Main Actor? Or does it directly go to the Main Actor?

Since we don’t provide a priority either, this code will get prioritised with the default value because I’m assuming Swift can’t magically bump up the priority of code we’ve written ourselves.

Can anyone tell me what happens in these situations?

3 Likes

It starts on the global pool and then hops to the main actor. We don’t have enough capability to immediacy notice that we should hop to the main actor immediately, it could be considered future work.

Relatedly a “send” operation could also go straight away to the right actor, but we don’t have that either yet

1 Like

If it doesn't start on the main actor directly, I would expect it to start within SomeActor's isolation, then hop to the main actor. Why does it start on the global executor?

2 Likes

I actually don't remember that case off the top of my head. AFAIR though even for the some actor it'll first be submitted to the global pool and only then hop to the target "some actor". There's no way for Task{} to know where the closure is expected to run before we actually run it. Point being, there is an extra hop here that is unfortunate.

To summarize:

The Task is first submitted to the global pool (since Swift doesn’t have the capability to determine where to run the Task right off the bat) and then the runtime figures out where to execute this task based on any attribute the closure is marked with. If I hadn’t put @MainActor in the closure then the runtime would’ve realized to send it to SomeActor and it would’ve executed there. But in this case, since it’s marked with @MainActor, it’ll context switch to the Main Actor and execute there.

So a total of two “movements” in a way. One to the cooperative thread pool, and then a full context switch to the Main Actor.

Have I missed anything / gotten anything wrong? @ktoso

2 Likes

Also, you say

I’m assuming this doesn’t happen asynchronously. The test() function must first either return or suspend before the Task can be submitted to the global pool.

So only when test() goes out of scope does the Task reach the thread pool. Right?

It does happen concurrently, that's the purpose of Task{}

1 Like

In this When does a Task get scheduled to the Cooperative Thread Pool? question, I asked why does work inside a Task execute after the function that created it returns. The answer I got was that Task queues up work on the current actor (unless I use .detached) so the current actor must first finish executing before it can run the closure in the Task.

So I was under the impression that Tasks weren’t scheduled asynchronously as something like DispatchQueue.global().async { } would. Rather, they have to wait for the current thread/actor to finish or suspend before they can be submitted anywhere @ktoso

Yeah, but that's due to the core principle of actors: no concurrent execution of two pieces of work.

I would recommend maybe taking a step back and watching some of the introductionary talks about actors and tasks from WWDC? E.g. Protect mutable state with Swift actors - WWDC21 - Videos - Apple Developer and Eliminate data races using Swift Concurrency - WWDC22 - Videos - Apple Developer as well as Protect mutable state with Swift actors - WWDC21 - Videos - Apple Developer

1 Like

@ktoso I saw the videos you linked and now have a better understanding. I don’t want to take more of your time so I’ll make one final request and ask you to clarify this sequence of events:

  1. Task is initialised in test()
  2. Task is scheduled on the current actor SomeActor
  3. Task can’t do anything yet because the actor SomeActor is still doing some work, namely, executing the test() function
  4. The Task waits “for the island to get free” before it’s sent out to the open sea (from the sea of concurrency analogy in the eliminating data races video you linked)
  5. Once the island is free (the actor is done executing the test() function), the island people can send the Task off to the open sea (cooperative thread pool) so it can make it’s journey to the MainActor island.
  6. It goes and jumps onto the MainActor island (context switch) and executes once the MainActor is freed up.
@main
struct T1 {
    actor Printer {
        static let shared = Printer ()
        
        func print (_ i: Int) {
            Swift.print ("-->", i)
        }
    }
    
    actor SomeActor {
        func test() {
            for i in 0..<8 {
                // Task {
                Task {@MainActor in
                    await Printer.shared.print (i)
                }
            }
        }
    }
    
    static func main () async {
        let a = SomeActor ()
        await a.test ()
        print ("test finished")
        try! await Task.sleep (nanoseconds: 1_000_000_000_000)
    }
}
test finished
--> 0
--> 1
--> 2
--> 3
--> 4
--> 5
--> 6
--> 7

Hey, thanks for this. I feel this is in line with the sequence of events I just posted:

  1. Once the actor finishes running (or suspends) the test function, it is able to send off the created Tasks into the open sea.
  2. Once that happens, a continuation is executed on the main thread and the “test finished” is printed.
  3. During your Task.sleep the earlier Tasks reach the MainActor island and execute in order - printing their values (edit: not in order but in whatever way the Main executor decided to execute them)
  4. After the sleep function returns, another continuation is executed on the main thread which exits the scope and thus exits the program.

Would you agree with this order? @ibex10

Edit: I changed #3 @ibex10 to reflect your new code

@main
struct T1 {
    actor Printer {
        static let shared = Printer ()
        
        func print (_ i: Int) {
            Swift.print ("-->", i)
        }
    }
    
    actor SomeActor {
        func test() async {
            for i in 0..<8 {
                // Task {
                Task {@MainActor in
                    await Printer.shared.print (i)
                }
            }
            try! await Task.sleep(nanoseconds: 1_000_000_000)
        }
    }
    
    static func main () async {
        let a = SomeActor ()
        await a.test ()
        print ("test finished")
    }
}

--> 0
--> 3
--> 4
--> 5
--> 6
--> 7
--> 1
--> 2
test finished