Module 9

Pthreads: Race Conditions and Mutexes

In Module 8, each worker wrote a different partial[id] slot. After joining the workers, the main thread combined their results. Now we will see what changes when several workers update the same variable.

This follows Chapter 4’s shared-sum problem (§4.4) and mutex solution (§4.6). The book estimates pi; we will start with a counter so we can concentrate on the shared update. The three complete programs are:

Why a shared update can go wrong

Suppose total is 10, and two workers each do total++. We expect 12. But an increment involves reading the old value, adding one, and writing the new value. One possible sequence is:

Step Worker A Worker B
1 Read 10
2 Read 10
3 Compute 11
4 Compute 11
5 Write 11
6 Write 11

One increment has been lost. This table illustrates the problem; it is not a promise that the compiler implements ++ as exactly these steps.

Predict: Would writing total = total + 1 solve it? Would joining the workers after they finish prevent this overlapping update?

Try an incorrect counter

Create pth_counter_race.c:

#include <stdio.h>
#include <pthread.h>

#define THREAD_COUNT 4
#define REPEATS 100000

long long total = 0;

void *count(void *argument) {
    (void)argument;
    for (int i = 0; i < REPEATS; i++) total++;
    return NULL;
}

int main(void) {
    pthread_t workers[THREAD_COUNT];
    for (int t = 0; t < THREAD_COUNT; t++) {
        pthread_create(&workers[t], NULL, count, NULL);
    }
    for (int t = 0; t < THREAD_COUNT; t++) {
        pthread_join(workers[t], NULL);
    }
    printf("Expected: %lld\n", (long long)THREAD_COUNT * REPEATS);
    printf("Observed: %lld\n", total);
    return 0;
}

Each worker does the same amount of work, so it does not need an ID. The last NULL in pthread_create supplies no argument. (void)argument tells the compiler that we intentionally do not use the parameter. It does not dereference a pointer or move any data.

gcc -Wall -O0 -pthread -o pth_counter_race pth_counter_race.c
./pth_counter_race
./pth_counter_race
./pth_counter_race

The intended result is 400,000, but this program has a data race: workers access the same memory without synchronization, and those accesses include writes. In C that is undefined behavior, so we cannot rely on the result. You might see lost updates, different results between runs, or even 400,000. A correct-looking run does not make the program correct. -O0 makes this a convenient demonstration; it does not repair the race. Do not use this incorrect version as a performance baseline.

The join loop makes the main thread wait for completion. It does not coordinate the workers’ increments while they run. Changing the spelling of the increment would not help either.

Protect the critical section

A critical section accesses shared data that needs protection. A mutex is a lock: only one worker can hold this lock at a time. Every worker that updates total must use the same mutex.

Copy the program:

cp pth_counter_race.c pth_counter_mutex.c

Add this shared declaration beside total:

pthread_mutex_t total_lock;

In main, initialize the mutex before creating any workers:

pthread_mutex_init(&total_lock, NULL);

Replace the worker’s loop with:

for (int i = 0; i < REPEATS; i++) {
    pthread_mutex_lock(&total_lock);
    total++;
    pthread_mutex_unlock(&total_lock);
}

After joining all workers, destroy the mutex before returning from main:

pthread_mutex_destroy(&total_lock);

The & passes the mutex’s address to these functions. This is the same address pattern we already used for IDs and thread handles. The second argument to initialization is NULL to request the default mutex attributes.

gcc -Wall -O0 -pthread -o pth_counter_mutex pth_counter_mutex.c
./pth_counter_mutex

The expected and observed values should now both be 400,000. As in our first thread examples, these teaching programs assume successful thread and mutex calls.

Discuss: Why must the lock cover the whole increment, rather than just the final write? What happens if a worker takes the lock and never unlocks it? Would a different mutex for each worker protect this shared total?

NoteWhat the mutex guarantees

The mutex protects access to total; it does not choose which worker goes first. If a worker waits for a lock, it cannot enter that critical section until it acquires the lock. All workers must follow the same locking rule.

Do most of the work locally

The previous version takes a lock 400,000 times. Only one worker at a time can update the counter, so the updates are effectively serialized. We can do better by keeping a local count and locking just once at the end.

Copy the mutex version to pth_counter_local.c. Keep the shared declarations, initialization, creation, joins, and destruction. Replace the worker function with:

void *count(void *argument) {
    (void)argument;
    long long local = 0;
    for (int i = 0; i < REPEATS; i++) local++;

    pthread_mutex_lock(&total_lock);
    total += local;
    pthread_mutex_unlock(&total_lock);
    return NULL;
}

Each worker’s local belongs to its own function call. The loop needs no lock. The shared total += local still needs protection, because several workers may reach that statement together.

gcc -Wall -O0 -pthread -o pth_counter_local pth_counter_local.c
./pth_counter_local

The answer is still 400,000, but there are now four lock acquisitions. This is the same improvement used by the book’s mutex-based pi program: calculate a local contribution, then protect the shared addition. For this simple counter, an optimizing compiler may simplify the local loop, so we will use it to understand synchronization rather than as a realistic performance benchmark.

Approach Who writes the combined result? How is it safe?
Module 8’s partial array Main, after the joins Workers use distinct slots; main waits before reading each result.
One lock per increment All workers Every increment uses the same mutex.
Local count, one locked addition All workers Local work is private; shared additions use the same mutex.

Check your understanding

  1. Why does pthread_join not repair the original race?
  2. Which variables in the local-count program need protection, and which do not?
  3. Where do initialization and destruction belong in the thread lifecycle?
  4. If workers filled distinct output-array elements, would every write need a lock?
  1. Joining waits for completion; the workers can still overlap their updates before finishing.
  2. Concurrent updates to total need the common mutex. A worker’s local counter does not. Main reads total after all joins, when workers no longer update it.
  3. Initialize before launching workers; destroy after they have all finished using the mutex.
  4. Distinct elements can be written without a lock when no other thread concurrently accesses those same elements in a conflicting way. Main waits for the workers before reading their output, as in Module 8.
Back to top