Skip to content
C.W.K.
Stream
Lesson 01 of 07 · published

The Main Thread Is a Room With One Door

~15 min · concurrency, main-thread, mainactor, responsiveness

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"The scrubber felt sluggish. Nobody's code was slow on its own. Everything was just queueing at the same door."

One Thread Owns the Screen

AppKit and UIKit are not thread-safe. Views, windows, the responder chain and most of their state may only be touched from the main thread, which runs the app's run loop: it takes an input event, lets your code react, lays out and draws, and goes back for the next event. Every millisecond your code holds the main thread is a millisecond no click is handled and no frame is drawn. Hold it for a few hundred milliseconds and the app feels broken; hold it for seconds and macOS shows the spinning cursor.

Swift gives that thread a static name: the @MainActor global actor. Code marked @MainActor runs on the main thread, and the compiler refuses to let non-isolated code reach into it synchronously. SwiftUI views are main-actor isolated. So are AppKit and UIKit view types in the SDK. The rule stops being a comment and becomes something the type checker enforces.

How a Room Gets Crowded

A family video player on the Mac felt sluggish whenever you dragged the scrubber. No single function was slow. Two patterns were queueing at the main thread's door:

  • Polling instead of observing. On every wakeup and on a 200 ms timer, the player re-read 70 to 100 engine properties synchronously from the main thread. Each read waited for the playback engine to reach a safe point — a whole playloop iteration mid-seek. Measured: about 10 ms per poll, 26 polls a second, a quarter of the main thread gone, plus the SwiftUI updates those writes triggered. The fix was to observe: let the engine push only the values that changed, and publish a UI update only when a value actually differed.
  • Waiting on the display. The video frame was rendered inside an AppKit draw call, and the render call deliberately sleeps until the frame's display time. That put the main thread to sleep for 43% of 4K playback. Moving rendering onto a dedicated queue behind a layer left the main thread 94% idle during the same playback.

The Shape of the Fix

Keep the main actor for what only it can do — mutating UI state and talking to views — and send everything that waits, scans, decodes or blocks somewhere else, then hop back with the result. In Swift concurrency that hop is an await: your @MainActor function suspends, the work runs off the main actor, and execution resumes on the main actor with the value. While it is suspended, the main thread is free to handle input.

Code

Hop off the main actor for slow work, and back on for the UI·swift
import Foundation

@MainActor
final class StatusModel {
    var line = "idle"
}

nonisolated func scanDisk() -> String {
    Thread.sleep(forTimeInterval: 0.2)          // stands in for slow, synchronous work
    return "412 files"
}

/// Blocking work gets a thread that is allowed to block, not the cooperative pool
/// (the last lesson of this track measures why).
nonisolated func scanDiskOffMain() async -> String {
    await withCheckedContinuation { continuation in
        DispatchQueue.global(qos: .utility).async {
            continuation.resume(returning: scanDisk())
        }
    }
}

@MainActor
func refresh(_ model: StatusModel) async {
    model.line = "scanning…"                   // on the main actor
    let summary = await scanDiskOffMain()      // suspended: a dispatch thread blocks, the main actor is free
    model.line = summary                       // back on the main actor
}

External links

Exercise

Add the StatusModel and refresh function to Spark. First write a naive version that calls scanDisk() directly from the @MainActor function and time how long the main actor is blocked. Then switch to the dispatch-queue hop and show that other main-actor work (a second task printing a timestamp every 50 ms) keeps running during the scan.
Hint
Start a second Task { @MainActor in … } loop before calling refresh. In the naive version its timestamps stall for the whole scan; in the fixed version they keep ticking, because the main actor is free while refresh is suspended.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.