Lesson 11 / 14
Mutexes
A goroutine blocks until the other goroutine holding the lock releases it, and its termination class is waited-for-arrived. The same counter is run through two setups, locked and unlocked, with 20 goroutines and 100 increments per goroutine, and both give 2000 — because the unlocked setup never sets up a real race, since each goroutine writes to its own cell. The limiting measurement models a single write that falls outside the lock's scope with an order forced by a channel: two increments are applied, but the result comes out as 1 instead of 101.
Published Updated
In this lesson
- Shows by running it that a goroutine waiting for the lock is blocked until the goroutine holding it releases it, and that its termination class is waited-for-arrived
- Increments the same counter through two setups, locked and unlocked, and distinguishes why the two totals come out equal and which assumption invalidates that equality
- Shows, by modeling an order forced through a channel, how a single write left outside the lock's scope invalidates the protection
Contents
The previous lesson’s closing drew a boundary: a timeout rescues the waiting goroutine but does not stop the waited-for work itself — the worker goroutine keeps running, only nobody is waiting for it anymore. This lesson moves away from that question. Every lesson so far measured goroutines passing a value to one another: a number crossing a channel, a context’s cancellation signal, the branch a timeout selects. In this lesson, goroutines do not send anything to each other; they share the same value. What changes when the choice is to protect shared state instead of communicating?
Channel-based mechanisms always dodged this question: a value crossing a channel was copied, the sending side never touched it again after sending, so a situation where two goroutines touch the same memory at the same time was never even set up. Shared state closes off that escape: more than one goroutine genuinely reads and writes the same variable. The course’s second hard constraint kicks in here — this lesson does not run an unprotected version of the sharing to produce a wrong result; instead, it measures what the protection provides and where it stops.
The Lock’s Scope
The concept of mutual exclusion — forcing only one execution unit to access a resource at a
time — was established in the Operating System Concepts course and measured there by
counting interleavings; it is not repeated here. What this lesson measures is not the
concept itself but Go’s one tool for realizing it: sync.Mutex, a mutex. The contract the
lock offers is short: a Lock call returns if someone else is holding the lock, but blocks
first; Unlock releases the lock. The lines between Lock and Unlock are the lock’s
scope — the lock guarantees only that these lines run under exactly one goroutine at a
time, and it has nothing to say about anything outside that scope. This scope is Go’s
counterpart to what the Operating System Concepts course calls the critical section: the
concept of a critical section was established there independent of how it gets bounded,
and sync.Mutex here makes it concrete as an interval between two calls.
It is also part of the contract that sync.Mutex needs no setup beyond a declaration: the
moment var lock sync.Mutex is written, the lock is ready to use, because a lock’s zero
value is already an unlocked lock. A single-sentence reference to the Go Fundamentals
course’s discussion of zero values is enough here — the lock itself is not this course’s
measurement axis, only a tool.
The defer s.mu.Unlock() line in the Increment method is also part of this contract: the
call that releases the lock runs no matter where the method body returns from — a normal
return, an early return, or a panic. Releasing the lock by hand, with an Unlock() call
written at the end of the body, leaves the door open for the lock to stay held forever once
an early return is added in the middle of the body; defer closes that gap. This lesson
does not run that gap separately, because the topic is already addressed in this lesson’s
limiting measurement, in a different form — as the lock’s own scope being drawn
incorrectly.
The first consequence of this contract ties directly back to the question asked since the
course’s first lesson: how will a goroutine waiting on a lock stop? The answer is one of
the shared definition’s own lines — when the goroutine holding the lock calls Unlock, the
waiting goroutine’s Lock call returns and the goroutine continues. This is an instance of
waited-for-arrived, one of the four termination classes: the waiting goroutine does not
stop on its own; a step taken by another goroutine — releasing the lock, here — sets it
free. When more than one goroutine is waiting on the same lock, sync.Mutex gives no
guarantee about which one proceeds first; this belongs to the same family as the Operating
System Concepts course’s discussion of starvation, and it is mentioned here in only one
sentence, because it does not matter for this lesson’s single waiter.
// counter.go — a counter protected by a mutex, and a half-protected counter modeling the lock's boundary
package main
import "sync"
// ProtectedCounter carries an integer protected by a mutex.
type ProtectedCounter struct {
mu sync.Mutex
value int
}
func (s *ProtectedCounter) Increment() {
s.mu.Lock()
defer s.mu.Unlock()
s.value++
}
// HalfProtected splits the increment into two: the read inside the lock, the write outside it.
// This models the single line that falls outside the lock's scope.
type HalfProtected struct {
mu sync.Mutex
value int
}
func (y *HalfProtected) ReadAndUnlock() int {
y.mu.Lock()
defer y.mu.Unlock()
return y.value
}
func (y *HalfProtected) WriteUnlocked(newValue int) {
y.value = newValue
}
ProtectedCounter sets up the contract correctly: everything Increment reads and writes
stays between Lock and Unlock. HalfProtected, by contrast, is built broken on
purpose — the read under the lock, the write entirely outside it, in two separate methods.
This second type is for the limiting measurement below; the first measurement uses only
ProtectedCounter.
Three Measurements, One Run
// main.go — lock scope, a locked/unlocked comparison, modeling the lock's boundary
package main
import (
"fmt"
"sync"
)
func main() {
fmt.Println("-- a goroutine waiting on the lock stops once the lock is released --")
var lock sync.Mutex
report := make(chan string, 1)
lock.Lock()
go func() {
lock.Lock()
lock.Unlock()
report <- "stopped: waited-for-arrived"
}()
lock.Unlock()
fmt.Println(<-report)
fmt.Println()
fmt.Println("-- result in the locked and unlocked setups --")
const goroutineCount = 20
const incrementCount = 100
locked := &ProtectedCounter{}
var group1 sync.WaitGroup
group1.Add(goroutineCount)
for i := 0; i < goroutineCount; i++ {
go func() {
defer group1.Done()
for j := 0; j < incrementCount; j++ {
locked.Increment()
}
}()
}
group1.Wait()
shares := make([]int, goroutineCount)
var group2 sync.WaitGroup
group2.Add(goroutineCount)
for i := 0; i < goroutineCount; i++ {
go func(index int) {
defer group2.Done()
for j := 0; j < incrementCount; j++ {
shares[index]++
}
}(i)
}
group2.Wait()
unlockedTotal := 0
for _, s := range shares {
unlockedTotal += s
}
fmt.Printf("locked total : %d\n", locked.value)
fmt.Printf("unlocked total : %d\n", unlockedTotal)
fmt.Printf("are they equal : %v\n", locked.value == unlockedTotal)
fmt.Println()
fmt.Println("-- limiting measurement: modeling a single write that falls outside the lock's scope --")
half := &HalfProtected{}
step1 := make(chan int)
step2 := make(chan struct{})
done := make(chan struct{})
go func() {
old := half.ReadAndUnlock()
step1 <- old
<-step2
half.WriteUnlocked(old + 1)
close(done)
}()
oldValue := <-step1
half.mu.Lock()
interimValue := half.value
half.value = interimValue + 100
half.mu.Unlock()
close(step2)
<-done
fmt.Printf("old value g1 read : %d\n", oldValue)
fmt.Printf("two increments applied (+1 and +100), expected total: 101, actual: %d\n", half.value)
}
-- a goroutine waiting on the lock stops once the lock is released -- stopped: waited-for-arrived -- result in the locked and unlocked setups -- locked total : 2000 unlocked total : 2000 are they equal : true -- limiting measurement: modeling a single write that falls outside the lock's scope -- old value g1 read : 0 two increments applied (+1 and +100), expected total: 101, actual: 1
SS1. The first line runs the shared definition’s own measurement directly: main
starts a goroutine while holding the lock, that goroutine waits on its Lock call, and
once main calls Unlock, the goroutine continues and reports its reason for stopping.
This mechanism is exactly one of the “waited-for-arrived” lines in the 2/5/2/3 distribution
measured in the Goroutines lesson — the lock is the fifth mechanism here.
SS2. The second measurement runs twenty goroutines, each incrementing a hundred times,
through two separate paths. In the locked setup, all of them share the same
ProtectedCounter, and every increment stays between a Lock/Unlock pair; the result is
deterministically 2000. In the unlocked setup, each goroutine instead increments a
separate slice cell belonging to its own index — twenty goroutines write to twenty
different memory locations, and none of them touches the same one. Both setups give 2000,
and the two numbers are equal because neither one actually set up a race that needed a
lock: in the locked setup the lock provides extra protection but is not protecting a
resource that was ever shared to begin with, and the unlocked setup excludes the race from
the start by using separate memory instead of sharing. The results coming out equal does
not mean “unlocked is safe too”; it only means both setups are safe, for different reasons.
We do not run the result produced by goroutines writing without a lock to a genuinely
shared single variable in this lesson — that comparison falls under this course’s second
hard constraint and is measured only through the answer the detector gives.
The lock itself has a cost: twenty goroutines wait in turn for the same lock, meaning each
Increment call takes over the lock the previous one released. This contention — more
than one goroutine trying to reach the same lock at the same time — can make the locked
setup slower than the unlocked one, because in the unlocked setup no goroutine waits for
another. But this lesson does not measure duration; the only thing measured is correctness,
both setups reaching the same total. The cost of contention is real, but it falls outside
this lesson’s question — a lock exists for correctness, not for speed.
Limiting Measurement: The Boundary of the Lock’s Scope
SS3. HalfProtected carries the same lock as ProtectedCounter but breaks the
contract: it reads the value and releases the lock immediately, then writes the new value
outside the lock. The measurement does not leave this gap to random timing — two channels
(step1, step2) step in and force the order exactly: the goroutine first reads the old
value and reports it, main takes its own lock only after receiving that report,
increments the value by 100, and releases the lock, and only then is the goroutine
signaled to continue. When the goroutine continues, the WriteUnlocked call outside the
lock writes old + 1, computed from the old value it read — overwriting the 100 main
wrote in between without ever seeing it. Two increments were applied (one +1, one +100),
but the result is not 101, it is 1.
This is not the running of a race condition, quite the opposite: because the order between
the two steps is fully determined by the channels, there is no timing uncertainty at all
between main‘s write and the goroutine’s write — both happen the same way, in the same
order, on every run. The race detector, looking at this code, gives no warning at all —
there is an ordering relationship established through a channel between the read and the
write, and the two goroutines never touch the same memory at the same time. The defect is
not a timing bug, it is a design bug: the lock’s scope does not cover the whole place the
“protect the value” contract requires. The entire operation between reading the value and
writing it — here, computing old + 1 and writing it back — should have stayed under the
lock; the lock protected only the moment of reading, not the moment of deciding and writing
back.
This is the lesson’s limiting reading: a lock protects exactly as much as its scope, not
one line more or one line less. What needs protecting is not a single variable but the
entire operation performed on that variable — as long as the read-compute-write triple
does not stay inside the same Lock/Unlock pair, the lock’s mere existence guarantees
nothing. ProtectedCounter.Increment gets this right: the read, the increment, and the
write happen in a single statement (s.value++) and inside a single lock scope.
HalfProtected breaks the contract by splitting the same three operations into two
separate methods, two separate lock scopes — the lock itself raises no error, because the
lock’s job is only to protect its scope, not to decide whether that scope was drawn
correctly.
When HalfProtected’s two methods are read separately, neither looks suspicious:
ReadAndUnlock uses the lock correctly, and WriteUnlocked, looked at on its own, carries
no problem either, because it just assigns to a single field. The defect only shows up in
how the two methods are called together — there is a gap between them where the lock can
be released and taken again, and this gap cannot be seen by reading the source; it can only
be seen by asking whether another goroutine can step in between the two calls. This is a
recurring pattern when designing shared state: when the methods a type exposes are split
into separate pieces like “read” and “write,” the code calling those pieces may not know
that the lock gets released in between. Keeping ProtectedCounter.Increment as a single
method is a deliberate choice: the contract it exposes becomes “apply one increment
atomically,” and the calling side never sees the gap between reading and writing, because
no such gap exists in the source at all.
Lock or Channel
Throughout the previous topic, every case of sharing was set up by passing it through a
channel: a goroutine wanting to change a value sent a message to the single goroutine
holding it, and that goroutine applied the change on its own turn. This lesson does the
same job with a lock: the ProtectedCounter holding the value carries its own lock, and
every goroutine that wants a change accesses that memory directly, with no intermediary
goroutine standing between them. Either can produce the same result — the worker pool
pattern in the previous topic provided this through a channel, and this lesson provides the
same protection through a lock. The shared definition’s third claim still holds here: what
stops a goroutine is not the channel or the lock itself, but a step taken by someone; the
choice between communication and sharing is not an escape, it is a preference.
The direction of this preference can be found with a simple question: is what is being
protected a memory location, or a piece of work in a queue? ProtectedCounter.value is
just an integer, and the only operation performed on it is incrementing; routing that
operation to a single owner through a channel takes more indirection than locking it
directly, without adding any extra guarantee. If the value is a more complex state — if
several fields need to stay consistent together, or an order needs to be established
between operations — a channel’s single ownership draws a more natural boundary. This
lesson measures only the lock’s own contract; which of the two approaches to prefer, and
when, is a broader question left for the course’s closing.
Summary
sync.Mutexis Go’s lock for realizing mutual exclusion; the lines betweenLockandUnlockform the lock’s scope, and only that scope is protected.- A goroutine waiting on a lock continues once another goroutine holding it calls
Unlock— an instance of waited-for-arrived, one of the four termination classes. - The same counter reaches 2000 through two setups, locked and unlocked; the equality is not proof of safety, because the unlocked setup already excludes the race by writing to separate memory cells instead of a single shared variable.
- A single write falling outside the lock’s scope was modeled with an order forced through a channel: two increments were applied, but the result came out as 1 instead of 101, because the lock protected only the read.
- A lock requires keeping the entire operation that needs protecting — reading, computing, writing — inside the same scope; where that scope gets drawn is a decision the lock itself cannot police.
Next Step
This lesson measured whether a single goroutine waits on a lock. But most shared-state
problems are not limited to a single waiter: waiting for an entire group of goroutines to
finish requires a different contract than waiting for each of them to separately release a
lock. The next lesson measures the tool that sets up this collective waiting — the
counter-based sync.WaitGroup — and its own contract: when the counter reaches zero, and
what happens if it never does.
To keep your progress and take notes, Log in
My notes
Log in to take notes.