Mobile Computing · Background Execution
Process Suspension and the Background Execution Grant
Why a backgrounded process keeps its memory but loses its CPU, what suspension does to timers and sockets, and how the two clock sources diverge across device sleep.
A backgrounded application looks deceptively alive. Its heap is intact, its file descriptors still refer to open files, its thread stacks sit exactly where they were when the last instruction retired. Nothing about that picture is wrong except the tense: the last instruction retired some time ago and no further instruction will retire until the platform decides otherwise.
Background execution is therefore not a property of a thread, a queue, or a task object. It is a grant, made by a system service to a process, with a term and a set of conditions. Everything else in this cluster — deferred work, timers, push wakeups, transfers, alarms — is a way of asking for that grant or a way of surviving without it.
The process is the scheduling unit
Neither platform schedules individual threads into the background. The unit that carries a state is the process, and every thread inside it inherits that state. Moving work from the main thread to a worker thread, or from a serial queue to a concurrent one, changes which core executes the work but not whether the process is allowed to execute at all.
The state names differ but the shape is the same. On Android, the activity manager assigns each process a rank derived from the components it hosts: a process with the focused activity ranks above one with only a visible activity, which ranks above one hosting a running service, which ranks above a cached process that hosts nothing the user can perceive. On Apple’s platforms the app process moves through not-running, inactive, active, background, and suspended, with suspension following background after a short interval. The rank on Android and the state on iOS are recomputed on events — a window becoming visible, a service starting, a broadcast arriving — not on a periodic poll.
The consequence that matters for scheduling is that a process cannot promote itself. It can host a component that the system ranks highly, and it can ask for a specific extension, but the ranking is computed by the service from observed facts. An app that starts a thread and calls it a background worker has changed nothing that the scheduler consults.
What suspension stops, and what keeps running
Suspension halts the process’s threads. Each thread stops at whatever instruction it reached, which may be inside a system call, inside a memory allocation, or holding a mutex. On Android the cached-app freezer implements this with the cgroup v2 freezer controller: a write to the group’s freeze file moves its tasks into a stopped state, and a write to the opposite file lets them continue. The kernel does not unwind stacks or release locks on the way in, so a process can be frozen with a database transaction open and a row lock held.
The clocks do not stop. A process that is frozen for forty minutes returns to find that forty minutes passed; every wall-clock deadline it computed before suspension is overdue. On resume, the queue drains: a repeating timer may fire once, several times in immediate succession, or be collapsed into a single invocation depending on the platform’s timer implementation and how far behind the queue is; the tolerance a timer was given decides how much of that backlog is even possible, as Timer Coalescing and the Deferrable Work Budget describes. Code that assumes one tick per interval and derives elapsed time by counting ticks will overcount or undercount, and either way it will not match the clock.
Sockets are a subtler case, because the kernel keeps running while the process does not. A peer can deliver bytes into the receive buffer of a suspended process, and the kernel will acknowledge them, right up to the point where the window closes. The connection is not broken by suspension alone. What breaks it is everything around it: a NAT binding that expires on the carrier side, a radio that drops to idle and loses its bearer, an address change when the device moves between networks. The failure is usually not reported while the process is frozen, because reporting requires the process to run. It surfaces on the first read or write after resume, as a reset or a timeout, long after the cause.
No callback of any kind runs while a process is suspended. Network completion handlers, dispatch sources, notification observers, signal handlers installed by the app — all are process-local and all are frozen with it. The only things that can act on the process from outside are the kernel and the system service that owns the freezer.
Two clocks that disagree after sleep
The distinction between process suspension and device sleep is where elapsed-time arithmetic goes wrong. Suspension stops the process; device sleep stops most of the system. Two clock sources treat the second event differently.
On Linux, CLOCK_MONOTONIC advances while the system runs but does not advance across system suspend, while CLOCK_BOOTTIME advances across both. On Apple’s platforms the same split appears as mach_absolute_time, which excludes time the device spent asleep, and mach_continuous_time, which includes it. In both cases the process-frozen interval counts on both clocks, because the rest of the system was awake and the clock was ticking.
#include <time.h>
/* A deadline 30 s out, measured on a clock that ignores device sleep.
If the device sleeps for a minute, this deadline is still 30 s away. */
static void deadline_from_monotonic(struct timespec *out)
{
clock_gettime(CLOCK_MONOTONIC, out);
out->tv_sec += 30;
}
/* The same deadline on the clock that keeps counting while the device
sleeps. Here a minute of sleep consumes a minute of the budget. */
static void deadline_from_boottime(struct timespec *out)
{
clock_gettime(CLOCK_BOOTTIME, out);
out->tv_sec += 30;
}
The choice is not cosmetic. A token refresh interval, a certificate expiry, or a cache entry’s age is a fact about wall time and belongs on the boottime clock. A watchdog that asks whether a computation has been starved of CPU belongs on the monotonic clock, because the computation was not starved while the device slept. Picking the wrong one produces a class of bug that only appears on devices that have been idle for hours, which is exactly the case that testing on a desk never reaches.
Why the grant is rationed
Three designs are available for a process that is no longer visible, and the two platforms have each tried more than one.
The first is to let it run. This is the desktop model, and its cost is that any process can hold the CPU and the radio indefinitely. On a phone the constraint is not fairness but energy: the battery has a fixed capacity, and a wakeup costs more than the instructions it delivers. Waking the application processor from a low-power state has a fixed latency, and a radio transmission is followed by a tail during which the receiver stays powered to hear retransmissions. A workload that wakes the device a hundred times to move a hundred small payloads spends far more energy than one that wakes it once, which is the reason coalescing exists.
The second is to terminate immediately. Android does this under memory pressure, and the low-memory killer chooses victims by rank and by age within a rank. Termination is cheap for the system and expensive for the app: the next launch pays process creation, dynamic linking, and state restoration.
The third is suspension, which is what iOS does routinely and what Android’s freezer does to cached processes. A suspended process costs its resident pages and nothing else. Resume is fast because the heap is still there. The trade is memory against resume latency, and it is resolved in favour of reclaim: the memory reclaim path treats suspended and cached processes as the first source of pages, so the state that suspension preserved is exactly the state most likely to be discarded.
That is the reason the state model has to be treated as a cache rather than as storage. Anything that must survive belongs on disk before the process stops mattering, which is the subject of Durable Deferred Work and the Persistence Boundary.
Three mechanisms that look like suspension but are not
A bounded extension, an ongoing service, and termination are frequently conflated with “the app is still running”, and each behaves differently.
A background task assertion on iOS asks for a finite extension after the app leaves the foreground. The system calls an expiration handler when the time is nearly exhausted, and an app that has not finished by then is terminated rather than suspended. The length of the extension is not a documented constant, so the expiration handler is the only reliable signal; treating the assertion as a fixed budget produces code that works on one OS version and is killed on the next.
An Android foreground service raises the process’s rank and keeps it out of the freezer, but it does not grant unlimited execution. The platform restricts which services may be started from the background, requires a declared type, and holds the app accountable for the notification that accompanies the service. It is a visibility contract, not a CPU contract.
Termination is the absence of all three. The process is gone, no callback announces it, and the only state that exists is what was written down. The reclaim path deliberately does not notify, because notification would require waking the process to receive it.
Breaks at the boundary
The most common defect is arithmetic across a gap. Code that records a timestamp, suspends, and later computes an interval from the same clock gets a number that includes the frozen interval. Whether that is correct depends on what the interval means, and the meaning is rarely written down.
The second is holding a lock across suspension. Apple documents a specific termination code, 0xdead10cc, for apps killed because they held a file lock or a database lock while suspended: the lock is a system-wide resource, and a frozen holder can block a process that is still running. The mitigation is to release locks before the app stops being active, which means the work that needs the lock must fit inside the active window or be restructured.
The third is a burst on resume. A queue that accumulated timer fires, retries, and completion handlers while frozen delivers all of them at once, and each one may touch the network. The result is a spike of exactly the kind of work the coalescing rules were designed to prevent, and it is indistinguishable from a retry storm to anything downstream.
The fourth is assuming a connection survived. The socket object is still valid after resume; the connection it represents may not be. The first write is the test, and the error arrives then, at a point in the code that was not written to handle it.
The last is trusting the state to mean something it does not. A process in a suspended state is not paused in a way that guarantees the same state on return. It is a cache entry that the system may drop at any point after its rank falls, which is why every durable artefact needs a path that does not depend on the process still being there.
References
- Android Developers — Processes and app lifecycle — Android Developers
- Apple Developer Documentation — UIApplication — Apple Inc.
- Linux Programmer's Manual — clock_gettime(2) — Linux man-pages project
- Linux kernel documentation — Control Group v2 — The Linux Kernel documentation