Threads in C with pthread — theory with bus delays, a join I forgot and a counter that loses increments (no drama)
Friday, ten past eight in the morning, on the bus to uni. I’d spent the whole week writing down how many minutes late each trip was (yes, I’m that person), and I wanted to practice threads for the IC2 exam: one thread that counted the trips more than five minutes late. I compiled, ran it, and this is what came out:
#include <stdio.h>
#include <pthread.h>
int delays[8] = {4, 0, 11, 7, 2, 15, 6, 9}; // minutes of delay of my 8 bus trips
void* count(void* arg) {
int n = 0;
for (int i = 0; i < 8; i++) {
if (delays[i] > 5) { // over 5 minutes: I was late for class
n++;
}
}
printf("Thread: %d trips with more than 5 minutes of delay\n", n);
return NULL;
}
int main() {
pthread_t thread;
pthread_create(&thread, NULL, count, NULL);
printf("main: I'm done\n");
return 0; // no pthread_join: the process ends and takes the thread down with it
}
I compiled it with gcc -Wall -o hook hook.c -lpthread, ran it with ./hook, and this is everything it printed:
main: I'm done
No error, no warning… and the thread said nothing at all. Where was my “5 trips with more than 5 minutes”? The problem was in the last line of main: I reached return 0 without waiting for the thread. When main finishes, the whole process finishes, and every thread goes with it, done or not. I was missing one single line: pthread_join.
Today you’ll see why that happens, plus everything else you need about threads for exercise 4 of the IC2 practical exam: what a thread is and what it shares with the others, how to create one and wait for it (pthread_create and pthread_join), why the thread function has that odd signature with void*, how to pass it data (a number, an array or a struct), why the output changes order from one run to the next, and what a race condition is and how a mutex fixes it. You’re coming from System calls in C and Processes in C, where fork() created processes; here everything happens inside a single process. When you’re done, the practice has three complete programs with these same data.
Table of Contents
What a thread is (and what it shares)
A thread (also called a lightweight process) is a line of execution inside a process. Every C program has at least one: the one running main. With the pthreads library (POSIX threads) you can create more, and they all work at the same time inside the same process.
The key point for the exam is what belongs to everyone and what belongs to each thread:
- Shared by every thread in the process: the code, the global variables (and dynamic memory) and the open files.
- Private to each thread: its program counter (where it’s up to), its registers and its stack, which is where its local variables live.
Look at the arrows in the drawing: all three threads use the same box at the top. If thread 1 changes delays[2], main sees the change immediately, because there’s no copy. On the other hand, the variable n that count() declares inside the function lives on that thread’s stack and nobody else sees it.
With fork() it was the other way round: the child got a copy of the parent’s memory, which is why returning a value needed exit(n) and wait(&status). With threads you need none of that: just write to a variable both can see. The operating systems theory in Processes and threads tells the story from the system’s side.
Child process (fork) | Thread (pthread_create) | |
|---|---|---|
| Memory | A copy of the parent’s | The same one, shared |
| PID | A new one | The process’s own |
| Returning a value | exit(n) + wait(&status) | Write it to a global or a struct |
| Waiting for it to end | wait / waitpid | pthread_join |
| If one crashes | The other carries on | The whole process goes down |
| Cost of creating it | Higher (copies the memory) | Lower |
Your first thread: pthread_create and pthread_join
This is the program from the hook, fixed. Read it slowly: every line with pthread has its comment.
#include <stdio.h>
#include <pthread.h> // pthread_t, pthread_create, pthread_join
int delays[8] = {4, 0, 11, 7, 2, 15, 6, 9}; // global: every thread of the process sees it
// The thread function: takes a void* and returns a void*, even if it doesn't use them
void* count(void* arg) {
int n = 0;
for (int i = 0; i < 8; i++) {
if (delays[i] > 5) {
n++;
}
}
printf("Thread: %d trips with more than 5 minutes of delay\n", n);
return NULL; // it returns nothing useful, but the signature forces it to return a pointer
}
int main() {
pthread_t thread; // pthread_create stores the thread's identifier here
printf("main: creating the thread\n");
pthread_create(&thread, NULL, count, NULL); // &thread, default attributes, function, argument
pthread_join(thread, NULL); // main waits until count finishes
printf("main: the thread has finished, now I can exit\n");
return 0;
}
You compile it with gcc -Wall -o first_thread first_thread.c -lpthread and run it with ./first_thread:
main: creating the thread Thread: 5 trips with more than 5 minutes of delay main: the thread has finished, now I can exit
Now all three lines appear, in order: main announces itself, the thread counts (4, 0, 11, 7, 2, 15, 6, 9: the ones above 5 are 11, 7, 15, 6 and 9, five trips) and main finishes afterwards.
Between the lines:
#include <pthread.h>brings in thepthread_ttype and thepthread_createandpthread_joinfunctions.pthread_t thread;works like a label for the thread:pthread_createwrites its identifier there andpthread_joinuses it to know who to wait for.pthread_create(&thread, NULL, count, NULL)has four arguments, always in this order: where to store the identifier (&thread, with&because the function fills it in), the attributes (NULL= the defaults; in IC2 alwaysNULL), the function the thread will run (count, no parentheses: you pass the function, you don’t call it) and the argument it will receive (NULLif you pass nothing). It returns 0 if everything went well.pthread_join(thread, NULL)keepsmainstopped until the thread finishes. The second argument is for collecting whatever the thread returns; if you don’t use it,NULL.
The timeline shows why the hook printed nothing: the dotted stretch is main waiting. Without pthread_join, main would have carried straight on to return 0, and the process would have taken the thread down before it reached its printf.
Printing or returning from main before pthread_join. The compiler says nothing because the code is valid; the bug is about order. Exam rule: in main, first pthread_create, then pthread_join, and only then whatever uses the thread’s results.
The odd signature: void* name(void* arg)
The thread function has to look exactly like this: it takes a void* and returns a void*. It isn’t a whim: pthread_create is a single function that works for any thread, so it can’t know in advance whether your thread needs an int, an array or a struct. C’s answer is the generic pointer void*: “a memory address, without saying of what type”.
| Piece | What it means |
|---|---|
void* before the name | The thread returns an address (almost always NULL) |
void* arg | It receives an address of any type |
return NULL; | Required even if you return nothing useful |
(int*)arg | “Treat that address as the address of an int“ |
*(int*)arg | “…and read the int stored there” |
If you write void count() instead of void* count(void* arg), modern versions of gcc (14 onwards; Fedora ships an even newer one) don’t compile it: pthread_create expects a different type of function. The exercises show you the exact error.
Compiling with -lpthread
In the exam, and in any program with threads, you compile like this:
$ gcc -Wall -o first_thread first_thread.c -lpthread
-lpthread tells the linker to use the threads library and goes at the end, after the .c. A curious detail: since version 2.34 of Linux’s C library (glibc), the thread functions live inside the library itself, so on a current Fedora the program also compiles without the option. I tried it with the same program, compiled with gcc -Wall -o first_thread first_thread.c (no -lpthread) and run: not a single warning, and this output:
main: creating the thread Thread: 5 trips with more than 5 minutes of delay main: the thread has finished, now I can exit
Always add -lpthread. The exam statement asks for it, it costs nothing, and on systems with a glibc older than 2.34 linking fails without it, with undefined reference to pthread_create, and there’s no executable to screenshot.
Two threads at once: the output changes order
With a single thread and main waiting, everything comes out in order. With two threads working at the same time, things change: each one moves forward when the operating system gives it the CPU, and nobody guarantees who goes first. Here two buses, line 1 and line 12, announce their four stops:
#include <stdio.h>
#include <pthread.h>
// Each thread announces its four stops; the text arrives through the argument
void* route(void* arg) {
char* line = (char*)arg; // the void* goes back to what it was: a string
for (int stop = 1; stop <= 4; stop++) {
printf("%s: stop %d\n", line, stop);
}
return NULL;
}
int main() {
pthread_t a, b;
pthread_create(&a, NULL, route, "Line 1"); // both start almost at the same time...
pthread_create(&b, NULL, route, "Line 12");
pthread_join(a, NULL); // ...and main waits for both
pthread_join(b, NULL);
printf("main: both buses have arrived\n");
return 0;
}
And this is the output of my run:
Line 12: stop 1 Line 12: stop 2 Line 12: stop 3 Line 12: stop 4 Line 1: stop 1 Line 1: stop 2 Line 1: stop 3 Line 1: stop 4 main: both buses have arrived
In my run Line 12 came out first, even though Line 1 was created earlier. On your computer it may come out the other way round, or even mixed (Line 1: stop 1, Line 12: stop 1, Line 1: stop 2…). It isn’t a bug: it’s what working at the same time means. Notice (char*)arg too: the argument was a string, so the thread turns the void* into a char*.
What if you need a fixed order? You can wait for each thread before creating the next one:
pthread_create(&a, NULL, route, "Line 1");
pthread_join(a, NULL); // I wait for Line 1 BEFORE creating 12: fixed order...
pthread_create(&b, NULL, route, "Line 12");
pthread_join(b, NULL); // ...but they no longer work at the same time
printf("main: both buses have arrived\n");
return 0;
The rest of the program is the same. Output:
Line 1: stop 1 Line 1: stop 2 Line 1: stop 3 Line 1: stop 4 Line 12: stop 1 Line 12: stop 2 Line 12: stop 3 Line 12: stop 4 main: both buses have arrived
Now the order is always the same, but you’ve lost the good part of threads: they no longer work at the same time. The usual solution is different: let the threads compute and leave their result in a variable, and let main print after every join, in whatever order it wants. You’ll see it in the practice.
If in the exam your output comes out in a different order from the statement’s, don’t panic: if each thread prints its own part and the numbers are right, it’s fine. If the statement asks for a specific order, have main print after the join calls.
Passing data to the thread
The fourth argument of pthread_create is the only way into the thread, and it only takes a void*. There are three cases you need to master.
A number: &x and (int)arg
#include <stdio.h>
#include <pthread.h>
int delays[8] = {4, 0, 11, 7, 2, 15, 6, 9};
void* count(void* arg) {
int threshold = *(int*)arg; // (int*) says "this points to an int"; the outer * reads that int
int n = 0;
for (int i = 0; i < 8; i++) {
if (delays[i] > threshold) {
n++;
}
}
printf("There are %d trips with more than %d minutes of delay\n", n, threshold);
return NULL;
}
int main() {
int threshold = 5; // lives in main, but the thread can read it through its address
pthread_t thread;
pthread_create(&thread, NULL, count, &threshold); // we pass the ADDRESS, not the value
pthread_join(thread, NULL); // threshold stays alive while the thread reads it
return 0;
}
Output:
There are 5 trips with more than 5 minutes of delay
main passes &threshold, the address of its variable. Inside the thread, *(int*)arg is read from the inside out: (int*)arg converts the void* into “pointer to int” and the * in front reads the int at that address, the 5. It works because threshold is still alive while the thread reads it: main is stopped at pthread_join and hasn’t left its function.
An array: no &
#include <stdio.h>
#include <pthread.h>
#define N 8
void* add_up(void* arg) {
int* v = (int*)arg; // the array arrives as void*: we convert it to int* and then use v[i]
int sum = 0;
for (int i = 0; i < N; i++) {
sum += v[i];
}
printf("Thread: %d minutes lost in the week (mean %.2f per trip)\n", sum, (double)sum / N);
return NULL;
}
int main() {
int delays[N] = {4, 0, 11, 7, 2, 15, 6, 9};
pthread_t thread;
pthread_create(&thread, NULL, add_up, delays); // no &: the array's name is already an address
pthread_join(thread, NULL);
return 0;
}
Output:
Thread: 54 minutes lost in the week (mean 6.75 per trip)
An array’s name is already the address of its first element (you saw it in Pointers in C), so you pass delays without &. Inside, int* v = (int*)arg; and from then on v[i] as usual. Check: 4 + 0 + 11 + 7 + 2 + 15 + 6 + 9 = 54 minutes, and 54 / 8 = 6.75 per trip. The (double) avoids integer division, which would have given 6.
Several values: a struct
What if the thread needs more than one value (where to start, where to stop…) and also has to return results? You put them all in a struct and pass its address. Each thread gets its own struct:
#include <stdio.h>
#include <pthread.h>
int delays[8] = {4, 0, 11, 7, 2, 15, 6, 9};
// Everything a thread needs (and what it returns) in a single package
struct Chunk {
int start; // first position it goes through
int end; // position where it stops (not included)
int sum; // result: the thread fills it in
int max; // result: the thread fills it in
};
void* summarise(void* arg) {
struct Chunk* t = (struct Chunk*)arg; // the void* goes back to being a pointer to ITS struct
t->sum = 0;
t->max = delays[t->start];
for (int i = t->start; i < t->end; i++) {
t->sum += delays[i];
if (delays[i] > t->max) {
t->max = delays[i];
}
}
return NULL; // the results are already inside the struct
}
int main() {
struct Chunk chunks[2] = {{0, 4, 0, 0}, {4, 8, 0, 0}}; // one half for each thread
pthread_t h[2];
for (int k = 0; k < 2; k++) {
pthread_create(&h[k], NULL, summarise, &chunks[k]); // each thread, its own struct
}
for (int k = 0; k < 2; k++) {
pthread_join(h[k], NULL);
}
// Only after both joins is it safe to read the results
for (int k = 0; k < 2; k++) {
printf("Chunk %d (trips %d-%d): sum %d, max %d\n",
k + 1, chunks[k].start + 1, chunks[k].end, chunks[k].sum, chunks[k].max);
}
printf("Week total: %d minutes\n", chunks[0].sum + chunks[1].sum);
return 0;
}
Output:
Chunk 1 (trips 1-4): sum 22, max 11 Chunk 2 (trips 5-8): sum 32, max 15 Week total: 54 minutes
Checking by hand: chunk 1 is trips 1-4 (4 + 0 + 11 + 7 = 22, max 11) and chunk 2 is trips 5-8 (2 + 15 + 6 + 9 = 32, max 15). Together, 54, the same total as before: two threads have split the work.
Between the lines:
struct Chunk* t = (struct Chunk*)arg;is the usual trick with another type. From there,t->startmeans “thestartfield of the structtpoints to” (review it in Structs and strings in C if the arrow looks strange).- The thread writes its results to
t->sumandt->max. It’s the simplest way to return data: no need to touch thereturn NULLor the second argument ofjoin. mainonly readschunks[k].sumafter bothjoincalls. Before that, the values could be half computed.- Each thread gets
&chunks[k], a different slot. If both shared the same struct, they would overwrite each other’s results.
| You want to pass… | In pthread_create | Inside the thread |
|---|---|---|
| Nothing | NULL | Use the globals |
An int | &x | int x = *(int*)arg; |
| A string | "Line 1" or text | char* s = (char*)arg; |
| An array | v (no &) | int* v = (int*)arg; |
| Several values | &data (a struct) | struct Data* d = (struct Data*)arg; |
Passing x instead of &x. You’re giving pthread_create the value 5 where it expects an address; gcc 14 and later reject it with a -Wint-conversion error. The practice shows the full message.
Race condition: when two threads touch the same thing
So far each thread wrote to its own data. What happens if two threads modify the same variable? Picture both doors of the bus validating passes at once, each adding 1 to a shared counter a million times. The result should be 2,000,000. The program repeats the experiment three times:
#include <stdio.h>
#include <pthread.h>
#define TIMES 1000000
long validations = 0; // shared: both doors of the bus add to it
void* door(void* arg) {
for (int i = 0; i < TIMES; i++) {
validations = validations + 1; // read, add and write: three steps, not one
}
return NULL;
}
int main() {
for (int attempt = 1; attempt <= 3; attempt++) { // we repeat the experiment three times
validations = 0;
pthread_t front, rear;
pthread_create(&front, NULL, door, NULL);
pthread_create(&rear, NULL, door, NULL);
pthread_join(front, NULL);
pthread_join(rear, NULL);
printf("Attempt %d: %ld validations (expected %d)\n", attempt, validations, 2 * TIMES);
}
return 0;
}
I ran it once and got this:
Attempt 1: 1550167 validations (expected 2000000) Attempt 2: 2000000 validations (expected 2000000) Attempt 3: 2000000 validations (expected 2000000)
In my single run, the first attempt lost 449,833 validations (2,000,000 − 1,550,167) and the other two gave exactly 2,000,000. If you run it, the numbers will change: sometimes you’ll lose a lot, sometimes a few, sometimes none. And that’s the most dangerous part: a program with this bug can pass all your tests and fail on exam day.
How can an increment get lost? Because validations = validations + 1 isn’t one operation for the CPU but three: read the value from memory into a register, add 1, and write it back. If two threads interleave between those steps, this happens:
Both read the 0 before anyone has written, both compute 1 and both write 1. Two increments, a single +1. This is called a race condition: the result depends on the order in which the threads interleave. The piece of code that touches the shared variable is the critical section.
With 8 values you’ll almost never see the race, because each thread finishes before the other gets in the way. That’s why the example uses a million iterations: so they cross often enough to see it. Not seeing it doesn’t mean it isn’t there.
The mutex: a lock for the critical section
The solution is for only one thread at a time to be inside the critical section. For that, pthreads has the mutex (from mutual exclusion): a lock that a thread closes when it enters and opens when it leaves. If another thread tries to close it while it’s closed, it waits.
#include <stdio.h>
#include <pthread.h>
#define TIMES 1000000
long validations = 0; // shared: both doors of the bus add to it
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // the lock that protects validations
void* door(void* arg) {
for (int i = 0; i < TIMES; i++) {
pthread_mutex_lock(&lock); // if the other door holds it, I wait here
validations = validations + 1; // critical section: one thread at a time
pthread_mutex_unlock(&lock); // I release it for the other door
}
return NULL;
}
int main() {
for (int attempt = 1; attempt <= 3; attempt++) { // we repeat the experiment three times
validations = 0;
pthread_t front, rear;
pthread_create(&front, NULL, door, NULL);
pthread_create(&rear, NULL, door, NULL);
pthread_join(front, NULL);
pthread_join(rear, NULL);
printf("Attempt %d: %ld validations (expected %d)\n", attempt, validations, 2 * TIMES);
}
return 0;
}
One run:
Attempt 1: 2000000 validations (expected 2000000) Attempt 2: 2000000 validations (expected 2000000) Attempt 3: 2000000 validations (expected 2000000)
Three attempts, 2,000,000 every time. The three new pieces:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;declares the lock as global (both threads must use the same one) and leaves it open.pthread_mutex_lock(&lock);closes it. If the other thread already closed it, this one waits there until it opens.pthread_mutex_unlock(&lock);opens it for the next one.
Compare this drawing with the previous one: now B can’t read until A has done its unlock, so it reads the 1 that A left and writes a 2. It’s slower (the threads wait for each other), but the result is always right.
Forgetting the unlock (or leaving the loop with a return between lock and unlock). The lock stays closed forever and the other thread waits forever: the program hangs without any error.
Put as little as possible inside the critical section. If the thread has to compute something, let it compute outside and only enter the lock to touch the shared variable. In the exercises you’ll see a thread that adds up its chunk locally and enters the lock just once, at the end.
When do you need a mutex and when not?
| Situation | Mutex? |
|---|---|
| Several threads read the same variable and nobody changes it | No |
| Each thread writes to its own variable or its own struct | No |
| Several threads write to the same variable (counter, total, histogram) | Yes |
| One thread writes while another reads | Yes |
The theory in Concurrency and synchronization explains race conditions from the operating system’s side, semaphores included; here you keep what you’ll program in the exam.
Common mistakes with threads
Not waiting for the thread. Without pthread_join, main can finish before the thread and the process takes it down: the thread prints nothing or stops halfway (this article’s hook).
The wrong signature. void f() or void f(int x) won’t do: it has to be void* f(void* arg) with its return NULL;.
pthread_create(thread, …) without the &. The function needs the address of thread to write into it.
Calling the function instead of passing it: pthread_create(&thread, NULL, count(), NULL). With parentheses you run it yourself, in main, and pass pthread_create whatever it returns.
Passing &i inside a loop that creates several threads. They all get the address of the same i, which the loop keeps changing. In the exercises you’ll see it fail with its real output.
Watch it step by step in Python Tutor
Python Tutor runs code line by line and draws the variables and memory; choose C in the drop-down. One catch: it runs a single thread and doesn’t support pthread. So we’ll simulate the race condition by hand: two variables, regA and regB, play the registers of two threads, and the lines follow the same interleaving as the drawing.
#include <stdio.h>
// Python Tutor doesn't run threads: we simulate by hand the steps of two threads, A and B
int main() {
int counter = 0; // the shared variable
int regA, regB; // the private copy each thread keeps in its registers
// Both want to do counter = counter + 1, and they interleave like this:
regA = counter; // A reads 0
regB = counter; // B reads 0 (A hasn't written yet)
regA = regA + 1; // A computes 1
regB = regB + 1; // B computes 1
counter = regA; // A writes 1
counter = regB; // B writes 1 on top: one increment is lost
printf("No mutex: counter = %d\n", counter);
// With a mutex, each thread does its three steps in a row
counter = 0;
regA = counter; regA = regA + 1; counter = regA; // A in full
regB = counter; regB = regB + 1; counter = regB; // B in full
printf("With mutex: counter = %d\n", counter);
return 0;
}
Its output, run with gcc:
No mutex: counter = 1 With mutex: counter = 2
What to watch step by step:
- At the steps
regA = counter;andregB = counter;, look at both “registers”: both keep a 0, because nobody has written yet. - When
counter = regA;runs,counterbecomes 1. Looks fine… - …but at
counter = regB;a 1 is written again, not a 2: A’s increment has vanished without any error. - In the second part, each “thread” does its three steps in a row (what the mutex achieves): you’ll see
regBread the 1 that A left, and the end result is 2.
Summary
| Concept | What to remember |
|---|---|
| Thread | A line of execution inside a process; shares code, globals and files; has its own stack |
pthread_create(&h, NULL, f, arg) | Creates the thread; f is void* f(void* arg) and ends with return NULL; |
pthread_join(h, NULL) | main waits for the thread; without it, the thread may die halfway |
-lpthread | At the end of the gcc command; always add it |
Passing an int | &x → *(int*)arg |
| Passing an array | v without & → (int*)arg |
| Passing several values | One struct per thread → (struct X*)arg; the thread leaves its results there |
| Output order | Not deterministic; if it matters, print in main after the join calls |
| Race condition | Two threads write the same variable and operations get lost |
| Mutex | lock before and unlock after the critical section, with a global lock |
Three ideas to take away:
- Threads share the process’s memory: that makes passing data easy and writing to the same thing dangerous.
- Everything going into the thread goes through a
void*: you decide which type it turns back into. pthread_joinbefore using results, and a mutex when two threads write to the same variable.
Ready to see it in complete programs? The threads in C practice has a delay report with a typed threshold, two threads with two tasks on the same week and four threads filling a shared histogram with their mutex. And once you’ve got it, the IC2 practical mock exam is waiting with its threads exercise.
Next step: practiceThreads in C — practice with a delay report, two tasks at once and a histogram with a mutex (and an & that got away from me)Did this article help you? Here is how you can support SergioLearns →




IC2 practical mock exam — flowcharts, fork and threads in C (and the screenshots you can’t leave out)