How does a DispatchSourceTimer differ from Timer, and what are the lifecycle rules that crash if you break them?

10.11.3 · L3 · Interview

Predict first

A repeating timer cancels itself on the third tick. What does it print?

import Foundation

final class Box: @unchecked Sendable {
    private let l = NSLock(); private var n = 0
    func inc() -> Int { l.lock(); defer { l.unlock() }; n += 1; return n }
    var value: Int { l.lock(); defer { l.unlock() }; return n }
}
let src = DispatchSource.makeTimerSource(queue: .global())
let box = Box()
let done = DispatchSemaphore(value: 0)
src.schedule(deadline: .now() + 0.05, repeating: 0.05, leeway: .milliseconds(5))
src.setEventHandler { if box.inc() == 3 { src.cancel() } }
src.setCancelHandler { print("cancelled after", box.value, "ticks"); done.signal() }
src.resume()
done.wait()
  • cancelled after 3 ticks
  • cancelled after 0 ticks
  • It never prints

Mental model. A dispatch timer is an alarm clock that rings in whichever room you point it at. You must switch it on (resume) and, when finished, tell it to stop and wait for its final “off” signal before throwing it away.

A dispatch timer source fires its handler on a queue you choose, independent of any run loop, so it works on background queues and is not paused when the main run loop switches modes (a Timer on the main run loop stops while the user scrolls in the default mode, Q217). It supports a repeat interval and leeway, a tolerance that lets the system coalesce wake-ups to save power: the check uses a 50 ms interval with 5 ms of leeway, fires three times, then cancels itself and prints “cancelled after 3 ticks”. Compare Timer: simpler API, tied to a run loop, retained by it until invalidated; use it for UI-adjacent timing, and a dispatch timer (or Task.sleep loops) for background periodic work.

Sources have a strict lifecycle, and breaking it crashes. A source is created suspended, so you must call resume() to start it. It must be resumed or have its handlers cleared before it is deallocated if it was suspended: releasing a suspended source is a crash. Calls to suspend() and resume() must balance. cancel() is asynchronous and prevents further events; the optional cancel handler runs after the last handler has finished, which is where to release resources. The event handler captures what it uses, so avoid capturing the object that owns the source strongly (a retain cycle). Prefer one long-lived source with a changing deadline over creating many.

The handler counts ticks, cancels at three, and the cancel handler runs afterwards.

Common traps

  • Forgetting to resume() a new source. Nothing ever fires.
  • Releasing a suspended source. It crashes; resume it or cancel it first.

They might follow up with

  • Why add leeway? It lets the system batch timers and wake the CPU less often, which saves battery (Q437).
  • What else is a dispatch source for? File descriptor readiness, signals, process events, and custom data-add or data-or events.

Related questions

  • 10.11.1 What goes wrong with an unbalanced DispatchGroup, and how do you use a semaphore to throttle work without…
  • 10.11.5 How does quality of service propagate between queues and blocks, and why does it matter for priority…
  • 6.3.2 What are run loop modes, and why does a Timer stop firing while the user scrolls?
  • 10.1.2 How do barriers, groups, semaphores and dispatch sources coordinate work?
  • 21.6.1 Where does an app’s energy go, and how do timers, networking, location and animation change the battery bill?

← What does DispatchQueue.concurrentPerform do, when is it better than…   How do Operation dependencies, cancellation and states interact, and… →