Mutexes and condition variables

Threads share memory, and two of them writing the same variable at the same time is a data race: the result depends on the schedule. A mutex serialises the access, and a condition variable lets a thread wait for a state instead of spinning. Both are POSIX calls from pthread.h.

The lost update

The increment counter++ is a read, an addition and a write. Two threads can read the same value before either writes, so one increment is lost. With a million iterations each, the total should be two million; the program prints less, and the number changes between runs.

#include <pthread.h> #include <stdio.h> static long counter = 0; static void *bump(void *argument) { (void) argument; for (int i = 0; i < 1000000; i++) { counter++; } return NULL; } int main(void) { pthread_t first; pthread_t second; pthread_create(&first, NULL, bump, NULL); pthread_create(&second, NULL, bump, NULL); pthread_join(first, NULL); pthread_join(second, NULL); printf("counter: %ld\n", counter); return 0; }

gcc -Wall -Wextra -pthread race.c -o race
./race

counter: 1291398

The run printed 1291398 instead of 2000000, and other runs give other numbers; one run may even reach the full total by luck. A data race is undefined behaviour, so the count is not merely approximate: the compiler may also keep the value in a register and lose every update. The mutex is the fix.

A mutex

A mutex has two operations that matter: lock and unlock. Only one thread can hold it, so the increment runs alone even though the two threads take turns. The static initialiser PTHREAD_MUTEX_INITIALIZER avoids a separate create call for a global lock.

#include <pthread.h> #include <stdio.h> static long counter = 0; static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; static void *bump(void *argument) { (void) argument; for (int i = 0; i < 1000000; i++) { pthread_mutex_lock(&lock); counter++; pthread_mutex_unlock(&lock); } return NULL; } int main(void) { pthread_t first; pthread_t second; pthread_create(&first, NULL, bump, NULL); pthread_create(&second, NULL, bump, NULL); pthread_join(first, NULL); pthread_join(second, NULL); printf("counter: %ld\n", counter); return 0; }

gcc -Wall -Wextra -pthread mutex.c -o mutex
./mutex

counter: 2000000

The total is exact on every run. The lock is held for two instructions and released immediately, so the contention is low; a critical section that does I/O or arithmetic that takes long would make the threads queue and the program slower than one thread.

A condition variable

A consumer that has nothing to do should sleep, not spin. pthread_cond_wait unlocks the mutex, sleeps until a signal, and locks the mutex again before it returns. The wait runs in a loop because a signal can wake a thread that still has no work: the predicate must be true, not just the wake-up.

#include <pthread.h> #include <stdio.h> static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t ready = PTHREAD_COND_INITIALIZER; static int item = 0; static int has_item = 0; static void *consumer(void *argument) { (void) argument; pthread_mutex_lock(&lock); while (!has_item) { pthread_cond_wait(&ready, &lock); } printf("consumed %d\n", item); pthread_mutex_unlock(&lock); return NULL; } int main(void) { pthread_t thread; pthread_create(&thread, NULL, consumer, NULL); pthread_mutex_lock(&lock); item = 42; has_item = 1; pthread_cond_signal(&ready); pthread_mutex_unlock(&lock); pthread_join(thread, NULL); printf("producer done\n"); return 0; }

gcc -Wall -Wextra -pthread cond.c -o cond
./cond

consumed 42
producer done

The consumer took the lock first, found the predicate false and slept; the producer set the item, signalled and unlocked, and the consumer woke with the value. The signal is sent while the mutex is held, which is safe and easy to reason about; a broadcast would wake every waiter.

Locking twice

A default mutex is not recursive: a thread that locks it twice deadlocks itself. The program below locks the same mutex twice and never reaches the second print. There is no compiler warning, because the deadlock depends on the run-time state.

#include <pthread.h> #include <stdio.h> static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; int main(void) { pthread_mutex_lock(&lock); printf("locked once\n"); pthread_mutex_lock(&lock); printf("never reached\n"); return 0; }

gcc -Wall -Wextra -pthread deadlock.c -o deadlock
timeout 2 ./deadlock; echo "status: $?"

locked once
status: 124

124 is timeout's status, meaning the program was still waiting after two seconds. The fix is one lock and one unlock around the whole critical section, or a recursive mutex when the design really needs re-entry. The same deadlock appears in a different form when two threads take two locks in opposite orders.

Exercises

  1. Protect a shared array of ten counters with one mutex and have two threads update different elements; explain whether one lock is needed.
  2. Change the condition program to a queue of three items and make the consumer drain it in a loop.
  3. Run the race program with -fsanitize=thread and read the report. Does the sanitizer need anything extra here?

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. Why does the unsynchronised counter lose updates?

    Answer

    counter++ is a read, an add and a write

    Two threads can read the same value before either writes, so one increment overwrites the other.

  2. What does a mutex guarantee around a critical section?

    Answer

    Only one thread runs the section at a time

    The lock admits one holder, so the shared update is serialised even though the threads take turns.

  3. Why does pthread_cond_wait run inside a while loop?

    Answer

    A wake-up does not prove the predicate is true

    A signal can wake a thread that still has no work, so the predicate is checked again after every return.

  4. What causes the deadlock in the code shown?

    Answer

    The same thread locks a non-recursive mutex twice

    A default mutex is not recursive, so the second lock waits for a release that the same thread cannot make.

  5. Why can two threads running counter++ finish with a total below the expected count?

    Answer

    The read, add and write steps can interleave and overwrite

    Two threads can read the same value before either writes, so one increment overwrites the other.

  6. What does this program print? #include <pthread.h> #include <stdio.h> static long counter=0;static pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER; static void *bump(void *a){(void)a;for(int i=0;i<1000000;i++){pthread_mutex_lock(&lock);counter++;pthread_mutex_unlock(&lock);}return 0;} int main(void){pthread_t a,b;pthread_create(&a,0,bump,0);pthread_create(&b,0,bump,0);pthread_join(a,0);pthread_join(b,0);printf("%ld\n",counter);return 0;}

    Answer

    2000000

    Every increment runs under the lock, so both threads add their full million.

  7. What does this program print? #include <pthread.h> #include <stdio.h> static pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;static pthread_cond_t ready=PTHREAD_COND_INITIALIZER;static int item=0,has=0; static void *consumer(void *a){(void)a;pthread_mutex_lock(&lock);while(!has)pthread_cond_wait(&ready,&lock);printf("consumed %d\n",item);pthread_mutex_unlock(&lock);return 0;} int main(void){pthread_t t;pthread_create(&t,0,consumer,0);pthread_mutex_lock(&lock);item=42;has=1;pthread_cond_signal(&ready);pthread_mutex_unlock(&lock);pthread_join(t,0);printf("producer done\n");return 0;}

    Answer

    consumed 42 / producer done

    The producer sets the item and signals, the consumer consumes it, and the join lets the final line print.

  8. What exit status does the shell report when the code shown is stopped after two seconds? #include <pthread.h> #include <stdio.h> int main(void){pthread_mutex_t l=PTHREAD_MUTEX_INITIALIZER;pthread_mutex_lock(&l);printf("locked once\n");pthread_mutex_lock(&l);printf("never reached\n");return 0;}

    Answer

    124

    The second lock blocks forever, and the timeout command reports status 124.

  9. What is the fix for a mutex locked twice in one thread?

    Answer

    One lock around the whole section, or a recursive mutex

    The critical section needs one matching lock and unlock, and re-entry needs a mutex created as recursive.

  10. What is the risk of an if instead of a while around pthread_cond_wait?

    Answer

    A spurious wake-up continues without the data

    A wake-up can happen without the predicate being true, and the loop is what rechecks it.

The code on this page was run with gcc (Alpine 15.2.0) 15.2.0 on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev

Banner image