Содержание урока · 16 разделов
- Мьютекс в Go: общий доступ под замком
- Критическая секция: замок защищает код, а не переменную
- defer mu.Unlock(): страховка, знакомая по WaitGroup
- Инкапсуляция: мьютекс не выносят наружу
- Границы секции: не носить замок дольше нужного
- Правила гигиены мьютекса
- RWMutex: читатели в параллель
- Когда RWMutex действительно выигрывает
- Race detector: детектор гонок по горячим следам
- Честно об ошибках семейства: двойной Unlock и само-дедлок
- Шпаргалка: чем защищать общее состояние
- sync/atomic: когда мьютекс — из пушки по воробьям
- Что дальше: защита доступа — ещё не вся координация
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Мьютекс в Go: общий доступ под замком#
В уроке про горутины мы одной фразой поставили диагноз, который стоит повторить здесь: counter++ из нескольких горутин без синхронизации — это гонка данных, приращения затирают друг друга, и счётчик из двух горутин по тысяче прибавлений выдал у нас 1682, 1877 и один раз честные 2000. Лекарство было названо там же — sync.Mutex — и обещано «в уроке про мьютексы». Это и есть текущий урок: держать обещание и научиться защищать общее состояние так, чтобы итог перестал зависеть от жеребьёвки планировщика.
Слово mutex — сокращение от mutual exclusion, «взаимное исключение». Идея механическая, как у санузла в самолёте: замок один, войти может один, следующий стучится, пока первый не выйдет. В Go это значение пакетного типа sync.Mutex с двумя методами: mu.Lock() — войти и держать замок, mu.Unlock() — выйти и освободить. Всё, что написано между ними, называется критической секцией: участком, внутри которого в каждый момент времени находится не больше одной горутины.
Самое приятное свойство — нулевое значение готово к работе: var mu sync.Mutex не требует конструктора, инициализации и настроек. Замок только что создан — и уже можно Lock. За это нулевое значение Go хвалят по всему стандартному пакету sync: WaitGroup из прошлого урока устроен так же.
Критическая секция: замок защищает код, а не переменную#
Первое, что надо честно понять: мьютекс ничего не знает о ваших переменных. Он не «прикреплён» к счётчику, не отслеживает записи и не умеет блокировать данные. Он защищает участок кода: кто успел взять замок — работает; остальные стоят на mu.Lock() и ждут своей очереди. Какой именно участок окажется критическим — решаете вы, расставляя Lock и Unlock, и от этого решения зависит буквально всё: поставили не вокруг того — защита мнимая.
Вернёмся к инкременту. counter++ выглядит одной операцией, но на уровне машины это три шага: прочитать значение, прибавить единицу, записать обратно. Две горутины, выполняющие эти шаги вразнобой, теряют обновления: обе прочитали одно и то же, обе записали «своё» — одно приращение исчезло. Критическая секция превращает одновременность в очередь: шаги одной горутины закончились — только тогда начинается вторая.
Отсюда и правило расстановки: замок берут непосредственно перед доступом к общему состоянию и отдают сразу после него. Между Lock и Unlock должно жить только то, что работает с общими данными, — ни сна, ни сети, ни тяжёлых вычислений, до которых замок не имеет отношения. Почему «сразу» — отдельная тема ниже; сначала посмотрим на замок в деле: тот же счётчик, что пострадал в четвёртом уроке, теперь под защитой.
Проверьте себя
Что именно делает sync.Mutex, когда две горутины встречаются на общем counter++?
Счётчик под замком: десять горутин, тысяча прибавлений каждая
Запустите несколько раз: всегда 10000 и всегда «потеряно приращений: 0» — десять горутин по тысяче прибавлений, ни одно не исчезло, при любом жребии планировщика. Эксперимент: уберите mu.Lock() и mu.Unlock() — вернётся гонка из четвёртого урока, и итог перестанет быть гарантированным. Заметьте дисциплину: counter++ стоит между Lock и Unlock — внутри секции ровно то, что работает с общими данными, и ничего больше.
Запустите код, чтобы увидеть вывод.
defer mu.Unlock(): страховка, знакомая по WaitGroup#
Если дисциплина «первая строка после Lock» показалась вам знакомой — не совпадение: это та же грамматика, что defer wg.Done() из прошлого урока. Обычная строка mu.Unlock() в конце функции обещает, что исполнение дойдёт до неё по прямой. Не дойдёт по двум причинам, обе уже изученные. Ранний выход: if с return посреди функции — и замок остался при вас навсегда; каждый, кто стучится в Lock, будет ждать вечно. Паника: любое исключительное событие внутри секции прерывает функцию — и Unlock обычной строкой не исполняется; замок умер вместе с горутиной, а с ним и всё общее состояние: следующий Lock блокируется навсегда.
defer mu.Unlock() в первой же строке после Lock снимает оба сценария: отложенный вызов выполняется при разворачивании стека — при любом return и при панике. Панику замок переживёт: если её перехватит recover-обёртка выше, программа продолжит работу, и замок уже свободен. Это стоит прочувствовать: пара mu.Lock() / defer mu.Unlock() — самая частая строчка concurrency-кода на Go, и пишут её почти машинально, как открывающую и закрывающую скобку.
Тонкость, о которой стоит знать заранее: страховка от забытого Unlock — defer; а вот страховки от двойного Unlock в языке нет, и это самая громкая ошибка семейства. Разберём её честно ниже, после predict-а про забытый замок — там контраст виден лучше всего.
Паттерн Counter: структура со встроенным мьютексом
Запустите несколько раз: итог 55 — сумма чисел от 1 до 10, независимая от порядка, в котором горутины добрались до замка. Обратите внимание на Value(): чтение общего состояния — такой же доступ, как запись, и тоже стоит в критической секции. Эксперимент: уберите замок из Inc, оставив в Value, — пишете без защиты, читаете с защитой; гонка никуда не делась, потому что замок защищает данные, а не методы.
Запустите код, чтобы увидеть вывод.
Инкапсуляция: мьютекс не выносят наружу#
Counter из runner-а — не просто учебная фигня, а канонический паттерн стандартной библиотеки: общий тип со встроенным замком. Правила паттерна стоит зафиксировать. Мьютекс и данные, которые он защищает, живут в одной структуре — глядя на тип, вы сразу видите, что состояние общее и как оно защищено. Поле mu не экспортируется: строчная буква делает его приватным, и никто снаружи не сможет ни взять замок без ведома методов, ни обойти его. Доступ — только через методы типа, каждый из которых сам открывает и закрывает секцию.
Так устроены типы по всему стандартному пакету sync и вокруг него: у sync.WaitGroup внутри мьютекс, у счётчиков статистики, у кэшей, у пулов соединений. Имя метода Value() на чтение — тоже каноника: чтение под Lock выглядит перестраховкой ровно до первого дня, когда кто-то добавляет вторую горутину чтения и третью записи; тогда перестраховка уже встроена.
И осторожность, переходящая из прошлого урока: структуру с мьютексом не копируют. Копия замка — независимый замок, и защита расщепляется пополам: половина горутин стоит у одного замка, половина у другого, а состояние у них уже разное. Компилятор молчит, но go vet помечает копирование любого типа с методами Lock/Unlock всё той же фразой, что и у WaitGroup: «passes lock by value». Поэтому методы таких структур объявляют на указателе — func (c *Counter) Inc — и передают структуру только &c.
Границы секции: не носить замок дольше нужного#
Замок корректен, даже когда секция слишком широкая — и это коварно. Возьмите Lock перед циклом на тысячу итераций и Unlock после: итог тот же честный 2000, программа правильная. Что сломалось? Скорость. Пока одна горутина держит замок на весь цикл, остальные стоят на Lock и не работают, даже если им нужно совсем другое. Корректность от ширины секции не зависит; параллельность — зависит напрямую.
Правило минимализма: секция должна покрывать доступ к общим данным и заканчиваться. Приращения посчитали — Unlock — дальше функция может хоть спать, хоть писать в сеть: замок уже у вас не в руках. Хрестоматийный анти-паттерн — I/O внутри секции: сетевой запрос под замком на полсекунды останавливает всех желающих на эти полсекунды, хотя общие данные вы правили первые несколько микросекунд. Замок — не периметр функции, а периметр доступа к данным.
С тонкой секцией связана и обратная ловушка, о которой говорят реже: чужой код внутри секции. Вызов метода чужой структуры, держащего тот же замок, — само-дедлок: замок в Go не реентерабельный, второй Lock из той же горутины ждёт сам себя вечно. Поэтому в секции — свои данные и свои строки; сложные взаимодействия выносятся за Unlock.
Правила гигиены мьютекса#
- Замок и данные живут вместе: мьютекс — поле той же структуры, что и состояние, которое он защищает; глядя на тип, видно, что общее и чем закрыто.
mu.Lock()— непосредственно перед доступом,defer mu.Unlock()— первой строкой после: замок освобождается при любом выходе, включая ранний return и панику.- Внутри секции — только работа с общими данными: ни сети, ни сна, ни вызовов чужого кода, который может взять тот же замок — само-дедлок.
- Один мьютекс на единицу состояния, а не один на всё приложение; если замков несколько — берите их в одном и том же порядке во всём коде, иначе получите дедлок из перекрёстного ожидания.
- Мьютекс не копируют: методы на указателе, передача по &; копия замка — независимый замок, а go vet пометит «passes lock by value».
Тот же счётчик из четвёртого урока — теперь с замком
Прочитайте код и выберите, что он выведет:
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
var wg sync.WaitGroup
counter := 0
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
mu.Lock()
counter++
mu.Unlock()
}
}()
}
wg.Wait()
fmt.Println("счётчик:", counter)
}RWMutex: читатели в параллель#
Мьютекс честен до суровости: он не различает, зачем вы вошли в секцию — прочитать или изменить. А ведь одновременных чтений можно не бояться: пока никто ничего не пишет, сколько угодно горутин могут читать общую строку, карту или слайс параллельно, ничего не ломая. Для этого в пакете sync живёт второй тип — sync.RWMutex: замок с двумя режимами.
Режим чтения — rw.RLock() и rw.RUnlock(): читателей внутри может быть сколько угодно одновременно, они друг другу не мешают. Режим записи — знакомые rw.Lock() / rw.Unlock(): писатель требует наедине со всеми. Пока писатель держит Lock, ни один новый RLock не входит; и наоборот — писатель ждёт, пока не выйдут все читатели, включая тех, кто пришёл до него. Именно поэтому RWMutex чуть дороже обычного: внутри счётчики читателей и более хитрая арифметика на атомарных операциях.
Правила гигиены те же, что у Mutex, плюс одно: каждый RLock требует RUnlock — и точно так же в defer. Забытый RUnlock не даст писателю когда-либо войти: счётчик читателей навсегда больше нуля. А попытка RUnlock без RLock — не паника, а fatal error на уровне рантайма, о ней честный разговор ниже.
Проверьте себя ещё раз
Три горутины вызвали rw.RLock() одновременно, писателей нет. Сколько горутин сейчас находится внутри секций чтения?
RWMutex: бригада читателей и один писатель
Запустите несколько раз: читатели всегда видят один и тот же старый адрес — каждый писал только в свой слот, а WaitGroup из седьмого урока дождался бригады. Писатель идёт после Wait: rw.Lock() требует наедине со всеми, и обновление видит следующее чтение. Эксперимент: перенесите обновление config до wg.Wait() — получите гонку между записью и чтениями: часть читателей может увидеть старое, часть новое, а детектор гонок (о нём ниже) пометил бы сам доступ.
Запустите код, чтобы увидеть вывод.
Когда RWMutex действительно выигрывает#
Соблазн заменить все мьютексы на RWMutex велик — «у нас же много чтения». Умерьте его честной инженерной оговоркой: RWMutex сложнее внутри, его операции дороже арифметики обычного Mutex, и выигрыш появляется не всегда. Он реален, когда чтения длинные (разбирают большую структуру, считают агрегат), а записи редкие и короткие: тогда параллельность читателей окупает накладные расходы. Если секции крошечные — пара наносекунд на доступ — накладные расходы на координацию читателей могут съесть весь выигрыш, и обычный Mutex окажется не хуже.
Второй практический нюанс — справедливость к писателю. Пока писатель ждёт, новые RLock-и не входят: режим «читатели никогда не ждут вечно» куплен ценой того, что поток читателей не блокирует писателя навсегда. Это правильное поведение, но знайте о нём, когда проектируете сильно read-heavy нагрузки: постоянный поток новых читателей не утопит писателя.
Стартовая эвристика для урока и для практики: по умолчанию берите обычный Mutex; RWMutex — осознанное решение под измеренную нагрузку с явным перекосом в чтение. А когда состояние — одно число, есть третий вариант, ещё более лёгкий, о нём в конце урока.
Race detector: детектор гонок по горячим следам#
В четвёртом уроке мы назвали диагностический прибор, но не разобрали. Настало время: по запросу гонка данных golang поисковики первым делом ведут в Memory Model Go — формальное определение, при каких доступах память считается согласованной, — а вторым делом к встроенному инструменту. Race detector — детектор гонок, встроенный в инструментарий Go: компилятор умеет собирать программу с инструментированием памяти, и тогда рантайм в каждый момент знает, какая горутина каким адресом владеет.
Пользоваться просто: локально запускайте go run -race main.go, тесты — go test -race. Как только случается несинхронизированный доступ, хотя бы один из которых — запись, детектор печатает WARNING: DATA RACE и два стека: кто читал и кто писал, обе стороны происшествия, плюс указание на замки, которые могли бы спасти. Это не статический анализатор: race detector — динамический, он ловит только те гонки, которые реально случились в этом прогоне. Чистый прогон — не гарантия отсутствия гонок; зато пойманная гонка — доказательство, оформленное двумя стеками.
Здесь же — честная нота про песочницу: флага -race в ней нет, инструментирование остаётся за пределами браузера. И ещё одна нота о редакторе: код гонки (тот же counter++ без замка) мы в этом уроке нарочно не даём в интерактивных блоках — его вывод недетерминирован, вы видели это вживую в четвёртом уроке. Гонку показывают числами-бродягами в тексте, а ловят — детектором локально.
Забытый Unlock: тихая мина
Прочитайте код и выберите, что он выведет:
package main
import (
"fmt"
"sync"
)
func update(mu *sync.Mutex) {
mu.Lock() // замок взят...
fmt.Println("обновил")
// ...а Unlock вызвать забыли
}
func main() {
var mu sync.Mutex
update(&mu)
mu.Lock() // кто-то другой хочет войти в секцию
fmt.Println("вошли второй раз")
mu.Unlock()
}Честно об ошибках семейства: двойной Unlock и само-дедлок#
Predict показал мягкий финал — зависание. У другой ошибки этого семейства финал громкий. Двойной Unlock — вызов mu.Unlock() на уже свободном замке — это не паника и не зависание, а fatal error: sync: unlock of unlocked mutex. Fatal отличается от паники принципиально: recover его не перехватывает, разворачивания стека нет — процесс умирает на месте, вместе со всеми горутинами и несохранёнными данными. То же касается RWMutex: RUnlock без RLock — fatal error sync: RUnlock of unlocked RWMutex.
Почему мы не даём этих примеров в редакторе — по той же честной причине, что и код гонки: в песочнице фатальная ошибка рантайма убивает не «программу в окошке», а всю исполняющую обёртку, и вы получите не аккуратное сообщение об ошибке, а мёртвый воркер. Поэтому — текстом, с точными сообщениями: пусть сообщения останутся в памяти, а не в консоли.
Третья ошибка — само-дедлок: повторный mu.Lock() в той же горутине, пока замок уже у неё. Замок в Go не реентерабельный: «я сам» для него не аргумент, горутина ждёт сама себя. В песочнице это оборвётся лимитом времени — как predict выше; в настоящем Go программа либо зависнет, либо детектор дедлоков поймает полное затишье. Все три ошибки лечатся одинаково: defer сразу после Lock, парность вызовов, и никаких вложенных Lock одного и того же замка.
Шпаргалка: чем защищать общее состояние#
| Инструмент | Ключевые вызовы | Годится для | Осторожно |
|---|---|---|---|
| sync.Mutex | Lock / Unlock | любые критические секции: счётчики, карты, слайсы, инварианты из нескольких полей | не копировать; забытый Unlock — дедлок; двойной Unlock — fatal error |
| sync.RWMutex | RLock/RUnlock, Lock/Unlock | много параллельных чтений, редкие короткие записи | каждому RLock — RUnlock в defer; RUnlock без RLock — fatal error |
| sync/atomic | AddInt64, LoadInt64, CompareAndSwap | одиночные счётчики и флаги без сложных инвариантов | защищает одно значение, а не согласованность группы полей |
| Каналы | ch <- v, v := <-ch | передача владения данными между горутинами, конвейеры | это не замок, а труба: очередь сообщений вместо общего доступа |
| sync.WaitGroup | Add / Done / Wait | дождаться конца бригады горутин | не защищает состояние — сообщает только факт завершения |
sync/atomic: когда мьютекс — из пушки по воробьям#
Если общее состояние — одно число, то и защита может быть одним вызовом. Пакет sync/atomic даёт атомарные операции над одиночными значениями: atomic.AddInt64(&n, 1) прибавляет единицу как неделимое действие процессора — без замка, без очереди, без критической секции; atomic.LoadInt64(&n) и atomic.StoreInt64(&n, v) читают и пишут атомарно, atomic.CompareAndSwapInt64 — «поменяй, если никто не успел раньше», основа lock-free конструкций. На простых счётчиках запросов, метрик и флагов это заметно дешевле мьютекса.
Граница у атомарности жёсткая: она защищает одно значение, а не согласованность нескольких. Если инвариант держится на паре полей — «баланс дебета равен кредиту», «имя и возраст обновляются вместе» — атомарные операции не спасут: горутина увидит половину обновления. Там нужен мьютекс, под которым оба поля меняются в одной секции. Правило выбора короткое: одно число — atomic; больше одного поля или инвариант — Mutex; много чтений — RWMutex; передача владения — канал.
В песочнице sync/atomic доступен в основном движке — можете проверить атомарный счётчик против мьютексного прямо в редакторе этого урока, скопировав код runner-а и заменив пару Lock/Unlock на atomic.AddInt64. Это и будет лучшим домашним экспериментом.
Кнопка «Запустить» в этом уроке работает на основном движке песочницы — настоящем Go (yaegi, собран в WASM): пакет sync там полный, Mutex, RWMutex и WaitGroup ведут себя как в локальном go run. Если WASM не загрузился, песочница тихо включает резервный упрощённый движок, а в нём пакет sync недоступен — при импорте он отвечает «пакет sync недоступен в песочнице (доступны: fmt, strings, strconv, math, sort, errors, time)». Поэтому все интерактивы с кодом этого урока — три runner-а, два predict-а, fill и order — проверяйте в основном движке; резервный для них честно покажет ту самую ошибку недоступности.
Впишите скелет мьютекс-счётчика
Восстановите защиту общего счётчика: имя типа замка, страховка отчёта бригады, вход и выход из критической секции — по одному слову на пропуск.
Соберите счётчик под замком
Соберите каноническую программу: два воркера через общий мьютекс прибавляют по 1000, отчёт — через defer, main дожидается бригады и печатает 2000. Замок и WaitGroup передаются указателями.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Что дальше: защита доступа — ещё не вся координация#
Мьютекс закрывает одну ось конкурентности — доступ к общим данным. Но у живых схем есть и вторая: управление самим ходом работы. Бригада воркеров под замком правит общую память, но её ещё нужно вовремя остановить, передать ей отмену или дедлайн — и вы уже умеете половину этого: широковещательный stop-канал из урока про select в связке с WaitGroup закрывает и приём, и счётчик. Следующий урок курса — про контекст: context.Context передаёт отмену и дедлайны по цепочке вызовов как часть сигнатуры, и это прямое продолжение линии синхронизации: мьютекс говорит «не одновременно», контекст — «долго не надо». Все инструменты вместе — замки, каналы, WaitGroup, контекст — складываются в конструктор конкурентных схем всего курса: разучив по одному за урок, дальше вы только комбинируете.
Карточки урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- sync.Mutex — замок взаимного исключения с нулевым значением, готовым к работе: mu.Lock() — вход, mu.Unlock() — выход, между ними критическая секция.
- Мьютекс защищает участок кода, а не переменную: counter++ остаётся тремя шагами машины, но внутри секции они исполняются по очереди — и итог перестаёт плыть.
- defer mu.Unlock() первой строкой после Lock — та же страховка, что defer wg.Done(): замок освобождается при раннем выходе и при панике.
- Секция покрывает только доступ к общим данным: I/O и чужой код под замком тормозят всех, а второй Lock из той же горутины — само-дедлок.
- sync.RWMutex: RLock — сколько угодно читателей параллельно, Lock — писатель наедине со всеми; каждому RLock — RUnlock в defer.
- Race detector: go run -race и go test -race печатают WARNING: DATA RACE с двумя стеками; детектор динамический, в песочнице флага нет.
- Двойной Unlock и RUnlock без RLock — fatal error, recover не спасает; для одиночных чисел смотрите в сторону sync/atomic.
Домашнее задание
Возьмите воркер-пул из седьмого урока: три воркера разбирают срез из тридцати чисел и прибавляют каждое к общей сумме. Сначала без замка — наблюдайте, как итог плавает от запуска к запуску; затем с sync.Mutex вокруг критической секции — сумма стабилизируется на честном значении при каждом прогоне. После этого оберните счётчик в структуру с непубличным полем-замком и методом Add, а чтение итога вынесите в метод Value под RLock, заменив Mutex на RWMutex. Напоследок сломайте всё по очереди: уберите Unlock и поймайте зависание по лимиту времени; вызовите Unlock дважды — в настоящем Go получите fatal error «sync: unlock of unlocked mutex» (в песочнице не делайте этого: падает вся обёртка); и запустите окончательную версию локально с go run -race, чтобы увидеть пустой отчёт детектора — лучший вид отчёта.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем мьютекс отличается от канала?
Мьютекс — замок на общее состояние: стучаться могут многие, внутри секции один. Канал — труба передачи владения: «не разделяйте память, передавайте её». Защитить переменную — мьютексом; передать работу и результаты — каналом; в воркер-пуле они работают в связке.
Что будет, если вызвать mu.Unlock() дважды?
fatal error: sync: unlock of unlocked mutex. Это не паника: recover её не перехватывает, процесс падает целиком со всеми горутинами. Поэтому Unlock держат в defer сразу после Lock — отложенный вызов выполняется ровно один раз.
Когда брать RWMutex вместо обычного Mutex?
Когда чтений много и они длинные, а записи редкие и короткие: RLock пропускает читателей параллельно. На крошечных секциях накладные расходы RWMutex могут съесть выигрыш — решение принимайте измерением, по умолчанию берите Mutex.
Как поймать гонку данных до продакшена?
Запускайте и тесты с флагом -race: go run -race main.go, go test -race. Детектор гонок печатает WARNING: DATA RACE с двумя стеками — обе стороны доступа. Он динамический: видит только гонки, случившиеся в прогоне, поэтому гоняйте с -race постоянно. В песочнице флаг недоступен — это локальный инструмент.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Fiber: экспресс-подобный веб-фреймворк на Fasthttp
Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.
- Поднимать HTTP-сервер на Fiber за минуты
- Проектировать маршруты, группы и версии API
- Писать middleware: логи, CORS, лимиты, recovery
- Принимать и отдавать JSON с валидацией