JavaRush /Courses /Go SELF /sync.Mutex: critical sections and the Lock → Unlock disci...

sync.Mutex: critical sections and the Lock → Unlock discipline

Go SELF
Level 68, Lesson 0
Available

1. Why you need a Mutex and where the race begins

If you are writing concurrent code for the first time, it is very easy to fall into a trap: “I ran it 10 times — everything is fine.” And then on the 11th run you suddenly get a weird result, data disappears, or the program hangs. This is not mysticism and not “the computer got tired.” This is normal life in parallel execution: two goroutines access a shared variable at the same time, one reads, the other writes, and you end up with a state that is simply impossible in single-threaded code.

Important: a Mutex is not an “accelerator” and not a “magic thread-safety button.” It is a simple (and strict) tool: at any given moment, only one goroutine enters the critical section.

Critical section

A critical section is a piece of code where you work with shared mutable state, and it is important to prevent simultaneous access. Imagine a dorm kitchen: if two people stir the same soup in the same pot at the same time with different spoons, the result will be… creative. In programming, the “pot” is a variable/struct/map, and the “spoons” are goroutines.

The problem almost always appears where you have an operation of the form “read → modify → write.” Even if it looks like a single operator n++, under the hood it is multiple steps: read n, add 1, write it back. Between these steps, another goroutine can manage to do its work — and the final result will be wrong.

Mini demo of a race: a counter that “sometimes cannot count”

Let us start with a short example. Here two goroutines increment a shared counter. Naively it seems: “incremented twice — it will be 2.” But without synchronization, that is not guaranteed.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var (
		n  int
		wg sync.WaitGroup
	)

	inc := func() {
		defer wg.Done()
		n++ // data race: two goroutines write to n without coordination
	}

	wg.Add(2)
	go inc()
	go inc()
	wg.Wait()

	fmt.Println(n) // may print 1 or 2 (but you expected always 2)
}

If it seems to you that “it always prints 2 for me,” do not расслабляйтесь. This is like a seat belt: until you need it, it seems unnecessary. And then it suddenly becomes your favorite part of the car.

2. sync.Mutex: basic usage

sync.Mutex is a mutual exclusion primitive. The logic is simple: while one goroutine holds the lock, the others wait.

A Mutex has two main methods:

  • Lock() — “enter the critical section”
  • Unlock() — “exit the critical section”

An important nuance in Go: the zero value of sync.Mutex is ready to use. That means you can write var mu sync.Mutex and use it right away, without make and without new.

The execution scheme usually looks like this:

flowchart TD
    A[Goroutine #1] -->|"mu.Lock()"| B[Critical section]
    B -->|"mu.Unlock()"| C[Exit]
    D[Goroutine #2] -->|"mu.Lock() blocks"| E[Waits]
    E -->|after Unlock| B

This is a very honest scheme: the lock does not speed things up, it enforces discipline.

Fixing the counter: a critical section around n++

Now we will do the same thing, but correctly: protect n++ with a mutex.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var (
		mu sync.Mutex
		n  int
		wg sync.WaitGroup
	)

	inc := func() {
		defer wg.Done()

		mu.Lock()
		n++
		mu.Unlock()
	}

	wg.Add(2)
	go inc()
	go inc()
	wg.Wait()

	fmt.Println(n) // 2
}

Now “enter → increment → exit” happens strictly one at a time. This is the minimal critical section: only what actually works with shared state.

3. The Lock(); defer Unlock() idiom and its limits

Why defer mu.Unlock() is a good style

When code becomes a bit more complex, branches and early return statements almost inevitably appear. And then the most dangerous mistake is forgetting Unlock(). This mistake usually does not scream immediately: the program just “stops” (deadlock) and looks like “nothing is happening.”

That is exactly why Go commonly uses the pattern:

mu.Lock()
defer mu.Unlock()

It is so common that it is mentioned in official materials as one of the classic use cases for defer: “release a mutex”.

Example with defer:

package main

import "sync"

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()

	c.n++
}

The idea is simple: as soon as you see Lock(), you immediately (literally on the next line) “buckle the seat belt” — defer Unlock(). This way, even with a return inside the function, the lock will be released.

Why defer Unlock() is dangerous inside a loop

This is where the subtlety begins: because of it, some developers first fall in love with defer, and then suddenly start being afraid of it (and sometimes for good reason).

defer runs when the function exits, not “at the end of the nearest curly braces.” Therefore, if you do defer mu.Unlock() inside a loop, you may hold the lock far longer than you think.

Here is an example of “how you can accidentally hold a lock until the end of the function”: inside a loop we lock a “bucket,” but because of defer we release it only when the function finishes — which may be very late.

Bad pattern (simplified):

for i := range buckets {
	b := &buckets[i]
	b.mu.Lock()
	defer b.mu.Unlock() // will be released only on return from sendReport

	// copy data...
}

Better in a loop is often to do a normal Unlock() “right after the work is done”:

for i := range buckets {
	b := &buckets[i]

	b.mu.Lock()
	count := b.guesses
	b.mu.Unlock()

	_ = count // use later without the lock
}

The moral is not that defer is bad. The moral is that defer is a “polite promise to release later,” and sometimes that “later” turns out to be too late.

4. Design: “the mutex belongs to the data” using TaskStore as an example

So the examples do not hang in the air, let us continue developing our учебное application (conceptually: a small task tracker). Earlier we already had a task model and an in-memory store. Today our goal is to make the store safe to access from different goroutines.

Suppose the task looks like this:

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

Now the storage. Important: mu sits next to the data it protects.

package main

import "sync"

type TaskStore struct {
	mu    sync.Mutex
	next  int
	tasks map[int]Task
}

Initialization (we are not using sync.Once today — just a regular constructor):

package main

func NewTaskStore() *TaskStore {
	return &TaskStore{
		next:  1,
		tasks: make(map[int]Task),
	}
}

Write: Add and a consistent invariant

Now adding a task. The shared invariant here is: “next is incremented and the task is stored under the new ID.” That means it must happen in one critical section.

package main

func (s *TaskStore) Add(title string) int {
	s.mu.Lock()
	defer s.mu.Unlock()

	id := s.next
	s.next++

	s.tasks[id] = Task{ID: id, Title: title}
	return id
}

Notice two things. First, the method is on *TaskStore (a pointer receiver) because we modify state. Second, defer is very appropriate here: the function has multiple steps, and it is easy to add branches in the future.

Read: why you cannot “return the map to the outside”

When you protect a map with a mutex, it is important to understand: not only writes are dangerous. It is also dangerous to “give someone a reference to your internals,” after which external code will start reading/writing without the lock and you will get races again.

Therefore, Get should return a copy of the value, not internal structures.

package main

func (s *TaskStore) Get(id int) (Task, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()

	t, ok := s.tasks[id]
	return t, ok
}

Note: we return Task by value — that is convenient. If there were slices/maps inside, we would have to think about “copy depth.” But today it is enough to lock in the principle: do not expose mutable internal structures without control.

Data snapshot: a short critical section

The longer you hold the lock, the longer other goroutines stand in line. And then it is no longer “thread safety,” it is “a line at a shawarma place on Friday evening.”

Good style: under the lock, quickly prepare the data; release the lock; and do everything heavy without it.

For example, let us implement ListSnapshot, which returns a slice of tasks. We do not want to return the internal map, so we make a “snapshot” — we copy the values.

package main

func (s *TaskStore) ListSnapshot() []Task {
	s.mu.Lock()
	defer s.mu.Unlock()

	out := make([]Task, 0, len(s.tasks))
	for _, t := range s.tasks {
		out = append(out, t)
	}
	return out
}

This “snapshot” is safe because the slice then lives its own life. Even if someone adds tasks to the map in parallel, your out has already been formed.

5. Mini check: several goroutines write to TaskStore

Let us make a small demonstration: several goroutines add tasks in parallel. We use WaitGroup to wait for all of them.

package main

import (
	"fmt"
	"sync"
)

func main() {
	store := NewTaskStore()

	var wg sync.WaitGroup
	add := func(title string) {
		defer wg.Done()
		id := store.Add(title)
		fmt.Println("added:", id) // added: 1 (example)
	}

	wg.Add(3)
	go add("learn mutex")
	go add("write code")
	go add("drink tea")
	wg.Wait()

	fmt.Println(len(store.ListSnapshot())) // 3
}

There is an important psychological point here: even if you do not see a problem “by eye,” the absence of races is not an “option,” it is a basic requirement for code that must survive more than one run.

6. Deadlock: what it looks like and how not to get burned

A Deadlock (mutual blocking) in the context of Mutex often looks simpler than it sounds. The most common scenario: you do Lock(), go down an if branch, and exit the function via return, forgetting Unlock(). As a result, the lock stays held forever, and all other goroutines that need that lock will wait infinitely.

With defer, this specific problem is almost completely solved: as soon as you “attach” defer Unlock(), the chance of forgetting to release the lock drops sharply. That is why the “Lock() and immediately defer Unlock()” style is considered idiomatic and widely used.

But deadlock has another flavor too: you can hold the lock and, inside the critical section, do something that can itself wait (for example, reading from a channel or waiting on another WaitGroup). This is more dangerous: you create a situation like “I am holding the lock and waiting for an event that will never happen because it requires this same lock.” Today we are not building complex concurrency схемы, but the rule is worth memorizing right now: do not hold a lock in places where you can block for a long time.

Cheat sheet: what is OK under a lock, and what you should avoid

Sometimes it is useful to have a simple cheat sheet in your head. This is not “the law,” but it is a good starting point.

Action Usually OK under Mutex Why
Change a field/counter/flag Yes That is the whole point of a critical section
Read/write a map Yes A map cannot be safely mutated concurrently without coordination
Make a copy of data (“snapshot”) Yes A fast way to return a safe value to the outside
Print to stdout (fmt.Println) Better to avoid I/O can be slow and will hold the lock longer
Wait for a channel read/write Dangerous You can accidentally create a “holding the lock and waiting” hang
Call “someone else’s” code (callback) Dangerous You do not know what it will do (it might try to take the same lock)

Why you must not copy a struct with a Mutex

This is a very important practical detail. sync.Mutex is state. If you copy a struct with a mutex, you get two copies of the lock that do not know about each other. And the data may remain shared (or you expected it to be shared). The result: synchronization breaks, and you get races that look “impossible.”

Therefore, the typical design is this: stores and other structs with a Mutex live behind a pointer, and their methods are also usually pointer receivers (func (s *TaskStore) ...).

7. Common mistakes when working with sync.Mutex

Mistake #1: forgot Unlock() and got a hang.
Most often this happens because of an early return or because the code “grew” and Unlock ended up at the bottom of the function while new branches were added. The most reliable everyday cure is the habit of writing defer mu.Unlock() immediately after Lock(). This pattern is so standard that it is cited as a canonical example of using defer.

Mistake #2: holding the lock for too long because “well, I played it safe.”
Sometimes a developer puts Lock() at the start of a function “just in case,” and then does everything inside: sorts, string formatting, fmt.Println, even network calls. As a result, other goroutines stand in line, and the program becomes slow and “jerky.” It is fixed by discipline: under the lock, only work with the protected fields; everything heavy — after Unlock().

Mistake #3: defer Unlock() inside a loop.
This is a classic trap: it seems that defer “will release at the end of the iteration,” but it releases at the end of the function. In real code, this can hold the lock until a long operation completes and block other goroutines. We analyzed such a scenario in the example where defer inside a loop held the lock longer than planned. Inside loops, it is often better to do an explicit Unlock() right after the required action.

Mistake #4: copying a struct with a Mutex.
If you copy the struct, you also copy the mutex. As a result, synchronization stops protecting what you think it protects. This is especially insidious because the compiler is not required to warn you. Usually it is solved by having such structs live behind a pointer and not being copied after work begins.

Mistake #5: “a Mutex protects a variable,” not an invariant.
For example, you have a pair of fields (next and tasks) that must change consistently. If you protect only updates to tasks with the lock but do not protect next, you can still get an inconsistent state: skipped IDs, overwrites, duplicates. The correct mental model: a lock protects consistency (an invariant), not a single line of code.

1
Task
Go SELF, level 68, lesson 0
Locked
Stadium turnstile
Stadium turnstile
1
Task
Go SELF, level 68, lesson 0
Locked
Chat statistics
Chat statistics
1
Task
Go SELF, level 68, lesson 0
Locked
Account Without Negative Balance
Account Without Negative Balance
1
Task
Go SELF, level 68, lesson 0
Locked
Snapshot of buckets
Snapshot of buckets
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION