iosswiftasync-awaitswift-concurrencymainactor

Using @MainActor in a Task which is calling async function declared as @MainActor


I would like to understand if it's needed to declare the Task itself as MainActor considering the following block of code.

func login() {
    Task { [weak self] in
        let result = await self?.loginService.start()

        if result == .successful {
            self?.showWelcomeMessage() // updating the UI here
        }
    }
}

final class LoginService {

    @MainActor
    func start() async -> LoginResult {
        // Doing some UI related operations here
    }
}

Do I need to declare the Task itself as MainActor too when the async function which is called inside the Task is declared as MainActor already? Like this:

func login() {
    Task { @MainActor [weak self] in
        let result = await self?.loginService.start()

        if result == .successful {
            self?.showWelcomeMessage() // updating the UI here
        }
    }
}

I believe if a Task itself is declared as MainActor, child async operations will inherit the parents configuration if not declared otherwise, but does it also work the other way around?


Solution

  • You said:

    I believe if a Task itself is declared as MainActor, child async operations will inherit the parent’s configuration if not declared otherwise, …

    Yes, but I would be wary of ever assuming this, as the child’s actor context is frequently, as you say, “declared otherwise”. It is imprudent to write code that is dependent upon these sorts of assumptions: We want our types to be as loosely coupled as possible.

    … but does it also work the other way around?

    No, the parent generally does not inherit the child’s actor context.

    The exception is that if the parent doesn’t declare a particular actor context, its continuation (the code after the await) may run on whatever context the child’s actor was using (though, IIRC, there are no formal assurances of that and I would advise against relying on any such assumptions).

    But the beauty of Swift concurrency is that we shouldn’t care what the child uses (for example, what actor context the login service uses), as any code (such as the presentation of the “welcome message”) that requires a particular actor should simply declare itself as such. It is the “show welcome message” function’s responsibility to dictate that it needs the main actor, not the caller.

    If every child successfully runs on its respective appropriate actor context, then the login method now doesn’t need to worry about this at all.

    Do I need to declare the Task itself as MainActor too when the async function which is called inside the Task is declared as MainActor already? Like this:

    func login() {
       Task { @MainActor [weak self] in
           let result = await self?.loginService.start()
    
           if result == .successful {
               self?.showWelcomeMessage() // updating the UI here
           }
       }
    }
    

    You could do that.

    Personally, as described above, I would simply annotate showWelcomeMessage (or the whole class, if that makes sense) with @MainActor, rather than this closure. It’s the showing of the message that has to be on the main actor, not the result checking code. And login now doesn’t need to make any assumptions either the login service or the “show message” routine.