Skip to main content
Parallel Programming · lesson 3 of 8
Parallel Programming intermediate Lesson 3 of 8

Race Conditions: A Sum That Gives Three Different Wrong Answers

Three runs of the same unsynchronized parallel sum produce three different wrong totals — reduction(+:sum) fixes it correctly and is 115x faster than the naive #pragma omp critical alternative, measured.

By SkillByExample · Updated

parallel-programming openmp race-conditions reduction

The same program, run three times, produces three different wrong answers — and the fix is also the fast version, not a slower-but-safer tradeoff.

The bug: a race condition with a different wrong answer every run

// race_sum.c
#include <stdio.h>
#include <omp.h>

int main() {
    long sum = 0;
    #pragma omp parallel for num_threads(8)
    for (int i = 1; i <= 1000000; i++) {
        sum += i;
    }
    printf("sum = %ld (expected 500000500000)\n", sum);
    return 0;
}
gcc -fopenmp -o race_sum race_sum.c
./race_sum
./race_sum
./race_sum
sum = 55969261013 (expected 500000500000)
sum = 197355475976 (expected 500000500000)
sum = 84382115934 (expected 500000500000)

Three runs, three different wrong totals, none of them correct. sum += i is really three separate steps — read sum, add i, write the result back — and eight threads doing this concurrently on the same shared variable interleave those steps unpredictably. One thread’s write can silently overwrite another’s, losing that addition entirely. Which additions get lost depends on OS thread scheduling, which is why the specific wrong answer changes every time.

The fix: reduction(+:sum)

// fixed_sum.c
#include <stdio.h>
#include <omp.h>

int main() {
    long sum = 0;
    #pragma omp parallel for num_threads(8) reduction(+:sum)
    for (int i = 1; i <= 1000000; i++) {
        sum += i;
    }
    printf("sum = %ld (expected 500000500000)\n", sum);
    return 0;
}
gcc -fopenmp -o fixed_sum fixed_sum.c
./fixed_sum
./fixed_sum
./fixed_sum
sum = 500000500000 (expected 500000500000)
sum = 500000500000 (expected 500000500000)
sum = 500000500000 (expected 500000500000)

Correct, every time. reduction(+:sum) gives each thread its own private copy of sum to accumulate into — no other thread can touch it, so there’s nothing to race over — and combines all eight private totals into the final answer in one step at the end.

Measuring the alternative: #pragma omp critical

#pragma omp critical also fixes the race — by forcing every += through a lock instead of giving each thread a private copy:

// critical_sum.c
#pragma omp parallel for num_threads(8)
for (int i = 1; i <= 1000000; i++) {
    #pragma omp critical
    sum += i;
}
gcc -fopenmp -o critical_sum critical_sum.c
./critical_sum
sum = 500000500000, time = 0.115 s
gcc -fopenmp -o timed_reduction timed_reduction.c   # same as fixed_sum.c, with timing added
./timed_reduction
sum = 500000500000, time = 0.001 s

Both correct — 0.115s versus 0.001s, a measured 115x difference. critical forces a million lock/unlock round trips, one per iteration, each one blocking every other thread that arrives while it’s held. reduction needs zero locking during the loop at all; the only coordination is combining eight private sums together once, at the very end.

Practice

1. Use a different reduction operator — find the maximum value in a loop.
int best = 0;
#pragma omp parallel for reduction(max:best)
for (int i = 0; i < 1000; i++) {
    int candidate = (i * 37) % 997;
    if (candidate > best) best = candidate;
}
printf("max = %d\n", best);
max = 996

reduction(max:best) works exactly like reduction(+:sum) — private per-thread copies, each tracking its own maximum, combined into the overall maximum at the end. Same pattern, same correctness guarantee, different combining operator.

2. Confirm the race condition reproduces with a smaller thread count too, just less severely.
OMP_NUM_THREADS=2 ./race_sum
sum = 107477038877 (expected 500000500000)

Still wrong — the race exists with as few as 2 threads racing on the same shared variable. Fewer threads means fewer possible interleavings and sometimes a result closer to correct by chance, but “closer” is not “correct,” and the bug is present regardless of thread count.

3. Verify reduction stays correct even at a much smaller thread count.
OMP_NUM_THREADS=2 ./fixed_sum
sum = 500000500000 (expected 500000500000)

Correct regardless of thread count, because reduction’s correctness comes from giving each thread its own private accumulator — that guarantee doesn’t depend on how many threads there happen to be.

Next: synchronization primitives — critical sections, atomics, and locks, and when each is the right tool.

Frequently Asked Questions

Why did the same program produce a different wrong answer on each run?
sum += i is not one atomic operation — it's a read of sum, an addition, and a write back, and multiple threads can interleave those three steps in any order. Which interleaving actually happens depends on OS thread scheduling, which varies run to run, so the corruption (and its exact wrong value) is different every time rather than being one fixed, reproducible bug.
Why is reduction(+:sum) both correct and fast?
Each thread gets its own private copy of sum, accumulates into it with no synchronization needed at all (since nothing else touches that private copy), and OpenMP combines all the private copies into the final result once, at the end of the parallel region. Only one combination step ever needs coordination, instead of every single += needing it.
Why was #pragma omp critical 115x slower than reduction for the same total?
critical forces every single iteration's += through a mutex, one thread at a time — for a million iterations, that's a million lock/unlock round trips, each one blocking every other thread that arrives while it's held. reduction needs zero locking during the loop itself; the only synchronization happens once, combining eight private sums together at the very end.
Is #pragma omp critical ever the right tool?
Yes — for protecting an update that reduction's built-in operators (+, -, *, min/max, and a few others) can't express, like updating a shared data structure or calling a non-thread-safe function. For a plain running total, reduction is faster by construction; critical exists for the more general case reduction doesn't cover.
Does reduction work with operations other than addition?
Yes — reduction(*:product), reduction(max:best), reduction(min:worst), and several others are built in, each with its own correct identity value and combination logic. The clause syntax is the same shape regardless of which operator is used: reduction(operator:variable).