Skip to main content
C advanced Lesson 19 of 23

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.

Frequently Asked Questions

What is a race condition?
A race condition occurs when two threads access shared data concurrently and at least one is writing, producing results that depend on the unpredictable order of execution. The fix is synchronization: mutex locks, atomics, or lock-free algorithms.
What is the difference between a mutex and a semaphore?
A mutex is owned by the thread that locks it and must be unlocked by the same thread. It protects mutual exclusion to a resource. A semaphore is a counter that can be signaled by any thread — it's used for signaling and limiting concurrent access to a pool of resources.
What is a deadlock?
A deadlock occurs when two or more threads are each waiting for a lock held by the other, so none can proceed. Prevent deadlocks by always acquiring multiple locks in the same order across all threads.