Lesson 04 / 10
Mutex and Read-Write Lock
The side holding a mutex advances on its own, the side waiting for it advances when something else finishes; this order is made deterministic with a channel handshake, and the read side of a read-write lock departs from a mutex by allowing more than one reader at the same time.
Published Updated
In this lesson
- Measures with a channel handshake, in a deterministic way, that the side holding the lock advances on its own and the side waiting for it advances when something else finishes
- Shows that which of two waiting sides enters first is not measured, and why it is not measured
- Verifies by running it that the read side of a read-write lock allows more than one reader at the same time
Contents
The previous lesson showed two threads operating in sequence on a shared integer, but showed
that the program itself forced that order through join calls: second never started before
first had completely finished. When these two operations need to run at the same time and
only need to fall into sequence at the moment they access the shared data, joining is not a
sufficient tool — the rest of the two threads can advance in parallel, and only the moments
they enter the critical section need to fall into sequence. This lesson measures directly how
a waiting side advances through a lock.
This lesson runs two different kinds of lock: Mutex, a mutex that allows only one side to
access at a time; RwLock, a read-write lock that handles read and write access separately.
Both offer a solution to the same basic problem — making concurrent access to shared data safe
— but they differ in the degree of sharing they permit. The lesson measures this difference
with three separate scenarios: the lock holder advancing on its own, the waiting side advancing
when something else finishes, and how the read side of the read-write lock loosens this rule.
The Lock Holder On Its Own, the Waiter When Something Else Finishes
The concept of a mutex is not established again here. The Operating System Concepts course
established what a lock is for; the JVM, Concurrency and Performance course measured the
identity of the thread holding a lock. This course asks a different question: what the side
holding the lock and the side waiting for the lock need in order to advance. The answer
is short: a lock call made while the lock is free returns needing no one — it falls into the
on-its-own class. A lock call made while the lock is held, on the other hand, waits until the
lock holder releases it — it falls into the when-something-else-finishes class. This has the
same structure as the recv call in the Message Passing with Channels lesson: both wait for an
event outside themselves to occur, and the difference is only whether the awaited event is a
send or a release.
The lock itself is represented by a handle the lock call returns. When this handle goes out
of scope (or is explicitly dropped), the lock is automatically released — an extension of the
drop rule established in the Ownership and the Memory Model course. In the program below, the
handle named entry inside the holder thread keeps the lock in hand until the function’s
body finishes and it goes out of scope; when the lock is released depends not on an explicit
call from the programmer, but on where this handle’s life ends. This is the same mechanism as
the ownership rule for a moved value seen in the previous lesson — here it is applied to a lock
handle.
This relationship has to be measured without looking at the runtime’s actual timing, because
timing depends on the environment and is never printed in this course. The program below proves
with a channel handshake that the lock holder genuinely touches the lock, and that the waiting
side genuinely enters afterward: the waiting side does not even start trying before it receives
a signal that the holder has entered the lock, and the order of the records both sides write to
a shared ledger is forced by the lock itself. Without this handshake — that is, if waiter made
its own lock call without waiting for any signal — the measurement would still produce the
correct order, because the lock already gives an absolute order guarantee; but in that case it
would remain unclear whether waiter’s call was actually made while A held the lock, or
before that. The handshake makes clear which moment the measurement is proving.
A Deterministic Order Through a Handshake
The program below runs three separate scenarios back to back, all three isolated within the
same main function with their own channels. The first scenario measures a single waiter and
uses two separate signal channels: a_entered_sender/a_entered_receiver tells waiter that
holder has entered the lock; proceed_sender/proceed_receiver tells holder that waiter
has started trying. This two-way handshake pins down which side touches the lock when — the
lock itself (log) knows nothing of this signaling, it is protected only by the standard
lock call.
// lock.rs — the lock holder advances on its own, the lock waiter when something else finishes.
use std::sync::mpsc::channel;
use std::sync::{Arc, Mutex, RwLock};
use std::thread;
fn main() {
// --- exit order is made deterministic with a handshake ---
let log = Arc::new(Mutex::new(Vec::<String>::new()));
let (a_entered_sender, a_entered_receiver) = channel::<()>();
let (proceed_sender, proceed_receiver) = channel::<()>();
let g = Arc::clone(&log);
let holder = thread::spawn(move || {
// on its own: the lock was free, taken without waiting for anyone
let mut entry = g.lock().unwrap();
entry.push(String::from("A-entered"));
a_entered_sender.send(()).unwrap();
proceed_receiver.recv().unwrap(); // held until B is confirmed to have started trying
entry.push(String::from("A-exited"));
// entry goes out of scope and is dropped here
});
a_entered_receiver.recv().unwrap();
let g = Arc::clone(&log);
let waiter = thread::spawn(move || {
proceed_sender.send(()).unwrap();
// when something else finishes: blocked here until A releases the lock
let mut entry = g.lock().unwrap();
entry.push(String::from("B-entered"));
entry.push(String::from("B-exited"));
});
holder.join().unwrap();
waiter.join().unwrap();
println!("single-waiter order: {:?}", *log.lock().unwrap());
// --- two waiters: which enters first is not measured, only that both entered ---
let log2 = Arc::new(Mutex::new(Vec::<String>::new()));
{
let mut first = log2.lock().unwrap();
first.push(String::from("start"));
}
let g = Arc::clone(&log2);
let c = thread::spawn(move || {
let mut entry = g.lock().unwrap();
entry.push(String::from("C-entered"));
});
let g = Arc::clone(&log2);
let d = thread::spawn(move || {
let mut entry = g.lock().unwrap();
entry.push(String::from("D-entered"));
});
c.join().unwrap();
d.join().unwrap();
let mut entrants: Vec<String> = log2
.lock()
.unwrap()
.iter()
.filter(|s| s.ends_with("-entered"))
.cloned()
.collect();
entrants.sort();
println!("records entered by the two waiters (order not measured): {:?}", entrants);
// --- read-write lock: more than one reader can hold the lock at once ---
let value = Arc::new(RwLock::new(42));
let (r1_entered_sender, r1_entered_receiver) = channel::<()>();
let (r2_done_sender, r2_done_receiver) = channel::<()>();
let (release_sender, release_receiver) = channel::<()>();
let v1 = Arc::clone(&value);
let reader1 = thread::spawn(move || {
let handle = v1.read().unwrap();
r1_entered_sender.send(()).unwrap();
release_receiver.recv().unwrap();
*handle
});
r1_entered_receiver.recv().unwrap();
let v2 = Arc::clone(&value);
let reader2 = thread::spawn(move || {
// this call also successfully takes the lock while R1 still holds the read lock
let handle = v2.read().unwrap();
let result = *handle;
r2_done_sender.send(()).unwrap();
result
});
r2_done_receiver.recv().unwrap();
release_sender.send(()).unwrap();
let result1 = reader1.join().unwrap();
let result2 = reader2.join().unwrap();
println!();
println!("both readers held the lock at once, values read: {} and {}", result1, result2);
println!("multiple readers were allowed: {}", result1 == result2);
}
single-waiter order: ["A-entered", "A-exited", "B-entered", "B-exited"] records entered by the two waiters (order not measured): ["C-entered", "D-entered"] both readers held the lock at once, values read: 42 and 42 multiple readers were allowed: true
TH6. The four-entry order in the first line comes out identical across three separate runs:
A-entered, A-exited, B-entered, B-exited. This order comes not from timing but from the
program’s structure. The waiter thread never makes its own lock call before receiving the
signal (a_entered_receiver.recv()) that holder has entered the lock; holder, in turn,
writes its own record (A-entered) and sends the signal, and only after that waits for a
confirmation (proceed_receiver.recv()) that waiter has started trying, writing its second
record (A-exited) and releasing the lock only once that confirmation arrives. This three-step
handshake guarantees that waiter’s lock call is genuinely made while A still holds the lock
— waiter is blocked at that call, unable to advance until holder releases the lock. The
blocking duration is not measured, only the order of the result: B entries never appear
before A’s exit.
Two Waiters: Order Is Not Measured
In the second scenario, C and D wait on the same lock. Both eventually enter — the output
proves that — but which one entered first is never measured anywhere. The program does not
hide this uncertainty; instead it builds it into its structure: the records are sorted before
they are collected, exactly as the log channel did in the Thread Creation lesson. The
entrants list comes out as ["C-entered", "D-entered"] on every run — this is the result of
alphabetical sorting, not of the actual entry order.
In this scenario, a "start" record is written to the log before C and D begin, and this
record does not appear in the reported list, because entrants filters for only the lines
ending in -entered. This filtering is deliberate: the exact order of the records log2 holds
is in fact known and deterministic (start is always first), but the relative order between
C-entered and D-entered does not carry that certainty. The filtering separates what can be
measured from what cannot within the same data structure.
The Read-Write Lock: Multiple Readers Allowed
The third scenario directly shows the difference between a mutex and a read-write lock.
reader1 sends a signal while holding the read lock; reader2, after receiving that
signal, requests its own read lock on the same value. If this were a mutex, reader2 would be
blocked until reader1 released the lock — and since reader1 is waiting for reader2 to
send a confirmation, that confirmation would never arrive, and the program would deadlock.
Since the program completes without deadlocking, reader2’s read-lock call must genuinely have
returned successfully while reader1 still held the lock.
This inverts the same logic as the handshake in the first scenario. There, the handshake was
used to prove a wait: to show that waiter genuinely requested the lock while holder held
it. Here, the handshake is used to prove the absence of a wait: if the read calls blocked
each other, the program could never complete this handshake and would wait forever. The program
finishing normally is, on its own, proof that the read side of a read-write lock is shareable —
the most indirect of the measurement methods used in this lesson, but also the most conclusive.
TH7. The value the two readers read is exactly identical (42 and 42), because both
read the same shared number and no write occurs during the reading. This measurement shows
that the read side of a read-write lock is shareable within itself: two readers can hold
the lock at the same time, whereas if the same scenario were set up with a Mutex, the program
would deadlock, because reader1 could never send its confirmation without reader2 being
able to request its own lock.
The source of this difference is that a read-write lock offers two separate entry points. A mutex carries a single call — whoever calls it is subject to the same rule. A read-write lock carries separate calls for reading and writing: the read call accepts as many readers as request it, as long as no writer holds the lock; the write call, by contrast, always insists on staying alone, sharing with neither another writer nor a reader. Only the read side was run in this measurement; the write call’s behavior is identical to a mutex’s — once a writer enters the lock, every side waiting on that lock at that moment, reader or writer, waits for it to finish.
The Limits of the Measurement
A lock protects data, it does not establish order. A mutex, or the write side of a
read-write lock, guarantees that only one side enters the critical section at a time — this is
what is measured in this lesson. But which of two waiters enters first is not part of the
lock’s own contract; that is a detail that depends on the operating system’s scheduler, the
lock’s internal implementation, and the load at that moment. In the C and D example, this
was deliberately neither measured nor printed — by sorting the records before they are
collected, the question of which order actually occurred was removed entirely from the
program’s output.
This boundary also clarifies the difference between a mutex and a read-write lock: the read side of a read-write lock grants permission for more than one waiter to enter at once, but that permission carries no order guarantee either — if a third reader had been added, when that reader entered still would not be measured. The only problem a lock solves is when access to the shared data is safe; it never solves the question of which side accesses it first.
This distinction is not an arbitrary choice, it is the natural boundary of the guarantee a lock provides. A lock defines how many sides can be in the critical section at once (one for a mutex, unlimited for the read side of a read-write lock); it does not define what determines the order of entry into the critical section, because that is generally a decision left to the operating system scheduler’s own policy. No scheduler policy is named anywhere in this course, and none will be; what is measured is only the guarantees a lock gives and does not give.
Summary
- The side holding the lock advances on its own; the side waiting for the lock advances when something else finishes, until the lock is released. This lesson proved that relationship in a deterministic way with a channel handshake.
- Which of two waiting sides enters first is neither measured nor printed; sorting the records before they are collected removes this uncertainty from the program’s output.
- The read side of a read-write lock allows more than one reader at the same time — this was compared against a situation where the same scenario would deadlock with a mutex.
- A lock protects access to shared data, but gives no guarantee about which side enters first; these two things are independent of each other.
- The JVM, Concurrency and Performance course measured which thread held a lock; this course measures what the side waiting for that same lock needs in order to advance.
Next Step
A lock stops the waiting side, because access to shared data has to be granted in sequence. In
all three scenarios seen in this lesson, one side had to wait for the lock — either because the
lock was held (waiter, C, D), or because a write request would have been involved (the
write side of a read-write lock). But not every sharing needs protection this strict —
especially when the shared value is as simple as a single number, and the operation performed
on it is small enough for the hardware itself to carry out in a single step. The next lesson
measures a form of sharing that no one waits for at all: a counter advancing lock-free, with
atomic operations, needing no one — and whether that gives the same result as a locked counter.
To keep your progress and take notes, Log in
My notes
Log in to take notes.