Module 2 · Unit 4 · Laboratory 3
POSIX Threads
This laboratory introduces the POSIX Threads
(pthreads) API — the standard interface for
multithreaded programming on Unix-like systems.
You will write, compile, run, and debug
multithreaded programs using pthread_create,
pthread_join, and the pthreads
synchronization primitives' basics. The laboratory
closes with the first encounter with the
race condition: the phenomenon the
synchronization unit will teach you to control.
01 Lab overview
| Course | COMP 3150 / CMIS 3150 — Operating Systems |
|---|---|
| Unit | Unit 4 — Threads |
| Weight | 1.25% of the final grade (1/12 of the 15% laboratory component) |
| Estimated duration | 3–4 hours of hands-on work |
| Submission | One archive containing the C source files, a Makefile, the test output, and a short lab report (see § 07) |
| Environment | Linux VM (the course's VM), gcc, make, gdb, valgrind (optional), perf (optional) |
This lab contributes to U4.1 through U4.4, and to course learning outcomes CLO2, CLO6, and CLO8.
02 Learning objectives
By the end of this laboratory you will be able to
- Create, join, and terminate POSIX threads using the pthreads API.
- Pass arguments to threads and retrieve their return values.
- Inspect thread identifiers and the process's thread list (
/proc/<pid>/task). - Identify thread-safety issues and articulate how race conditions arise.
- Compile multithreaded programs with
-pthreadand diagnose common linking errors.
03 Background
3.1 Threads and processes
A thread is the smallest unit of
execution the operating system can schedule. A
process contains one or more threads; all threads in
a process share the process's address space,
open file descriptors, signal handlers, and other
resources. Each thread has its own stack, registers,
and thread-local storage. On Linux, threads are
created with the clone system call and
are visible in /proc/<pid>/task/.
3.2 The pthreads API
POSIX Threads (pthreads) is the
standard thread API defined by IEEE 1003.1c. The
principal calls are:
pthread_create(pthread_t *tid, const pthread_attr_t *attr, void *(*start)(void *), void *arg)- Creates a new thread that begins execution at
start(arg). The thread's identifier is written to*tid. Returns 0 on success, an error number otherwise. pthread_join(pthread_t tid, void **retval)- Blocks the calling thread until the thread
tidterminates. Ifretvalis non-NULL, the thread's return value is stored there. pthread_exit(void *retval)- Terminates the calling thread and makes
retvalavailable to a joiner. pthread_self(void)- Returns the calling thread's identifier.
pthread_detach(pthread_t tid)- Marks the thread as detached so its resources are reclaimed automatically upon termination.
3.3 Compilation
Multithreaded programs must be linked against the
pthreads library. With gcc, pass
-pthread:
gcc -Wall -Wextra -O2 -pthread -o hello hello.c
On modern glibc, -pthread is
sufficient; on older systems you may also need
-lpthread. Omitting the flag produces
undefined behaviour (often a link-time error or a
run-time failure in pthread_create).
3.4 The anatomy of a multithreaded program
A minimal pthreads program has four parts: the thread function, the thread identifiers, the creation loop, and the join loop. The program below creates 4 worker threads and waits for them.
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define N 4
static void *worker(void *arg) {
long id = (long)arg;
printf("worker %ld: hello from thread %lu\n",
id, (unsigned long)pthread_self());
return (void *)(id * 10);
}
int main(void) {
pthread_t tid[N];
for (long i = 0; i < N; i++) {
if (pthread_create(&tid[i], NULL, worker, (void *)i) != 0) {
perror("pthread_create");
exit(EXIT_FAILURE);
}
}
for (int i = 0; i < N; i++) {
void *ret;
if (pthread_join(tid[i], &ret) != 0) {
perror("pthread_join");
exit(EXIT_FAILURE);
}
printf("joined thread %d, returned %ld\n", i, (long)ret);
}
return 0;
}
04 Guided activities
Activity 4.1 — Build and run the canonical pthreads program
- Create the lab directory:
mkdir -p ~/comp3150/lab3 && cd ~/comp3150/lab3 - Copy the program in § 3.4 into
hello_threads.c. - Compile and run it:
gcc -Wall -Wextra -O2 -pthread -o hello_threads hello_threads.c ./hello_threads - Observe the output. In what order do the worker threads print? Is the order stable across runs? Why or why not?
- Compile the same program without
-pthreadand record what happens. Restore the correct compilation.
Activity 4.2 — Passing and returning values
- Extend the program so each worker receives a heap-allocated
structcontaining anidand adelay_msfield. - Have each worker
usleep(delay_ms * 1000), then return a heap-allocated result that the main thread frees afterpthread_join. - Run the program with delays that differ by a factor of 10 and observe the completion order with
time ./hello_threads. - Modify one worker to pass the address of a stack variable of the loop instead of a heap copy, and observe the result. Explain why this is incorrect.
Activity 4.3 — Threads are visible in the kernel
- Run the program with a long sleep in the main thread after creating the workers, and identify the process's PID.
- In a second terminal, run:
ls /proc/<pid>/task/ cat /proc/<pid>/status | grep Threads - Compare the thread IDs in
/proc/<pid>/task/with the pthreads identifiers printed by the program. Are they the same? - Run
ps -L -o pid,tid,comm -p <pid>and record the output.
Activity 4.4 — Scheduling and concurrency
- Write a program that creates n threads, each performing a CPU-bound loop that increments a counter until a shared deadline.
- Run the program with
n = 1, 2, 4, 8, 16and record the total wall-clock time for each configuration. Usetaskset -c 0 ./programto pin the process to a single core, and repeat without pinning. - Plot the times. What is the speedup? At what value of n does it saturate?
- Explain the difference between the pinned and the unpinned runs in terms of the kernel's thread scheduling.
Activity 4.5 — The first race condition
- Write a program that creates n threads, each incrementing a shared global counter
1,000,000times:volatile long counter = 0; void *worker(void *arg) { for (long i = 0; i < 1000000; i++) counter++; return NULL; } - Run the program with n = 1, n = 2, and n = 4. Record the final value of
countereach time. - Repeat the experiment ten times for each n. Is the final value always the expected
n × 1,000,000? - Using
objdump -d, inspect the assembly forcounter++. Identify the three instructions (load, increment, store) and explain how the interleaving of two threads produces a lost update. - Do not fix the race in this lab; you will fix it in Lab 5.
05 Exercises
-
Thread identity. Write a
program that creates 8 threads. Each thread
prints its index, its pthreads identifier, and
its Linux TID (read from
gettid()or/proc/self/task/). Run it several times and explain the differences between the pthreads identifier, the Linux TID, and the process's PID. -
Detached threads. Modify the
canonical program so that each worker is
created detached. What changes in the main
thread's responsibility? What happens if you
still call
pthread_joinon a detached thread? -
Argument passing. Write a
program that creates 1,000 threads, each of
which receives a pointer to a distinct
inton the heap and returns a pointer to a distinctlongon the heap. Verify withvalgrind --leak-check=fullthat no memory is leaked. - Threads and files. Write a program that creates 4 threads, all sharing the same open file descriptor. Each thread writes a distinct character to the file 10,000 times, then closes the shared descriptor. What is the content of the file? Does the order of writes depend on scheduling?
- Performance comparison. Compare the wall-clock time of a program that computes the sum of the first 108 integers using (a) a single thread, (b) two threads each summing half, and (c) four threads each summing a quarter. Pin the process to a single core and repeat. Explain the results.
06 Deliverables
Submit a single archive (tar.gz or zip) named
lab3-<lastname>-<studentnumber>.tar.gz
containing:
-
hello_threads.c(or the final version after all activities) with your name and student number at the top. -
race.c— the program from Activity 4.5, with theobjdumpoutput's relevant fragment in a comment. -
Makefilewith a default target building all programs, acleantarget, and the correct-pthreadflags. -
results.txt— the output of all runs (Activities 4.1–4.5 and Exercises 1–5), with the commands that produced them. -
report.md(orreport.pdf) — the lab report described below.
Lab report
The report should be 3–4 pages (excluding code) and contain:
-
Environment. The output of
uname -a,gcc --version,nproc, andlscpu's relevant lines. - Activity results. For each activity, the command used, the observed output, and a one-paragraph interpretation.
- Exercise results. For each exercise, a short answer supported by the relevant output or code fragment.
-
Race condition analysis. The
objdumpfragment, a step-by-step trace of two interleaved threads that produce a lost update, and a statement of the correct final value that is not observed. - Reflection. Two or three paragraphs on the difference between concurrency (the composition of independent activities) and parallelism (the simultaneous execution on several cores), as observed in Activity 4.4.
07 Grading rubric
| Criterion | Points | CLOs | Exemplary (90–100%) | Proficient (80–89%) | Developing (70–79%) |
|---|---|---|---|---|---|
| Correctness | 30 | CLO2, CLO8 | All programs compile without warnings, run correctly, and produce the documented output. | Programs compile and run; minor warnings or edge-case issues remain. | Programs run but with observable defects; some outputs missing or wrong. |
| Concurrency concepts | 25 | CLO2 | Race condition analysis is correct, complete, and uses the assembly trace precisely. | Race condition explained correctly with minor gaps. | Race condition described but the explanation conflates cause and symptom. |
| Use of the pthreads API | 20 | CLO6, CLO8 | All pthreads calls are used with the correct signatures, error handling, and return-value conventions. | Most calls correct; one or two error-handling omissions. | API used but with the common pitfalls (missing joins, wrong flags, ignored errors). |
| Report quality | 15 | CLO8 | Report is well-structured, evidence-based, and accurate; every claim is supported. | Report is clear with occasional unsupported claims. | Report is disorganised or contains significant inaccuracies. |
| Code quality | 10 | CLO6 | Code is readable, commented, uses the Makefile correctly, and avoids memory leaks. | Code is readable with minor style issues. | Code is difficult to read or leaks memory that was easy to free. |
Late submission penalty: 10% per day, up to a maximum of three days, after which the lab is accepted only with documented extenuating circumstances.
08 Knowledge check
Check your understanding
Question 1
In the canonical pthreads program, why must
the main thread call pthread_join
for every thread it creates before the process
exits?
Show answer
pthread_t) until another thread
joins it. If the main thread returns from
main without joining, the process
exits and the operating system reclaims the
process's address space, which is technically
safe. However, the return values of the
unjoined threads are lost, and any resources
the thread allocated (its stack in
particular) may be released late. More
importantly, in a long-running server, joinable
threads that are never joined accumulate
zombie thread records, eventually exhausting
the thread table. The correct pattern is
either to join every thread (which also
synchronises the main thread with the
workers) or to detach every thread whose
completion does not matter.
Question 2
Explain why passing the address of a local
loop variable to pthread_create
as the thread argument is incorrect, and how
to fix it.
Show answer
pthread_create was called.
Depending on the scheduler, several threads
may observe the same value or values may be
skipped. The fix is to pass a value whose
lifetime is independent of the loop's stack
frame: either allocate a heap copy
(malloc(sizeof(int))) per
iteration and free it in the thread, or
pass the integer by value in the
void * argument (casting an
int to void * is
implementation-defined but works on the
common platforms).
Question 3
What is a race condition? Using the
counter++ example, list the
three machine-level operations that make the
increment non-atomic and show how two
threads can both read the same value.
Show answer
counter++ compiles to three
machine-level operations: (1) load
counter into a register,
(2) increment the register, and (3) store
the register back into counter.
Suppose two threads T1 and T2 both
execute the sequence. If T1 loads
counter = 0, then T2 loads
counter = 0, then T1
increments its register to 1 and stores
counter = 1, then T2
increments its register (still 0 in its
view) to 1 and stores counter = 1,
one of the two increments has been lost.
The final value is 1 instead of 2. This is
a lost update, and it is the
canonical race condition.
Question 4
In Activity 4.4, why is the speedup from multiple threads often less than linear, even on a multicore processor? Identify at least three causes.
Show answer
Question 5
Suppose you create 4 threads, each incrementing a shared counter 1 million times, and you observe the final value 3.87 million instead of 4 million. Is the observed value deterministic? If you run the program again, will you see the same value? Explain.
Show answer
09 References
- IEEE Std 1003.1c — POSIX Threads. The standard's definition of the pthreads API.
-
Linux man-pages:
man 7 pthreads,man 3 pthread_create,man 3 pthread_join,man 3 pthread_exit,man 3 pthread_self,man 3 pthread_detach. - Kerrisk, M. The Linux Programming Interface. No Starch Press, 2010. Chapters 29–33.
- Butenhof, D. R. Programming with POSIX Threads. Addison-Wesley, 1997.
- Arpaci-Dusseau, R. H., & Arpaci-Dusseau, A. C. Operating Systems: Three Easy Pieces. Chapters 26 and 27.