Threads and Concurrency in Operating Systems
Threads enable multiple execution paths within a single process. They share memory but must coordinate access to shared data. Understanding the thread model your operating system provides is essential for writing correct and efficient concurrent programs.
Processes vs. Threads
| Feature | Process | Thread |
|---|---|---|
| Address space | Separate (isolated) | Shared with process |
| Context switch overhead | High (TLB flush, page tables) | Low (registers only) |
| Creation time | Slow (fork + exec) | Fast (clone) |
| Communication | IPC (pipes, sockets, shared mem) | Shared memory (direct) |
| Fault isolation | Yes (process crash) | No (one thread crash = all crash) |
| Security | Strong (separate address space) | Weak (same address space) |
Process isolation is the operating system’s strongest security boundary. A crash in one process cannot corrupt another process’s memory, and an unprivileged process cannot read another process’s data. Threads sacrifice this isolation for performance — context switching between threads is orders of magnitude faster because the TLB and page tables remain unchanged.
Thread Models
User-Level Threads (N:1)
Many user threads mapped to one kernel thread.
User space: T1 T2 T3 T4 (user threads)
|
Kernel: P (single process/thread)| Pros | Cons |
|---|---|
| Fast context switch (no syscall) | Blocking one thread blocks all |
| Portable (no kernel support needed) | Can’t use multiple CPUs |
| Custom scheduling | Page faults block everything |
Kernel-Level Threads (1:1)
Each user thread mapped to a kernel thread.
User space: T1 T2 T3 (user threads)
| | |
Kernel: KT1 KT2 KT3 (kernel threads)| Pros | Cons |
|---|---|
| True parallelism (multiple CPUs) | Slower context switch (syscall) |
| Blocking one thread doesn’t block others | Kernel resources per thread |
| Kernel handles scheduling | Less control |
Hybrid (M:N)
Many user threads multiplexed on fewer kernel threads.
User space: T1 T2 T3 T4 T5 T6 (user threads)
\ / \ / \ /
Kernel: KT1 KT2 KT3 (kernel threads)Used in: Go (goroutines), Java (green threads).
Go’s goroutines are the most successful M:N implementation in widespread use. The Go runtime schedules millions of goroutines across a small pool of kernel threads, multiplexing them with cooperative sharing at yield points and preemption at safe points.
Thread States
New → Ready ↔ Running → Blocked → Terminated
↑ │
└─────────┘
(Preempted or yield)| State | Description |
|---|---|
| New | Created but not started |
| Ready | Waiting for CPU |
| Running | Executing on CPU |
| Blocked | Waiting for I/O, lock, or condition |
| Terminated | Finished execution |
POSIX Threads (pthreads)
Standard threading API in Unix-like systems.
Creating Threads
#include <pthread.h>
void *worker(void *arg) {
int id = *(int *)arg;
printf("Thread %d running\n", id);
return NULL;
---
int main() {
pthread_t threads[4];
int ids[4] = {0, 1, 2, 3};
for (int i = 0; i < 4; i++) {
pthread_create(&threads[i], NULL, worker, &ids[i]);
}
for (int i = 0; i < 4; i++) {
pthread_join(threads[i], NULL);
}
return 0;
---Mutex
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void *safe_increment(void *arg) {
int *counter = (int *)arg;
pthread_mutex_lock(&lock);
(*counter)++; // Critical section
pthread_mutex_unlock(&lock);
return NULL;
---Condition Variables
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int ready = 0;
// Consumer
void *consumer(void *arg) {
pthread_mutex_lock(&lock);
while (!ready) {
pthread_cond_wait(&cond, &lock); // Release lock, wait
}
// ready == true, proceed
pthread_mutex_unlock(&lock);
return NULL;
---
// Producer
void *producer(void *arg) {
pthread_mutex_lock(&lock);
ready = 1;
pthread_cond_signal(&cond); // Wake one consumer
pthread_mutex_unlock(&lock);
return NULL;
---Thread Pools
Creating and destroying threads is expensive. Pre-create a pool:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
ExecutorService pool = Executors.newFixedThreadPool(4);
for (Task task : tasks) {
pool.submit(() -> processTask(task));
---
pool.shutdown();| Thread Pool | Use Case |
|---|---|
| Fixed thread pool | Known workload, bounded resources |
| Cached thread pool | Many short-lived tasks |
| Single thread | Serial execution |
| Work-stealing (ForkJoin) | Divide-and-conquer |
Work-stealing thread pools, like Java’s ForkJoinPool, allow idle threads to steal tasks from busy threads’ queues. This is particularly effective for divide-and-conquer algorithms where subproblems may have very different sizes, as it keeps all cores busy even when the workload distribution is uneven.
Thread Safety Strategies
| Strategy | Description | Performance |
|---|---|---|
| Mutual exclusion | Lock shared data | Moderate (contention) |
| Atomic operations | Lock-free CAS | Fast |
| Thread-local storage | No sharing needed | Fastest |
| Immutable data | Don’t modify, copy | Depends on copy cost |
| Message passing | Send data, don’t share | Good (no contention) |
Lock-Free Programming with Atomic Operations
#include <stdatomic.h>
atomic_int counter = 0;
void *safe_increment(void *arg) {
atomic_fetch_add(&counter, 1); // Atomic, no lock
return NULL;
---The ABA Problem
Lock-free data structures using compare-and-swap (CAS) must handle the ABA problem: a pointer changes from A to B and back to A between the read and the CAS, causing the CAS to succeed when it should not. Solutions include tagged pointers (storing a version counter alongside the pointer) and double-word CAS that atomically updates both the pointer and a tag.
Thread Pools in Different Languages
Python (ThreadPoolExecutor)
from concurrent.futures import ThreadPoolExecutor
def process_item(item):
return item * 2
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(process_item, range(100)))Note that Python’s Global Interpreter Lock (GIL) prevents true parallelism for CPU-bound work within a single process. ThreadPoolExecutor is best for I/O-bound tasks. For CPU parallelism in Python, use ProcessPoolExecutor or write performance-critical sections in C extensions.
Go (Goroutines)
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * 2
}
---
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
for w := 1; w <= 4; w++ {
go worker(w, jobs, results)
}
for j := 1; j <= 100; j++ {
jobs <- j
}
close(jobs)
---Rust (std::thread)
use std::thread;
let handles: Vec<_> = (0..4).map(|i| {
thread::spawn(move || {
println!("Thread {} running", i);
})
---).collect();
for handle in handles {
handle.join().unwrap();
---Rust’s ownership system enforces thread safety at compile time. The Send and Sync traits determine which types can be safely transferred and shared across threads, eliminating data races without a garbage collector.
Thread Debugging
| Tool | Use |
|---|---|
| GDB | Thread backtrace, breakpoints |
| Helgrind (Valgrind) | Race condition detection |
| ThreadSanitizer | Fast data race detection |
| strace | System call tracing |
| perf | Performance profiling |
Detecting Data Races
Data races occur when two threads access the same memory location without synchronization, and at least one access is a write. ThreadSanitizer (TSan) instruments every memory access at compile time and detects races at runtime. Integrate TSan into your test suite — it has near-zero false positives and catches races that would take weeks to reproduce manually.
Capability vs. Performance Tradeoffs
Each thread model makes different tradeoffs. User-level threads offer fast creation and context switching but cannot exploit multiple cores. Kernel threads provide true parallelism but at higher overhead per thread and context switch. The optimal choice depends on whether your workload is CPU-bound, I/O-bound, or mixed, and whether you need fine-grained control over scheduling or can rely on the OS scheduler.
Threads make programs faster — or much harder to debug. Always use the simplest concurrency model that works.
FAQ
What is the difference between concurrency and parallelism? Concurrency is about dealing with multiple tasks at once (structuring a program to handle overlapping tasks). Parallelism is about executing multiple tasks simultaneously (using multiple CPU cores). Concurrency enables parallelism, but they are distinct concepts.
Why are threads lighter than processes? Threads share the same address space, file descriptors, and other kernel resources with their parent process. Creating a thread does not require duplicating the entire address space — only a new stack and thread-local storage are allocated. Context switching between threads is faster because the virtual memory configuration stays the same.
What causes a race condition? A race condition occurs when two or more threads access shared data without proper synchronization, and the result depends on the order of execution. Race conditions are non-deterministic bugs that may only manifest under specific timing conditions, making them extremely difficult to reproduce and debug.
How many threads should I use? For CPU-bound work, use as many threads as there are CPU cores. For I/O-bound work, you can use more threads — often many more — because most threads will be waiting on I/O at any given time. Profile your application to find the optimal thread count rather than guessing.
Synchronization Guide — Deadlocks Guide — Process Scheduling Guide