Why does a Timer created on a background thread never fire, and when does a thread’s run loop return immediately?

6.10.1 · L3 · Interview

Predict first

What does a worker thread’s run loop do with and without something attached?

import Foundation
let done = DispatchSemaphore(value: 0)
Thread.detachNewThread {
    let started = Date()
    let ran = RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 2))
    print("loop with nothing attached ran:", ran, "and returned at once:", Date().timeIntervalSince(started) < 0.5)
    nonisolated(unsafe) var fired = 0
    let timer = Timer(timeInterval: 0.05, repeats: true) { _ in fired += 1 }
    RunLoop.current.add(timer, forMode: .default)
    print("timer added, loop not running, fired:", fired)
    RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.3))
    print("after running the loop for 0.3 s, fired at least 4 times:", fired >= 4)
    done.signal()
}
done.wait()
  • The empty loop returns at once; the timer does not fire until the loop is run, then fires at least 4 times
  • The empty loop waits two seconds; the timer fires without running the loop
  • The timer fires even though the loop is never run

Mental model. A run loop is a waiter. Creating a thread hires nobody; creating a timer only leaves an order slip. If no waiter is walking the floor, the slip is never read, and a waiter with an empty floor goes home.

Every thread can have a run loop, created lazily the first time you ask for RunLoop.current, but it is not running until you call run. A Timer only registers itself with a loop; the loop is what fires it. In the check the timer added on a worker thread had fired 0 times while the thread simply continued, and fired at least four times (50 ms interval) once the loop was run for 0.3 s. A second trap: a run loop with no sources, timers or observers attached to the mode it runs in returns immediately (run(mode:before:) returned false at once instead of waiting two seconds). That is why the old “keep a thread alive” idiom adds a dummy port (RunLoop.current.add(Port(), forMode: .default)) before running, and why a bare while !done { RunLoop.current.run() } can spin at full CPU.

The modern guidance follows from this: do not hand-build long-lived threads with run loops for new code. Use a serial dispatch queue with a DispatchSourceTimer, or Swift concurrency with Task.sleep or a Clock, which need no thread or loop of yours. Reach for a real thread run loop only for APIs that demand one: some delegate-based NSURLConnection-era or stream APIs, CFRunLoopSource from C libraries, or a Timer you must schedule on a specific thread. Also remember run(until:) returns after the date or when the loop has nothing left to do, and that run(mode:before:) returns after handling one source, so loops that must stay alive call it repeatedly until the deadline.

A loop only fires what is attached to its mode, and only while it is running.

Common traps

  • Scheduling a Timer from a DispatchQueue.global() block. The worker thread has no running loop, so the timer never fires; use a dispatch timer source.
  • Calling RunLoop.current.run() in a loop with nothing attached. It returns immediately and burns CPU.

They might follow up with

  • Does the main thread need any of this? No: UIApplicationMain runs its loop for the life of the process.
  • How do I keep a thread alive deliberately? Add a port or a source, then run the loop until a flag is set, calling run(mode:before:) repeatedly.

Related questions

  • 6.3.1 What is a run loop, and what happens in one iteration?
  • 6.3.2 What are run loop modes, and why does a Timer stop firing while the user scrolls?
  • 6.10.2 What are run loop observers, in which order do they fire, and what do frameworks use them for?
  • 6.10.3 Which work still runs while the run loop is in a tracking or custom mode, and what does “common modes” really…

What are run loop observers, in which order do they fire, and what do… →