COMP1521 1,535 words·8 min read

11. Concurrency and Threads

Concurrency and Threads#

Concurrency means multiple computations make progress during overlapping periods of time. They do not need to execute at the same instant. Parallelism means multiple computations literally execute simultaneously, which normally requires multiple CPU cores or other processing units.

A concurrent program may become parallel when the OS schedules its work across cores, but concurrency is primarily about program structure and overlapping progress.

Flynn's Taxonomy#

Flynn's taxonomy classifies computation by the number of instruction and data streams:

Class Meaning Course-scale example
SISD single instruction, single data ordinary sequential MIPS in mipsy
SIMD single instruction, multiple data vector instructions or GPU-style data parallelism
MISD multiple instruction, single data uncommon; sometimes used to describe replicated fault-tolerant processing
MIMD multiple instruction, multiple data independent threads or processes across CPU cores

This is a vocabulary for kinds of parallel hardware and computation. It is separate from the concurrency/parallelism distinction: a program can be concurrent while taking turns on one SISD core.

Processes or Threads?#

Processes can run concurrently and provide strong isolation, but each process has its own address space. Communicating through Pipes and File Redirection or another IPC mechanism requires explicit work.

Threads provide concurrency within one process. Threads in the same process share:

  • program code;
  • global and static variables;
  • heap allocations;
  • open file descriptors;
  • environment variables and current working directory.

Each thread has its own:

  • registers and program counter;
  • stack;
  • thread ID;
  • thread-local state such as errno.

Although each thread has its own stack, the stacks occupy the shared address space. One thread can technically access another thread's stack through a pointer. The problem is not access permission; it is ensuring that the pointed-to object still exists.

Creating a POSIX Thread#

POSIX threads, or pthreads, are declared in <pthread.h>. Compilation commonly needs -pthread:

BASH
dcc -pthread program.c -o program

A thread starts in a function with this exact shape:

C
void *worker(void *argument);

pthread_create() launches it:

C
int pthread_create(
	pthread_t *thread,
	const pthread_attr_t *attributes,
	void *(*start_routine)(void *),
	void *argument
);
C
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

void *worker(void *argument) {
	char *name = argument;
	printf("hello from %s\n", name);
	return NULL;
}

int main(void) {
	pthread_t thread;
	int error = pthread_create(&thread, NULL, worker, "the worker");
	if (error != 0) {
		fprintf(stderr, "pthread_create failed: %d\n", error);
		return 1;
	}

	error = pthread_join(thread, NULL);
	if (error != 0) {
		fprintf(stderr, "pthread_join failed: %d\n", error);
		return 1;
	}

	return 0;
}

Like posix_spawn(), pthread functions usually return an error number directly rather than setting errno.

Joining Threads#

C
int pthread_join(pthread_t thread, void **result);

Joining waits until the chosen thread finishes and optionally collects its return pointer. If main() returns, the process terminates and all other threads disappear, whether or not they finished. A process must therefore join threads whose work it needs.

A worker can finish by returning from its start routine or by calling pthread_exit(result). Returning is usually clearer. The result pointer follows the same lifetime rules either way.

C
void *worker(void *unused) {
	long *answer = malloc(sizeof *answer);
	if (answer == NULL) {
		return NULL;
	}
	*answer = 42;
	return answer;
}

// Later in main:
void *result;
int error = pthread_join(thread, &result);
if (error != 0) {
	fprintf(stderr, "pthread_join failed: %d\n", error);
	return 1;
}

long *answer = result;
if (answer != NULL) {
	printf("%ld\n", *answer);
	free(answer);
}

Never return a pointer to a worker's local variable. Its stack frame ceases to exist when the thread returns.

Passing Data Safely#

pthread_create() passes one void *, so a structure is useful for multiple values:

C
struct task {
	const int *values;
	size_t start;
	size_t end;
	long result;
};

void *sum_range(void *raw) {
	struct task *task = raw;
	task->result = 0;
	for (size_t i = task->start; i < task->end; i++) {
		task->result += task->values[i];
	}
	return NULL;
}

The pointed-to task must remain alive until the worker has finished. Safe choices include:

  • storage in main() when main() joins all workers before returning;
  • a heap allocation freed after the join;
  • static storage, when sharing that lifetime and access pattern is intentional.

A classic bug passes the address of one loop variable to every worker:

C
for (int i = 0; i < THREADS; i++) {
	pthread_create(&threads[i], NULL, worker, &i); // broken
}

All workers receive the same address. By the time they read it, the loop may have changed i or ended. Store one stable argument object per thread.

C
struct task tasks[THREADS];

for (size_t i = 0; i < THREADS; i++) {
	tasks[i].values = values;
	tasks[i].start = starts[i];
	tasks[i].end = ends[i];
	pthread_create(&threads[i], NULL, sum_range, &tasks[i]);
}

// Keep tasks alive and unchanged until the corresponding joins complete.

Note

Checkpoint

  • Threads share globals, heap objects, and descriptors, but each thread has its own registers and stack.
  • The argument passed to a worker must remain alive and stable until that worker has finished using it.
  • pthread_join() both waits for completion and lets the caller collect the worker's result.

When Threads Help#

Threads can improve a program when:

  • independent CPU-heavy work can run on several cores;
  • one thread can make progress while another blocks on I/O;
  • a user interface must remain responsive during background work;
  • shared-memory communication is cheaper than process IPC.

They do not automatically make code faster. Thread creation, scheduling, cache traffic and synchronisation all have costs. If the task is tiny, sequential code can be quicker. A calculation with unavoidable sequential dependencies cannot be divided arbitrarily.

Data Races#

A data race occurs when threads access the same memory concurrently, at least one access writes it, and the accesses are not properly synchronised.

C
int bank_account = 0;

void *deposit(void *unused) {
	bank_account++;
	return NULL;
}

bank_account++ looks like one C expression, but at machine level it is a read-modify-write sequence:

CODE
load bank_account
add 1
store bank_account

Two threads can both load 42, both calculate 43, and both store 43. One increment is lost.

In C, a data race is undefined behaviour. It is not merely a rare arithmetic error. The compiler and CPU are allowed to make assumptions which can produce outcomes more surprising than an occasional lost update.

Critical Sections and Mutexes#

A critical section accesses shared state which must not be manipulated by multiple threads at once. A mutex establishes mutual exclusion: only the thread which successfully locks it enters the protected region; another thread trying to lock it waits.

C
int bank_account = 0;
pthread_mutex_t bank_lock = PTHREAD_MUTEX_INITIALIZER;

void *deposit_many(void *unused) {
	for (int i = 0; i < 100000; i++) {
		pthread_mutex_lock(&bank_lock);
		bank_account++;
		pthread_mutex_unlock(&bank_lock);
	}
	return NULL;
}

The mutex protects a rule about data, not just a line of code. Every access participating in that rule must use the same mutex. Locking only the writers while readers access the variable unsynchronised is still broken.

Keep critical sections as small as correctness permits, but not smaller. If two fields must change together to preserve an invariant, protect the whole multi-step update.

Warning

- Always unlock on every path
A return, break or error path inside a critical section can leave the mutex permanently locked. Structure the function so cleanup is unavoidable and easy to verify.

Deadlock#

A deadlock occurs when threads wait forever for one another. Consider two locks:

CODE
Thread 1: lock A -> lock B -> work -> unlock B -> unlock A
Thread 2: lock B -> lock A -> work -> unlock A -> unlock B

If Thread 1 owns A while Thread 2 owns B, each waits for a lock which the other cannot release.

A powerful prevention rule is to enforce a strict global lock acquisition hierarchy. If every thread acquires Lock A before Lock B, circular waiting cannot occur. Releasing locks in reverse order of acquisition is standard practice.

Note

Checkpoint

  • A data race is undefined behaviour, not merely an unlucky final value.
  • A mutex protects an invariant by making the entire critical section mutually exclusive.
  • Consistent lock ordering prevents the circular wait that causes deadlock.

Atomics#

For simple shared values, C atomic types can make individual operations indivisible:

C
#include <stdatomic.h>

atomic_int bank_account = 0;

void *deposit_many(void *unused) {
	for (int i = 0; i < 100000; i++) {
		bank_account += 1;
	}
	return NULL;
}

Atomic compound operations such as ++, +=, -=, |=, &= and ^= perform an atomic read-modify-write on an atomic object. Explicit functions are also available:

C
int old_value = atomic_fetch_add(&bank_account, 1);

However:

C
bank_account = bank_account + 1;

is an atomic load followed by a separate atomic store. Another thread can intervene between them, so the whole increment is not atomic.

Atomics are not a universal mutex replacement. If correctness depends on a relationship between several variables or several operations, one atomic operation may not protect the invariant. Memory ordering also becomes subtle beyond COMP1521's scope.

Mutex or Atomic?#

Situation Usually appropriate
one counter increment atomic operation
several fields must change together mutex
complex conditional update mutex, unless carefully designed otherwise
blocking I/O while holding protection reconsider the design; avoid a long-held mutex

A mutex is easier to reason about for compound state. Atomics can be cheaper and cannot themselves form a lock-order deadlock, but lock-free does not mean mistake-free.

Common Mistakes#

  • Assuming concurrency always means parallel execution.
  • Passing a pointer whose object expires before the worker reads it.
  • Passing the same loop-variable address to every thread.
  • Returning from main() before workers are joined.
  • Treating x++ on an ordinary shared int as atomic.
  • Protecting writes but leaving reads unsynchronised.
  • Locking different mutexes around accesses to the same invariant.
  • Acquiring several mutexes in inconsistent orders.
  • Putting one mutex around an entire program and eliminating all useful parallelism.
  • Assuming atomic_x = atomic_x + 1 is one atomic update.

Practice#

  1. What resources are shared among threads of the same process, and what is private to each thread?
  2. Why is counter++ not thread-safe without synchronisation?
  3. What is a data race, and what condition makes it undefined behaviour?
  4. How can deadlock be prevented when threads must acquire multiple mutex locks?
  5. Why is pthread_join() necessary before main() exits?

Note

- Answers

  1. Threads share the virtual address space (global variables, heap memory) and open file descriptors. Each thread has its own stack, CPU registers, and program counter.
  2. counter++ is broken into three distinct machine steps: load the value, increment it and store it back. Without a lock or atomic type, interleaving on one core or simultaneous execution on several cores can cause lost updates.
  3. A data race occurs when two concurrent threads access the same memory location simultaneously, at least one access is a write, and no synchronisation (like a mutex) coordinates them.
  4. Enforce a strict, global lock acquisition ordering across all threads (e.g. always acquire Lock A before Lock B).
  5. When main() returns (or calls exit()), the entire process terminates and all running threads are abruptly destroyed before completing their work. pthread_join() blocks main() until the thread finishes and reaps its exit state.

Note

- Further reading
The course's official Threads topic notes contain the full progression from pthread_create() to atomics. The exact contracts are in POSIX; in the CSE environment, start with man 3 pthread_create, man 3 pthread_join and man 3 pthread_mutex_lock.