Modern systems demand concurrent programming. Let’s master POSIX threads and synchronization primitives.A useful mental model: threads are like multiple cooks in a shared kitchen. They can all work simultaneously (parallelism), but if two cooks try to use the same knife at the same time, someone gets cut (data corruption). Synchronization primitives are the rules that prevent collisions — “only one person uses the stove at a time” (mutex), “wait until the ingredients are prepped before cooking” (condition variable), “anyone can read the recipe, but only one person can write changes” (read-write lock).
Threads are “lightweight processes”. Unlike fork(), which creates a copy of the process, threads share the same memory space (heap, data segments, file descriptors) but have their own stack and registers.Key differences:
Shared Memory: All threads can access global variables and the heap.
Independent Execution: Each thread runs independently (scheduled by the kernel).
Low Overhead: Creating a thread is much faster than creating a process.
#include <stdio.h>#include <stdlib.h>#include <pthread.h>#include <unistd.h>void *thread_function(void *arg) { int thread_num = *(int*)arg; printf("Thread %d starting\n", thread_num); sleep(1); // Simulate work printf("Thread %d finishing\n", thread_num); // Return value (will be collected by pthread_join) int *result = malloc(sizeof(int)); *result = thread_num * 10; return result;}int main(void) { pthread_t threads[5]; int thread_args[5]; // Create threads for (int i = 0; i < 5; i++) { thread_args[i] = i; int ret = pthread_create(&threads[i], NULL, thread_function, &thread_args[i]); if (ret != 0) { fprintf(stderr, "pthread_create failed: %d\n", ret); return 1; } } // Wait for threads to complete for (int i = 0; i < 5; i++) { void *result; pthread_join(threads[i], &result); printf("Thread %d returned: %d\n", i, *(int*)result); free(result); } return 0;}// Compile with: gcc -pthread program.c -o program
When multiple threads access shared data concurrently, race conditions occur. Without proper synchronization, the final state of your data becomes unpredictable.
Use synchronization primitives (mutexes, condition variables, atomics) to ensure only one thread accesses shared data at a time. The diagram above shows a classic Producer-Consumer pattern where:
Mutex provides mutual exclusion (only one thread in critical section)
Condition variables allow threads to wait for specific conditions
Bounded queue is the shared resource protected by synchronization
Key Insight: Synchronization trades performance (threads must wait) for correctness (data remains consistent).
Atomics provide lock-free thread safety for individual variables. A common misconception: volatile does NOT provide thread safety in C. volatile only prevents the compiler from caching the value in a register — it says nothing about CPU caches, memory ordering, or atomicity. For thread-safe shared variables, you need _Atomic (or explicit mutexes). This is a frequent source of subtle bugs in code ported from single-core embedded systems to multi-core servers.
#include <stdio.h>#include <stdatomic.h>#include <pthread.h>atomic_int counter = 0;void *atomic_increment(void *arg) { for (int i = 0; i < 100000; i++) { atomic_fetch_add(&counter, 1); } return NULL;}// Lock-free stacktypedef struct Node { int data; struct Node *next;} Node;typedef struct { _Atomic(Node*) head;} LockFreeStack;void lfs_push(LockFreeStack *stack, int value) { Node *node = malloc(sizeof(Node)); node->data = value; Node *old_head = atomic_load(&stack->head); do { node->next = old_head; } while (!atomic_compare_exchange_weak(&stack->head, &old_head, node));}int lfs_pop(LockFreeStack *stack, int *value) { Node *old_head = atomic_load(&stack->head); Node *new_head; do { if (old_head == NULL) return 0; // Empty new_head = old_head->next; } while (!atomic_compare_exchange_weak(&stack->head, &old_head, new_head)); *value = old_head->data; free(old_head); // DANGER: This is a classic ABA problem / use-after-free. // Between loading old_head and the successful CAS, another thread could have // popped this node, freed it, and pushed a NEW node at the same address. // The CAS succeeds (same pointer value) but old_head->next is now garbage. // Production lock-free code uses hazard pointers, epoch-based reclamation, // or tagged pointers to solve this. Do not use this pattern in real systems. return 1;}// Memory orderingvoid memory_ordering_example(void) { atomic_int x = 0, y = 0; // Relaxed (weakest) atomic_store_explicit(&x, 1, memory_order_relaxed); // Release (writes before are visible) atomic_store_explicit(&x, 1, memory_order_release); // Acquire (reads after see previous writes) int val = atomic_load_explicit(&x, memory_order_acquire); // Sequentially consistent (strongest, default) atomic_store(&x, 1); // Uses memory_order_seq_cst}
Deadlocks occur when threads wait for each other in a circular dependency. The diagram above shows the classic scenario where Thread 1 holds Mutex A and wants Mutex B, while Thread 2 holds Mutex B and wants Mutex A.
1. Lock Ordering (Most Common)Always acquire locks in the same global order across all threads - this breaks the circular wait condition.2. Lock TimeoutUse pthread_mutex_timedlock() and retry:
struct timespec timeout;clock_gettime(CLOCK_REALTIME, &timeout);timeout.tv_sec += 1;if (pthread_mutex_timedlock(&mutex_b, &timeout) != 0) { // Couldn't get lock, release what we have pthread_mutex_unlock(&mutex_a); // Retry or handle error}
3. Try-Lock and Backoff
pthread_mutex_lock(&mutex_a);if (pthread_mutex_trylock(&mutex_b) != 0) { // Couldn't get mutex_b, release mutex_a pthread_mutex_unlock(&mutex_a); usleep(rand() % 1000); // Random backoff // Retry}
Best Practice: Design your system to minimize the number of locks needed simultaneously. Consider using lock-free data structures or message passing instead of shared memory.
Race Condition (TOCTOU — Time of Check to Time of Use)
The check-then-act pattern is one of the most common concurrency bugs. Between the moment you check a condition and the moment you act on it, another thread can change the state, making your action invalid. This class of bug is called TOCTOU (Time Of Check to Time Of Use) and it appears not just in threading but also in file system operations (checking if a file exists, then opening it — another process can delete it in between).
// Dangerous: check-then-act without lockvoid dangerous_update(void) { if (counter > 0) { // Thread 1 checks: counter is 1 // <-- Thread 2 runs here and decrements counter to 0 counter--; // Thread 1 decrements: counter is now -1 (BUG!) }}// Safe: check and act atomicallyvoid safe_update(pthread_mutex_t *mutex) { pthread_mutex_lock(mutex); if (counter > 0) { counter--; } pthread_mutex_unlock(mutex);}