Skip to content
academia.sh

Lesson 05 / 15

POSIX Threads

Two threads in the same process changing the same memory under a lock is not a handoff, consumed once like the bytes a pipe carries, but a continuously shared exchange.

Published Updated

In this lesson

  • shows that two threads incrementing the same counter under a lock produce a complete, deterministic result
  • distinguishes what the lock guarantees as visibility, not ordering
  • explains that threads share the memory space, unlike fork, comparing it against the classes from previous lessons
Contents

The last lesson on system interfaces stays within the same process, but gives up the form of carrying that crosses a process boundary and moves to two streams that share the same memory space: the thread. fork gave a copy, a signal left a handoff, a pipe and mmap crossed the inter-process boundary in two different ways. Two threads, on the other hand, never cross a boundary at all — they share the same process’s same memory from the start. A change made under a lock therefore falls into the course’s fourth class: shared.

pthread_create also differs from fork in how it sets this up. The moment fork is called, the kernel copies the memory’s page table — thanks to copy-on-write the physical pages are not duplicated right away, but the two processes now have separate address spaces, and when one writes the other does not see it. pthread_create, however, does not open a new address space; it only allocates a new stack and starts a second point of execution inside the same process. The code segment, the data segment, the heap — all a single copy, both streams see the same addresses. That is why a global variable like counter is the same memory location for both threads; compared with the two independent copies of a same-named variable after fork, this difference is what directly determines this lesson’s class.

The Counter Written Under a Lock

observe.h and observe.c do not change. This lesson’s own file, thread_measurement.c, runs a single measurement in which two threads increment the same counter under a lock and the result reaches exactly the expected value.

// observe.h — a record that classifies whom a change reaches
#ifndef OBSERVE_H
#define OBSERVE_H

enum Class { COPY, SHARED, HANDOFF, UNOBSERVED };

/* Records one change: seen is whether the side that did not make the
   change can read it (1 or 0). */
void observe(const char *change, enum Class cls, int seen);

/* Prints the count per class and the reached / not reached totals. */
void observe_summary(void);

#endif
// observe.c — the counts live only in this unit
#include <stdio.h>
#include "observe.h"

static const char *const names[] = { "copy", "shared", "handoff", "unobserved" };
static int counts[4];
static int reached = 0;
static int not_reached = 0;

void observe(const char *change, enum Class cls, int seen) {
    /* shared and handoff reach, copy and unobserved do not */
    int expected = (cls == SHARED || cls == HANDOFF);
    counts[cls]++;
    if (seen) {
        reached++;
    } else {
        not_reached++;
    }
    printf("%-34s %-8s %s%s\n", change, seen ? "saw" : "missed",
           names[cls], seen == expected ? "" : "  MISMATCH");
}

void observe_summary(void) {
    printf("classes: copy %d, shared %d, handoff %d, unobserved %d\n",
           counts[COPY], counts[SHARED], counts[HANDOFF], counts[UNOBSERVED]);
    printf("reached %d, not reached %d\n", reached, not_reached);
}
// thread_measurement.c — two threads incrementing the same counter under a lock
#define _POSIX_C_SOURCE 200809L
#include <pthread.h>
#include <stdio.h>
#include "observe.h"

#define STEPS 1000

static long counter = 0;
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

static void *increment(void *data) {
    (void)data;
    for (int i = 0; i < STEPS; i++) {
        pthread_mutex_lock(&lock);
        counter = counter + 1;
        pthread_mutex_unlock(&lock);
    }
    return NULL;
}

int main(void) {
    pthread_t t1, t2;

    pthread_create(&t1, NULL, increment, NULL);
    pthread_create(&t2, NULL, increment, NULL);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    observe("total written by both threads under the lock", SHARED,
           counter == 2L * STEPS);

    pthread_mutex_destroy(&lock);
    observe_summary();
    return 0;
}
total written by both threads under the lock saw      shared
classes: copy 0, shared 1, handoff 0, unobserved 0
reached 1, not reached 0

SI16. The single line says saw, and this result is not a matter of chance, it is guaranteed. Both threads run the increment function, each incrementing counter a thousand times; the main thread looks at counter after both pthread_join calls return and finds exactly 2000. This is fundamentally different from the independent copies fork gives: pthread_create does not open a new memory copy, it starts a new flow of execution in the same address space of the same process. The counter variable sits in a single place, and both streams write to that one place — that is why the class is shared, not the copy of fork. Because the STEPS constant was chosen to be a thousand, the total is also exact: the two threads together perform 2 * 1000 = 2000 increments, and if the lock never loses one, the result can only be that number — a measurement that occasionally comes out 1998 or 2001 is not a result this lesson accepts, because the course’s measurement method only verifies deterministic results that come out the same every time.

The line counter = counter + 1 may look like a single C expression, but it corresponds to three separate steps: go to memory, read the value, add one, write the result back to memory. Without a lock these three steps are open to interruption — while one thread is between reading and writing, the other thread can read the same old value, both write back their own computed +1, and one of the two increments is lost. pthread_mutex_lock turns these three steps into an indivisible unit: the thread holding the lock finishes all three before the other thread can acquire the lock, so no increment is ever lost. The size of STEPS is also deliberate — with a small enough loop count even a lockless version might give the correct result by chance, because the two threads’ steps could proceed in sequence without colliding; a thousand-step loop reduces that chance to practically zero, though the course text describes this without ever running it.

Java’s standard collections show a different face of the same problem: if a collection is modified by another thread while iterating over an ArrayList or a HashMap, most implementations throw a ConcurrentModificationException instead of silently corrupting the data — this “fail-fast” behavior is the scope of M08/K03’s “Iterators and Concurrent Modification” topic. The counter in thread_measurement.c, however, is a single long, not a structure being iterated over; the lock keeps it intact, but C itself has no runtime check comparable to Java’s — code that forgets the lock and writes directly to counter compiles without error and silently produces the wrong result, without giving a warning like ConcurrentModificationException. The check here is not in the compiler or at runtime, it is entirely in the programmer’s discipline.

What the Lock Guarantees: Visibility, Not Ordering

SI17. The pthread_mutex_lock/pthread_mutex_unlock pair wraps the line counter = counter + 1, and this wrapping guarantees two separate things at once. The first is mutual exclusion: two threads can never hold the lock at the same time, so the step of reading counter, adding one, and writing it back is never interrupted midway — one thread cannot start until the other has completed all three steps. The second is visibility: when a thread releases the lock, the change it made is guaranteed to be visible to the next thread that acquires the lock. Without a lock both guarantees are lost — two threads could read the same counter at the same time and add one to the same old value, and an increment could be lost. thread_measurement.c never runs this lost-update case; a lockless version is described but not executed, because its result would not be deterministic, and this course’s measurement method only verifies deterministic results.

While the lock guarantees visibility, it does not guarantee which thread runs first. No matter in what order t1 and t2 happen to be scheduled, which one acquires the lock more times first is never measured or printed inside thread_measurement.c — only the final total is measured. This also fits the rule the course set from the start: a thread’s identity is never printed, and neither stream prints anything among themselves, only the main thread prints a single line after both join calls return.

In the Java Memory Model (JMM) a similar visibility guarantee is built with the volatile keyword and synchronized blocks — M08/K04’s “JVM, Concurrency, and Performance” topic defines this through the “happens-before” relation: all writes a thread makes before releasing a lock become visible to the next thread that acquires that lock. The rule matches, word for word, the guarantee the pthread_mutex_unlock/pthread_mutex_lock pair gives; the difference is that the JMM defines this in the language’s own specification, through the virtual machine’s memory ordering, whereas in C this guarantee comes from the POSIX thread specification’s calling contract — the pthread_mutex_lock/unlock calls themselves constrain compiler and hardware ordering, the language has no separate concept of a memory model of its own.

Go’s concurrency model solves the same problem from the other direction. The principle in M09/K03’s “Goroutines and Channels” topic — “share memory by communicating, don’t communicate by sharing memory” — defines passing a value through a channel as transferring that value’s ownership from the sender to the receiver; two goroutines never write to the same variable at the same time, a value flows once over a channel. The counter in thread_measurement.c is the exact opposite: ownership is never transferred, both threads have the right to access the same location at the same time, and that right is bounded with a pthread_mutex_t. Had the channel pattern been chosen in Go, instead of sharing counter each goroutine would compute its own partial sum and send it over a channel, and the summing step would happen at a single receiver — this would not be the shared class this course measures, it would be a handoff pattern similar to what the pipe carries.

The Bounding Measurement

SI18. This measurement verifies only the final value, not the intermediate state. Which intermediate values counter passes through while under the lock — whether 1, 2, 3 in order, or however the two threads’ steps happen to interleave — is never measured anywhere, because measuring it would itself require an extra locked read, and that would change the system being measured. The only thing thread_measurement.c verifies is that none of the 2000 steps was lost; the question of which step came from which thread is outside this lesson’s scope. This bound is deliberate: the course measures whether two streams see each other, it does not reconstruct the race between them. A measurement that wanted to observe a race condition itself — for example counting which thread acquired the lock in which position — would become dependent on timing and would break this course’s deterministic-result rule; that is why no such measurement appears here.

This lesson’s biggest limitation is that it assumes the lock is used correctly. The lock variable is acquired before every access inside increment, but nothing enforces that — if a third function were added that read counter without the lock, the compiler would give no warning, the code would silently become wrong. Rust’s concurrency model moves this gap into the type system: M10/K04’s “Send and Sync” topic marks, at compile time, whether a type can safely be moved between threads (Send) and whether it can safely be used as a shared reference (Sync). A shared counter in Rust is not held as a bare variable but inside a wrapper like Mutex<i64>, and code that tries to access that wrapper without acquiring the lock does not compile — it moves an error like the one in thread_measurement.c from runtime to compile time. C has no such check: even though pthread_mutex_t lock sits in the same file as counter, the bond between them is only a rule described in this lesson’s text, not a constraint the compiler knows about. This course’s measurement method does not close that gap either — it only verifies a single run in which the lock is used correctly, it never runs and shows a version where the lock is forgotten.

Next Step and a Combined View of the Classes

SI19. Across these five lessons the same four classes appeared with different carriers. A variable after fork was a copy — the two sides independent, no change ever reached the other side. The flag left by a signal handler was a handoff — it reached the main flow once. The byte a pipe carried was also a handoff, the second read ended in end-of-file. A mapping set up with MAP_SHARED and this lesson’s locked counter were shared — a record continuously shared, when one side changes it the other side sees it too. The fourth class, unobserved, stayed within the first lesson’s own scope: cases where the observation itself goes unseen.

It was also possible to produce the same result with a different mechanism — for example incrementing counter with an atomic operation instead of a lock, or using some other synchronization tool over shared memory instead of a thread. This lesson’s limit is that it only measures the most common pattern built with pthread_mutex_t; atomic operations and other lock kinds fall outside this course’s scope.

These four classes answer a question independent of the carrier itself: can a side other than the one that made a change see that change, and if so, when, how many times? fork answers no — a copy does not show it. A signal and a pipe answer yes, but only once — a handoff is consumed. MAP_SHARED and the locked counter answer yes and continuously — a shared value is not consumed. The first lesson’s observation mechanism itself is a fourth, rotten answer to this question: one side sees something but the measurement never catches it — unobserved. The observe() function used across all five lessons tagged these four answers with a single word each and checked on its own whether that tag was consistent with the measured saw/missed value; no lesson skipped this check, and no measurement ended with a MISMATCH tag.

Because this classification looks at the visibility pattern, not the carrier, it can also be applied to concurrency tools in other languages — the JMM in M08/K04, the channel in M09/K03, Send/Sync in M10/K04 all answer the same four questions in their own syntax, only where the guarantee is built (runtime, type system, language specification) changes. C’s POSIX thread and process interface always builds this guarantee into the calling contract: the fork call itself defines the copy class, the pthread_mutex_lock/unlock pair the shared class, signal delivery and pipe reads the handoff class. System interfaces close here: five lessons answered, with four classes, the question of who outside a process or a thread can see a change.

Summary

  • Two threads share the same memory space within the same process; unlike fork, no separate copy is opened.
  • This lesson’s class is shared: a change made under a lock is visible to both sides, continuously shared like a record.
  • pthread_mutex_lock/unlock give two guarantees: mutual exclusion and visibility. They do not guarantee which thread runs first.
  • The measurement verifies only the final total; it does not measure the intermediate state or the ordering between threads, because measuring that would itself become dependent on timing.
  • A lockless version is only described in prose, never run; because its result is not deterministic, it does not fit this course’s measurement method.
  • The same visibility question is solved in different places in other languages: in the JMM’s happens-before relation in Java, in channel ownership transfer in Go, in the Send/Sync types in Rust — in C only in the pthread_mutex_t calling contract, with no compiler support.

Next Step

System interfaces are complete here. Across five lessons the same observe() unit measured five different carriers — copied memory, the flag left by a handler, a pipe, a shared page, a locked counter — with the same four classes; none replaced another, because each answered a different form of the same question, “who sees what and when.” The next lesson (Linked Structures) carries the same observation unit out of the process and thread boundary and into a single data structure: when two pointers point at the same node, does one see the change the other makes?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close