Mobile Computing · Mobile Platforms
Process Launch: From Launcher Tap to First Instruction
How a tap on an icon becomes a running process, why mobile systems fork from a preloaded template instead of spawning fresh, and where a cold start spends its time.
A launcher icon is not a program entry point. The icon is a record in a database owned by another application, and the tap that hits it travels through a system service, a socket, and at least one process boundary before any code belonging to the launched app executes. Understanding that path explains a set of behaviours that otherwise look arbitrary: why the first frame of a cold start is slow, why a preloaded runtime is worth hundreds of megabytes of shared memory, and why an app can appear in the task switcher while its process does not exist.
The request never reaches the app directly
Three processes are involved before the target app exists. The launcher is an ordinary sandboxed app; it holds an Intent describing the component to open and passes it to the activity manager inside system_server through an inter-process call. The activity manager resolves the intent to a concrete component, checks that the caller is allowed to start it, and looks for an existing process for that package. If one exists and is in a usable state, the launch is a message to that process. If none exists, the service has to create one, and it cannot call fork itself: system_server is a large, multi-threaded process holding the system’s state, and duplicating it would duplicate that state.
Instead the activity manager writes the launch parameters to a socket that a small, long-lived process has been listening on since boot. That process is the zygote. It holds a running language runtime, a set of preloaded framework classes, and the resources needed to draw a first frame, but almost no application-specific state. On the other side of the same boundary, Apple’s platforms invert the arrangement: the system’s bootstrap process, launchd, is the only process entitled to create others, and apps are started by a spawn request that names the executable and its environment rather than by duplicating an existing image.
Both designs answer the same constraint. Process creation must be cheap, must produce a process with exactly the right identity and privileges, and must not give the new process any handle on the state of the service that requested it.
Why the zygote forks instead of spawning
fork is attractive because of how the kernel implements it. The child gets a new task structure and a new set of page tables, but the page table entries point at the same physical frames as the parent, marked read-only in both. No bytes are copied. Only when either side writes to a page does the kernel take a protection fault, allocate a private copy, and resume. A process with 200 MB of resident memory can therefore be created for the cost of copying a few hundred page table entries plus the kernel’s bookkeeping.
That economics only pays off if the parent’s memory is worth sharing. The zygote is started once at boot from a small native executable and told to initialise the managed runtime, load a list of framework classes, and preload drawables and locale data. Every app process forked from it afterwards shares those pages. The first app to touch a preloaded class does not fault in a file from storage; the code page is already resident and shared, so the cost is a minor fault and a mapping, not a read from flash. Multiply that by the number of classes a typical framework touch pulls in and the difference between a cold start and a warm one becomes legible: a cold start is largely the cost of the pages the app needs that the template did not already have.
Forking without a subsequent exec has a sharp precondition. The child inherits the parent’s entire address space, including every mutex, every lock word, and every runtime data structure. If the parent had two threads and one of them held a lock at the moment of the fork, the child has a thread that will never release it. The zygote therefore runs a single-threaded control loop, and the runtime suspends its internal daemon threads immediately before the fork and restarts them in the parent afterwards. The child resumes with a coherent heap and continues in the same image, which is the entire point: an exec would discard the preloaded pages and the runtime state that made the fork worthwhile.
Apple’s platforms take the other branch and make the shared state static instead of dynamic. System libraries and frameworks are prelinked into a single shared cache image, built once per OS release, mapped at a fixed address in every process. dyld does not parse hundreds of Mach-O files at launch; it maps the cache and fixes up bindings, and the launch closure for a given app can be computed ahead of time and cached. The effect is the same as preloading, achieved by preparing the shared image at build time rather than by forking a live one. posix_spawn is then safe to use from a multi-threaded system daemon, which is why the spawn model survives there.
What the child does before the first line of app code
The forked child is not yet the app. It has the right address space but the wrong identity, the wrong working directory, and no mapping of the app’s own code. The sequence that turns it into an app is fixed, and each step matters for the sandbox described in Per-App UID Isolation and the Data Directory Contract.
- Change identity to the application’s numeric user and group IDs and install the supplementary group list that encodes its granted permissions.
- Enter a private mount namespace, so that paths presented to the app, particularly external storage, can be arranged per app without affecting any other process.
- Install the process’s resource limits and scheduling class, inherited from the template with adjustments for the process’s expected importance.
- Load the application code, constructing a class loader rooted at the app’s installed package so that the app’s own classes resolve from its file and everything else resolves to the shared preloaded set.
- Attach back to the activity manager, identifying which launch this process belongs to.
The identity change in the first step has an order that is not negotiable. The group list must be set before the user identity is dropped, because changing the group list requires the privilege the process is about to give up.
/* Groups first: setgroups needs the credentials we are about to discard. */
if (setgroups(ngroups, groups) != 0) _exit(1);
if (setgid(app_gid) != 0) _exit(1);
if (setuid(app_uid) != 0) _exit(1);
/* Irreversible: the saved set-user-ID now names the app, not the parent. */
prctl(PR_SET_DUMPABLE, 0, 0, 0, 0);
The _exit calls are deliberate. Returning from a failed privilege drop would continue into app code with the parent’s identity, so the only safe response is to end the process before any of that code runs. The dumpable flag is cleared last because the kernel resets it on a credential change, and a process that can be traced by another app’s debugger has lost the isolation the identity change was for.
The last step is the one that closes the loop. The activity manager does not block a binder thread waiting for it; it records the pending launch and returns, and the app’s own message wakes the service later. A process that dies, or fails to attach within the window the service allows, is recorded as a failed launch and the request is dropped rather than retried indefinitely.
Where the time actually goes
A cold start is usually attributed to “loading the app”, which is misleading. Three costs dominate, and they have different shapes.
Page faults come first. Mapping the app’s executable and its data does not read them; the first instruction fetch and the first data access do. The kernel’s readahead helps for sequential code but not for the scattered access pattern of a class loader resolving symbols.
Class and symbol resolution comes second. The runtime must verify, link, and initialise the classes the app touches before their static initialisers run. This work is proportional to the transitive closure of what the entry point references, which is why a large dependency graph hurts startup far more than a large binary does.
Then there is the window between the process starting and the first frame being drawn, during which the system is showing something else, usually the launcher’s snapshot of the app’s last state. The platform measures this interval because it is what the user perceives; the process may well have started hundreds of milliseconds earlier.
None of this is improved by preloading more into the template beyond a point. Shared pages that no app touches are wasted physical memory, and the resident set of the template is charged to every process that forks from it, which raises the pressure that the reclaim logic described in Memory Pressure, Reclaim Order, and the Kill Decision has to resolve.
Degenerate cases
A launch can complete while the app is already running, in which case no fork happens at all and the existing process receives a message. That path skips every cost above and is why warm starts are not simply faster cold starts; they are a different mechanism.
A launch can also target a component in a process that is being killed. The activity manager serialises the two operations, but a process that has already passed the point of no return cannot accept the message, and the launch is reissued. Repeated attempts against a package that crashes on startup are rate-limited by the service rather than retried at the caller’s pace.
Finally, the fork model constrains how the platform can be upgraded. Because preloaded state is baked into a running process, a framework update requires the zygote and every process forked from it to be replaced, which is why system updates restart the runtime rather than patching it in place. The same constraint appears at the app level in Code Signing and Entitlement Enforcement at Load Time: the identity of the code is established before it executes, and changing it afterwards is not something the platform supports.
References
- fork(2) — Linux manual page — Linux man-pages project
- execve(2) — Linux manual page — Linux man-pages project
- Android Developers — App startup time — Android Developers
- Apple Platform Security — Runtime protections and code loading — Apple Inc.