Mobile Computing · Background Execution
Timer Coalescing and the Deferrable Work Budget
How a kernel batches timer expiries, how slack and tolerance let a deadline drift, and why a wakeup that can wait is worth more than one that cannot.
A timer that fires on time is expensive in a way its own code does not show. The cost is paid by the hardware: the CPU must leave a low-power state, the interrupt controller must deliver, the scheduler must run, and then the whole thing unwinds and the core goes back to sleep. On a device that spends most of its life idle, the number of these transitions, not the amount of work inside them, is the dominant term in the energy budget.
Coalescing is the response. It exploits a fact about deadlines in general: most of them are upper bounds rather than appointments. A cache that should be refreshed within the hour does not need to be refreshed at a particular millisecond, and a retry that should happen after a minute gains nothing from happening at 60.000 seconds instead of 61.4. Given that freedom, the kernel and the platform can move expiries so they land together, and one wakeup can serve several timers.
One wakeup, many expiries
The mechanism at the bottom is a per-CPU queue of timers, ordered by expiry. A naive implementation wakes the core for the earliest deadline, runs its callback, and goes back to sleep until the next one. If ten timers are due within a two-millisecond window, that is up to ten wakeups.
Coalescing changes the rule for when the core wakes. Instead of waking at the earliest expiry, it wakes at the earliest expiry that cannot be postponed, and when it does, it runs every timer whose deadline has arrived or whose postponement budget allows it to be run early relative to the wake time. The queue is still ordered; what changes is the trigger condition and the set of timers considered due at that trigger.
The trade is direct and measurable in one direction only: latency of the individual timer against the count of wakeups. Moving a timer later by a bounded amount cannot break a correctness property that was expressed as “no earlier than”, and can break one expressed as “no later than”. That asymmetry is why the postponement budget is always one-sided in the direction of later, and why the API surface for it is called tolerance or slack rather than jitter.
Slack, tolerance, and leeway
Linux exposes the postponement budget as timer slack. Every task has a slack value, defaulting to 50 microseconds, that the kernel adds to the task’s relative timers: a timer set for 100 milliseconds may expire anywhere from 100 milliseconds to 100 milliseconds plus the slack. The value is inherited by new threads and can be read or changed with prctl.
Fifty microseconds is small because the default must not surprise code that measures its own latency. An application that knows better can raise it substantially. A background sync that runs every fifteen minutes can tolerate seconds of slack, and doing so lets the kernel align it with other work that is already scheduled.
Apple’s platforms use the same idea under a different name. A Timer has a tolerance property, and dispatch timer sources take a leeway parameter; both default to zero, meaning the timer is delivered as close to its deadline as the system can manage. A non-zero value grants the system permission to defer. For a timer whose purpose is to refresh a screen element, zero is correct. For a timer that polls a mailbox, a tolerance measured in seconds is free.
The pattern that matters is that slack is a property of the timer’s purpose and almost never of its implementation. Code that copies a fifteen-minute interval from a specification and leaves the tolerance at zero has thrown away the entire budget the system was designed to spend.
Deadline versus interval
Two kinds of timer coexist in the same API and behave differently under coalescing.
A one-shot timer has a single absolute or relative deadline. Its slack applies once, and the resulting expiry is a point the callback can trust as an upper bound.
A repeating timer has an interval and a phase. If each fire is scheduled relative to the previous fire’s actual time, error accumulates: a timer with a 60-second interval and a 3-second tolerance drifts by up to 3 seconds per period, and after an hour the phase has moved by minutes. If each fire is computed from the original start time plus a multiple of the interval, the phase is preserved and only the individual fire drifts.
/* Phase-preserving periodic timer on Linux.
Each deadline is derived from the base, not from the previous fire,
so slack cannot accumulate into drift. */
struct itimerspec next_fire(struct timespec base, long period_ns, int tick)
{
struct itimerspec spec = {0};
long long offset = (long long)period_ns * (tick + 1);
spec.it_value.tv_sec = base.tv_sec + offset / 1000000000LL;
spec.it_value.tv_nsec = base.tv_nsec + offset % 1000000000LL;
if (spec.it_value.tv_nsec >= 1000000000L) {
spec.it_value.tv_nsec -= 1000000000L;
spec.it_value.tv_sec += 1;
}
return spec;
}
Wall-clock schedules need a third treatment. A task that must run at 03:00 local time is not periodic in the monotonic sense at all: it is a sequence of absolute local times, and the correct next deadline is computed from the calendar each time, so that a daylight-saving transition shifts it by an hour rather than silently running at 02:00 or 04:00.
What the platform adds on top
Above the kernel, the platform batches at a coarser grain. Android’s alarm facility distinguishes exact alarms from inexact ones; an inexact alarm is a request that the system deliver within a window of its choosing, and the system is free to move it to a moment when the device is already waking. Under doze, the platform defers alarms and network access and delivers them in maintenance windows, whose spacing widens the longer the device stays still. The widening is the mechanism: a device that has been stationary and unplugged for a long time is a device whose background work has been repeatedly demonstrated not to be urgent.
Apple’s platforms do not offer a comparable user-space alarm API. Background refresh is requested as an earliest-begin date, and the system decides when to grant a launch based on usage patterns, battery, and thermal state. The lesson carries over: the requested time is a floor, not a promise, and code that computes an interval from the moment it asked rather than the moment it ran gets the interval wrong.
Both systems share a rule that is easy to miss when reading the API. A grant is consumed when the app is launched, not when the app finishes. An app that is launched for background refresh and then requests another refresh during that launch is not extending its budget; it is asking for a future grant that may or may not arrive.
The budget is per wakeup, not per unit of work
Once a process is running, the marginal cost of additional work inside that run is small. The fixed cost — leaving idle, restoring state, reacquainting the radio — was already paid. This is why batching is more valuable than shortening: ten jobs run in one launch cost far less than ten launches, even if the ten jobs together take longer than any single one of them would.
The consequence for design is that deferred work should be grouped by trigger rather than by feature. Five features that each schedule a fifteen-minute refresh produce five wakeups; one scheduler that owns the fifteen-minute cadence and fans out to the five features produces one. The grouping has to happen above the platform, because the platform sees five independent requests and cannot know they share a purpose — the same reason a per-feature alarm is the wrong instrument for periodic work, as Exact Alarms, Doze, and the Maintenance Window explains.
There is a failure mode on the other side of this. Batching converts a set of small, independently retryable operations into one large one, and a failure part-way through the batch leaves the remainder unattempted. The batch needs its own progress record — which items succeeded — so that a retry resumes rather than restarts. Without that, the energy saved is paid back in duplicated network calls.
When coalescing breaks the caller
Three failure patterns recur.
The first is a timeout that was implemented with a timer. A network request wrapped in a timer with generous slack can be reported as timed out after the response has arrived, because the timer’s expiry was deferred past the completion. Timeouts belong on the socket, not on a user-space timer with a tolerance.
The second is an animation or polling loop whose interval was set to match a frame rate. Coalescing has no way to distinguish a timer that must run at 60 Hz from one that merely happens to be set to 16 milliseconds, so the API’s default of zero tolerance is what protects it. Setting a non-zero tolerance on a timer that drives visible motion produces stutter that is intermittent and hard to attribute.
The third is a countdown that assumes monotonic delivery. A repeating timer that fires every second and decrements a counter is not a countdown; it is a count of deliveries. After a device sleep or a long freeze, the count is wrong, and the display shows a duration that never elapsed. Elapsed time comes from a clock, and the counter comes from the difference between two clock readings, which is the clock discipline described in Process Suspension and the Background Execution Grant.
Measuring the effect without measuring the machine
The effect of coalescing is visible in a quantity the kernel already tracks: the number of times the CPU left and re-entered idle. On Linux this appears in the idle and frequency statistics exposed by the kernel’s cpuidle framework, and the ratio of wakeups to elapsed time is the figure that matters. Halving that ratio while keeping the same work done is the entire point.
It is also the figure that is hardest to obtain honestly on a phone, because the platform’s own background policy moves the goalposts: a build that wakes less often may simply be receiving fewer grants. The reliable comparison holds the grant count fixed and varies only the tolerance, and even then it measures the effect on one device with one radio, which is why published energy figures for background work rarely transfer between devices.
References
- Linux Programmer's Manual — prctl(2) — Linux man-pages project
- Linux Programmer's Manual — timerfd_create(2) — Linux man-pages project
- Apple Developer Documentation — Timer — Apple Inc.
- Linux kernel documentation — Timers and time management — The Linux Kernel documentation