A Race Condition, Made to Happen
Chapter Thirty-Three
Syllabus topic Module 1, "Process Synchronization - race condition"
Pages 130 to 134 of 452
In one line
A race condition is when the answer depends on which of two processes happens to get there first.
The form to write: a race condition is a situation in which several processes access and manipulate the same data concurrently, and the outcome depends on the particular order in which the accesses take place.
Why one line of C is three instructions
counter++ looks indivisible. It is not. The processor has no instruction that adds one to a number in memory; it must fetch it into a register, add, and store it back.
| Step | What happens |
|---|---|
| 1. Load | read counter from memory into a register |
| 2. Add | add one to the register |
| 3. Store | write the register back to counter |
A context switch can happen between any two of those, and on a machine with two cores the two threads need not even take turns: they can be in step 1 at the same instant. Then both read the same value, both add one to it, both write the same value back, and one of the two increments has vanished.
The word for this is a lost update, and it is the commonest race there is.
Asking the machine, and being told something inconvenient
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <pthread.h>
#define EACH 1000000
static long counter; /* shared, and unprotected */
static void *bump(void *unused)
{
(void)unused;
for (long i = 0; i < EACH; i++) {
counter++; /* three instructions, not one */
}
return NULL;
}
int main(void)
{
pthread_t a, b;
pthread_create(&a, NULL, bump, NULL);
pthread_create(&b, NULL, bump, NULL);
pthread_join(a, NULL);
pthread_join(b, NULL);
long should_be = 2 * (long)EACH;
printf("expected %ld, got %ld, lost %ld\n",
should_be, counter, should_be - counter);
return 0;
}$ gcc -std=c17 -Wall -Wextra -pthread -o race race.c
$ bad=$(for i in $(seq 20); do ./race; done | grep -vc 'lost 0$')
$ echo "of twenty runs, $bad gave the wrong answer and $((20 - bad)) gave the right one"
of twenty runs, 3 gave the wrong answer and 17 gave the right oneMost runs are right. Two million increments, no protection at all, and on most runs not one is lost; on the others hundreds of thousands vanish. Which runs are which is not the program's choice and not yours.
The lab machine is a container that receives a fraction of one core, which Chapter thirty one measured, so the two threads rarely run at the same instant and each usually gets a long stretch without interruption. When one of them is interrupted in the wrong place, a great deal is lost at once.
The program is not correct. On a good run it is lucky, and it is lucky in exactly the way that puts a program into production and keeps it there until the day the machine changes.
A Race Condition, Made to Happen
Making the machine show its hand
The three steps are the same three steps. Written out, with the thread giving up the processor between reading and writing, the interleaving the machine is allowed to produce at any moment is produced every time.
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <pthread.h>
#include <sched.h>
#define EACH 20000
static long counter;
static void *bump(void *unused)
{
(void)unused;
for (long i = 0; i < EACH; i++) {
long seen = counter; /* 1. load */
sched_yield(); /* the switch that is always allowed */
counter = seen + 1; /* 2. add and 3. store */
}
return NULL;
}
int main(void)
{
pthread_t a, b;
pthread_create(&a, NULL, bump, NULL);
pthread_create(&b, NULL, bump, NULL);
pthread_join(a, NULL);
pthread_join(b, NULL);
long should_be = 2 * (long)EACH;
printf("expected %ld, got %ld, lost %ld\n",
should_be, counter, should_be - counter);
printf("the answer is %s\n", counter == should_be ? "RIGHT" : "WRONG");
return 0;
}$ gcc -std=c17 -Wall -Wextra -pthread -o race-shown race-shown.c
$ ./race-shown
expected 40000, got 20000, lost 20000
the answer is WRONG
$ for i in $(seq 5); do ./race-shown | tail -1; done | sort | uniq -c
5 the answer is WRONGHalf the increments vanished, five runs out of five.
sched_yield changed nothing about what the program computes. It asks the scheduler to run somebody else now, which the scheduler was free to do at that point anyway, and at a thousand other points besides. The two programs are the same program: one hides the bug and one shows it.
So the lesson is the opposite of a demonstration. A race condition is not something you can decide is absent by running the program. Chapter thirty two's shape.c, with a lock, gave 200000 five times out of five and is correct; race.c gave 2000000 five times out of five and is wrong. The two transcripts look the same. Only the code tells you which is which.
And the part that makes it dangerous
Run the second program with far fewer increments.
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <pthread.h>
#include <sched.h>
#define EACH 50
static long counter;
static void *bump(void *unused)
{
(void)unused;
for (long i = 0; i < EACH; i++) {
long seen = counter;
sched_yield();
counter = seen + 1;
}
return NULL;
}
int main(void)
{
pthread_t a, b;
pthread_create(&a, NULL, bump, NULL);
pthread_create(&b, NULL, bump, NULL);
pthread_join(a, NULL);
pthread_join(b, NULL);
printf("%s\n", counter == 2 * (long)EACH ? "right" : "wrong");
return 0;
}$ gcc -std=c17 -Wall -Wextra -pthread -o race-small race-small.c
$ bad=$(for i in $(seq 20); do ./race-small; done | grep -c wrong)
$ echo "fifty increments each: $bad of twenty runs wrong, $((20 - bad)) right"
fifty increments each: 11 of twenty runs wrong, 9 rightA Race Condition, Made to Happen
Fifty increments each is enough to go wrong, and not enough to go wrong every time. The size of the loop is not what decides it. Whether the two threads overlap is, and that is the scheduler's business and not the programmer's.
That is the whole danger of a race condition, and it is what an examination answer should say. The bug is not in the program's logic, it is in the program's timing, so:
- it passes testing, as
race.cdid five times over; - it works on the developer's machine and fails on the customer's;
- it appears under load, which is when it costs most;
- it can disappear when you add a print statement to look for it, because the print changes the
timing;
- and it can sit for years in code everybody believes is correct.
The interleaving, written out
Two threads, one counter, starting at 5. The correct answer is 7.
| Time | Thread A | Thread B | Register A | Register B | counter |
|---|---|---|---|---|---|
| 1 | load | 5 | 5 | ||
| 2 | load | 5 | 5 | 5 | |
| 3 | add | 6 | 5 | 5 | |
| 4 | add | 6 | 6 | 5 | |
| 5 | store | 6 | 6 | 6 | |
| 6 | store | 6 | 6 | 6 |
The counter ends at 6 and should be 7. Neither thread did anything wrong. A question that asks you to demonstrate a race condition wants exactly this table, with the shared value in the last column.
Note that the ordering 1, 3, 5, 2, 4, 6, where A finishes before B starts, gives 7. Both orderings are allowed and only one is right, which is the definition.
A race is not only a counter
| Shared thing | The race |
|---|---|
| A counter | a lost update, as above |
| A linked list | two inserts at the head, and one is lost or the list is broken in half |
| A file | two appends, and one overwrites the other |
| A bank balance | two withdrawals both check the balance, both succeed, the account goes negative |
| A seat count | Chapter thirty two's booking, sold twice |
| A file's existence | one process checks a name is free and another creates it before the first does |
The last one has a name, time of check to time of use, and it is a security bug rather than a counting bug: a program that checks it may write a file and then writes it can be tricked by anybody who changes the file in between.
Distinctions that carry marks
| A race condition | An ordinary bug | |
|---|---|---|
| In | the timing | the logic |
| Reproducible | sometimes, and not on demand | yes |
| Found by testing | rarely | usually |
| Changes when you add a print | yes | no |
| Fixed by | mutual exclusion | correcting the code |
A Race Condition, Made to Happen
| The three instructions | What a lock does | |
|---|---|---|
| Load, add, store | can be split by a switch or by another core | are made indivisible as a group |
| Cost | none | a lock and an unlock |
What it does not mean
A race condition is not a compiler bug. The compiler did what the language allows.
It is not solved by making the variable volatile. volatile stops the compiler caching the value in a register across statements. It does nothing about two threads doing load, add and store at the same time, and a student who offers it as the answer has not understood the problem.
It is not solved by making the loop shorter, or by hoping. The small version above was right twenty times and is exactly as wrong as the large one.
It is not limited to threads. Two processes sharing memory (Chapter twenty two) race in the same way, and so do two programs appending to one file.
Quick revision
- A race condition is when several processes touch the same data at once and the outcome
depends on the order.
counter++is load, add, store. A switch or a second core between any two of them loses an
update.
- One million increments each, two threads, no protection: the answer was wrong on every run and
nearly a million increments disappeared.
- One thousand each: right twenty times out of twenty, because the threads never overlapped.
A race that does not appear is still there.
- It is a bug in timing, so it survives testing, appears under load, and vanishes when you
add a print to look for it.
volatiledoes not fix it. Mutual exclusion does.- The examinable demonstration is the interleaving table with the shared value in the last
column.
Test yourself
- Define a race condition. A situation in which several processes access and change shared
data concurrently and the result depends on the order in which the accesses happen.
- Why is
counter++unsafe for two threads? It is three machine steps, load, add and store,
and two threads can both load the same value before either stores, so one increment is lost.
- Draw the interleaving that loses an increment. Both threads load the same value, both add
one to their own register, both store the same result. Two increments, one effect.
- A program with a race was tested a hundred times and passed. Is it correct? No. A race is
a timing bug; passing a test means the bad interleaving did not happen, not that it cannot.
- Does
volatilefix a race condition? No. It affects how the compiler keeps the value, not
A Race Condition, Made to Happen
whether two threads can be inside load, add and store at the same time.
- Why does adding a print statement often make a race disappear? Printing takes time and
changes the timing, so the window in which the two threads overlap moves or closes.
- Name a race that is a security problem rather than a counting problem. Time of check to
time of use: a program checks that it may write a file and the file is changed between the check and the write.
The rest of this subject
These notes are cut from the University's printed syllabus. Open the syllabus itself, or the past papers, for the same subject.