Concurrency with pthreads in C
Learn POSIX threads, mutex locks, semaphores, condition variables, and how to avoid race conditions in C.
Creating and Joining Threads
POSIX threads (pthreads) let a single process run multiple concurrent tasks. Each thread has its own stack but shares the process’s heap, globals, and file descriptors. Threading is essential for utilizing multi-core CPUs and for keeping a program responsive while waiting on I/O. pthread_create starts a thread; pthread_join waits for it to finish and collects its return value.
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
typedef struct {
int id;
int iterations;
} ThreadArgs;
void *worker(void *arg) {
ThreadArgs *args = (ThreadArgs *)arg;
for (int i = 0; i < args->iterations; i++) {
printf("Thread %d: iteration %d\n", args->id, i);
usleep(10000); /* 10ms — simulate work */
}
/* Allocate result on the heap — it must outlive the thread */
int *result = malloc(sizeof(int));
*result = args->id * 100;
return result; /* returned to pthread_join caller */
}
int main(void) {
const int NUM_THREADS = 4;
pthread_t threads[NUM_THREADS];
ThreadArgs args[NUM_THREADS];
/* Create all threads — they start running immediately */
for (int i = 0; i < NUM_THREADS; i++) {
args[i].id = i;
args[i].iterations = 3;
if (pthread_create(&threads[i], NULL, worker, &args[i]) != 0) {
perror("pthread_create");
return 1;
}
}
/* Wait for each thread and collect its return value */
for (int i = 0; i < NUM_THREADS; i++) {
void *retval;
pthread_join(threads[i], &retval);
int *result = (int *)retval;
printf("Thread %d returned: %d\n", i, *result);
free(result); /* caller owns the heap-allocated result */
}
return 0;
}
Compile with -lpthread: gcc -Wall -std=c11 -o prog prog.c -lpthread
Mutex — Protecting Shared Data
When multiple threads read and write the same variable, the result depends on which thread runs first — a race condition. A mutex (mutual exclusion lock) ensures only one thread at a time executes the critical section. Without it, even a simple increment (counter++) is unsafe because it compiles to three non-atomic operations: read, add, write.
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define NUM_THREADS 8
#define INCREMENTS 100000
/* Shared counter — every thread reads and writes this */
static long counter = 0;
static pthread_mutex_t counter_lock = PTHREAD_MUTEX_INITIALIZER;
void *increment_counter(void *arg) {
(void)arg;
for (int i = 0; i < INCREMENTS; i++) {
pthread_mutex_lock(&counter_lock);
counter++; /* critical section — only one thread here at a time */
pthread_mutex_unlock(&counter_lock);
}
return NULL;
}
int main(void) {
pthread_t threads[NUM_THREADS];
for (int i = 0; i < NUM_THREADS; i++) {
pthread_create(&threads[i], NULL, increment_counter, NULL);
}
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
printf("Expected: %ld\n", (long)NUM_THREADS * INCREMENTS);
printf("Got: %ld\n", counter); /* correct with mutex */
pthread_mutex_destroy(&counter_lock);
return 0;
}
Without the mutex, the counter would be less than expected because increments would be lost due to race conditions.
Producer-Consumer with Condition Variables
Condition variables solve the problem of one thread waiting for a condition that another thread will satisfy. They are always used with a mutex: the mutex protects the shared state, and the condition variable provides an efficient way to sleep until the state changes — without busy-waiting.
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define BUFFER_SIZE 8
#define NUM_ITEMS 20
typedef struct {
int buffer[BUFFER_SIZE];
int count, head, tail;
pthread_mutex_t lock;
pthread_cond_t not_full; /* signaled when a slot becomes available */
pthread_cond_t not_empty; /* signaled when an item is added */
} BoundedQueue;
void bq_init(BoundedQueue *q) {
q->count = q->head = q->tail = 0;
pthread_mutex_init(&q->lock, NULL);
pthread_cond_init(&q->not_full, NULL);
pthread_cond_init(&q->not_empty, NULL);
}
void bq_push(BoundedQueue *q, int val) {
pthread_mutex_lock(&q->lock);
while (q->count == BUFFER_SIZE) {
pthread_cond_wait(&q->not_full, &q->lock); /* atomically release lock and sleep */
}
q->buffer[q->tail] = val;
q->tail = (q->tail + 1) % BUFFER_SIZE;
q->count++;
pthread_cond_signal(&q->not_empty); /* wake one waiting consumer */
pthread_mutex_unlock(&q->lock);
}
int bq_pop(BoundedQueue *q) {
pthread_mutex_lock(&q->lock);
while (q->count == 0) {
pthread_cond_wait(&q->not_empty, &q->lock);
}
int val = q->buffer[q->head];
q->head = (q->head + 1) % BUFFER_SIZE;
q->count--;
pthread_cond_signal(&q->not_full); /* wake one waiting producer */
pthread_mutex_unlock(&q->lock);
return val;
}
static BoundedQueue queue;
void *producer(void *arg) {
int id = *(int *)arg;
for (int i = 0; i < NUM_ITEMS / 2; i++) {
int item = id * 100 + i;
bq_push(&queue, item);
printf("Producer %d: sent %d\n", id, item);
}
return NULL;
}
void *consumer(void *arg) {
int id = *(int *)arg;
for (int i = 0; i < NUM_ITEMS / 2; i++) {
int item = bq_pop(&queue);
printf("Consumer %d: got %d\n", id, item);
}
return NULL;
}
int main(void) {
bq_init(&queue);
pthread_t p1, p2, c1, c2;
int ids[] = {1, 2, 1, 2};
pthread_create(&p1, NULL, producer, &ids[0]);
pthread_create(&p2, NULL, producer, &ids[1]);
pthread_create(&c1, NULL, consumer, &ids[2]);
pthread_create(&c2, NULL, consumer, &ids[3]);
pthread_join(p1, NULL);
pthread_join(p2, NULL);
pthread_join(c1, NULL);
pthread_join(c2, NULL);
return 0;
}
Semaphores
A semaphore is a counter with two atomic operations: wait (decrement, block if zero) and post (increment, wake a waiter). Unlike mutexes, semaphores can be signaled by a different thread than the one that waited — making them ideal for signaling events and limiting concurrent access to a pool of resources.
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
/* Semaphore as a one-shot signal: worker waits until setup is complete */
sem_t ready_signal;
void *setup_thread(void *arg) {
(void)arg;
printf("Setup: doing initialization...\n");
sleep(1);
printf("Setup: done.\n");
sem_post(&ready_signal); /* signal that setup is complete */
return NULL;
}
void *worker_thread(void *arg) {
(void)arg;
printf("Worker: waiting for setup...\n");
sem_wait(&ready_signal); /* block until setup posts */
printf("Worker: setup complete, starting work.\n");
return NULL;
}
/* Semaphore as a resource pool: limit to MAX_CONNECTIONS concurrent uses */
#define MAX_CONNECTIONS 3
sem_t connection_pool;
void *use_connection(void *arg) {
int id = *(int *)arg;
sem_wait(&connection_pool); /* acquire one connection slot */
printf("Thread %d: using connection\n", id);
usleep(100000); /* simulate work */
printf("Thread %d: releasing connection\n", id);
sem_post(&connection_pool); /* release the slot for another thread */
return NULL;
}
int main(void) {
sem_init(&ready_signal, 0, 0); /* initial count = 0 — worker will block */
pthread_t t1, t2;
pthread_create(&t1, NULL, setup_thread, NULL);
pthread_create(&t2, NULL, worker_thread, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
sem_destroy(&ready_signal);
/* Resource pool example: only 3 of 8 threads can hold a connection at once */
sem_init(&connection_pool, 0, MAX_CONNECTIONS);
const int N = 8;
pthread_t threads[N];
int ids[N];
for (int i = 0; i < N; i++) {
ids[i] = i + 1;
pthread_create(&threads[i], NULL, use_connection, &ids[i]);
}
for (int i = 0; i < N; i++) pthread_join(threads[i], NULL);
sem_destroy(&connection_pool);
return 0;
}
Thread-Local Storage
Thread-local storage gives each thread its own private copy of a variable. This eliminates the need for locks on per-thread state like error codes, random number seeds, or logging context. The _Thread_local keyword (C11) or __thread (GCC extension) marks such variables.
#include <stdio.h>
#include <pthread.h>
/* Each thread has its own copy — reads and writes never interfere */
_Thread_local int thread_id = 0;
_Thread_local int error_code = 0;
void *thread_func(void *arg) {
thread_id = *(int *)arg;
error_code = 0;
/* Writing to thread_id here does not affect other threads' copies */
printf("Thread %d: my id is %d\n", thread_id, thread_id);
return NULL;
}
int main(void) {
pthread_t t[4];
int ids[] = {1, 2, 3, 4};
for (int i = 0; i < 4; i++) {
pthread_create(&t[i], NULL, thread_func, &ids[i]);
}
for (int i = 0; i < 4; i++) {
pthread_join(t[i], NULL);
}
return 0;
}
Atomic Operations (C11)
C11 introduced <stdatomic.h> for lock-free operations on single variables. Atomics are faster than mutexes for simple counter operations because they use hardware-level atomic instructions instead of OS-level locking. For complex multi-step operations that must be atomic as a group, you still need a mutex.
#include <stdio.h>
#include <pthread.h>
#include <stdatomic.h>
#define NUM_THREADS 8
#define INCREMENTS 100000
/* Atomic counter — hardware guarantees each increment is indivisible */
static atomic_long counter = 0;
void *increment(void *arg) {
(void)arg;
for (int i = 0; i < INCREMENTS; i++) {
atomic_fetch_add(&counter, 1); /* read-modify-write as a single atomic op */
}
return NULL;
}
int main(void) {
pthread_t threads[NUM_THREADS];
for (int i = 0; i < NUM_THREADS; i++) {
pthread_create(&threads[i], NULL, increment, NULL);
}
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
printf("Expected: %ld\n", (long)NUM_THREADS * INCREMENTS);
printf("Got: %ld\n", (long)counter); /* always correct */
return 0;
}
Atomics are faster than mutexes for simple counter operations. For complex multi-step operations that must be atomic as a group, use a mutex.