What is the main-thread contract, and why does DispatchQueue.main.sync from the main thread crash?

6.3.3 · L2 · Interview

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.sync from code that might already be on the main thread. It crashes. Check first or use async.
  • 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 @MainActor mean “runs on the main queue immediately”? It means the code runs on the main actor’s executor; calling from elsewhere needs await.

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…