Mobile Computing · Background Execution
Wake Locks, Reference Counting, and the Cost of a Held CPU
Why a wake lock is a counted system resource rather than a flag, what a partial lock actually keeps powered, and how an unbalanced acquire turns into a battery complaint.
A phone suspends by default. When no thread needs the CPU, no window is being drawn, and no radio session is active, the kernel enters a system-wide sleep state in which the application processor stops executing and most peripherals lose power. Everything else in this cluster is an argument with that default: the freezer argues per-process, doze argues per-device, and a wake lock argues directly against the suspend itself.
The argument is won by counting. A wake lock is not a boolean; it is a reference count on the system’s decision to stay awake, and the system suspends only when the count reaches zero.
Counting, not setting
The reason for counting is that several unrelated pieces of work can legitimately need the CPU at the same time. If the mechanism were a flag, the second piece of work to acquire it would find it already set and would be unable to tell whether the first piece had released it. A count gives each holder a private claim: acquire increments, release decrements, and the suspend decision is a comparison against zero.
The consequence is that an imbalance is not immediately visible. An extra acquire leaves the count permanently above zero, the device never suspends, and nothing in the system reports an error. The symptom appears later and elsewhere: the battery drains at idle, the device is warm in a pocket, and the only evidence is a diagnostic listing that shows a lock held by a process that has nothing to do.
On Android the platform exposes this through the power manager. A wake lock is acquired with a level and a timeout, and the reference-counted form requires the caller to pair every acquire with a release. The timeout is the safety net: a lock with a timeout expires on its own, and the platform logs the expiry, which is how an unbalanced acquire is eventually found.
What the levels actually keep powered
The lock’s level names the smallest set of hardware that must stay on for the work to proceed, and getting the level wrong is a common source of either wasted power or broken work.
A partial lock keeps the CPU running and the screen off. It is the level for computation: decoding, parsing, writing to storage, encrypting. It does not keep the screen on, does not keep the radio at full power, and does not prevent the display from turning off.
A screen-dim or screen-bright lock keeps the display on, at reduced or full brightness. These exist for activities that must remain visible — a navigation view, a video player — and they are the ones that most obviously cost power, because a backlit display at full brightness is among the largest consumers in the system.
The distinction matters because the lock levels do not compose upward. Holding a partial lock does not keep the screen on, and holding a screen lock implies the CPU stays awake but says nothing about whether the network will be usable. A download that acquires only a partial lock will keep computing while the radio idles and the transfer stalls.
The radio is not covered by a CPU lock
A wake lock holds the CPU. It says nothing about the modem, and the modem has its own state machine with its own tail timer. A device that holds a partial lock while waiting for a network response keeps the application processor awake and lets the radio drop to its idle state, so the response arrives after a paging delay and the energy spent keeping the CPU on was wasted.
This is the interaction described in the radio resource control model: the radio’s power state is driven by traffic and by an inactivity timer, not by the CPU’s wakefulness. Work that is waiting on the network should not hold a CPU lock at all — it should let the process be suspended and rely on the completion callback to wake it, which is what a socket’s own wakeup mechanism is for.
The general rule is that a lock should be held for computation, not for waiting. Holding it across a wait converts an energy cost that the kernel would have managed into one the app has fixed at its worst value.
Timeouts as the enforcement mechanism
Every acquire should carry a timeout, and the timeout should be an estimate of the longest the work can legitimately take rather than a round number chosen for comfort.
A timeout that is too short produces a partially completed operation: the lock expires, the device suspends, and the write is left half-finished. A timeout that is too long produces the same drain as an unbalanced lock, just with a bounded end. The estimate is possible because the work is known: a file of a given size written at a storage throughput of some hundreds of megabytes per second, a decode of a known number of frames, an encryption pass over a known byte count. All of those produce a duration that can be stated as an upper bound without measuring anything.
The platform’s own diagnostic surface treats an expired lock as an event worth reporting. On Android, the expiry appears in the system log with the lock’s tag, and the tag is the only handle a developer has on which lock leaked. Tags should therefore be unique per acquisition site, not per feature, because a tag shared by five call sites identifies nothing.
val pm = context.getSystemService(PowerManager::class.java)
// Tag identifies the acquisition site, not the feature.
// Timeout bounds the damage if the release path is skipped.
val lock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"sync:flushPendingWrites"
).apply { setReferenceCounted(false) }
lock.acquire(MAX_FLUSH_MILLIS)
try {
flushPendingWrites()
} finally {
if (lock.isHeld) lock.release()
}
Setting the lock as non-reference-counted is deliberate here: with reference counting, a release from a different code path than the acquire silently decrements to a nonzero value and the lock stays held. A non-counted lock makes a double release an error rather than a leak, which is the failure direction worth having.
Kernel-level holders and userspace holders
The count is not only made of app requests. The kernel itself takes wake locks for work that cannot be interrupted: an interrupt handler that has queued work, a filesystem operation in flight, a display pipeline that is mid-frame. These are internal and are released when the work completes, and they are the reason a device with no app holding anything can still be awake for short intervals.
The userspace surface is layered on top. The platform’s power manager aggregates app requests into a single kernel-level claim, so the kernel’s count and the app-visible count are not the same number. A diagnostic that reads only one of them will disagree with the other, and the disagreement is expected rather than a bug.
On kernels with the runtime power management framework, the same counting idea applies per device rather than per system: a device is suspended when its own usage count drops to zero, and a driver that increments without decrementing keeps one peripheral powered while the rest of the system sleeps. The failure is quieter than a system wake lock, because the device still suspends and the battery drain is smaller, but it is the same class of bug.
Why the model survives despite the risk
An alternative design would let an app declare a duration of work and have the system keep the device awake for exactly that interval, with no counting and no release. That is simpler to use and strictly worse in one respect: it removes the ability to overlap. Two pieces of work declared independently would each extend the awake interval, and the second would pay the full cost of waking the device even though the device was already awake.
The counted model lets the second piece of work join the first at zero marginal cost, which is the same batching argument that motivates coalescing. It also lets the system observe the count as a signal: a device that never reaches zero is a device whose background work is unbounded, and that observation feeds the battery diagnostics that surface per-app usage to the user.
The cost of the model is that correctness depends on discipline at every call site. There is no compiler check that a release exists, and the failure is silent until the battery report. That is why the timeout is not optional in practice: it is the only mechanism that converts a discipline failure into a bounded, observable event.
Ways an acquire goes wrong
An acquire without a release is the first. The lock stays held until the process dies, and a process that is kept awake by its own lock does not become a candidate for the freezer, which means it also does not get reclaimed — the state the freezer would otherwise preserve is described in Process Suspension and the Background Execution Grant. The two mechanisms reinforce each other in the wrong direction.
An acquire in a callback that is not guaranteed to run is the second. A lock acquired in a success path and released in a failure path that cannot be reached leaves the count high exactly when the operation failed, which is the case that happens most often on a bad network.
A lock acquired on one thread and released on another is the third. The reference count is atomic and the operations are safe, but the pairing is not verifiable by reading either site, and the timeout becomes the only bound.
A lock held across a user-visible pause is the fourth. Work that is waiting for the user — an authentication prompt, a permission dialog — should not hold the CPU, because the wait is unbounded. The lock should be released before the prompt and reacquired after the answer, which requires the state machine around the operation to be explicit rather than implied by the call stack.
The last is a lock acquired to keep a timer accurate. A timer does not need a wake lock; it needs an alarm, and an alarm wakes the device without holding anything. Using a lock for this purpose keeps the CPU awake for the entire interval between now and the deadline, which is the most expensive possible way to wait for a time. See Exact Alarms, Doze, and the Maintenance Window for the mechanism that actually fits.
Accounting as the design tool
The useful way to think about a lock is as a line item in an energy budget. A partial lock held for one second keeps the application processor out of its deepest state for one second, and the difference between the deepest state and an active core is the cost. That cost is not a constant across devices — the deep-state power depends on the process technology and the number of powered domains — which is why the honest statement is a ratio rather than a figure: holding the CPU awake costs a small multiple of the deepest idle state, and holding the display on costs another order of magnitude above that.
Designing against that budget means asking, for each acquisition, whether the work could instead be done during a window when the device is awake for another reason. If it can, the lock is unnecessary. If it cannot, the lock should be as narrow in level and as short in duration as the work allows, and its timeout should be derived from the work rather than chosen.
References
- Linux kernel documentation — System Sleep States — The Linux Kernel documentation
- Android Developers — Keep the device awake — Android Developers
- Android Developers — PowerManager.WakeLock — Android Developers
- Linux kernel documentation — Runtime Power Management — The Linux Kernel documentation