skip to Main Content

I have recently started using async/await (in an existing, Non Swift-UI codebase).
Two things that I haven’t been able to get an answer to (neither by reading nor experimenting) is whether its wrong to

await something on the @MainActor

and

Access properties inside a Task/other Actor

In my experience, accessing properties from different Threads can cause race conditions and shouldn’t be done, and performing blocking operations on the Main thread blocks the UI and shouldn’t be done.

Yet, I see a lot of code like what I have tried to isolate in the (fully runnable) example below.

So in loadStuff1, we may or may not be on the Main Thread (depending on whos calling). Its accessing two properties, stuffLoader and param and they are probably read/written to from the Main Thread. Will this, as I expect, lead to Race condtions? What do you pros do instead?

In loadStuff2, we are definitely on the Main Thread. Is it safe to await stuff here? If it is, then I guess this is the way to go, but I havent seen any statement about await not blocking the Main Thread.

In loadStuff3 we have started a Task, which in my understanding moves our execution to another thread, which will never be the Main Thread. Just as in loadStuff1 I expect this to produce race conditions.

import PlaygroundSupport
import UIKit

struct Stuff { }

enum Param {
    case a
    case b
}

class VM {
    private let stuffLoader = StuffLoader()
    var param: Param = .a

    func loadStuff1() async -> Stuff {
        // Is on the Main thread if the calling Thread is the Main Thread.
        await stuffLoader.load(param: param)
    }

    @MainActor func loadStuff2() async -> Stuff {
        // Is always on the Main thread.
        await stuffLoader.load(param: param)
    }

    func loadStuff3(completion: @escaping (Stuff) -> Void) {
        Task {
            // Is never on the Main thread.
            let stuff = await stuffLoader.load(param: param)
            completion(stuff)
        }
    }
}

class StuffLoader {
    func load(param: Param) async -> Stuff {
        // Here, a network call occurs and returns in 1 second.
        Stuff()
    }
}

class VC: UIViewController {
    let vm: VM

    init(vm: VM) {
        self.vm = vm
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        Task {
            let stuff = await vm.loadStuff2()
            print("Stuff 2 loaded.")
        }
    }

    func onSomethingHappened() {
        Task {
            let stuff = await vm.loadStuff1()
            print("Stuff 1 loaded.")
        }
    }

    func onSomethingElseHappened() {
        vm.loadStuff3 { stuff in
            print("Stuff 3 loaded.")
        }
    }
}

let vm = VM()
PlaygroundPage.current.liveView = VC(vm: vm)

2

Answers


  1. Overview:

    • It is best to watch the WWDC videos on Swift Concurrency, there is a lot of content best to learn the basics
    • Every time a compiler encounters await, it is a potential suspension point, meaning it would suspend work on that thread (doesn’t block) and relinquishes the thread to the system.
    • Later the system when it determines it is ready to resume, the it resumes the work.

    Safety checks to turn on

    • In Build Settings set the Strict Concurrency Checking to Complete to catch any warnings.

    Testing

    • Add breakpoints at places where you want to detect the thread and see the thread the code runs on in the Debug Navigator

    Code

    • Test it with loadStuff2
    • StuffLoader.load would make a network call for example: let(data, response) = try await session.data(for: request) this would not block the thread it is running on, it would suspend and resume back.
    • This would run in the background
    class StuffLoader {
        func load(param: Param) async -> Stuff {
            let session = URLSession(configuration: .ephemeral)
            //WARNING: I am just using some random URL I found on the internet
            //Please change the URL
            guard let url = URL(string: "https://dummyjson.com/products") else {
                return Stuff()
            }
            let request = URLRequest(url: url)
            
            do {
                let(data, response) = try await session.data(for: request)
                //To simulate some JSON parsing
                for index in 1...10000 {
                    print(index)
                }
                print("data = (data)")
                print("response = (response)")
            } catch {
                print("Error: (error)")
            }
            
            return Stuff()
        }
    }
    
    Login or Signup to reply.
  2. You ask …

    … whether it’s wrong to await something on the @MainActor.

    No, it is not wrong to be on the main actor and await some other asynchronous function/task. It is the whole purpose of asyncawait, to offer us graceful patterns to manage dependencies between asynchronous tasks without ever blocking any threads or actors.

    Legacy API offer various renditions of wait or sleep that are blocking and are therefore dangerous. But await does not suffer these problems. Go ahead and use await from the main actor.

    (If you want to get into the weeds of what really happens when you await, what “suspension points” and “continuations” are, and what the Swift concurrency threading model is, check out WWDC 2021 video Swift concurrency: Behind the scenes.)

    You continue to ask …

    … whether it’s wrong to … access properties inside a Task/other Actor

    In my experience, accessing properties from different Threads can cause race conditions and shouldn’t be done, and performing blocking operations on the Main thread blocks the UI and shouldn’t be done.

    This is the precise problem that Swift actors solve, to provide safe access to properties from different threads, without ever blocking any threads (including the main thread). I might suggest watching WWDC 2021’s Protect mutable state with Swift actors.

    Bottom line, whenever you see await (e.g., when fetching a result from a Task or Swift actor), that means that the caller is not blocked and it is free to go on to other tasks until the result is safely returned from the other concurrency context.

    And what’s so nice about the contemporary compiler, is that at compile-time (rather than the old pattern of waiting for a run-time crash or some TSAN warning), the compiler will tell you whether you need to await the result or not.

    So, bottom line, the compiler will tell you whether or not you are permitted to “access properties inside a task/other actor”, and when you must await and when you do not.


    In your code snippet, you show us an example of VM, which is not actor-isolated. So all bets are off and you lose any benefits of actor-isolation. You could make it an actor rather than a class (conferring all those thread-safety benefits). Or, since view models generally exist solely to interface with a view, you can isolate it to the main actor, e.g.

    @MainActor
    class VM {
        …
    }
    

    Whether you make it its own actor or isolate the class to the main actor (or some other global actor) is up to you. But view models generally exist solely to update and interface with the view, which needs to occur on the main thread. So, I generally designate my view models as @MainActor, but my various services/helpers as their own actor.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search