Skip to content
academia.sh

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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close