programmingconcepts

§ 6.3 · Concurrency

Race conditions

A bug where the result depends on the timing of threads or tasks that share data, so the same code is right on one run and wrong on the next.

A race condition is a bug whose result depends on the timing of threads or tasks that share data. The classic case is two threads adding 1 to a counter: both read the old value before either writes, so one update is lost. Locks, atomic operations or message passing prevent it. Races are hard to test because most orderings work.

Classic symptom
Lost updates
Fix
A lock, or an atomic operation
Hard to test because
Most orders work
Tools
Go -race, ThreadSanitizer
Requires
Threads

Both threads add 1 to counter, which starts at 0. Pick which thread runs its next line.

step 0 / 0ran lastreadwritten

Fig. 6.3. Two threads each add 1 to a shared counter, one line at a time. Choose who runs next with Run A and Run B. The blue line is where each thread will run next; tmp is each thread's own copy.

Intuition

You and a flatmate share a jar for the electricity money. You both count it (£10), each go to the cash machine, each add £5 to your own count, and each write “£15” on the label. Two people added money, but the label says one did. Neither of you did anything wrong on your own; the problem is that you both read the jar before either of you updated it.

That’s a race condition. Whoever writes last “wins,” and the other person’s update is lost.

How it works

counter = counter + 1 looks like one step, but the computer does three:

  1. Read counter into a private copy (tmp = counter).
  2. Add one to the copy (tmp = tmp + 1).
  3. Write the copy back (counter = tmp).

If a second thread reads between the first thread’s read and write, both start from the same old value and one increment is lost. The figure lets you choose exactly when each thread runs, so you can make that happen on purpose.

A lock (also called a mutex) fixes it by making the three lines a critical section: a thread must acquire the lock before reading and release it after writing. While A holds the lock, B waits, so B always reads the value A wrote.

The fix in code

Each program starts several workers that add 1 to a shared counter many times, guarded by a lock. The counter lives on the heap, where every thread can see it. Remove the lock and the total comes out short, by a different amount on each run.

import threading


def count_up(times, workers):
    counter = 0
    lock = threading.Lock()

    def work():
        nonlocal counter
        for _ in range(times):
            with lock:  # only one thread at a time past here
                tmp = counter
                counter = tmp + 1

    threads = [threading.Thread(target=work) for _ in range(workers)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()  # wait for every thread to finish
    return counter

In each language

Language Shared memory and the usual fix
Python Threads share objects. counter += 1 is several bytecode steps, and the GIL can switch threads between them, so use threading.Lock. Free-threaded Python (3.13+) has no GIL at all.
JavaScript One thread runs your code, but every await lets other async tasks run, so read, await, write can lose updates. Web Workers share memory only through SharedArrayBuffer and Atomics.
Go Goroutines share memory; guard it with sync.Mutex, use sync/atomic, or pass values over channels. go test -race finds data races.
Rust The compiler rejects unguarded shared writes, so data races can’t compile. Shared counters go in a Mutex or an AtomicUsize. Logic-level races are still possible.
Java Use synchronized blocks, ReentrantLock, or AtomicInteger.
C A data race is undefined behaviour. Use pthread_mutex_t or C11 <stdatomic.h>.

Race condition or data race?

A data race is two threads touching the same memory at the same time, at least one writing, with no synchronization. Tools like Go’s race detector and ThreadSanitizer find these.

A race condition is any bug that depends on timing. Every data race can cause one, but you can also have a race condition without a data race: lock, read, unlock, lock, write, unlock is free of data races and still loses updates, because the read and the write aren’t in the same critical section.

Common mistakes

  1. Trusting that it passed the tests. Most orders are fine; a race shows up under load, on a different machine, or once a week.
  2. Locking only the write. The read and the write must be inside the same critical section.
  3. Holding a lock while waiting for something slow, such as a network call. Other threads stall behind it.
  4. Taking two locks in different orders in different threads. Each can end up waiting for the other forever: a deadlock.

Check yourself

Check yourself

Both threads read counter = 0, then both write. What does counter end at?

Check yourself

With the lock, A holds it and has read counter. Can B read counter now?

Sources

  • Maurice Herlihy and Nir Shavit, The Art of Multiprocessor Programming, chapter 2, “Mutual Exclusion”.
  • The Go blog, “Introducing the Go Race Detector”.
  • Python documentation, threading.Lock; PEP 703, “Making the Global Interpreter Lock Optional in CPython”.

Newsletter

One new concept each week, by email.

Signup opens soon.