What is the main-thread contract, and why does DispatchQueue.main.sync from the main thread crash?
Predict first
What happens?
import Foundation
DispatchQueue.main.sync { print("never") }
- Prints never
- Crashes: sync onto the queue the current thread owns
Predict first
What does this print when run from the main thread?
import Foundation
DispatchQueue.global().sync { print(Thread.isMainThread) }
- false
- true
Mental model. There is one cashier. Asking the cashier to wait for a job that only the cashier can do means the line never moves.
UIKit and most of SwiftUI’s view layer are main-thread only: you create and update views, touch layout and read many properties on the main thread. This is partly because the main run loop is what drives touch delivery and drawing, and partly because the framework state is not guarded for concurrent access. Tools help: the Main Thread Checker reports UIKit calls off the main thread, and @MainActor lets the compiler enforce it. The other half of the contract is do not block the main thread: synchronous network or disk work, heavy computation and waiting on a lock all stop the run loop, which causes hangs and eventually the watchdog.
The main queue is a serial queue drained by the main run loop. Calling DispatchQueue.main.sync { } from the main thread asks the main thread to wait for a block that can only run on the main thread after the current code finishes: a deadlock, which libdispatch detects and turns into a crash. Use async to hop to the main queue, and prefer await MainActor.run or an @MainActor function in Swift concurrency. A related trap: queue.sync usually runs the block on the calling thread, so Thread.isMainThread inside DispatchQueue.global().sync { } called from the main thread is true.
The main thread would wait for a block that can only run on itself, so libdispatch reports a client bug and terminates the process.
sync blocks the caller anyway, so libdispatch runs the block on the calling thread as an optimisation. The queue is global, but the thread is the main one.
Common traps
- Calling
DispatchQueue.main.syncfrom code that might already be on the main thread. It crashes. Check first or useasync. - Doing JSON parsing or image decoding in a UI callback. It blocks touches and animations.
They might follow up with
- How do you find hangs? Instruments’ Time Profiler and Hangs, and MetricKit hang reports from real users.
- Does
@MainActormean “runs on the main queue immediately”? It means the code runs on the main actor’s executor; calling from elsewhere needsawait.
Related questions
- 6.3.1 What is a run loop, and what happens in one iteration?
- 6.6.3 What is the watchdog, and what causes the 0x8badf00d exception?
← What are run loop modes, and why does a Timer stop firing while the…