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.