Home > Blockchain >  Is it a coincidence that @MainActor seems to get inherited by withCheckedContinuation, but not other
Is it a coincidence that @MainActor seems to get inherited by withCheckedContinuation, but not other

Time:01-28

In a Swift method with @MainActor specified, which calls withCheckedContinuation and makes other async calls, it seems like withCheckedContinuation is called on the @MainActor thread, but the other async calls are not.

Is this a coincidence?

The following code can be dropped into the ContentView.swift of the default iOS App that XCode creates for you:

import SwiftUI

func printCurrentThread(_ label: String) {
    print("\(label) called on \(Thread.current)")
}

class SomeNonThreadSafeLibrary {
    func getFromServer(msg: String, completionHandler: @escaping (String) -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now()   2.0) {
            completionHandler(msg)
        }
    }
}

class AppController: ObservableObject {
    @Published var something1: String = "not set"
    @Published var something2: String = "not set"
    var someNonThreadSafeLibrary: SomeNonThreadSafeLibrary? = SomeNonThreadSafeLibrary()

    func someOtherAsyncFunction(calledFrom: String) async {
        printCurrentThread("\(calledFrom) -> someOtherAsyncFunction")
    }

    func getSomethingAsynchronously() async -> String {
        printCurrentThread("getSomethingAsynchronously")
        await someOtherAsyncFunction(calledFrom: "getSomethingAsynchronously")
        return await withCheckedContinuation { continuation in
            printCurrentThread("getSomethingAsynchronously -> withCheckedContinuation")
            do {
                try Task.checkCancellation()
                self.someNonThreadSafeLibrary?.getFromServer(msg: "getSomethingAsynchronously") { result in
                    printCurrentThread("getSomethingAsynchronously -> getFromServer closure")
                    continuation.resume(returning: result)
                }
            }
            catch {}
        }
    }

    @MainActor
    func getSomethingAsynchronouslyWithMainActor() async -> String {
        printCurrentThread("getSomethingAsynchronouslyWithMainActor")
        await someOtherAsyncFunction(calledFrom: "getSomethingAsynchronouslyWithMainActor")
        return await withCheckedContinuation { continuation in
            printCurrentThread("getSomethingAsynchronouslyWithMainActor -> withCheckedContinuation")
            do {
                try Task.checkCancellation()
                self.someNonThreadSafeLibrary?.getFromServer(msg: "getSomethingAsynchronouslyWithMainActorWithMainActor") { result in
                    printCurrentThread("getSomethingAsynchronouslyWithMainActor -> getFromServer closure")
                    continuation.resume(returning: result)
                }
            }
            catch {}
        }
    }

    @MainActor
    func getValueForSomething() async {
        something1 = await getSomethingAsynchronously()
    }

    @MainActor
    func getValueForSomethingWithMainActor() async {
        something2 = await getSomethingAsynchronouslyWithMainActor()
    }

}

struct ContentView: View {
    @StateObject private var appController = AppController()
    var body: some View {
        VStack {
            Text("something1 is \(appController.something1)")
            Text("something2 is \(appController.something2)")
        }
        .task {
            await appController.getValueForSomething()
        }
        .task {
            await appController.getValueForSomethingWithMainActor()
        }
        .padding()
    }
}

The output of the code is as follows...

The func with @MainActor is called on thread 1, and the func without @MainActor is called on an arbitrary thread, as expected:

getSomethingAsynchronouslyWithMainActor called on <_NSMainThread: 0x600001910400>{number = 1, name = main}
getSomethingAsynchronously called on <NSThread: 0x60000195c300>{number = 7, name = (null)}

Then the do-nothing func someOtherAsyncFunction is called from each of those methods, and in both cases (i.e. with or without @MainActor on the calling function), it gets called on an arbitrary thread. This suggests that @MainActor on a function is not inherited by functions called within it:

getSomethingAsynchronously -> someOtherAsyncFunction called on <NSThread: 0x60000195c300>{number = 7, name = (null)}
getSomethingAsynchronouslyWithMainActor -> someOtherAsyncFunction called on <NSThread: 0x6000019582c0>{number = 6, name = (null)}

And yet, in the specific case of the closure to withCheckedContinuation, it does seem to run on thread 1 when @MainActor is on the calling function:

 getSomethingAsynchronously -> withCheckedContinuation called on <NSThread: 0x60000195c300>{number = 7, name = (null)}
 getSomethingAsynchronouslyWithMainActor -> withCheckedContinuation called on <_NSMainThread: 0x600001910400>{number = 1, name = main}

So... is it just a coincidence that withCheckedContinuation seems to run on @MainActor via its calling function, or is it by design? And why is it only withCheckedContinuation and not also someOtherAsyncFunction?

If it is by design, where can I get more information on where this is specified?

CodePudding user response:

The someOtherAsyncFunction is an async method which is not isolated to any particular actor, so it is free to run it on whatever thread it wants (presumably one of the threads from the cooperative thread pool). But the closure parameter of withCheckContinuation is not an async closure, but rather a simple synchronous closure, so it seems perfectly logical that it runs on whatever thread from which you launched it (though I do not see any formal assurances to that end in the documentation).

All of that said, it is worth noting that if you want a particular asynchronous function to run on a particular actor, unlike GCD, we never make assumptions about where something is called on the basis of the caller’s context. Instead, we would just isolate the method we are calling to the appropriate actor, either with some global actor designation (such as @MainActor), or by defining the async method within an actor, and we are done.

In contrast, in GCD, the caller would often dispatch some code to some particular queue. And as our projects would get more complicated, we would sometimes introduce some defensive programming techniques with dispatchPrecondition or assert tests to make sure a method was actually running on the queue we thought it was.

But Swift concurrency eliminates all of these assumptions (and possibly tests of those assumptions) in our code. We just isolate a particular method to a particular actor, and Swift concurrency takes care of it from there.

For a practical example of this, see WWDC 2021 video Swift concurrency: Update a sample app, notably about 27 minutes in.

Also, in your question, you are examining which thread some task or continuation runs. But, Swift concurrency can defy our traditional thread expectations (and in a future compiler release, you may not even be able use Thread.current from an asynchronous context at all). But if you are interested in some of the details, see WWDC 2021 video Swift concurrency: Behind the scenes. It is not directly related to your question, but it does offer an interesting glimpse into the Swift concurrency threading model.

  • Related