A race condition occurs when the result depends on the timing of interleaved operations. Shared mutable state is the common cause.
- Both read
Thread A and thread B each read counter 0.
- Both compute
Each independently computes 0 + 1 = 1.
- Both write
A writes 1; B writes 1. The final counter is 1, not 2.
- Protect the operation
Use an appropriate lock, atomic operation, or ownership strategy to prevent the lost update.
A read-modify-write operation must be synchronized as a whole.
How to read this model
Two independent read carriages sharing a single counter register. The object is a teaching analogy. Inspecting a part highlights its role; it does not execute or simulate the software. Cabinet trays are records, workpieces are data, and latches are checks. Unless an explanation states a quantity, compartments and sample workpieces do not denote data counts. Part dimensions are not a quantitative scale.
The lost update
An increment has three steps: read, add one, and write. Two tasks can interleave like this:
counter = 0
task A: read 0 -> calculate 1 --------> write 1
task B: read 0 -> calculate 1 -> write 1
expected: 2, actual: 1
The same race can happen in single-threaded async code. await creates an interleaving point even when every callback runs on one OS thread.
if (balance >= amount) {
await recordPayment();
balance -= amount;
}
Two calls can both read 100, both pass the check, and both deduct after the await. The check and the act were not one atomic operation.
Protect a critical section with a mutex
Use a lock when several threads or goroutines must access the same mutable state. The lock gives mutual exclusion, so one task performs the read-check-write sequence while other tasks wait.
lock
check balance
record deduction
unlock
Keep the critical section small. Locks prevent races but can introduce deadlocks when two tasks acquire locks in opposite order, and contention can reduce throughput.
Send messages to one owner
Channels avoid direct shared mutation. One goroutine owns the state, and other goroutines send it commands:
workers -> channel: Deduct(amount)
owner -> receive command -> check and update balance
The owner serializes updates. This makes ownership explicit, but the channel still needs a capacity, shutdown rule, and error policy.
The core check is simple. Find every read-modify-write or check-then-act sequence that crosses a context switch or await. Make it atomic with a mutex or database transaction, or give one owner the state and communicate through messages. Keep cancellation and failure behavior part of the design.
See transactions in services when the shared state is persisted, and goroutines for the Go scheduling model.