A race condition is a bug where the result depends on the timing of operations you don’t control: which thread the scheduler runs next, which request reaches the database first, which process creates a file a millisecond earlier. The code is “correct” for some interleavings and wrong for others, so it passes tests on your laptop and fails under production load. Interviewers want you to spot the two shapes these bugs usually take and to know what makes a state change genuinely atomic.
Lost updates in four lines of Node
The classic shape is read-modify-write. x++ looks like one step, but the CPU does three: load the value into a register, add one, store it back. If two threads interleave those steps, one increment disappears:
thread A: load 5
thread B: load 5
thread A: store 6
thread B: store 6 <- A's update is lostTo see it happen, here are four Node worker threads, each incrementing the same Int32Array slot in a SharedArrayBuffer a million times, once with ++ and once with Atomics.add:
import { Worker, isMainThread, workerData } from 'node:worker_threads';
const WORKERS = 4, N = 1_000_000;
if (isMainThread) {
for (const mode of ['plain', 'atomic']) {
const shared = new Int32Array(new SharedArrayBuffer(4));
const t0 = performance.now();
await Promise.all(Array.from({ length: WORKERS }, () =>
new Promise((done, fail) => new Worker(new URL(import.meta.url), { workerData: { shared, mode, N } })
.on('exit', done).on('error', fail))));
const ms = (performance.now() - t0).toFixed(0);
console.log(`${mode.padEnd(6)} expected ${WORKERS * N}, got ${shared[0]} (${ms} ms)`);
}
} else {
const { shared, mode, N } = workerData;
if (mode === 'plain') for (let i = 0; i < N; i++) shared[0]++; // load, add, store
else for (let i = 0; i < N; i++) Atomics.add(shared, 0, 1); // one indivisible step
}Three runs on Node 22 on an 8-core M1:
plain expected 4000000, got 1747418 (54 ms)
atomic expected 4000000, got 4000000 (117 ms)
plain expected 4000000, got 1171773 (29 ms)
atomic expected 4000000, got 4000000 (115 ms)
plain expected 4000000, got 1214239 (27 ms)
atomic expected 4000000, got 4000000 (110 ms)Between 56% and 71% of the increments vanished, and the number changes every run, which is the signature of a race. Atomics.add performs the load, add and store as one hardware operation that no other core can split, so it’s always exactly right. It’s also roughly two to four times slower here, because every core is fighting over one cache line. That’s the real price of shared mutable state, and it’s why the fastest fix is often to give each worker its own counter and add them up at the end.
Why the Python version passed, and why that proves nothing
Run the same experiment with Python threads and counter += 1, and on CPython 3.14 with the GIL you’ll get 4,000,000 every time. The bytecode is still three steps:
LOAD_GLOBAL 0 (counter)
LOAD_SMALL_INT 1
BINARY_OP 13 (+=)
STORE_GLOBAL 0 (counter)It passes because modern CPython only hands the GIL to another thread at certain points, such as function calls and loop back-edges, and none of those falls between this load and store. That’s an implementation detail, not a guarantee. Here’s a slightly more realistic deposit method, with a validation call between reading and writing the balance:
def validate(amount):
return amount if amount > 0 else 0
class Account:
def __init__(self): self.balance = 0
def deposit_inline(self, amount):
current = self.balance # read
self.balance = current + amount # compute and write
def deposit_checked(self, amount):
current = self.balance # read
new = current + validate(amount) # a Python call sits between read and write
self.balance = new # writeFour threads, a million deposits each:
inline read/write expected 4000000, got 4000000
call between read/write expected 4000000, got 2501264
call between read/write expected 4000000, got 2753282
same, under a Lock expected 4000000, got 4000000Adding one function call lost over a million deposits. The free-threaded build of Python, a future interpreter change, or someone adding a log line could do the same to the “inline” version. If an interviewer asks “is += thread-safe in Python?”, the answer is no; it merely happens to be hard to break today.
Making the transition atomic
The underlying rule: if a state change involves reading something and then writing based on what you read, the read and the write must happen as one indivisible step, or under the same lock for everyone who touches that state. Making each read and each write atomic on its own isn’t enough. You have four broad tools:
- Hardware atomics for single words:
fetch_add, compare-and-swap (Atomics.compareExchange, Java’sAtomicLong, C11atomic_fetch_add). Fast and lock-free, but only for one memory location at a time. - A lock around the whole sequence, when the invariant spans several fields, such as moving money between two balances. Every reader and writer of those fields must take the same lock.
- Confinement. Let one thread or actor own the state and send it messages. No sharing, no race.
- Push it to the database.
UPDATE accounts SET balance = balance + 100 WHERE id = 7is one atomic statement; reading the balance into your application and writing back a new number is not.
A common wrong answer is volatile. In Java, volatile makes writes visible to other threads but count++ on a volatile field is still three steps. In C and C++, volatile has nothing to do with threads at all. Another is “it’s a single-core machine, so it can’t race”. Preemption can stop a thread between the load and the store just as well as a second core can.
Check-then-act
The second shape is checking a condition and then acting on it, when the condition can change in between. This toy leader election has three threads create a lock file only if it doesn’t exist yet:
def check_then_create(name, out):
if not os.path.exists(path): # check
time.sleep(0.01) # widen the gap a scheduler might create anyway
with open(path, "w") as f: # act
f.write(name)
out.append(f"{name} thinks it is leader")
def atomic_create(name, out):
try:
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) # check and act in one syscall
os.write(fd, name.encode()); os.close(fd)
out.append(f"{name} is leader")
except FileExistsError:
out.append(f"{name}: FileExistsError, someone else is leader")check_then_create ['w1 thinks it is leader', 'w0 thinks it is leader', 'w2 thinks it is leader'] file says w2
atomic_create ['w2: FileExistsError, someone else is leader', 'w1: FileExistsError, someone else is leader', 'w0 is leader'] file says w0With the separate check, all three threads believed they were leader. With O_CREAT | O_EXCL, the kernel does the existence check and the creation as one operation, so exactly one succeeds. The sleep only widens a gap that’s always there. The security version of this bug is called TOCTOU (time of check to time of use): a privileged program calls access() on a path and then open()s it, and an attacker swaps in a symlink between the two calls.
Because races depend on timing, ordinary tests rarely catch them. ThreadSanitizer (-fsanitize=thread in clang and gcc) and Go’s go test -race detect unsynchronised accesses while the program runs; stress tests with many threads and iterations raise the odds; and code review should ask “who else can touch this between my read and my write?”
Practice question: two bookings for the last seat
A ticketing service handles a booking like this, in a transaction at READ COMMITTED isolation, on several app servers:
SELECT available FROM seats WHERE event_id = 42; -- returns 1
-- application checks available > 0
UPDATE seats SET available = 0 WHERE event_id = 42;
INSERT INTO bookings (event_id, user_id) VALUES (42, :user);Two users click “book” at the same moment for the last seat, and both get confirmations. Explain the race and fix it.
Answer
It’s check-then-act across processes. Both transactions run the SELECT before either has updated the row, both see available = 1, both pass the check in application code, and both write. READ COMMITTED doesn’t stop this, since each transaction only ever reads committed data; the problem is that the decision was made on data that changed before the write. A mutex in the application won’t help either, because the two requests may be on different servers.
The cleanest fix makes the check and the change one atomic statement and inspects the result:
UPDATE seats SET available = available - 1
WHERE event_id = 42 AND available > 0;
-- rows affected = 1: seat is yours, insert the booking
-- rows affected = 0: sold out, roll backThe database locks the row for the update, so the second transaction re-evaluates available > 0 after the first commits and matches zero rows. Alternatives are SELECT ... FOR UPDATE to lock the row before checking, or SERIALIZABLE isolation, where the database aborts one of the conflicting transactions and the application must retry it. A unique constraint on the booking’s seat number is a good safety net in any design, because it turns a missed race into an error instead of a double booking.
Related concurrency notes
- Operating Systems interview questions
- Operating Systems MCQs
- Processes Versus Threads and Shared State
- Operating Systems: Thread Pools
- OSTEP, Concurrency: An Introduction, which walks through the same lost-update trace in assembly