Thread.isMainThread throwing compiler warnings

Hi folks,

Thread.isMainThread throws this compiler warning:

Class property 'isMainThread' is unavailable from asynchronous contexts; Work intended for the main actor should be marked with @MainActor; this is an error in the Swift 6 language mode

I'm curious, is there a technical reason for this (as in, it would be too difficult to maintain correctness)? Or is it to encourage us to stop thinking about threads?

If it's the latter, may I offer that adding in these runtime checks helps us understand that our mental model of swift concurrency matches the true reality.

At runtime, processes will spit out things like "Publishing changes from background threads is not allowed", yet tools that help us prevent that are being taken away.

I found in a thread from two years ago that dispatchPrecondition(condition: .onQueue(.main)) works without a compiler warning. It's still true today, which I'm happy about. But if correctness can be maintained for the dispatchPrecondition why can't it be maintained for Thread.isMainThread?

I'd also just like to offer one more thing: these swift concurrency warnings encourage folks that otherwise use Treat warnings as errors to flip it off. So we lose some hygiene with these warnings too.

Thanks for reading,
Lou

While I do think it's a bit too aggressive to ban that property entirely...

The new world™ replacement for this is coming over here: SE-0471: Improved Custom SerialExecutor isolation checking for Concurrency Runtime

Via this you'd be able to check if you're on some executor, including the "main actor's executor".

The main actor is not necessarily the main thread (though most of the time it is).

2 Likes

I don’t have answers to your actual questions, but I did want to share a titbit: You can avoid warnings like this by wrapping the call. For example, replace this:

func test() async {
    print(Thread.isMainThread)
              // ^ Class property 'isMainThread' is unavailable from
              // asynchronous contexts; Work intended for the main actor should
              // be marked with @MainActor
}

with this:

func inner() {
    print(Thread.isMainThread)
}

func testWithInner() async {
    inner()
}

This works because the noasync attribute doesn’t propagate. That’s explained in SE-0340 Unavailable From Async Attribute


Taking a step back, this whole class of APIs is problematic because folks use them for two different reasons:

  • To assert invariants, for example, to trap if you’re not running in the right context

  • To alter runtime behaviour, for example, to bounce to the main thread if you’re not on the main thread

The first usage is fine but the second is a path with many pitfalls. That’s why Dispatch’s variant is structured in terms of a precondition.

Finally, Konrad wrote:

The main actor is not necessarily the main thread (though most of the time it is).

Indeed. And that “most of the time” makes things hard because folks write code that assumes this correlation and then that code fails in odd circumstances.

I also want to stress that the main queue is not necessarily serviced by the main thread. I’ve included some fun examples below.

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple


import Foundation
 
let queue = DispatchQueue(label: "not-main")
 
func main() {
    queue.async {
        DispatchQueue.main.sync {
            // This doesn’t trap:
            dispatchPrecondition(condition: .onQueue(.main))
            // But this prints false:
            print(Thread.isMainThread)
        }
    }
    dispatchMain()
}
 
main()

import Foundation
 
let queue = DispatchQueue(label: "not-main")
 
func main() {
    DispatchQueue.main.async {
        queue.sync {
            // Neither of these trap:
            dispatchPrecondition(condition: .onQueue(.main))
            dispatchPrecondition(condition: .onQueue(queue))
            // This prints true:
            print(Thread.isMainThread)
        }
    }
    dispatchMain()
}
 
main()
10 Likes

What platform did you test this on? dispatch_sync is explicitly documented not to perform the same-thread optimization when targeting the main queue.

2 Likes

Calling dispatchMain() immediately deletes the main thread entirely, so once it’s been called, nothing can ever run on the main thread.

(Note that this means this issue will never apply to app programmers because calling dispatchMain in an app will break everything. This is a daemon programmer thing)

5 Likes

Good to know...

Interestingly adding usleep(100) before calling dispatchMain() also changes the behaviour (true is printed).

2 Likes

This is a daemon programmer thing

Yep. Although it can crop up in other cases, like XPC services and some appexen.

Another weird consequence of this is that you can end up with a process with no threads!

Oh, and just because I shoulda said this earlier, Kyle asked:

What platform did you test this on?

Nothing weird. macOS 15.4 building with Xcode 16.3.

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

1 Like

Thank you all and Quinn for the great tip!

Konrad mentioned and Quinn reiterated this bit:

The main actor is not necessarily the main thread (though most of the time it is).

That's good to know! I thought main actor implied main thread. But now I have to ask:

  1. What are the conditions that lead to main actor code executing on a thread other than main?
  2. Are we supposed to still be using DispatchQueue.main.async and friends in our MainActor code when we want to guarantee we're on main (because the Apple framework demands it, for example)?

Edit to add: I guess that Quinn's examples of the main queue not being serviced by the main thread confuse matters further. It's hard to guarantee you're on main! Perhaps the happy path of MainActor being on main thread is happy enough that I shouldn't worry about adding additional wrinkles to satisfy framework demands

There is only one on Darwin platforms (iOS, macOS, etc…): when the main thread no longer exists (which only happens if it has been explicitly exited, such as by calling dispatchMain)

2 Likes

How is that possible? And what would pthread_self / Thread.current return?

For pthread_self to return anything, a thread would have to exist to call it :slight_smile:

There are various things that can directly create threads in processes to respond to events; for example, incoming XPC messages can be delivered that way.

All that said, I have never personally seen a zero thread process. I've wanted to for years though, it seems like a lovely efficiency win.

2 Likes

From asynchronous contexts, consider MainActor.assertIsolated() instead. It is designed for verifying whether you are on the main actor, or not.

In terms of why you should not use any of the Thread API from asynchronous contexts, I presume it is because there are optimizations that can defy one’s threading expectations. When Swift concurrency first came out, it was quite aggressive in optimizing code, where, for example, if a particular continuation isolated to the main actor did nothing that actually required the main actor, it didn’t always run it on the main thread (!). Recent Swift versions have ratcheted back this optimization: I suspect they did this because so many developers were losing their minds over this optimization, as it defied naive thread expectations.

As a final aside, the vast majority of the time, assertIsolated is simply not needed. This “let me check what thread I’m on” is a common GCD problem (as the thread used by a function is typically dictated by the caller‘s thread, and defensive programmers would put in dispatchPrecondition tests to make sure). In Swift concurrency, the asynchronous context of a function is dictated by how you define the function, not by the context of the caller. So, the vast majority of the time, if you find yourself in a situation where you feel like “I want to insert a main actor isolation test”, the right answer is often to just isolate that function to the correct actor and be done with it.

2 Likes

@louzell – The compiler will sometimes determine whether it needs to be on the main thread or not. For example, if we call two asynchronous functions and put a breakpoint before the second call, you will see that you are not on the main thread. You are still in the cooperative thread pool:

In previous versions of Swift concurrency, it was even more confusing: If you put something in between these two calls which did not require any actor isolation (say, a simple print statement), the compiler would successfully determine that main actor isolation was not required for that particular continuation, and would elegantly optimize out the executor hop back to the main thread, like above. But, nowadays, if you put anything in between these two calls, it will reintroduce the executor hop back to the main thread, whether it is really needed or not. They presumably removed this optimization to avoid unnecessarily confusing programmers about the threading logic. But as you can see above, this optimization is still present, if in a more limited manner.

To be honest, when I first stumbled across this behavior, it felt like a Heisenbug, where the act of observing on which thread it was running (e.g., printing Thread.isMainThread) actually changed on which thread it ran. But in retrospect, it was just a clever optimization going on at compile-time.

But, bottom line, the choice to not permit Thread API within Swift concurrency is a very conscious decision. The MainActor.assertIsolated is the preferred way of determining whether you are isolated to the main actor or not.

1 Like

Note that this cannot impact program behavior. If there was any synchronous code there, it would be required to run on the main thread.