---
title: Mutexes
source: 'https://academia.sh/en/courses/go-concurrency/mutexes'
course: Concurrency
language: en
updated: '2026-09-14T10:59:31+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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"
---

# 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.

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.

```go
// 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

```go
// 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.
