A lost update in six small steps
Trace two increments that lose an update, define the critical section, and distinguish a teaching interleaving from undefined C++ data races.

A lost update happens when concurrent operations read the same old value and later overwrite each other’s result. If you are learning threads, a counter initialized to zero gives you a small example: two intended increments should produce two, but an unprotected read-modify-write sequence can leave one.
The trace below is an abstract interleaving model. It is not a prediction of what an unsynchronized C++ program must print. In C++, conflicting unsynchronized accesses to an ordinary shared integer can create a data race and undefined behavior. The C++ working draft’s data-race rules define that boundary. A language’s memory model must come before an explanation based only on possible machine-instruction schedules.
The OSTEP locks chapter uses critical sections to explain how a program can make an operation appear indivisible to competing threads. The protected unit is the entire state transition that must preserve the counter’s meaning.
Expand one increment into its steps
In the abstract model, an increment reads the shared counter into a local register, adds one to that local value and writes the result back. Thread A and thread B each own their local register. Their shared counter is the only value they both update.
| Step | Thread A | Thread B | Shared counter |
|---|---|---|---|
| 1 | Reads 0 | Waiting | 0 |
| 2 | Waiting | Reads 0 | 0 |
| 3 | Computes 1 | Waiting | 0 |
| 4 | Waiting | Computes 1 | 0 |
| 5 | Writes 1 | Waiting | 1 |
| 6 | Waiting | Writes 1 | 1 |
Every addition was arithmetically correct. The error comes from both operations basing their result on the same old state. B’s final write carries no evidence that A already contributed an increment. Changing the local arithmetic does not recover the lost contribution.
Compare a serial history. A reads zero and writes one before B reads; B then reads one and writes two. The operation’s specification is that each completed increment contributes exactly one. That specification tells you what synchronization must preserve.
Protect the whole transition
A mutex can prevent another thread from entering the increment’s critical section while one thread owns it. The protected region includes the read, addition and write. Locking only the final assignment leaves both threads free to compute from the same old value before either assignment takes the lock.
This C++ fragment assumes every access to count follows the same mutex discipline and the objects remain alive for all callers:
std::mutex count_mutex;
int count = 0;
void increment() {
std::lock_guard<std::mutex> guard(count_mutex);
++count;
}
The guard also releases the mutex when the scope ends. Resource lifetime still matters: destroying the mutex while another thread can call the function is a separate correctness error. A lock does not supply ownership of the object containing it.
Readers need a compatible rule. A thread that reads count without synchronization can conflict with the protected writer. Protecting every writer while leaving ordinary readers unprotected does not automatically establish a valid C++ program. The synchronization boundary applies to accesses, not just to functions named update.
An atomic operation can express the smaller contract
For a standalone counter, an atomic read-modify-write operation can implement the increment directly. fetch_add adds a value as one atomic operation with respect to the atomic object’s modification order. It avoids the separate load-plus-store race even if the caller chooses a relaxed ordering appropriate to a pure count.
A separate atomic load followed by an atomic store is different. Both accesses can be individually atomic while the combined increment still loses an update. The same abstract trace can occur if both threads load zero before either stores one. You need an atomic read-modify-write or another synchronization protocol for the whole operation.
The memory-order choice answers an additional question: whether the counter is also publishing other memory. A statistics counter used only for its numeric total may not need the ordering required by a flag that announces initialized data. Keep those contracts separate before selecting an ordering mode.
Extend the example without losing the invariant
Suppose the counter is accompanied by a sum, and readers require a matching pair to compute a mean. Incrementing two independent atomics can expose a new count with an old sum. Each field can be race-free while the pair violates the reader’s required snapshot relationship.
A mutex around both updates and the corresponding snapshot read can preserve that relationship. Other designs are possible, but they require a stated invariant and a proof that the reader sees an acceptable pair. Picking atomic types field by field is not enough to specify a multi-field operation.
Testing can force the abstract lost-update schedule by coordinating a model’s read and write steps with barriers. That proves the model has the bad interleaving. A stress test of an actual program can expose a race, but repeated correct totals do not prove the absence of one. Compiler optimization and different hardware can change which outcomes appear.
When reviewing concurrent code, write the sequential operation first. Name the state it reads, the state it changes and the invariant that must hold afterward. Then inspect whether every concurrent access participates in a protocol that preserves that transition. For this counter, the missing unit was one complete increment.
Reproduce the logical race without a C++ data race
A useful experiment must have defined language behavior. This complete C++20 program uses an atomic counter for every shared counter access. A two-participant barrier holds each worker after its load until both workers have loaded. Only then may either store. Compile with a C++20 implementation that provides std::barrier, for example g++ -std=c++20 -pthread demo.cpp -o demo, then run the executable.
#include <atomic>
#include <barrier>
#include <iostream>
#include <thread>
int main() {
std::atomic<int> count{0};
std::barrier both_loaded{2};
auto split_increment = [&] {
int old = count.load(std::memory_order_relaxed);
both_loaded.arrive_and_wait();
count.store(old + 1, std::memory_order_relaxed);
};
std::thread a(split_increment), b(split_increment);
a.join();
b.join();
if (count.load() != 1) return 1;
std::cout << "split load/store: " << count.load() << '\n';
count.store(0);
auto whole_increment = [&] {
count.fetch_add(1, std::memory_order_relaxed);
};
std::thread c(whole_increment), d(whole_increment);
c.join();
d.join();
if (count.load() != 2) return 2;
std::cout << "fetch_add: " << count.load() << '\n';
}
The expected lines are split load/store: 1 and fetch_add: 2. The first result is forced by the protocol, not by a sleep that merely makes a timing window more likely. Both loads finish before either store can begin, so each worker stores one. The barrier’s phase rule is described in the C++ barrier specification. Joining both workers lets the main thread check the completed phase before reusing the counter.
The second phase uses the atomic read-modify-write operation that matches the counter’s sequential specification. The two additions occupy distinct positions in the counter’s modification order. It does not matter which worker gets the earlier position: the final total is two. Relaxed ordering does not weaken the indivisibility of either addition, although it does not supply a general publication protocol for unrelated data.
Changing the first phase’s loads and stores to sequentially consistent ordering would still leave its logical bug. There can be a single global order consisting of load A, load B, store A, store B, and that order still ends at one. Stronger ordering of separate operations is not the same as combining them. This distinction helps when a program has no reported data race but occasionally loses business-level updates.
For a variation, initialize the counter to ten and have each worker add three. Predict thirteen for the split protocol and sixteen for fetch_add, then adapt the checks. Keep the barrier only in the split version: placing it between an unsynchronized ordinary integer load and store would not make every access race-free. The exercise is about operation boundaries, not a workaround for the language’s data-race rules.