Why does a tiny repeating poll drain the battery, and how can you count wake-ups yourself?

21.12.1 · L5 · Interview

Predict first

How many context switches does a poll cause compared with one blocking wait?

import Foundation

func switches() -> Int {
    var info = task_events_info()
    var count = mach_msg_type_number_t(MemoryLayout<task_events_info>.size / MemoryLayout<natural_t>.size)
    _ = withUnsafeMutablePointer(to: &info) { $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { task_info(mach_task_self_, task_flavor_t(TASK_EVENTS_INFO), $0, &count) } }
    return Int(info.csw)
}
func wakeups(_ body: () -> Void) -> Int { let b = switches(); body(); return switches() - b }
let polling = wakeups { let end = Date().addingTimeInterval(1); while Date() < end { usleep(10_000) } }
let sem = DispatchSemaphore(value: 0)
let waiting = wakeups { _ = sem.wait(timeout: .now() + 1) }
let coarse = wakeups { let end = Date().addingTimeInterval(1); while Date() < end { usleep(250_000) } }
print("10 ms polling for one second: at least 50 context switches:", polling >= 50)
print("one blocking wait for the same second: under 10:", waiting < 10)
print("250 ms polling for one second: under 20:", coarse < 20)
  • 10 ms poll: at least 50 / blocking wait: under 10 / 250 ms poll: under 20
  • All three cause the same number
  • The blocking wait causes the most

Mental model. Waking a sleeping baby every ten minutes to ask if they are hungry. Each question is tiny; the sleep is what you ruined.

A processor in a deep idle state saves most of its power, and waking it costs a fixed amount of energy plus the time it needs to stay awake and fall back asleep, whether the work is ten microseconds or ten milliseconds. So a timer that fires every 10 ms with trivial work can cost more than a one-off task that runs for a second. You can count the process's context switches with task_info(TASK_EVENTS_INFO) (the csw field). In the check, a thread that polled every 10 ms for one second produced about 80 context switches (asserted: at least 50); the same second spent in one blocking wait (a semaphore with a timeout) produced about one (asserted: under 10); and a 250 ms poll produced a handful (asserted: under 20). The relationship is what matters: roughly one switch per tick.

The fixes follow. Replace polling with an event: a delegate callback, a notification, a DispatchSource or file-descriptor readiness, an AsyncStream, a push. When you truly need a repeating timer, use the longest interval that works and set tolerance so the system can coalesce your wake-up with others (Q437). Stop timers when the UI is not visible, and do not hold a display link when nothing animates. In Instruments, the Energy Log and CPU Counters show the same effect on a device, and a long idle test with the Debug navigator’s energy gauge is a good regression check (Q438). Not asserted: actual power draw or battery percentage, which needs a device and external measurement (powermetrics needs administrator rights and was not run); the check measures context switches on macOS only, as a proxy.

Each tick of a poll is a wake-up; a blocking wait is one.

Common traps

  • Judging a timer by the work in its callback. The wake-up itself is the cost.
  • A repeating timer that never stops. It keeps the app waking while off-screen.

They might follow up with

  • Does tolerance really help? It lets the system batch wake-ups across apps; the effect is on power, which this check does not measure.
  • How do I wait without a thread? Structured concurrency suspends (try await Task.sleep) and frees the thread, though the timer still fires.

Related questions

  • 21.6.1 Where does an app’s energy go, and how do timers, networking, location and animation change the battery bill?
  • 21.6.2 How should an app respond to Low Power Mode and thermal pressure, and how do background work and the tooling…
  • 21.2.1 Which Instruments templates do you reach for, how do you read a Time Profiler trace, and where can a sampling…