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:
- No shared state: functions that only use local variables are inherently thread-safe
- Immutable shared state: read-only data shared between threads needs no synchronization
- 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
ifinstead ofwhilewith condition variables — Spurious wakeups are possible. Always usewhile (condition)aroundpthread_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 errors —
pthread_createcan 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 awhileloop aroundpthread_cond_waitto 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.