macOS Run Loops: Sources, Modes, Timers, and Thread Ownership
Understand macOS run-loop sources, modes, timers, and thread ownership to avoid stalled callbacks, reentrancy bugs, and unreliable timing.
Run loops are easy to mistake for timers or generic background queues. They are neither. A run loop is the event-processing mechanism associated with a thread: it waits for input sources and timers, dispatches their callbacks on that thread, and can notify observers as it enters, handles events, sleeps, and exits. On macOS, Cocoa’s application event loop is built on the main thread’s run loop.
Understanding the ownership and mode rules helps explain several otherwise surprising bugs: a timer fires only while its mode is being processed, callbacks may arrive during a nested modal loop, a secondary thread exits immediately if its loop has no work source to wait on, and a long callback delays all later events on that thread. This article focuses on Foundation and Core Foundation run loops in macOS applications, not on replacing the AppKit event loop with a custom one.
One run loop belongs to each thread
Each thread has one associated run loop. You do not create a thread’s run loop as a separate object; RunLoop.current and CFRunLoopGetCurrent() retrieve the loop for the current thread. AppKit starts and drives the main application loop during normal startup. Most application code should add work to that loop when appropriate, not call run() recursively to keep the interface responsive.
Secondary threads are different. A run loop does not keep a thread alive merely because the thread exists. Before running a secondary thread’s loop, register at least one input source or timer. Without one, the loop has nothing to monitor and exits immediately. If a thread only performs a finite computation, use a normal worker task and let the thread finish; a run loop is useful when the thread must remain interactive and receive asynchronous events.
Run-loop sources include asynchronous input sources, timer sources, and observers. Input sources can be port-based or custom. A source is associated with one or more modes, and it delivers only while the loop is running in a matching mode. An observer is different: it watches run-loop lifecycle activities rather than representing an event to consume. This distinction matters when choosing a callback mechanism. Do not create a custom Core Foundation source when a dispatch queue, delegate callback, or framework-provided source already expresses the event.
Modes filter which events a loop processes
A run loop executes one mode at a time. During an iteration in a mode, it services sources, timers, and observers registered for that mode. Events belonging to another mode wait until the loop runs that mode. This is how a UI framework can temporarily prioritize a modal interaction or event tracking without processing every source identically.
The common modes value is a pseudo-mode representing a set of modes, not a separate mode that is always running. Adding a timer to common modes registers it with the run loop’s configured common-mode set. In a Cocoa app that can keep a timer firing during common modal or event-tracking modes, but the set is per run loop and can be extended. Do not assume that every custom mode is automatically common, or add everything to common modes to hide a scheduling mistake.
For example, a timer added only to .default may pause while the main run loop is tracking a drag. If that pause is acceptable, keep it in the default mode. If the timer must also run during the common UI modes, add it explicitly to .common:
import Foundation
let housekeepingTimer = Timer(timeInterval: 30, repeats: true) { _ in
performSmallHousekeepingStep()
}
RunLoop.main.add(housekeepingTimer, forMode: .common)
func stopHousekeeping() {
DispatchQueue.main.async {
housekeepingTimer.invalidate()
}
}
func performSmallHousekeepingStep() {
// Keep this bounded; long work blocks the main run loop.
}
The callback runs on the main run loop and therefore on the main thread. Keep it short, avoid blocking I/O, and schedule expensive work onto a background queue. Invalidate the timer when its owner is done; a repeating timer retained by the run loop can otherwise continue to invoke code after the UI object that created it is no longer relevant. Foundation requires invalidation on the same thread where the timer was installed, which is why the sample marshals teardown onto the main queue.
Timers are notifications, not real-time deadlines
A Timer is associated with a run loop mode. It does not fire if the run loop is stopped, busy inside another callback, or running only in a mode that does not include the timer. A fire date is therefore an earliest scheduling intention, not a guarantee that your closure begins at that instant. Run-loop work on the same thread is serialized, so one blocked callback delays the others.
Repeating Foundation timers schedule from their intended firing schedule rather than simply sleeping a fixed delay after each callback. If the loop misses several intervals, the system does not replay every missed callback in a burst; it delivers a firing and advances to the next scheduled time. This is useful for UI refresh and debounce-like work but makes a timer unsuitable for precise accounting or periodic catch-up logic.
Choose a mechanism based on what the task needs:
- Use a run-loop timer when the callback belongs on a particular thread and should be serviced by that thread’s event loop.
- Use
DispatchSourceTimerwhen the timer belongs to a dispatch queue and should be coordinated with queue-owned work. - Use
NSBackgroundActivitySchedulerfor low-priority maintenance that the system may defer for energy efficiency. - Use a persistent service manager or durable job queue when work must be launched independently of an interactive application’s run loop.
These choices are not interchangeable. Moving a timer to .common fixes only mode registration; it does not make the callback real-time, nonblocking, or safe to execute on the main thread.
Avoid recursive loop pumping and callback reentrancy
Calling RunLoop.current.run() or a nested CFRunLoopRunInMode from inside application logic can allow unrelated events to run before the current operation returns. A modal dialog, synchronous wait, or helper that pumps a run loop can re-enter code that the caller assumed was not yet reachable. That can expose partially updated state, trigger a second action while the first is still active, or make a deadlock appear to disappear in one test path.
Prefer asynchronous APIs and explicit state machines over manually pumping the loop. If a legacy API requires a nested loop, document which callbacks can execute during it, make state transitions reentrancy-safe, and test cancellation and teardown during the nested period. Avoid blocking the main thread waiting for a callback that itself requires the main run loop to run.
The same discipline applies to secondary threads. Register the source before entering the loop, keep source mutation and thread state under clear ownership, and provide a deliberate shutdown path. A custom source typically needs both a mechanism for another thread to signal that data is ready and a way to wake the target loop. After waking, the loop processes callbacks on its owning thread, so shared data still needs synchronization or message passing.
Diagnose delivery with mode and thread evidence
When a callback appears late, collect the information that constrains delivery: the thread expected to run it, the exact mode used to schedule the source or timer, the mode currently being processed, callback duration, and whether the loop was running. A timer registered on .default and a main thread in event tracking is a different failure from a timer whose callback is blocked behind long-running work.
For a small diagnostic, log callback start and finish times with a monotonic clock, the owning thread, and the operation identifier. Avoid logging private payloads. If the callback is an observer, record the observer activities you registered for and ensure the observer is removed or invalidated on teardown. Core Foundation observers can be useful for instrumentation, but they add callbacks at lifecycle points and should not become a hidden source of expensive work.
An acceptance test for a timer-backed feature should verify: it fires in every required mode; it pauses in modes where pausing is intended; a long callback does not exceed a documented budget; invalidation prevents later work; and UI actions remain responsive while background operations are running. A test that checks only the nominal interval on an idle Mac says little about behavior during dragging, modal interaction, or a busy main thread.
Keep run-loop ownership explicit
Frameworks such as Disk Arbitration can schedule callbacks on a run loop or a dispatch queue. Choose one delivery model and make the callback’s thread contract explicit instead of scheduling the same session through both mechanisms. Similarly, a Core Audio device listener should deliver state changes to a controlled queue or thread before updating app-owned state. The central rule is consistent: callbacks execute in the scheduling context documented by the API, not in whichever context is most convenient to the caller.
For most modern macOS code, use the framework’s documented queue-based or async interface when it directly matches your task. Reach for run-loop control when you need thread-affine event processing, integration with Cocoa modes, or a legacy API that explicitly uses a run loop. Treat the main loop as shared infrastructure: every slow callback delays input, drawing, timers, and other work that depends on that thread.
Related:
- macOS Disk Arbitration: Observing and Approving Mounts Without Racing Finder
- Core Audio Device Discovery: Enumerating and Tracking macOS Audio Hardware
Sources: