Before you start#
Required earlier lessons: C/C++ for source readers, Memory and system calls, and Android’s security model. No kernel knowledge is required. This lesson begins with ordinary user-space threads and objects; its final section is a clearly optional bridge to kernel contexts.
Learning goals#
By the end, you can explain a race, why shared state needs a synchronization rule, what a mutex protects, how waiting differs from spinning, why an object's lifetime must outlast every user of it, and how cancellation/joining prevents callbacks from using freed state. You should also be able to explain why these ideas matter more—not less—in a kernel driver.
Practice#
Start with two ordinary threads
- Shared totalThread A
A reads 0
- Shared totalThread B
B also reads 0
- Thread AShared total
A writes 0 + 1 → 1
- Thread BShared total
B writes 0 + 1 → 1; update lost
Time runs downward. Dashed arrows show returns.
Read the text version
- Shared total → Thread A: A reads 0
- Shared total → Thread B: B also reads 0
- Thread A → Shared total: A writes 0 + 1 → 1
- Thread B → Shared total: B writes 0 + 1 → 1; update lost
A thread is one sequence of instructions executing inside a process. Threads in a process usually share the process's memory. A race occurs when correctness depends on which of two overlapping operations happens first. Here is a deliberately unsafe C-like example:
int total = 0;
void add_one(void) {
total = total + 1;
}
If two threads call add_one, one possible interleaving is that each reads total == 0, computes 1, and writes 1; the observed result could be 1 even though two increments were requested. The source line looks like one operation, but the machine may perform a load, addition, and store. More importantly, in C and C++ an unsynchronized conflicting access to a non-atomic object is a data race, which makes the program's behavior undefined by the language C11 committee draft, §5.1.2.4 · C++23 working draft, data races; the lost-update interleaving is an illustration, not a promise about the only possible result. A schedule that happens not to overlap does not prove the program is safe; it only means the race did not show up in that run.
The result is not fixed by declaring the variable volatile. In C/C++, volatile qualifies objects that may be modified in ways the implementation cannot predict; atomicity and mutual exclusion come from atomic types or locks, not from volatile C11 committee draft, §6.7.3 · C++23 working draft, cv-qualifiers. Nor does “it worked on my phone” settle the matter: a different CPU, compiler, optimization, or timing can expose the same bug.
A mutex gives shared state a rule
A mutex (mutual-exclusion lock) lets one thread at a time enter a protected region. Both the read and write belong inside the same lock:
pthread_mutex_lock(&lock);
total = total + 1;
pthread_mutex_unlock(&lock);
The rule is not “put a lock near the variable”; it is “every access that must be coordinated follows the same lock protocol.” Successful unlock/lock operations on the same mutex also synchronize memory, so changes made before one thread unlocks are visible after another thread successfully locks it. POSIX mutex and memory-synchronization requirements A lock around only the write, or different locks around reads and writes, does not protect the shared invariant.
A mutex may make a thread wait while another holds the lock. Waiting yields execution so the thread can run later; it does not burn a CPU continuously. By contrast, spinning repeatedly checks whether a condition changed. Spinning can be useful for very short waits in constrained contexts, but it wastes CPU if used for long work. In ordinary application code prefer a well-understood mutex or condition variable rather than inventing a spin loop. The C++ standard library's std::mutex and condition variables, or POSIX pthreads, provide these primitives; their exact APIs differ, but the shared-state reasoning is the same.
Waiting for a condition
Sometimes a thread needs to wait until another thread has produced work. A condition variable lets it sleep until notified, while a mutex protects the condition being tested. The standard pattern is conceptually:
lock mutex
while queue is empty:
wait on condition variable (atomically releases lock while asleep)
take item from queue
unlock mutex
The loop matters. A notification is a hint to check the condition again, not a stored promise that a queue item exists. Another thread may consume the item first, or a wake-up may happen without the exact condition being true. Always test the condition while holding the mutex. POSIX condition waits can return spuriously; C++ provides predicate overloads to recheck the condition (C++23 draft N4950, condition_variable::wait with Predicate). POSIX condition-variable requirements
Here is the race as an explicit interleaving. Each row is one indivisible thought step, not necessarily one machine instruction:
| Time | Thread A | Thread B | total |
|---|---|---|---|
| 1 | reads total | 0 | |
| 2 | reads total | 0 | |
| 3 | calculates 0 + 1 | calculates 0 + 1 | 0 |
| 4 | writes 1 | 1 | |
| 5 | writes 1 | 1 |
This table illustrates one lost-update schedule; because the unsynchronized C/C++ program has undefined behavior, it does not predict every compiler or machine outcome. A correct mutex protocol prevents this interleaving by making each full read/modify/write critical section exclusive. If a value is used to protect a larger invariant—such as “queue length equals number of allocated entries”—protecting just one integer operation may still leave the overall invariant racy. Identify the invariant first, then decide which operations must be coordinated.
Lifetime: a pointer is not ownership
An object has a lifetime: the interval from when it is created until its storage is released. A pointer is only an address; it does not automatically keep an object alive. Consider a worker callback that stores a pointer to a request object. If the caller frees the request while the worker can still run, the callback may read memory now reused for something else. This is a use-after-free. It can cause crashes, corrupted data, or a security vulnerability.
Safe designs make ownership explicit. Common user-space strategies include:
- The creator owns the object and waits for/join()s every worker before freeing it.
- A reference count keeps shared data alive until the last legitimate user releases it.
- A scoped owner (such as C++ RAII) arranges cleanup, while still ensuring asynchronous users have stopped before the scope ends.
- A queue transfers ownership so exactly one side is responsible for release.
None is magic: a reference count must itself be synchronized; a join must happen on the correct worker; a destructor cannot safely free state while a callback still holds a raw pointer. The design question is always: who can access this object, and what event proves all such access has ended?
Cancellation is not the same as completion
- Stop accepting new work
Prevent new references and callbacks from entering.
- Request cancellation
A request may arrive while an existing callback is running.
- Wait for active users to finish
Join, flush, or the documented ownership protocol must establish completion.
- Release state and resources
Only after no pending or active user can access the object.
Asking a task to stop does not prove it has stopped. A cancellation flag can be observed late, or the callback may already be running. A safe teardown often has an ordered sequence:
stop accepting new work
request cancellation / prevent new callbacks
wait for active callback or worker to finish
release resources and free callback state
If cleanup frees state before the wait, an already-running callback can dereference dead memory. If cleanup waits while holding a lock the callback needs, both can deadlock: cleanup waits for callback; callback waits for cleanup's lock. These are temporal ordering bugs, not merely “bad pointers.”
Do not confuse visibility with lifetime. A lock or atomic variable can make a stop flag visible to a worker, but it does not necessarily wait for the worker to observe it. A reference count can keep storage alive, but it does not by itself stop new operations or make the object's fields consistent. A join/cancel-and-wait answers the completion question; a mutex/atomic answers a coordination question. Many real designs need both, with a documented order.
Safe paper exercise: catch the lifetime bug
Read this timeline; do not compile or run it:
Thread A: allocate state S ─ queue callback(S) ─ free(S)
Thread B: callback begins ─ read S
Write three lines in a note:
- Name the bug: Thread B may use freed state (use-after-free).
- Name a repair: cancel/flush and wait for the callback before freeing S, or use an ownership/reference scheme that keeps S alive through callback completion.
- Name a test artifact: a before/after timeline showing that “stop requested” precedes “callback complete,” which precedes “free.”
Expected result: simply adding a mutex around free(S) is not enough unless the callback uses that same synchronization protocol and teardown waits until it can no longer run. If the proposed fix cannot identify who waits for completion, revise it.
Optional bridge: kernel execution contexts
The shared-state and lifetime concepts carry into kernel code, but kernel code has extra execution contexts and stricter rules. A process context is code running on behalf of a task where sleeping may be allowed; an interrupt handler can run asynchronously in a context where sleeping is not allowed. A workqueue defers work to a kernel worker thread (Linux 6.1 workqueue docs). An interrupt is a hardware notification, not an ordinary function call from an app.
Do not translate “mutex” mechanically into “use this lock everywhere.” A kernel lock must match who can access the data and whether the code may sleep. Spinlocks protect very short critical sections that cannot sleep; mutexes are sleeping locks and therefore are not legal in every context (Linux 6.1 mutex design; spinlocks). Those rules are kernel-specific and require source/configuration documentation for the target. This is an orientation only; the later driver chapter introduces the specific lifecycle examples.
One important Android example: a driver may schedule delayed work after a hardware event. During device removal, it must prevent new work and synchronously cancel or flush pending/running work before the data it references is released (pinned GPIO keys driver: gpio_keys_quiesce_key calls cancel_delayed_work_sync). Managed resource helpers (devm_*) can release resources associated with a device (Linux 6.1 devres docs), but do not infer that they automatically understand every callback, worker, open file, or reference. The driver must define and enforce those relationships.
This is optional kernel specialization, not a prerequisite for understanding Android apps or Binder. Learners going on to inspect kernel code should retain the invariant—stop future access, wait for current access, then release—and verify the concrete API's contract rather than guessing.
Knowledge check#
Try these before reading the answer key.
- Two threads increment a shared counter and it sometimes ends one short. Why can a single source line still race, and what shared rule fixes it?
- A worker has been asked to cancel. Why is it unsafe to free its state immediately?
- During device removal, a callback may already be running. State the safe ordering for stopping access and freeing state.
- Is a kernel mutex safe inside every interrupt handler? Explain the context issue.
Answers#
- The increment is a read/modify/write sequence; both threads can read the same old value. Put the whole operation under the same mutex or use a correctly chosen atomic operation.
- Cancellation can be pending or the callback can already be executing. Wait for confirmed completion (or another valid ownership guarantee) before freeing state.
- Prevent new work, cancel/flush and wait for existing work, then release resources/free state. Avoid waiting while holding a lock the worker requires.
- No. A mutex may sleep; a hard interrupt handler cannot sleep. Use an appropriate short atomic-context operation or defer work, following the specific kernel API rules.
Checkpoint#
- Ready: you can describe both the shared-state rule and the “no users remain before free” rule in your own words.
- Revise: you can name mutexes but cannot explain which accesses they protect; revisit “A mutex gives shared state a rule.”
- Not yet evidenced: your cancellation fix says only “set a stop flag.” Add the missing completion/wait event to the timeline before proceeding to Binder or kernel driver lifetimes.
Sources#
- C++ Core Guidelines: concurrency and resource management — general user-space guidance; this lesson is conceptual, not a complete C++ tutorial.
- POSIX memory synchronization and condition waits — normative mutex synchronization and condition-wait behavior for the pthread concepts shown above.
- C11 committee draft N1570 and C++23 working draft N4950 — language memory-model/data-race and volatile semantics; drafts, not a claim that these PDFs are final published standards.
- Linux kernel locking documentation — kernel lock types and context constraints; use later for kernel-specific details.
- Linux workqueue documentation — deferred kernel work and cancellation/flush contracts.
- Linux 6.1 mutex design and spinlocks — mutexes are sleeping locks; spinlock context rules. Linux 6.1 devres —
devm_*managed-resource scope. - Linux GPIO keys driver at the pinned ACK commit — optional later example of delayed work and quiescing during managed cleanup.