In Swift 6, Thread.isMainThread
is illegal in an async
context. To prove you’re “on the main thread”, you now assert you’re on the main actor:
MainActor.assertIsolated()
That line will crash if we’re not on the main actor (in a debug build), and so your assumptions are confirmed when you don’t crash.
My question is: Is there a way to assert that you’re not on the main actor? If I have another actor, I can use assertIsolated()
with that actor, of course; but I’m talking about the nonisolated
actor, the “default” actor that the runtime supplies for situations where there is no named actor. What is the name of that actor, to which I can send assertIsolated()
? Or is there any way, short of crashing, to confirm that I’m not on the main actor?
11
tl;dr:
- If you want to know whether a
nonisolated async
func is running on the main actor: it never will be - If you want to know whether a
nonisolated async
func is running on the main thread: you’ll need to wrapThread.isMainThread
/pthread_main_np
in C/Objective-C/Swift to make it callable in async contexts
If you look at the implementation of MainActor.assumeIsolated()
, all it’s really doing is grabbing the actor’s executor and checking whether the current task is running on that executor (using a low-level silgen’d function).
Given the tools available from outside of the Concurrency
module, you can’t implement a similar (but inverse) check yourself, but digging into the docs shows that this doesn’t appear to be necessary:
Executor Types
The low-level interface for concurrency underpinning Task
s and Actor
s is ExecutorJob
s running on Executor
s. Every Actor
has an associated Executor
that performs the actual work, and every Task
is broken up into ExecutorJob
s and submitted to that executor. (A job is all of the synchronous work in a task between two await
statements)
The Executor
protocol is bifurcated into two mutually-exclusive sub-protocols:
SerialExecutor
, andTaskExecutor
SerialExecutor
s are the executors defined by Actor
s to perform their work:
- If an actor specifies an
unownedExecutor
, all work done on that actor will be submitted to its executor - If an actor does not specify an
unownedExecutor
, a default executor is provided which uses the global concurrent thread pool
nonisolated async
functions, however, are not isolated to a specific actor, and thus don’t use their executors; instead, they either:
- Run on a preferred
TaskExecutor
if a preference is specified viawithTaskExecutorPreference(_:isolation:operation:)
(or similar), or - Run on the global concurrent executor
From the docs:
By default, without setting a task executor preference, nonisolated asynchronous functions, as well as methods declared on default actors – that is actors which do not require a specific executor – execute on Swift’s default global concurrent executor. This is an executor shared by the entire runtime to execute any work which does not have strict executor requirements.
From a much more specific doc comment in the code:
/// [ func / closure ] - /* where should it execute? */
/// |
/// +--------------+ +===========================+
/// +-------- | is isolated? | - yes -> | actor has unownedExecutor |
/// | +--------------+ +===========================+
/// | | |
/// | yes no
/// | | |
/// | v v
/// | +=======================+ /* task executor preference? */
/// | | on specified executor | | |
/// | +=======================+ yes no
/// | | |
/// | | v
/// | | +==========================+
/// | | | default (actor) executor |
/// | v +==========================+
/// v +==============================+
/// /* task executor preference? */ ---- yes ----> | on Task's preferred executor |
/// | +==============================+
/// no
/// |
/// v
/// +===============================+
/// | on global concurrent executor |
/// +===============================+
Importantly:
- The global concurrent executor is never going to be the main actor executor, and
- All of the
Task
executor preference APIs take aTaskExecutor
, which cannot be a specific actor’sSerialExecutor
, unless the executor publicly adopts both protocols or offers a way to expose itself as a task executor too
So, for a given nonisolated async
function, you shouldn’t need to check that you’re not on the main actor, because you never will be.
Caveat
Crucially: the main actor is not the same thing as the main thread/queue, in that the main actor is guaranteed to execute on the main thread/queue, but other executors might too.
The global pool does not guarantee any thread (or dispatch queue) affinity
Theoretically, the global thread pool could include the main thread (though I don’t think this can really be done safely in practice), so just checking that you’re not on the main actor does not necessarily imply that you’re not on the main thread.
In the general case, if you really need to check whether you’re on the main thread specifically, you’re going to need to wrap Thread.isMainThread
/pthread_main_np
in your own C/Objective-C/Swift code to bypass the unavailable-in-Swift-concurrency limitations.
3
Taking a hint from Itai Ferber’s answer, I can work around the restriction by hiding the call to Thread.isMainThread
behind a nonisolated(unsafe)
wall:
nonisolated(unsafe) let swiftThreadReporter = MySwiftThreadReporter()
class MySwiftThreadReporter {
func isOnMainThread() -> Bool {
return Thread.isMainThread
}
}
Now any code can say swiftThreadReporter.isOnMainThread()
and get back the right answer. This is a good enough solution to allow me to move forward.
You probably wouldn’t want to include that code in a shipping application, but I wasn’t going to anyway; the goal is merely to print
to learn whether I’ve gotten off the main thread. The caveats from Itai’s answer still apply, obviously; in particular, there isn’t a perfect one-to-one correspondence between the main thread and the main actor.
I still feel like Apple has a left us with a hole in the language here. If they can give us assertIsolated()
they should be able to give us assertNotIsolated()
(meaning, not “is nonisolated”, but “is not isolated to the actor to whom this method is sent”).
While I confess that I have used this trick, myself, I have grown wary of this approach (a check of Thread.isMainThread
wrapped in a synchronous function). Yes, it lets us examine isMainThread
without the compiler warning/error, but there are likely reasons (beyond just abstracting us away from thinking about threads) why Apple went through all the work of adding a warning if you use this property from an asynchronous context.
As far as I know, Apple has not specifically articulated their rationale for disabling this in Swift concurrency contexts, but the concern might be that one might draw incorrect conclusions and/or sacrifice some clever optimizations. The one that jumps out at me is partial tasks, whereby the compiler performs a static analysis of the code and determines whether the partial task (i.e., the code after an await
) actually requires a hop back to the main task’s isolation context, and if not, it optimizes that out, entirely. It is an elegant little performance optimization, if not immediately intuitive.
In previous versions of the Swift compiler, when people were first learning Swift concurrency, and using isMainThread
to validate their understanding, many were confused by some of the arcane behaviors (e.g., @MainActor closure does not seem to execute on the main thread deterministically). In recent versions of the compiler, these behaviors have been harder to reproduce, as it appears that inserting even nonisolated code in a partial task appears to reintroduce the hop that might otherwise have been optimized out. The optimization is still there if you don’t have anything in the continuation, e.g., two successive await
calls to a separate context (verified by adding breakpoints and examining what thread you are on), but it appears to not happen in the face of any code in the continuation. But I would be hesitant to rely on this undocumented behavior (and might even welcome the return of the previous, admittedly aggressive, optimization).
Your point still stands, that it would be nice if there was a way to confirm that you are not on a particular actor/thread, if only for diagnostic purposes, even if we sacrificed some Swift concurrency optimizations. Especially since they’ve introduced the executor-preference logic for nonisolated
async
functions (which, as an aside, is one of my least favorite implementations), the ability to have these sorts of diagnostics would be useful. Something akin to GCD‘s dispatchPrecondition(condition: .notOnQueue(.main))
would be nice, but I can imagine all sorts of reasons why they might be reluctant to introduce that in Swift concurrency.
That having been said, the need for these sorts of preconditions is somewhat diminished in Swift concurrency: Back in the GCD days, the caller generally dictated what thread some code was running on, so these preconditions were fairly essential when coding defensively. But in Swift concurrency, the context is generally dictated by the called routine’s definition (with some exceptions), so it the argument for introducing more preconditions is a little less compelling.