Linux System Programming · beginner · ~8 min
- By the end you can explain the difference between a **process** and a **thread**, and say precisely which resources they share and which they keep private. - You can name three concrete reasons to use threads (parallelism, overlap of waiting, cheap shared state) and one big risk (data races). - You can write, compile (`-pthread`), and run a minimal C program that creates a worker thread and waits for it with `pthread_create`/`pthread_join`. - You can recognise a **race condition** in code and explain why a `mutex` fixes it. - You can read a thread's `void *` argument/return convention and pass data in safely.
You already know how to write functions — a named block of code you call, that takes arguments and returns a value. A thread is that same idea taken one step further: instead of calling a function and waiting for it to return, you hand the function to the operating system and say "run this at the same time as my current code." The function keeps the exact same shape you already know — it takes an argument and returns a value — but now two (or more) copies of your program's execution are moving through code simultaneously.
This lesson builds directly on functions: the thread entry point is a function with a fixed signature (void *f(void *)). Everything you know about local variables, parameters, and return values still applies — the twist is that several threads run inside one process and share its memory, which is both the whole point and the whole danger. The next lesson drills into pthread_create/pthread_join in detail; here we build the mental model.
Almost every non-trivial program you use is multithreaded: a web server handles thousands of connections on a thread pool, a browser paints one thread while fetching on another, a game runs physics and audio in parallel. Threads are how you keep an application responsive while something slow happens, and how you actually use the multiple CPU cores in every modern machine. But shared memory makes threads the single richest source of security-relevant bugs in C: data races produce undefined behaviour, and time-of-check/time-of-use (TOCTOU) gaps between threads have caused real privilege-escalation and memory-corruption CVEs. Understanding what threads share is the first line of defence.
A process is a running program. The kernel gives it a private address space (its own view of memory), its own set of open file descriptors, and its own signal handlers. Two processes are isolated: one cannot see the other's variables.
A thread is a single flow of execution inside a process. One process can hold many threads. Crucially, threads in the same process share almost everything the process owns, but each keeps a private stack and its own CPU register state (including the program counter and stack pointer).
PROCESS (one address space)
+-------------------------------------------------+
| code (text) globals/BSS heap (malloc) | <-- SHARED by all threads
| file descriptor table | <-- SHARED
| |
| Thread A Thread B |
| +---------+ +---------+ |
| | stack A | | stack B | | <-- PRIVATE per thread
| | regs A | | regs B | | <-- PRIVATE (PC, SP, ...)
| +---------+ +---------+ |
+-------------------------------------------------+
Because the heap and globals are shared, a pointer created by thread A is valid in thread B — that is how threads communicate. Because each stack is private, a local variable in one thread is invisible to another (unless you deliberately share its address).
| Resource | Shared between threads? | Notes |
|---|---|---|
| Code / text segment | Yes | Same functions for everyone |
| Global & static variables | Yes | A common (and dangerous) sharing channel |
Heap (malloc'd memory) |
Yes | Pass a pointer; every thread sees it |
| Open file descriptors | Yes | All threads read/write the same fds |
| Process ID, signal dispositions | Yes | One PID for the whole process |
| Stack (locals, call frames) | No | Each thread has its own |
| Registers (PC, SP, general) | No | Each thread has its own |
errno |
No | Per-thread on modern POSIX |
Thread ID (pthread_t) |
No | Identifies each thread |
Knowledge check: two threads in the same process — do they share the heap and open file descriptors, or their stacks and registers?
They share the heap and file descriptors (and code and globals). Each thread keeps its own stack and registers. That split is the entire model: shared data for communication, private stack for independent execution.
The same shared memory that makes threads powerful makes them dangerous. When two threads touch the same variable and at least one of them writes, without coordination, you have a data race — and in C, a data race is undefined behaviour, not merely a wrong number.
Consider counter++. It looks atomic but is really three steps: load the value, add one, store it back.
counter = 0
Thread A: load 0 Thread B: load 0
Thread A: add 1 -> 1 Thread B: add 1 -> 1
Thread A: store 1 Thread B: store 1
result: counter == 1 (one increment was LOST; should be 2)
The fix is mutual exclusion: a mutex (mutual-exclusion lock) that lets only one thread execute the critical section at a time. Thread B must wait at pthread_mutex_lock until Thread A calls pthread_mutex_unlock. The example below runs both a locked and an unlocked counter side by side so you can see the lost updates.
On Linux and POSIX systems the C threading API is pthreads (POSIX threads): include <pthread.h>, and compile/link with -pthread (which both defines the right macros and links the thread runtime). The four functions you meet first: pthread_create (start a thread), pthread_join (wait for one to finish and reclaim it), pthread_mutex_lock/pthread_mutex_unlock (guard shared data).
Knowledge check: why can counter++ from two threads lose an update, and what removes the race?
counter++is a non-atomic read-modify-write; two threads can both read the old value before either stores, so one increment vanishes. Wrapping it inpthread_mutex_lock/unlock(or using an atomic type) serialises the three steps so they cannot interleave.
#include <pthread.h> /* all pthread_* declarations; compile with -pthread */
/* Start a new thread.
* thread : out-parameter; receives the new thread's handle.
* attr : thread attributes, or NULL for defaults (joinable, default stack).
* start : entry function, MUST have signature void *(*)(void *).
* arg : the single argument passed to start (as a void*).
* Returns 0 on success, or a positive error number (does NOT set errno). */
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start)(void *), void *arg);
/* Wait for `thread` to finish, then release its resources.
* retval : out-parameter; receives the thread's return value, or NULL to ignore.
* Returns 0 on success, else an error number. A joinable thread that is never
* joined leaks resources (a "zombie" thread). */
int pthread_join(pthread_t thread, void **retval);
/* Mutual-exclusion lock. Initialise statically or with pthread_mutex_init. */
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
int pthread_mutex_lock(pthread_mutex_t *m); /* blocks until the lock is held */
int pthread_mutex_unlock(pthread_mutex_t *m); /* must be called by the locker */
/* The entry-function signature you must match: one void* in, one void* out. */
void *worker(void *arg);
Conventions to remember: pthreads functions return an error number directly (0 = success) and do not set errno — check the return value. pthread_create writes the handle through its first argument. Every joinable thread must eventually be pthread_joined (or created detached) or it leaks. PTHREAD_MUTEX_INITIALIZER needs no matching destroy; a mutex made with pthread_mutex_init should be freed with pthread_mutex_destroy.
A process is a running program. It has its own memory, its own file descriptors, and its own signal handlers.
A thread is a single execution context inside a process. A process can have many threads.
Threads in the same process:
malloc). If two threads write the same variable without coordinating, you get a race condition — two threads racing to touch the same data, with undefined behaviour as the result.On Linux and POSIX systems, the C threading API is pthreads (POSIX threads).
<pthread.h>.-pthread flag when you compile.#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define NTHREADS 4
#define BUMPS 100000
/* Shared state lives in globals: EVERY thread sees the same bytes. */
static long shared_counter = 0; /* protected by the mutex below */
static long racy_counter = 0; /* deliberately UNprotected: shows a race */
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
/* Each worker receives its own argument through a void* and returns a void*. */
static void *worker(void *arg) {
int id = *(int *)arg; /* private copy: this int lives per-thread */
for (int i = 0; i < BUMPS; i++) {
/* Safe path: only one thread at a time may run the critical section. */
pthread_mutex_lock(&lock);
shared_counter++;
pthread_mutex_unlock(&lock);
/* Unsafe path: read-modify-write with no lock -> lost updates. */
racy_counter++;
}
printf("worker %d finished %d bumps\n", id, BUMPS);
return NULL; /* nothing to hand back this time */
}
int main(void) {
pthread_t threads[NTHREADS];
int ids[NTHREADS];
for (int i = 0; i < NTHREADS; i++) {
ids[i] = i;
/* &threads[i] out-handle, default attrs, entry fn, per-thread arg. */
if (pthread_create(&threads[i], NULL, worker, &ids[i]) != 0) {
perror("pthread_create");
return EXIT_FAILURE;
}
}
/* join = wait for the thread to end AND reclaim its resources. */
for (int i = 0; i < NTHREADS; i++)
pthread_join(threads[i], NULL);
long expected = (long)NTHREADS * BUMPS;
printf("expected total : %ld\n", expected);
printf("mutex-protected total: %ld (always correct)\n", shared_counter);
printf("racy total : %ld (usually too low: lost updates)\n", racy_counter);
return 0;
}
/* compile: cc -std=c11 -Wall -Wextra threads.c -o threads -pthread */
#include <pthread.h> brings in every pthread_* declaration. You still must pass -pthread at compile time for the program to link.shared_counter, racy_counter, lock (globals) — because they are globals, all four threads see the same variables. This is the shared-memory channel in action. lock is initialised with PTHREAD_MUTEX_INITIALIZER, the zero-setup static initialiser.static void *worker(void *arg) is the thread entry point. Its signature void *(void *) is fixed by the API — this is just a function (the prerequisite you know) with a specific shape.int id = *(int *)arg; casts the generic void * back to int * and copies the value into a local. id lives on this thread's private stack, so each thread has its own.pthread_mutex_lock / shared_counter++ / pthread_mutex_unlock block is the critical section: only one thread at a time may be between the lock and unlock, so the increment is never lost.racy_counter++; with no lock is the deliberate bug. Multiple threads read-modify-write it concurrently, so updates collide and the final value comes out too low — a visible data race.pthread_create(&threads[i], NULL, worker, &ids[i]) launches each thread: out-handle, default attributes, the entry function, and a pointer to this thread's own ids[i]. We store each id in its own array slot so the pointer stays valid and distinct.pthread_join(threads[i], NULL) blocks main until thread i finishes and reclaims its resources; NULL says we don't want its return value. Without joining, main might reach the printfs (or exit) before the workers finish.printfs compare the expected total against the always-correct locked counter and the usually-wrong racy counter, making the race observable.1. Forgetting -pthread when compiling.
/* compiled with: cc threads.c -o threads (no -pthread) */
Why it breaks: the linker cannot find the pthread runtime -> undefined reference to 'pthread_create'.
cc -std=c11 -Wall -Wextra threads.c -o threads -pthread /* fixed */
2. Sharing one loop variable's address with every thread.
for (int i = 0; i < N; i++)
pthread_create(&t[i], NULL, worker, &i); /* all threads point at the SAME i */
Why it breaks: i keeps changing (and goes out of scope); threads race to read it, so ids are wrong or garbage — a data race on i itself.
int ids[N];
for (int i = 0; i < N; i++) { ids[i] = i; pthread_create(&t[i], NULL, worker, &ids[i]); }
3. Modifying shared state with no lock.
global_total += n; /* two threads: read-modify-write races, updates lost */
Why it breaks: += is a non-atomic load/add/store; concurrent threads lose updates — undefined behaviour.
pthread_mutex_lock(&lock); global_total += n; pthread_mutex_unlock(&lock);
4. Never joining (or detaching) a thread.
pthread_create(&t, NULL, worker, NULL);
return 0; /* main returns; worker may never run, resources leak */
Why it breaks: main returning ends the whole process, possibly before the thread runs; an un-joined joinable thread also leaks its bookkeeping.
pthread_create(&t, NULL, worker, NULL);
pthread_join(t, NULL); /* wait for it, reclaim resources */
-Wall -Wextra) — a wrong entry-function signature or bad cast often shows up here first.-fsanitize=thread (ThreadSanitizer): it instruments memory accesses and prints the exact two stacks that raced. This is the single most effective tool for this topic. -fsanitize=address catches use-after-free and stack-overrun from bad pointer sharing.valgrind --tool=helgrind ./prog and --tool=drd detect lock-ordering problems and unprotected shared accesses without recompiling.BUMPS does), adding threads, or running in a loop — races are timing-dependent, so more contention makes them show up.gdb: use info threads to list threads, thread N to switch, and thread apply all bt to dump every thread's backtrace — invaluable when the program hangs (often a deadlock).printf is itself synchronised; interleaved-looking output is normal and does not by itself prove a bug._Atomic/<stdatomic.h> types for simple counters.malloc'd memory the thread frees).int id = *(int*)arg; does) so later changes to the source don't affect this thread.pthread_join (join establishes a happens-before edge, so the memory is safely visible)."hello from thread", then pthread_joins it in main. Confirm it compiles only with -pthread.void *. Have each print "thread <id> running". Make sure each thread sees a distinct, correct id (avoid the shared-&i bug).pthread_mutex_t, then in a second version using an _Atomic long and counter++. Compare the code and reason about which is simpler.1..k for its own k, return the result through the thread's void * return value, and have main collect all results via the second argument of pthread_join and print the grand total.pthread_mutex_lock/unlock) or an atomic type fixes it.#include <pthread.h>, create with pthread_create, wait/reclaim with pthread_join, and compile with -pthread.errno; every joinable thread must be joined.