Module 2 · Unit 6 · Lesson 1 of 7
Race Conditions
The central problem of concurrent programming. When two or more threads share memory and at least one of them writes, the result of the computation depends on the order in which the threads happen to run — an order the programmer does not control and the scheduler does not guarantee. This lesson defines the problem precisely, demonstrates it with the classic lost-update example, and explains why race conditions are so much harder to find than ordinary bugs.
01 Introduction
Unit 4 introduced threads and showed why they are useful: they share an address space, so they can communicate through ordinary memory accesses without the cost of message passing; and they can run in parallel on multiple cores, so a program can use more than one processor at once. Both of these advantages come from the same property — the shared address space — and both come with the same hazard. When two threads access the same memory and at least one of them writes, the outcome can depend on the precise order in which their operations are interleaved. That order is determined by the scheduler, not by the programmer.
This is the race condition, and it is the central problem of concurrent programming. A race condition is not a syntax error, not a type error, and not a logic error in the usual sense: the program is correct as written, and it produces the right answer most of the time. It fails only when the scheduler happens to interleave the threads in a particular way — a way that may occur once in a million runs, or never on the developer's machine and always on the customer's. Race conditions are the reason concurrent programming has a reputation for being difficult, and they are the reason this unit exists.
This lesson defines the problem before presenting any solution. It introduces shared state, interleaving, and atomicity; it works through the classic lost-update example at the level of individual machine instructions; it distinguishes data races from the broader category of race conditions; and it explains why concurrency bugs are qualitatively harder to reproduce and diagnose than sequential bugs. The remaining six lessons of the unit present the mechanisms — critical sections, locks, semaphores, monitors, condition variables — that make concurrent programs correct. Understanding the problem in this lesson is the prerequisite for understanding why those mechanisms are designed as they are.
Prerequisites
This lesson builds on Unit 3 (process states, context switching), Unit 4 (threads, the shared address space, POSIX threads), and Unit 5 (the scheduler's freedom to preempt a thread at almost any point). The POSIX threads API from Lesson 4.5 is used in the interactive activity. No prior knowledge of locks or semaphores is assumed — those are the subject of the later lessons in this unit.
02 Learning objectives
By the end of this lesson you will be able to
- Define a race condition and identify the conditions under which one can occur.
- Trace the interleaving of two threads through the lost-update example at the instruction level.
- Explain atomicity and why ordinary high-level statements are not atomic.
- Distinguish data races from race conditions and explain the relationship between them.
- Explain why race conditions are difficult to reproduce, diagnose, and test for.
This lesson contributes to U6.1 and U6.2, and to course learning outcomes CLO2, CLO3, and CLO8.
03 Required reading
- OSTEP — Chapter 26, Concurrency: An Introduction, through the section on the Race Condition and the counter example. This is the single most important reading for this lesson: the chapter develops the problem from the ground up and shows the assembly-level interleaving that produces the lost update.
- Operating System Concepts — Chapter 6, Sections 6.1–6.3, Background, The Critical- Section Problem, and Peterson's Solution. The formal definition of the race condition and the critical-section problem.
- Tanenbaum & Bos — Modern Operating Systems, Chapter 2, Section 2.3, Interprocess Communication, through the subsection on race conditions. A complementary treatment that places the problem in the context of interprocess communication more broadly.
- Love, R. — Linux System Programming, Chapter 10, Concurrency. A practical view of the problem from a systems-programming perspective, with examples in C on Linux.
-
Linux man pages:
man 7 pthreads,man 7 attributes(for thevolatileandrestrictqualifiers),man 3 pthread_create.
Reading strategy
Read OSTEP Chapter 26 first. Its treatment of the counter example — building up from the C code to the assembly to the interleaving — is the clearest available explanation of why race conditions occur, and it is the basis for the worked example in this lesson. Then read the Silberschatz section on the critical-section problem for the formal definitions. Pay particular attention to the distinction between a data race and a race condition; many textbook treatments use the terms interchangeably, but the distinction is important in practice.
04 Core content
4.1 Concurrency and shared state
A concurrent program is one in which two or more execution contexts — threads, processes, or signal handlers — can be in progress at the same time. If the contexts do not share any state, they are independent: each can be reasoned about in isolation, and concurrency introduces no new correctness problems. This is the case for processes that communicate only through pipes or sockets, or for threads that operate on entirely disjoint data.
The difficulties begin when contexts share state. In a threaded program, all threads share the global variables, the heap, and the file descriptor table. When two threads access the same memory location and at least one of them writes, the outcome of the program depends on the order in which the accesses occur. That order is called the interleaving, and it is chosen by the scheduler — which may switch threads at almost any point, including in the middle of a high-level statement.
A race condition is a situation in which the correctness of a program depends on the interleaving of operations on shared state. The program is written correctly: each thread performs a meaningful sequence of operations, and the intended result is unambiguous. But the operations are not atomic — they can be interrupted partway through — and if they are interrupted in the wrong place, the result is wrong.
4.2 The lost-update problem
The classic illustration is a shared counter incremented by two threads. The code looks harmless:
#include <pthread.h>
#include <stdio.h>
int counter = 0;
void *worker(void *arg) {
for (int i = 0; i < 1000000; i++) {
counter++; /* not atomic */
}
return NULL;
}
int main(void) {
pthread_t t1, t2;
pthread_create(&t1, NULL, worker, NULL);
pthread_create(&t2, NULL, worker, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("counter = %d (expected 2000000)\n", counter);
return 0;
}
Each thread increments the counter one million times, so the
expected final value is 2,000,000. On a real system, the
program usually prints something smaller — often much
smaller, and different on every run. The discrepancy is not
caused by a bug in the increment operation; it is caused by
the fact that counter++ is not a single
operation.
On a typical machine, counter++ compiles to
three instructions:
movl counter(%rip), %eax ; load counter into register
addl $1, %eax ; add 1 to the register
movl %eax, counter(%rip) ; store the register back
The scheduler may preempt a thread between any two of these instructions. If it does, another thread may execute its own load–add–store sequence on the same variable, and the two sequences can interleave in a way that loses one of the increments.
4.3 Why the high-level statement is not atomic
The lost-update problem arises because counter++
— a single statement in C — is not a single operation at the
machine level. A statement is atomic if it
executes as an indivisible unit: no other thread can observe
an intermediate state, and no interleaving can split it. Most
high-level statements in C and similar languages are not
atomic, and the compiler is not required to make them so.
Several things can make a statement non-atomic:
- Multiple machine instructions. As in the counter example, a read-modify-write sequence requires several instructions, and the scheduler can preempt between them.
- Compiler reordering. The compiler may reorder memory accesses for optimisation, and the processor may execute them out of order. Even if the source code looks sequential, the machine instructions may not be.
- Non-atomic data types. On many architectures, a 64-bit value on a 32-bit machine is loaded in two 32-bit halves. Another thread can observe the value between the two loads — a so-called torn read.
-
Function calls that hide multiple operations.
A function such as
list_insert()may perform several pointer updates, each of which is a potential preemption point.
volatile does not fix race conditions
The volatile qualifier in C tells the
compiler that the variable may change at any time, so it
must not cache the value in a register or optimise away
accesses to it. This prevents some compiler-level
reorderings, but it does not make a
read-modify-write sequence atomic. A
volatile int counter incremented by two
threads still loses updates. volatile is a
tool for memory-mapped I/O and signal handlers, not a
synchronization primitive.
4.4 The three conditions for a race
A race condition can occur when three conditions hold simultaneously. Removing any one of them eliminates the race.
| Condition | Description | How to remove it |
|---|---|---|
| Shared state | Two or more execution contexts access the same memory location. | Give each context its own private copy, or pass data by value. This is the approach of message passing and of thread-local storage. |
| At least one write | At least one of the accesses modifies the location. | Make the shared state immutable, or serialize all writes through a single context. |
| Non-atomic access | The access is not indivisible; the scheduler can preempt between operations. | Make the access atomic, either with a hardware atomic instruction or with a synchronization primitive. |
Most of this unit is concerned with the third condition. The mechanisms we will study — locks, semaphores, monitors, condition variables — all work by making a sequence of operations appear atomic to other threads. But it is worth keeping the first two conditions in mind, because they sometimes offer a simpler fix. If two threads do not need to share state, giving each its own copy is cheaper and less error-prone than adding a lock.
4.5 Data races versus race conditions
In the literature, the terms data race and race condition are often used interchangeably, but they refer to related yet distinct phenomena.
- Data race
- A data race is a specific, well-defined property of a program's memory accesses. Two memory accesses form a data race if they are to the same location, at least one is a write, and they are not ordered by any synchronization operation. A data race is a property of the program text and the language's memory model; it can be detected statically by tools such as ThreadSanitizer.
- Race condition
- A race condition is a broader and more semantic notion: the correctness of the program depends on the interleaving of operations that are not properly synchronized. A race condition can exist even in a program with no data races, if a thread makes a decision based on a value that another thread has changed between the decision and its use.
The distinction matters for two reasons. First, eliminating data races does not automatically eliminate race conditions: a program can be data-race-free and still have a logical race. Second, data races are easier to detect: tools like ThreadSanitizer can flag them at runtime, but they cannot detect a logical race that is not a data race, because detecting it requires understanding the program's intent.
The classic example of a race condition that is not a data race is the check-then-act pattern:
if (list->head != NULL) { /* check */
struct node *n = list->head; /* act */
use(n);
}
If another thread removes the head node between the check and the act, the program uses a freed or stale pointer. Each individual access is atomic; there is no data race in the formal sense. But the composition of the two accesses is not atomic, and the program is incorrect. The fix is not to make each access atomic but to make the pair atomic — which is exactly what a lock or a critical section does.
4.6 A second example: the bank transfer
The counter is the simplest race condition, but it is not the most illustrative of real-world consequences. Consider a bank account with two operations: deposit and withdraw.
struct account {
long balance;
};
void deposit(struct account *a, long amount) {
a->balance += amount;
}
void withdraw(struct account *a, long amount) {
if (a->balance >= amount) {
a->balance -= amount;
return 1;
}
return 0;
}
The withdraw operation checks the balance and then decrements it. If two threads withdraw from the same account concurrently, both may see a sufficient balance, both may proceed, and the account may be overdrawn. This is the same check-then-act pattern as the list example. The consequences are more serious — money is lost — but the structure of the bug is identical.
The check-then-act pattern is the most common form of race condition in practice, because it appears in every place where a program asks a question about shared state and then acts on the answer. The window between the question and the action is the vulnerability, and making the question and the action atomic is the fix. This is the subject of Lesson 6.2, which introduces the critical-section abstraction.
4.7 Why race conditions are hard to find
Race conditions are qualitatively harder to diagnose than ordinary bugs. Several factors contribute.
- Non-determinism. The outcome depends on the interleaving, which depends on the scheduler. The same program on the same input can produce different results on different runs, or on the same run at different times.
- Rare occurrence. Many races require a preemption at a precise point. On a lightly loaded system the scheduler may never preempt at that point; on a heavily loaded one it may do so frequently. The bug may appear only under load, or only on a machine with many cores.
- Hardware and compiler dependence. The same source code may be correct on one architecture and incorrect on another, because the memory model and the instruction set differ. Compiler optimisations can also expose or hide races in ways that are not obvious from the source.
- Delayed symptoms. A race may corrupt a data structure silently and cause a crash thousands of instructions later, in a completely unrelated part of the program. The stack trace points to the symptom, not the cause.
- Heisenbug behaviour. Adding debugging output, running under a debugger, or enabling an analyser can change the timing enough to make the race disappear. The act of observing the bug changes whether it occurs.
- Test difficulty. A test that runs the program once proves nothing about a race: the race may not manifest on that run. Only running under a race detector or under a scheduling stress tool gives meaningful evidence.
The cost of a race condition
Race conditions have caused some of the most expensive failures in computing history. The Therac-25 radiation therapy machine administered massive overdoses to patients in the 1980s, in part because of a race between the operator's input and the machine's control loop. The Nasdaq and other financial systems have suffered from race conditions in their matching engines. The Mars Pathfinder's priority-inversion bug — discussed in Lesson 5.4 — is a related failure. Race conditions are not academic; they are the source of failures that cost money and, in some cases, lives.
4.8 The scheduler's role
It is important to be precise about the relationship between the scheduler and a race condition. The scheduler does not create the race: the race is a property of the program's use of shared state. The scheduler only chooses whether the race manifests on a given run. On a single-threaded program there is no race, because there is no other thread to interleave with. On a multi-threaded program the race exists in the code, and the scheduler determines whether the particular interleaving that triggers it occurs.
This has a practical consequence: a race cannot be "fixed" by changing the scheduler. A program with a race is incorrect, and it will fail eventually — perhaps on a different machine, perhaps under a different load, perhaps only after a software update changes the timing. The only reliable fix is to eliminate the race from the code by making the access to shared state atomic.
4.9 Common misconceptions
"A race is caused by the scheduler."
The race exists in the code. The scheduler only decides whether it manifests on a given run. A correctly synchronized program is correct regardless of the scheduler's decisions.
"The program works, so there is no race."
A race may not manifest on a given run, or on a given machine, or under a given load. "Works on my machine" is not evidence of correctness in concurrent programming.
"Adding volatile fixes the race."
volatile prevents some compiler
optimisations but does not make a read-modify-write
sequence atomic. It is not a synchronization primitive.
"Race conditions and data races are the same."
A data race is a specific memory-access property; a race condition is a broader correctness property. A program can be free of data races and still have a logical race.
"More cores make races easier to find."
More cores make races more likely to manifest, but not more likely to be found deterministically. The bug still depends on timing, and a test run may still miss it.
"A single-threaded program cannot have a race."
A signal handler runs concurrently with the main program, and the same race conditions can arise between a signal handler and the code it interrupts. Races are not limited to multi-threaded programs.
"Atomic means fast."
Atomic operations are often slower than ordinary ones, and synchronization can serialize otherwise parallel work. Correctness comes first, but performance is a real consideration in the design of concurrent programs.
"The race is in the library, not my code."
If a library function is documented as thread-safe, the race is in the caller's use of the library's return values, not in the library. If the library is not thread-safe, the race is in the caller's decision to call it from multiple threads.
05 Worked example
The following example traces the lost-update problem at the instruction level, computes the expected and observed results, and shows how the outcome depends on the interleaving.
Two threads, one counter
Two threads each execute the following loop:
for (int i = 0; i < N; i++)
counter++;
where counter is a global int
initialized to 0, and each iteration compiles to the
three instructions:
L: movl counter(%rip), %eax
A: addl $1, %eax
S: movl %eax, counter(%rip)
Case 1: no interleaving. If the scheduler runs each thread's iteration to completion before switching, the sequence is:
Time T1 counter T2
0 L (0) 0 —
1 A (1) 0 —
2 S (1) 1 —
3 — 1 L (1)
4 — 1 A (2)
5 — 1 S (2)
...
Each iteration completes atomically, and the final value is 2N — the expected result.
Case 2: interleaving between L and S. Suppose the scheduler preempts T1 after instruction L and runs T2 through its full iteration:
Time T1 counter T2
0 L (0) 0 —
1 — 0 L (0)
2 — 0 A (1)
3 — 1 S (1)
4 A (1) 1 —
5 S (1) 1 —
...
Both threads loaded the same value 0, both incremented it to 1, and both stored 1. The counter ends at 1 instead of 2: one increment was lost. If this interleaving occurs on every iteration, the final value is N instead of 2N — a 50 percent loss. If it occurs on some iterations and not others, the final value is somewhere between N and 2N, and it varies from run to run.
Case 3: interleaving in the middle of the sequence. The scheduler can also preempt between A and S:
Time T1 counter T2
0 L (0) 0 —
1 A (1) 0 —
2 — 0 L (0)
3 — 0 A (1)
4 — 1 S (1)
5 S (1) 1 —
...
The result is the same — one lost update — but the interleaving is different. In general, any interleaving in which both threads load before either stores produces a lost update; the number of lost updates depends on how often this pattern occurs.
Expected result versus observed result
On a modern multi-core machine, the observed result of the program from section 4.2 is typically between 1,000,000 and 1,600,000 — never the expected 2,000,000. The exact value varies from run to run and depends on the machine, the number of cores, the compiler optimisation level, and the system load.
| Scenario | Expected | Typical observed | Loss |
|---|---|---|---|
| Single core, no preemption | 2,000,000 | 2,000,000 | 0% |
| Two cores, −O0, lightly loaded | 2,000,000 | 1,400,000–1,900,000 | 5–30% |
| Two cores, −O2, busy system | 2,000,000 | 1,000,000–1,500,000 | 25–50% |
| Four cores, −O2, busy system | 2,000,000 | 900,000–1,300,000 | 35–55% |
The loss is larger with more cores and more aggressive optimization, because the threads spend more time running concurrently and the window between load and store is more likely to be interrupted.
A second trace: the bank transfer
The check-then-act pattern produces a different kind of failure. Consider an account with balance 100, and two threads each withdrawing 80:
Time T1 T2
0 check balance (100 ≥ 80) → yes —
1 — check balance (100 ≥ 80) → yes
2 balance = 100 − 80 = 20 —
3 — balance = 100 − 80 = 20
Both withdrawals succeed, and the final balance is 20 — but the account should have been reduced by 160, leaving −60, or one of the withdrawals should have failed. The check-then-act pattern allowed both threads to observe a sufficient balance before either had decremented it. The consequence is an overdraft: the bank has lost 80 units.
The fix is to make the check and the act atomic with respect to each other — a single critical section that includes both. This is the subject of Lesson 6.2.
Using an atomic operation
The simplest fix for the counter is to use a hardware
atomic instruction. In C11, the
_Atomic qualifier and the
<stdatomic.h> header provide this:
#include <stdatomic.h>
atomic_int counter = 0;
void *worker(void *arg) {
for (int i = 0; i < 1000000; i++)
atomic_fetch_add(&counter, 1);
return NULL;
}
atomic_fetch_add performs the load, the add,
and the store as a single indivisible operation. No
interleaving can split it, so no update is lost. The
program now prints 2,000,000 reliably.
But atomic operations are not a general solution to race conditions. They work for a single variable; they do not protect a data structure, an invariant that spans several variables, or a check-then-act sequence. The general solution is the critical section, introduced in the next lesson. Atomic operations are a special case, useful when the entire operation can be expressed as a single hardware-supported primitive.
06 Interactive activity
Activity · reproduce, detect, and fix a race condition
The goal of this activity is to see a race condition occur on a real system, to use tools to detect it, and to fix it with an atomic operation. Work through the steps in your course VM.
-
Write the racy counter.
Write a program with two threads, each incrementing a
shared counter one million times. Compile it and run it
several times.
#include <pthread.h> #include <stdio.h> int counter = 0; void *worker(void *arg) { for (int i = 0; i < 1000000; i++) counter++; return NULL; } int main(void) { pthread_t t1, t2; pthread_create(&t1, NULL, worker, NULL); pthread_create(&t2, NULL, worker, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("counter = %d (expected 2000000)\n", counter); return 0; }
Do you observe the expected value? Is the output consistent across runs? Try compiling withgcc -O2 -pthread -o racy racy.c for i in $(seq 1 10); do ./racy; done-O0instead of-O2and run again. Does the result change? -
Examine the assembly.
Use
objdumpto see the machine instructions generated for the increment. Confirm that the compiler produces a load–add–store sequence, not a single atomic instruction.
Withobjdump -d racy | grep -A 12 '<worker>:'-O2, the compiler may optimize the loop so that the counter is held in a register and only stored once at the end. This is a legal optimization under the C memory model for a non-volatile, non-atomic variable, and it makes the race even worse. Note the difference between the-O0and-O2assembly. -
Detect the race with ThreadSanitizer.
Compile with the ThreadSanitizer option and run the
program. ThreadSanitizer instruments the memory accesses
and reports the race with a stack trace.
Read the output carefully. It reports the two accesses that conflict, the threads involved, and the location in the source. This is the standard tool for finding data races in C and C++ programs.gcc -O1 -g -fsanitize=thread -pthread -o racy_tsan racy.c ./racy_tsan -
Increase the contention.
Modify the program to use more threads, and run it again.
Does the discrepancy grow? What happens if the threads
run on a single core (use
taskset -c 0)?
On a single core, the threads are time-sliced and the race may manifest less often. On multiple cores, the threads run truly in parallel and the race manifests more often.taskset -c 0 ./racy taskset -c 0-3 ./racy -
Fix the race with an atomic operation.
Use C11 atomics to make the increment atomic. Recompile
and run the program several times. Confirm that the
result is now always 2,000,000.
Run under ThreadSanitizer again. Does it report a race? Why or why not?#include <stdatomic.h> atomic_int counter = 0; void *worker(void *arg) { for (int i = 0; i < 1000000; i++) atomic_fetch_add(&counter, 1); return NULL; } -
Demonstrate the check-then-act race.
Write a program with a shared account balance and a
withdraw function that checks the balance before
decrementing. Run two threads that each try to withdraw
more than half the balance. Run the program many times
and count how often the account is overdrawn.
This race is not detected by ThreadSanitizer, because each individual access is atomic — there is no data race. It is a logical race condition. This illustrates the distinction from section 4.5.struct account { long balance; }; int withdraw(struct account *a, long amount) { if (a->balance >= amount) { a->balance -= amount; return 1; } return 0; } -
Inspect a running program's memory.
Use
/proc/<pid>/mapsand/proc/<pid>/statto see where the global counter is stored and how the threads share it.
Thecat /proc/$$/maps | head cat /proc/$$/status | grep -i threadsThreadsfield reports the number of threads in the process. The address space maps show that all threads share the same memory — the basis of the race condition.
Reflect: the racy counter program is correct as written — each thread performs a meaningful operation, and the intended result is unambiguous. The bug is not in the logic but in the failure to make the operation atomic. How would you go about finding a race condition in a large program where the symptom appears only occasionally and only on production hardware? What tools and techniques would you use? What would you do if ThreadSanitizer reported no race but the bug persisted?
07 Knowledge check
Check your understanding
Question 1
Define a race condition. What three conditions must hold for a race condition to be possible? Give an example of a program that violates each condition and therefore has no race.
Show answer
Question 2
Explain why counter++ is not atomic. Trace
the interleaving of two threads that produces a lost
update, showing the value of the counter at each step.
Show answer
counter++ is not atomic because it
compiles to three machine instructions: a load of the
counter into a register, an add of 1 to the register,
and a store of the register back to memory. The
scheduler can preempt a thread between any two of
these instructions. The interleaving that produces a
lost update is:
- T1 loads the counter (value 0) into its register.
- T2 loads the counter (value 0) into its register.
- T1 adds 1 to its register (value 1).
- T2 adds 1 to its register (value 1).
- T1 stores its register to the counter (counter = 1).
- T2 stores its register to the counter (counter = 1).
Question 3
What is the difference between a data race and a race condition? Give an example of a race condition that is not a data race, and explain why it is not detected by ThreadSanitizer.
Show answer
if (a->balance >= amount) {
a->balance -= amount;
return 1;
}
return 0;
Each individual access to a->balance
is atomic — a single load or a single store — so there
is no data race. But the pair of accesses (the check
and the decrement) is not atomic, and another thread
can modify the balance between them. The program can
overdraw the account. ThreadSanitizer detects data
races by instrumenting individual memory accesses; it
cannot detect this pattern because each individual
access is properly synchronized. Detecting it requires
understanding the program's intent — that the check
and the act must be atomic together — which is beyond
what a dynamic race detector can infer.
Question 4
Why are race conditions harder to reproduce than sequential bugs? Give at least three reasons, and explain how each contributes to the difficulty.
Show answer
Question 5
Why does adding volatile not fix a race
condition on a counter? What does volatile
actually do, and what is the correct fix?
Show answer
volatile tells the compiler that the
variable may change at any time, so it must not cache
the value in a register or optimise away accesses to
it. This prevents some compiler-level reorderings and
ensures that every read and write in the source code
corresponds to an actual memory access in the
generated code. But it does not make a
read-modify-write sequence atomic. The sequence still
consists of separate load, add, and store
instructions, and the scheduler can still preempt a
thread between them. Two threads incrementing a
volatile int can still lose updates. The
correct fix is to make the operation atomic, either by
using a hardware atomic instruction (such as
atomic_fetch_add from
<stdatomic.h>) or by protecting the
operation with a lock or critical section.
volatile is a tool for memory-mapped I/O
and signal handlers, not a synchronization primitive.
08 Unit discussion
Discussion prompt
Race conditions are notoriously difficult to find and fix. The counter program in this lesson is correct in every sense that a compiler or a static analyser can check — it has no syntax errors, no type errors, and no undefined behaviour in the single-threaded sense. The bug is entirely in the interaction between the threads, and it manifests only under specific timing conditions.
This raises a question about responsibility. In a sequential program, the programmer is clearly responsible for correctness: the compiler catches syntax and type errors, and the programmer reasons about the logic. In a concurrent program, the compiler catches the same errors, but the correctness of the concurrent interaction is the programmer's responsibility alone. The tools — race detectors, model checkers, formal verifiers — are incomplete: ThreadSanitizer detects data races but not logical races; no tool can prove a program free of race conditions in general.
Should the language or the runtime do more? Some languages (Rust) use the type system to prevent data races at compile time. Others (Java, Go) provide a memory model and built-in synchronization primitives. C and C++ leave almost everything to the programmer. What is the right balance between safety and performance, and between language-level enforcement and programmer responsibility? What would you change about C if you could?
Post your response to the Unit 6 discussion forum and reply to at least two classmates. Supports CLO2, CLO3, and CLO8.
09 Practice exercise
This ungraded exercise consolidates the lesson and prepares you for Lesson 6.2. Aim to spend 60–90 minutes on it.
-
Write and run the racy counter.
Write the two-thread counter program from section 4.2.
Compile it with
-O0and with-O2. Run each version twenty times and record the results. What is the range of observed values? What is the average? How does the optimisation level affect the result? Explain your observations. -
Detect the race with ThreadSanitizer.
Compile the program with
-fsanitize=threadand run it. Read the output carefully. Identify: which two accesses conflict, which threads are involved, and at what source lines. Then compile the fixed version (withatomic_fetch_add) with the same option and confirm that the race is no longer reported. - Demonstrate the check-then-act race. Write the bank-account program from section 4.6. Run two threads that each attempt to withdraw 80 from an account with balance 100. Run the program many times and count how often the account is overdrawn. Then run it under ThreadSanitizer. Does ThreadSanitizer report a race? Explain why or why not.
- Trace the lost update by hand. For the counter program with two threads, each incrementing five times, list every interleaving that produces a final value of 10, of 9, of 8, and of 5. How many distinct interleavings are there in total? How many produce each result? What does this tell you about the probability of observing the race?
- Investigate the memory model. Read section 5.1.2.4 of the C11 standard (or an equivalent summary of the C memory model) and answer: what does the standard guarantee about the ordering of memory accesses between threads when no atomic or synchronization operation is used? What is the "as-if" rule and how does it interact with concurrent execution? Write a short paragraph explaining why a compiler is permitted to make optimisations that expose race conditions.
- Design a test for a race. Suppose you have a function that is suspected of containing a race condition. Describe how you would test it. What tools would you use? How many runs would you perform? What would you do if the race did not manifest in any run? Write a short testing plan that addresses these questions.
- Reflect. In your own words, explain why the lost-update problem is not a bug in the increment operation but a bug in the program's use of shared state. Then describe one situation in your own experience — in a program you have written or used — where a race condition could plausibly have occurred. What would the symptom have been, and how would you have diagnosed it?
10 Summary
- A race condition is a situation in which the correctness of a program depends on the interleaving of operations on shared state. The scheduler chooses the interleaving; the programmer does not control it.
- A race condition can occur when three conditions hold: shared state, at least one write, and non-atomic access. Removing any one condition eliminates the race.
- The classic example is the lost update: two threads increment a shared counter, both load the same original value, and both store the same incremented value, so one increment is lost.
-
counter++is not atomic: it compiles to a load, an add, and a store, and the scheduler can preempt between them. Most high-level statements in C are not atomic. -
volatiledoes not fix a race. It prevents some compiler optimisations but does not make a read-modify-write sequence atomic. - A data race is a specific property of memory accesses: two accesses to the same location, at least one a write, without synchronization ordering them. A race condition is a broader correctness property. Every data race is a race condition, but not every race condition is a data race.
- The check-then-act pattern is the most common form of race condition in practice: a thread asks a question about shared state and then acts on the answer, and another thread modifies the state in between. Making the check and the act atomic is the fix.
- Race conditions are hard to find because they are non-deterministic, may occur rarely, may have delayed symptoms, and may disappear under observation. A test that runs once proves nothing.
- Tools such as ThreadSanitizer detect data races at runtime, but they cannot detect logical race conditions that are not data races.
- The general fix for a race condition is to make the sequence of operations atomic with respect to other threads. The mechanisms for doing this — critical sections, locks, semaphores, monitors, condition variables — are the subject of the rest of this unit.
Looking ahead
This lesson defined the problem. Lesson 6.2 introduces the critical section: a region of code that must be executed by at most one thread at a time. The critical-section abstraction is the foundation of every synchronization mechanism in the rest of the unit. Lesson 6.3 presents locks as the basic mechanism for implementing critical sections; Lesson 6.4 covers semaphores, a more general primitive; Lesson 6.5 covers monitors, a language-level abstraction; Lesson 6.6 covers condition variables; and Lesson 6.7 examines the synchronization primitives that Linux actually provides.
11 References
-
Arpaci-Dusseau, R. H., & Arpaci-Dusseau, A. C.
Operating Systems: Three Easy Pieces, Chapter 26.
Available online at
ostep.org. - Silberschatz, A., Galvin, P. B., & Gagne, G. Operating System Concepts, 10th ed., Chapter 6, Sections 6.1–6.3.
- Tanenbaum, A. S., & Bos, H. Modern Operating Systems, 4th ed., Chapter 2, Section 2.3.
- Love, R. Linux System Programming, 2nd ed., Chapter 10.
- ISO/IEC 9899:2011, Programming Languages — C, Section 5.1.2.4 (the memory model) and Section 7.17 (atomics).
- Serebryany, K., & Iskhodzhanov, T. ThreadSanitizer — Data Race Detection in Practice. Proceedings of the Workshop on Binary Instrumentation and Applications, 2009.
-
Linux man-pages:
man 7 pthreads,man 7 attributes,man 3 pthread_create. - Leveson, N. G., & Turner, C. S. An Investigation of the Therac-25 Accidents. IEEE Computer, 1993. The authoritative account of the Therac-25 incidents, including the role of race conditions.