Skip to content
academia.sh

Lesson 06 / 15

Race Conditions

All interleavings of the read-increment-write triplet, the number of interleavings that produce a wrong result, and how the scheduler's quantum makes the error invisible.

Contents

The previous lesson measured the scheduler’s cost in time: as the quantum shrank, context switching increased and the run took longer. There, the quantum was a performance setting. This lesson brings out a second face of the same setting — the quantum also determines in which orders the steps of two threads can interleave, and therefore whether the program produces a correct result at all.

This is where the course must pay off its most valuable claim: a correct output does not mean a correct program. The measurement below shows this once, in numbers. The machine being measured is, again, the machine from the shared definition; no real thread is ever created, and no real time is measured.

One Increment Is Three Steps

Incrementing a shared counter is a single line in the source text. It is not a single step in execution: the value is read, incremented by one, and written back. The scheduler can switch to another thread between these three steps.

CC1. A counter increment is three separate steps; execution can be split between them. CC2. There are two threads, each incrementing the counter once; the expected result is 2. CC3. Reads and writes are instantaneous; two steps never coincide in the same time unit. Execution can always be described by an interleaving — a single ordering of the two step sequences.

The shared definition’s counter_run routine takes an interleaving and returns the counter value it produces. An interleaving is a sequence of 0s and 1s showing which thread advances at each step.

def counter_run(pattern: list[int]) -> int:
    """Two threads each do read-increment-write once. pattern: who advances at each step."""
    counter, local = 0, {0: None, 1: None}
    stage = {0: 0, 1: 0}
    for who in pattern:
        s = stage[who]
        if s == 0:
            local[who] = counter              # read
        elif s == 1:
            local[who] = local[who] + 1       # increment
        else:
            counter = local[who]              # write
        stage[who] = s + 1
    return counter


def interleavings(a: int, b: int) -> list[list[int]]:
    """All interleavings of two sequences of length a and b."""
    if a == 0:
        return [[1] * b]
    if b == 0:
        return [[0] * a]
    left = [[0] + s for s in interleavings(a - 1, b)]
    right = [[1] + s for s in interleavings(a, b - 1)]
    return left + right


for name, pattern in (("sequential", [0, 0, 0, 1, 1, 1]),
                       ("fully interleaved", [0, 1, 0, 1, 0, 1]),
                       ("partial", [0, 0, 1, 1, 1, 0])):
    print(f"{name:18s} {pattern} -> counter {counter_run(pattern)}")

everything = interleavings(3, 3)
correct = [d for d in everything if counter_run(d) == 2]
wrong = len(everything) - len(correct)
print()
print(f"interleavings {len(everything)} | correct {len(correct)} | wrong {wrong} "
      f"| wrong ratio {wrong / len(everything):.4f}")
print("correct ones:", correct)
sequential         [0, 0, 0, 1, 1, 1] -> counter 2
fully interleaved  [0, 1, 0, 1, 0, 1] -> counter 1
partial            [0, 0, 1, 1, 1, 0] -> counter 1

interleavings 20 | correct 2 | wrong 18 | wrong ratio 0.9000
correct ones: [[0, 0, 0, 1, 1, 1], [1, 1, 1, 0, 0, 0]]

Two three-step sequences have 20 interleavings. Only 2 of them produce the expected result; 18, that is ninety percent of the execution orders, leave the counter at 1. The two interleavings that do produce the correct result are already saying the same thing: one thread finishes completely before the other begins.

Where the Loss Happens

It is enough to trace the interleaving [0, 1, 0, 1, 0, 1] step by step. The first thread reads the counter and sees 0. The second thread also reads and sees 0 as well — the first thread’s write has not happened yet. Both set their local copy to 1. The first writes, and the counter becomes 1; the second writes, and the counter becomes 1 again. The second write overwrote the first thread’s result.

This is called a race condition: the result depending on the order of the steps rather than on the work itself. The programmer does not choose the order of the steps; the scheduler does. This is why a race condition is a defect that cannot be seen by reading the source text — the source text has only one increment line, and that line is correct.

The region where the counter breaks is called the critical section: the sequence of steps in which two threads must never both be present at the same time. Here, the critical section is three steps.

Two Distinct Defects

The situation above stacks two defects on top of each other, and the two must not be confused.

A data race is two unordered accesses to the same memory location where at least one is a write. It is a structural property: it is detected by looking at the accesses, not at the result. A race condition, by contrast, is the result depending on step order; it is a correctness property.

The counter example has both at once. But one does not require the other. If a container is locked on every access, there is no data race; yet if the sequence “check whether it is empty, and if not, take an element” consists of two separately locked operations, another thread can take the last element between the two, and the result still depends on order. The data race is gone; the race condition remains.

The practical consequence of this distinction is: protecting each access separately is not enough. What needs protection is not the access but the span over which an invariant could be violated — the boundary of the critical section is drawn by the programmer, not by a tool.

The Quantum Constrains Interleaving

The scheduler from the previous lesson does not switch threads at every step; it holds a thread for the length of the quantum and releases it once the quantum is used up. So not all 20 interleavings are reachable.

CC4. The scheduler switches only at quantum boundaries; there is no preemption within a quantum. CC5. What is measured is not a probability but whether an interleaving is reachable: that interleaving either can or cannot be produced by this scheduler.

The shared definition’s quantum_interleavings routine counts the interleavings producible with a given quantum.

def counter_run(pattern: list[int]) -> int:
    counter, local = 0, {0: None, 1: None}
    stage = {0: 0, 1: 0}
    for who in pattern:
        s = stage[who]
        if s == 0:
            local[who] = counter
        elif s == 1:
            local[who] = local[who] + 1
        else:
            counter = local[who]
        stage[who] = s + 1
    return counter


def quantum_interleavings(steps: int, quantum: int) -> list[list[int]]:
    """Interleavings reachable when the scheduler switches only every `quantum` steps."""
    result = []

    def walk(remaining_a, remaining_b, current, sequence):
        if not remaining_a and not remaining_b:
            result.append(list(sequence))
            return
        for candidate in (0, 1):
            remaining = remaining_a if candidate == 0 else remaining_b
            if not remaining:
                continue
            n = min(quantum, remaining) if candidate != current else min(quantum, remaining)
            sequence.extend([candidate] * n)
            walk(remaining_a - n if candidate == 0 else remaining_a,
                 remaining_b - n if candidate == 1 else remaining_b, candidate, sequence)
            del sequence[len(sequence) - n:]
    walk(steps, steps, None, [])
    distinct = []
    for d in result:
        if d not in distinct:
            distinct.append(d)
    return distinct


print("quantum  reachable  wrong  wrong ratio")
for quantum in (1, 2, 3, 4, 5):
    reachable = quantum_interleavings(3, quantum)
    wrong = sum(1 for d in reachable if counter_run(d) != 2)
    print(f"  {quantum:3d}  {len(reachable):9d}  {wrong:5d}  "
          f"{wrong / len(reachable):11.4f}")
print()
print("quantum 2 reachable:")
for d in quantum_interleavings(3, 2):
    print("  ", d, "-> counter", counter_run(d))
print("quantum 3 reachable:")
for d in quantum_interleavings(3, 3):
    print("  ", d, "-> counter", counter_run(d))
quantum  reachable  wrong  wrong ratio
    1         20     18       0.9000
    2          6      4       0.6667
    3          2      0       0.0000
    4          2      0       0.0000
    5          2      0       0.0000

quantum 2 reachable:
   [0, 0, 0, 1, 1, 1] -> counter 2
   [0, 0, 1, 1, 0, 1] -> counter 1
   [0, 0, 1, 1, 1, 0] -> counter 1
   [1, 1, 0, 0, 0, 1] -> counter 1
   [1, 1, 0, 0, 1, 0] -> counter 1
   [1, 1, 1, 0, 0, 0] -> counter 2
quantum 3 reachable:
   [0, 0, 0, 1, 1, 1] -> counter 2
   [1, 1, 1, 0, 0, 0] -> counter 2

At quantum 1, the scheduler can switch at every step, and all 20 interleavings are reachable; the wrong ratio is 0.9000. At quantum 2, the reachable interleavings drop to 6, wrong is 4, and the ratio is 0.6667. At quantum 3, the reachable interleavings drop to 2, and none of them are wrong.

The Error Did Not Disappear, It Became Invisible

The two interleavings that remain at quantum 3 are [0, 0, 0, 1, 1, 1] and [1, 1, 1, 0, 0, 0]. Both run one thread’s three steps uninterrupted. Because the quantum is greater than or equal to the critical section, no switch can land in the middle of the critical section.

How this result is read is the course’s distinguishing point. The program has not changed: the counter is still unprotected, the increment is still three steps, the critical section is still open. The only thing that changed is the scheduler’s setting. If run a thousand times at quantum 3, it would print 2 a thousand times; these runs produce no evidence at all that the program is correct — they only show that the error is unreachable at that setting.

The setting is not a guarantee. Drop to quantum 2, and the wrong result comes back immediately: 4 of the 6 interleavings are broken. If the critical section grows from three steps to four, quantum 3 is no longer enough either. And once a second core — the subject of the next lesson — is added, the quantum’s protection disappears entirely, because the two threads then genuinely advance at the same time.

The rule that follows is this: the existence of a concurrency error is tested not by output but by the set of reachable interleavings. Output is a sample; the set is proof.

Run Count Is Not Proof

The quantum is not the only way to forbid a switch; making it rare produces the same result. A real critical section is a few steps, and a real quantum is thousands of steps; the probability of a switch landing on exactly those few steps is small. Small does not mean absent, but it erases the observation.

CC6. The switch probability is per step and constant throughout the run; sampling changes not the reachable set but how often that set is visited.

The measurement below counts this. At every step, the scheduler switches to the other thread with a certain probability; two thousand runs are performed and the wrong results are counted. The generator is the shared definition’s generator, with the same seed; the result is deterministic.

SEED = 20260218


def generator(seed):
    """Deterministic pseudo-random generator. The same seed gives the same sequence."""
    d = seed

    def next_val(n):
        nonlocal d
        d = (d * 1103515245 + 12345) % 2147483648
        return d % n
    return next_val


def counter_run(pattern: list[int]) -> int:
    counter, local = 0, {0: None, 1: None}
    stage = {0: 0, 1: 0}
    for who in pattern:
        s = stage[who]
        if s == 0:
            local[who] = counter
        elif s == 1:
            local[who] = local[who] + 1
        else:
            counter = local[who]
        stage[who] = s + 1
    return counter


def sample_run(r, per_ten_thousand: int) -> list[int]:
    """The scheduler switches to the other thread with probability `per_ten_thousand` at each step."""
    remaining, who, pattern = {0: 3, 1: 3}, 0, []
    while remaining[0] or remaining[1]:
        if not remaining[who] or (remaining[1 - who] and r(10000) < per_ten_thousand):
            who = 1 - who
        pattern.append(who)
        remaining[who] -= 1
    return pattern


print("switch probability  runs  wrong  observed wrong ratio")
for per_ten_thousand in (5000, 1000, 100, 10, 1):
    r = generator(SEED)
    wrong = sum(1 for _ in range(2000) if counter_run(sample_run(r, per_ten_thousand)) != 2)
    print(f"  per 10000 {per_ten_thousand:6d}   2000  {wrong:6d}  {wrong / 2000:21.4f}")
switch probability  runs  wrong  observed wrong ratio
  per 10000   5000   2000    1519                 0.7595
  per 10000   1000   2000     346                 0.1730
  per 10000    100   2000      40                 0.0200
  per 10000     10   2000       1                 0.0005
  per 10000      1   2000       0                 0.0000

The last row is the lesson’s sentence: in two thousand of two thousand runs the counter comes out 2 — and the program is still wrong. The rows in between are just as instructive — at a probability of ten per ten thousand, only 1 wrong result is seen in two thousand runs. A test that happens to catch this single observation will, if run again, most likely come back clean, and the bug will be considered “fixed.”

The reachable interleaving set did not change across any of these five rows: 20 interleavings, 18 wrong. What changed is only how often the sample visits that set. Increasing the run count is not a solution; seeing the error at its expected value at a probability of one per ten thousand requires hundreds of thousands of runs, and even that number produces no proof — only observation.

Three Numbers

Metric Baseline without abstraction Setup with abstraction Cost
Execution order single thread, 1 order two threads, 20 interleavings 19 extra orders
Wrong result 0 18 18 broken executions
Wrong ratio 0.0000 0.9000

A fourth row shows what the quantum setting does: at quantum 3, reachable interleavings are 2, wrong is 0, ratio 0.0000. This row enters the table not as a fix but as a measure of visibility. The error itself is still sitting in 18 interleavings; those interleavings cannot be reached with this setting.

The Real Fix Is Exclusion, Not a Setting

What eliminates a race condition cannot be how the scheduler happens to be set, because the scheduler is not under the program’s control. What eliminates it is mutual exclusion: protecting the critical section so that it admits at most one thread at any given moment.

The shared definition’s counter_run routine carries this as an option: when lock=True is passed, the critical section is treated as indivisible, and the routine returns 2 regardless of the interleaving. All twenty of the twenty interleavings become correct. What is gained is not narrowing the reachable set but making every interleaving in that set correct.

This has a cost, and that cost is the next lesson’s measurement: once the critical section is serialized, threads wait for one another, waiting steps accumulate, and processor utilization drops.

Summary

  • Incrementing a shared counter is three steps — read, increment, write; execution can be split between them, and when it is, one write overwrites the other.
  • Two threads’ three-step sequences have 20 interleavings; 18 produce a wrong result, a wrong ratio of 0.9000.
  • The scheduler’s quantum narrows the reachable interleaving set: 20 interleavings are reachable at quantum 1, 6 at quantum 2, and 2 at quantum 3.
  • When the quantum is greater than or equal to the critical section, none of the reachable interleavings are wrong; the program has not changed, only the error has become unreachable.
  • A concurrency error cannot be tested by output; testing is done over the reachable interleaving set.

Next Step

This lesson set up the problem and named the fix: mutual exclusion. The next lesson builds that fix and counts its cost. Threads with a 10-step critical section and 10 steps of non-critical work will be run under a mutex; it will be shown that a single thread takes 20 time units, that a second thread raises this to 29, a fourth to 47, and an eighth to 83, and that the waiting steps rise from zero to 252. How a semaphore brings that number down, and what it relaxes in doing so, sits in the same table.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close