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/unlockgive 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-beforerelation in Java, in channel ownership transfer in Go, in theSend/Synctypes in Rust — in C only in thepthread_mutex_tcalling 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.