Skip to content
academia.sh

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.Mutex is Go’s lock for realizing mutual exclusion; the lines between Lock and Unlock form 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close