COMP 3150Operating Systems

01 Lab overview

CourseCOMP 3150 / CMIS 3150 — Operating Systems
UnitUnit 4 — Threads
Weight1.25% of the final grade (1/12 of the 15% laboratory component)
Estimated duration3–4 hours of hands-on work
SubmissionOne archive containing the C source files, a Makefile, the test output, and a short lab report (see § 07)
EnvironmentLinux 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 -pthread and 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 tid terminates. If retval is non-NULL, the thread's return value is stored there.
pthread_exit(void *retval)
Terminates the calling thread and makes retval available 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

  1. Create the lab directory:
    mkdir -p ~/comp3150/lab3 && cd ~/comp3150/lab3
  2. Copy the program in § 3.4 into hello_threads.c.
  3. Compile and run it:
    gcc -Wall -Wextra -O2 -pthread -o hello_threads hello_threads.c
    ./hello_threads
  4. Observe the output. In what order do the worker threads print? Is the order stable across runs? Why or why not?
  5. Compile the same program without -pthread and record what happens. Restore the correct compilation.

Activity 4.2 — Passing and returning values

  1. Extend the program so each worker receives a heap-allocated struct containing an id and a delay_ms field.
  2. Have each worker usleep(delay_ms * 1000), then return a heap-allocated result that the main thread frees after pthread_join.
  3. Run the program with delays that differ by a factor of 10 and observe the completion order with time ./hello_threads.
  4. 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

  1. Run the program with a long sleep in the main thread after creating the workers, and identify the process's PID.
  2. In a second terminal, run:
    ls /proc/<pid>/task/
    cat /proc/<pid>/status | grep Threads
  3. Compare the thread IDs in /proc/<pid>/task/ with the pthreads identifiers printed by the program. Are they the same?
  4. Run ps -L -o pid,tid,comm -p <pid> and record the output.

Activity 4.4 — Scheduling and concurrency

  1. Write a program that creates n threads, each performing a CPU-bound loop that increments a counter until a shared deadline.
  2. Run the program with n = 1, 2, 4, 8, 16 and record the total wall-clock time for each configuration. Use taskset -c 0 ./program to pin the process to a single core, and repeat without pinning.
  3. Plot the times. What is the speedup? At what value of n does it saturate?
  4. 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

  1. Write a program that creates n threads, each incrementing a shared global counter 1,000,000 times:
    volatile long counter = 0;
    void *worker(void *arg) {
        for (long i = 0; i < 1000000; i++) counter++;
        return NULL;
    }
  2. Run the program with n = 1, n = 2, and n = 4. Record the final value of counter each time.
  3. Repeat the experiment ten times for each n. Is the final value always the expected n × 1,000,000?
  4. Using objdump -d, inspect the assembly for counter++. Identify the three instructions (load, increment, store) and explain how the interleaving of two threads produces a lost update.
  5. Do not fix the race in this lab; you will fix it in Lab 5.

05 Exercises

  1. 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.
  2. 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_join on a detached thread?
  3. Argument passing. Write a program that creates 1,000 threads, each of which receives a pointer to a distinct int on the heap and returns a pointer to a distinct long on the heap. Verify with valgrind --leak-check=full that no memory is leaked.
  4. 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?
  5. 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 the objdump output's relevant fragment in a comment.
  • Makefile with a default target building all programs, a clean target, and the correct -pthread flags.
  • results.txt — the output of all runs (Activities 4.1–4.5 and Exercises 1–5), with the commands that produced them.
  • report.md (or report.pdf) — the lab report described below.

Lab report

The report should be 3–4 pages (excluding code) and contain:

  1. Environment. The output of uname -a, gcc --version, nproc, and lscpu's relevant lines.
  2. Activity results. For each activity, the command used, the observed output, and a one-paragraph interpretation.
  3. Exercise results. For each exercise, a short answer supported by the relevant output or code fragment.
  4. Race condition analysis. The objdump fragment, 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.
  5. 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

Lab 3 grading rubric (100 points)
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
A thread that is neither joined nor detached is joinable: when the thread terminates, the kernel keeps its return value and a minimal thread record (the 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
When the loop variable is a stack local, all threads receive the same address. Each thread's view of the value at that address depends on when the thread actually reads it: a thread that starts after the loop has advanced sees the new value, not the value the loop had when 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
A race condition is a situation in which the correctness of a program depends on the timing or interleaving of concurrent operations. When two threads access a shared variable without synchronisation, the result can depend on the order in which their operations are executed. The expression 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
The speedup is less than linear for several reasons. (1) Amdahl's law: any serial portion of the work bounds the maximum speedup, and the parallel portion cannot exceed the number of cores. (2) Memory bandwidth and cache contention: all threads share the memory hierarchy, and when several threads stream through memory, the bus and the caches become contended, so each thread's effective bandwidth falls. (3) Thread creation and scheduling overhead: creating and destroying threads has a cost, and when there are more threads than cores the scheduler must switch between them, adding context-switch overhead. (4) False sharing and cache-line ping-pong: two threads updating variables that happen to share a cache line force the line to bounce between cores, slowing both. (5) Lock contention and synchronisation: if the workload involves shared data structures protected by locks, the locks serialise the threads.

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
The observed value is not deterministic. Race conditions are timing-dependent: the exact interleaving of the threads' load/increment/store sequences depends on the scheduler, the processor's cache state, the other activity on the machine, and the hardware's memory model. Running the program again on the same machine may produce a different total (3.82 million, 3.94 million, or even the correct 4 million, though that is unlikely with 4 million interleaved increments). The important point is that the program's behaviour is undefined: even though the program usually produces a value between 3 and 4 million, there is no guarantee, and on a different machine or with a different scheduling policy it could produce a very different result. The correct fix (in Lab 5) is to protect the increment with a mutex or to use an atomic operation.

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.
👤