4 min read
On this page

Concurrency in C

Concurrency in C means threads that share memory. This is both powerful and dangerous. POSIX threads (pthreads) give you the primitives: create threads, lock mutexes, wait on condition variables. C11 added atomic operations to the language standard. Every concurrency model in every language — Go's goroutines, Rust's ownership system, Java's synchronized blocks — exists to solve the same problem that C makes explicit: shared mutable state is hard to get right.

POSIX Threads: Creating & Joining

A thread is a separate flow of execution within a process. All threads share the process's address space, file descriptors, and global variables.

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

void *worker(void *arg) {
    int id = *(int *)arg;
    printf("Thread %d running\n", id);
    return NULL;
}

int main(void) {
    pthread_t threads[4];
    int ids[4];

    for (int i = 0; i < 4; i++) {
        ids[i] = i;
        int err = pthread_create(&threads[i], NULL, worker, &ids[i]);
        if (err != 0) {
            fprintf(stderr, "pthread_create failed: %d\n", err);
            return 1;
        }
    }

    for (int i = 0; i < 4; i++) {
        pthread_join(threads[i], NULL);
    }

    printf("All threads finished\n");
    return 0;
}
gcc -pthread program.c -o program
./program
Thread 0 running
Thread 2 running
Thread 1 running
Thread 3 running
All threads finished

The output order varies between runs. Threads execute concurrently, and the scheduler determines the order.

Thread Return Values

void *compute(void *arg) {
    int input = *(int *)arg;
    int *result = malloc(sizeof(int));
    *result = input * input;
    return result;
}

int main(void) {
    int value = 7;
    pthread_t thread;
    pthread_create(&thread, NULL, compute, &value);

    void *ret;
    pthread_join(thread, &ret);
    int *result = ret;
    printf("Result: %d\n", *result);
    free(result);

    return 0;
}
Result: 49

Detached Threads

A detached thread cleans up its own resources when it finishes. You cannot join a detached thread.

pthread_t thread;
pthread_create(&thread, NULL, background_task, NULL);
pthread_detach(thread); /* No need to join */

Use detached threads for fire-and-forget background work. Use joinable threads when you need to wait for the result or ensure completion.

Mutexes: Mutual Exclusion

When multiple threads access shared data, you need mutual exclusion to prevent data races. A mutex ensures only one thread can access the protected data at a time.

The Problem

int counter = 0; /* Shared between threads */

void *increment(void *arg) {
    (void)arg;
    for (int i = 0; i < 1000000; i++) {
        counter++; /* DATA RACE: read-modify-write is not atomic */
    }
    return NULL;
}

Two threads running this function will not produce 2,000,000. The counter++ operation reads the value, adds 1, and writes it back. If both threads read the same value simultaneously, one increment is lost.

The Solution

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

int counter = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void *increment(void *arg) {
    (void)arg;
    for (int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&lock);
        counter++;
        pthread_mutex_unlock(&lock);
    }
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, increment, NULL);
    pthread_create(&t2, NULL, increment, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    printf("Counter: %d\n", counter);
    return 0;
}
Counter: 2000000

The mutex guarantees that only one thread executes counter++ at a time. The result is always correct.

Mutex Best Practices

/* Always pair lock and unlock - use a consistent pattern */
pthread_mutex_lock(&lock);
/* Critical section: access shared data here */
do_work_on_shared_data();
pthread_mutex_unlock(&lock);

/* Hold the lock for the shortest time possible */
pthread_mutex_lock(&lock);
int local_copy = shared_value; /* Copy while locked */
pthread_mutex_unlock(&lock);
process(local_copy); /* Process the copy without the lock */

Condition Variables: Signaling Between Threads

A condition variable lets a thread wait until another thread signals that a condition is true. This is the mechanism for producer-consumer patterns.

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

#define QUEUE_SIZE 10

struct Queue {
    int items[QUEUE_SIZE];
    int front;
    int rear;
    int count;
    pthread_mutex_t lock;
    pthread_cond_t not_empty;
    pthread_cond_t not_full;
};

void queue_init(struct Queue *q) {
    q->front = 0;
    q->rear = 0;
    q->count = 0;
    pthread_mutex_init(&q->lock, NULL);
    pthread_cond_init(&q->not_empty, NULL);
    pthread_cond_init(&q->not_full, NULL);
}

void queue_push(struct Queue *q, int item) {
    pthread_mutex_lock(&q->lock);
    while (q->count == QUEUE_SIZE) {
        pthread_cond_wait(&q->not_full, &q->lock); /* Wait until space */
    }
    q->items[q->rear] = item;
    q->rear = (q->rear + 1) % QUEUE_SIZE;
    q->count++;
    pthread_cond_signal(&q->not_empty); /* Wake a waiting consumer */
    pthread_mutex_unlock(&q->lock);
}

int queue_pop(struct Queue *q) {
    pthread_mutex_lock(&q->lock);
    while (q->count == 0) {
        pthread_cond_wait(&q->not_empty, &q->lock); /* Wait until data */
    }
    int item = q->items[q->front];
    q->front = (q->front + 1) % QUEUE_SIZE;
    q->count--;
    pthread_cond_signal(&q->not_full); /* Wake a waiting producer */
    pthread_mutex_unlock(&q->lock);
    return item;
}

The while loop around pthread_cond_wait is essential — condition variables can wake spuriously (without being signaled). Always recheck the condition after waking.

Producer-Consumer Example

void *producer(void *arg) {
    struct Queue *q = arg;
    for (int i = 0; i < 20; i++) {
        queue_push(q, i);
        printf("Produced: %d\n", i);
    }
    return NULL;
}

void *consumer(void *arg) {
    struct Queue *q = arg;
    for (int i = 0; i < 20; i++) {
        int item = queue_pop(q);
        printf("Consumed: %d\n", item);
    }
    return NULL;
}

int main(void) {
    struct Queue q;
    queue_init(&q);

    pthread_t prod, cons;
    pthread_create(&prod, NULL, producer, &q);
    pthread_create(&cons, NULL, consumer, &q);
    pthread_join(prod, NULL);
    pthread_join(cons, NULL);

    pthread_mutex_destroy(&q.lock);
    pthread_cond_destroy(&q.not_empty);
    pthread_cond_destroy(&q.not_full);
    return 0;
}

Read-Write Locks

When reads are much more frequent than writes, a read-write lock allows multiple concurrent readers but exclusive access for writers.

pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;

void *reader(void *arg) {
    pthread_rwlock_rdlock(&rwlock);
    /* Multiple readers can hold this simultaneously */
    read_shared_data();
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

void *writer(void *arg) {
    pthread_rwlock_wrlock(&rwlock);
    /* Exclusive access - no readers or other writers */
    modify_shared_data();
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

Atomic Operations (C11)

C11 introduced stdatomic.h for lock-free operations on shared variables. Atomics are faster than mutexes for simple operations like incrementing a counter.

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

atomic_int counter = 0;

void *increment(void *arg) {
    (void)arg;
    for (int i = 0; i < 1000000; i++) {
        atomic_fetch_add(&counter, 1);
    }
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, increment, NULL);
    pthread_create(&t2, NULL, increment, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    printf("Counter: %d\n", atomic_load(&counter));
    return 0;
}
Counter: 2000000

Atomic operations guarantee that the read-modify-write happens as a single indivisible step. No mutex is needed for single-variable operations.

When to Use Atomics vs Mutexes

  • Atomics: single variable updates (counters, flags, simple state)
  • Mutexes: multi-variable invariants (anything where multiple values must be updated together consistently)
/* Atomics are NOT enough here */
atomic_int balance_a, balance_b;

void transfer(int amount) {
    /* These two operations are individually atomic but not jointly atomic */
    atomic_fetch_sub(&balance_a, amount);
    /* Another thread could read inconsistent state here */
    atomic_fetch_add(&balance_b, amount);
}

Use a mutex when you need multiple operations to appear as a single atomic unit.

Thread Safety

A function is thread-safe if it can be called simultaneously from multiple threads without producing incorrect results. There are three strategies:

  1. No shared state: functions that only use local variables are inherently thread-safe
  2. Immutable shared state: read-only data shared between threads needs no synchronization
  3. Synchronized mutable state: shared data protected by mutexes or atomics
/* Thread-safe: no shared state */
int add(int a, int b) {
    return a + b;
}

/* NOT thread-safe: static variable */
int get_next_id(void) {
    static int id = 0;
    return id++; /* Race condition */
}

/* Thread-safe: synchronized */
int get_next_id_safe(void) {
    static atomic_int id = 0;
    return atomic_fetch_add(&id, 1);
}

Common Pitfalls

  • Data races — Accessing shared data from multiple threads without synchronization is undefined behavior. Use mutexes, atomics, or thread-local storage. ThreadSanitizer (-fsanitize=thread) finds data races.
  • Deadlocks — Thread A holds lock 1 and waits for lock 2. Thread B holds lock 2 and waits for lock 1. Neither can proceed. Always acquire multiple locks in a consistent order.
  • Forgetting to unlock — If a function returns early or an error path skips pthread_mutex_unlock, the mutex stays locked forever. Consider wrapping critical sections in helper functions.
  • Using if instead of while with condition variables — Spurious wakeups are possible. Always use while (condition) around pthread_cond_wait.
  • Passing stack variables to threads — If the creating function returns before the thread reads the argument, the pointer becomes dangling. Either allocate on the heap or ensure the creator outlives the thread.
  • Over-locking — Holding a mutex during I/O or expensive computation serializes your program. Copy shared data under the lock and process it after unlocking.
  • Ignoring thread creation errorspthread_create can fail if the system runs out of resources. Always check the return value.

Key Takeaways

  • POSIX threads (pthread_create, pthread_join) provide the concurrency primitives in C. Compile with -pthread.
  • Mutexes (pthread_mutex_t) provide mutual exclusion. Lock before accessing shared data, unlock after. Hold the lock for the shortest time possible.
  • Condition variables (pthread_cond_t) let threads wait for conditions. Always use a while loop around pthread_cond_wait to handle spurious wakeups.
  • Read-write locks allow multiple concurrent readers but exclusive writers.
  • C11 atomics (stdatomic.h) provide lock-free operations for simple shared variables. Use mutexes for multi-variable invariants.
  • Thread safety requires either no shared state, immutable shared state, or properly synchronized mutable state.
  • The shared mutable state problem is fundamental. Go solves it with channels, Rust with ownership, Java with synchronized blocks. C gives you the raw primitives and trusts you to use them correctly.