swiftconcurrencyactor

What is the "current actor" in the Swift Concurrency model?


Reading the Concurrency section of the Swift Guide, I came across:

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.

There is the phrase "current actor". This is also mentioned in the documentation of Task.init:

Runs the given nonthrowing operation asynchronously as part of a new top-level task on behalf of the current actor.

Before, I thought I had understood what "current actor" meant:

@SomeOtherActor
func somethingElse() {
    DispatchQueue.main.async {
        // current actor is MainActor
    }
}

@MainActor
class Foo: NSObject {
    func foo() { /* current actor is MainActor */ }
}

actor Bar {
    func foo() { /* current actor is Bar */ }
}

However, recently I realised that I forgot to consider a situation - what happens on a global queue (or any other random queue)?

@SomeOtherActor
func somethingElse() {
    DispatchQueue.global().async {
        // current actor is?
    }
}

When I tried accessing one of the actor-isolated properties of SomeOtherActor in the closure, I get the message:

Property 'x' isolated to global actor 'SomeOtherActor' can not be mutated from a non-isolated context

Is that Swift's way of saying there is no current actor? If so, what will Task.init do? The documentation doesn't really say.

Ideally, is there a way to programmatically print the current actor?

I thought the SE proposals would explain what "current actor" meansNone of the SE proposals mention the word "current actor": SE-0306, SE-0313, SE-0316, SE-0327, SE-0344.


Solution

  • I suspect you have resolved your question by now, but for the sake of future readers, let me provide an answer.

    You suggested:

    if the code runs on the main queue, then the current actor can be thought of as the MainActor, since its executor is the main queue.

    Not necessarily so. Just because something runs on the main queue (or main thread) doesn’t mean it is isolated to the main actor. Now, UIViewController, SwiftUI body, and the like are isolated to the main actor, so, as a result, much stuff we initiate from the UI is automatically actor-isolated. But you can easily introduce a non-isolated function (either one that is explicitly marked as nonisolated or a random method in a non-isolated type or a non-isolated closure) into the process.

    Let’s consider a few of your examples. First:

    @SomeOtherActor
    func somethingElse() {
        DispatchQueue.main.async {
            // is this isolated to the main actor?
        }
    }
    

    It appears that there is some compiler optimization (in Swift 5.9, at least) by which the compiler can successfully infer that this is on the main actor (despite the lack of any @MainActor qualifier). But consider the following:

    @SomeOtherActor
    func somethingElse() {
        let queue: DispatchQueue = .main
        queue.async {
            self.baz()     // some stuff isolated to the main actor
            self.qux = 1
        }
    }
    

    That looks very similar, but the compiler is no longer able to infer that you are isolated to the main actor (at this point, at least). Anyway, you may now receive a warning:

    Call to main actor-isolated instance method 'baz()' in a synchronous nonisolated context; this is an error in Swift 6

    (You may need to set the “Strict Concurrency Checking” build setting to “Complete”.)

    enter image description here

    The bottom line, just because you are on the main queue/thread does not ensure that you are isolated to the main actor. When this happens, if the methods/properties you call are isolated to the main actor, the compiler infer misuse (with “Strict Concurrency Checking” build setting, at least), and warn you as such.

    Now, I admit that my above example of queue.async {…} is a bit contrived. But there are lots of examples where you can accidentally introduce non-isolated contexts (e.g., some helper class that is not actor-isolated, some legacy @preconcurrency contexts/callbacks, etc.), where this general caveat of “just because I may be on the main thread, it doesn’t mean I’m isolated to the main actor” applies. But if you ensure your types, methods, and properties are correctly isolated, then the compiler will take care of it from there, without any additional decoration required at the call point.


    Now, given that we should (with a few exceptions) retire GCD API during the transition to Swift concurrency, if you want it on the main actor, you theoretically could isolate a Task to the main actor:

    @SomeOtherActor
    func somethingElse() {
        Task { @MainActor in
            // current actor is MainActor
        }
    }
    

    But that is a very GCD-way of thinking of the logic (placing the burden on the caller to ensure stuff is run on the main actor). It also unnecessarily introduces unstructured concurrency.

    It is generally better to mark stuff that needs to run on the main actor as such, and just call it (with compile-time verification of code correctness):

    @SomeOtherActor
    func somethingElse() async {
        …
        await somethingThatRequiresMainActor()
        …
    }
    
    @MainActor
    func somethingThatRequiresMainActor() {
        …
    }
    

    The idea is that we should simply isolate methods that need to run on the particular actor as such (or, a little cleaner, put them inside a type that is isolated to the appropriate actor; that way you don't have to decorate all the actor-isolated methods individually) and let the compiler check for code-correctness. That way, we enjoy compile-time verification of our code rather than relying on manual runtime checks.

    FWIW, this evolution from GCD to Task { @MainActor in … } to actor-isolated methods and properties is illustrated in WWDC 2021’s Swift concurrency: Update a sample app.


    You then asked:

    However, recently I realised that I forgot to consider a situation - what happens on a global queue (or any other random queue)?

    @SomeOtherActor
    func somethingElse() {
        DispatchQueue.global().async {
            // current actor is?
        }
    }
    

    It probably goes without saying at this point, but the dispatch to the global queue is not isolated to any actor.

    Nowadays, you would either replace that dispatch to the global queue with a Task.detached {…} or, as of Swift 5.7 (as a result of SE-0338), you can just have a nonisolated async function to get that off the current actor, if that is your intent. E.g.:

    @SomeOtherActor
    func somethingElse() async {
        await somethingThatDoesNotRequireActorIsolation()
    }
    
    nonisolated func somethingThatDoesNotRequireActorIsolation() async {
        …
    }