Race conditions

Identify lost updates and check-then-act races, then choose mutual exclusion or message passing.

In this article

A race condition occurs when the result depends on the timing of interleaved operations. Shared mutable state is the common cause.

Fig 1Two increments can produce only one
STUDY MODEL
Inspect one part at a timeBoth read
  1. Both read

    Thread A and thread B each read counter 0.

  2. Both compute

    Each independently computes 0 + 1 = 1.

  3. Both write

    A writes 1; B writes 1. The final counter is 1, not 2.

  4. 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.

Keep track of your reading.

Your reading progress stays in this browser.