Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 8 из 25 ~22 мин +50 XP

Мьютексы и общее состояние

мьютекс go — запрос, который набирают сразу после первой настоящей гонки: счётчик обещал 2000, а насчитал 1682, и каждый запуск даёт новое число. В уроке про горутины мы увидели эту рану вживую и назвали лекарство — sync.Mutex. Теперь лечим по всей программе: критическая секция и defer Unlock, границы секции, RWMutex для читателей, race detector — детектор гонок, который ловит злодеяние по горячим следам, и честные границы замка: что мьютекс умеет, а что нет. Все примеры запускаются прямо на странице.

Содержание урока · 16 разделов

Мьютекс в 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.MutexLock / Unlockлюбые критические секции: счётчики, карты, слайсы, инварианты из нескольких полейне копировать; забытый Unlock — дедлок; двойной Unlock — fatal error
sync.RWMutexRLock/RUnlock, Lock/Unlockмного параллельных чтений, редкие короткие записикаждому RLock — RUnlock в defer; RUnlock без RLock — fatal error
sync/atomicAddInt64, LoadInt64, CompareAndSwapодиночные счётчики и флаги без сложных инвариантовзащищает одно значение, а не согласованность группы полей
Каналыch <- v, v := <-chпередача владения данными между горутинами, конвейерыэто не замок, а труба: очередь сообщений вместо общего доступа
sync.WaitGroupAdd / 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. Это и будет лучшим домашним экспериментом.

Впишите скелет мьютекс-счётчика

Восстановите защиту общего счётчика: имя типа замка, страховка отчёта бригады, вход и выход из критической секции — по одному слову на пропуск.

package main import ( "fmt" "sync" ) var ( mu sync. // замок над общим состоянием counter = 0 // само состояние ) func main() { var wg sync.WaitGroup wg.Add(2) for i := 0; i < 2; i++ { go func() { defer wg.() // отчёт бригаде — только со страховкой for j := 0; j < 500; j++ { mu.() // вход в критическую секцию counter++ // читай-прибавь-запиши — теперь по очереди mu.() // выход: замок не тащим дальше } }() } wg.Wait() fmt.Println("счётчик:", counter) // всегда 1000 }

Соберите счётчик под замком

Соберите каноническую программу: два воркера через общий мьютекс прибавляют по 1000, отчёт — через defer, main дожидается бригады и печатает 2000. Замок и WaitGroup передаются указателями.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Что дальше: защита доступа — ещё не вся координация#

Мьютекс закрывает одну ось конкурентности — доступ к общим данным. Но у живых схем есть и вторая: управление самим ходом работы. Бригада воркеров под замком правит общую память, но её ещё нужно вовремя остановить, передать ей отмену или дедлайн — и вы уже умеете половину этого: широковещательный stop-канал из урока про select в связке с WaitGroup закрывает и приём, и счётчик. Следующий урок курса — про контекст: context.Context передаёт отмену и дедлайны по цепочке вызовов как часть сигнатуры, и это прямое продолжение линии синхронизации: мьютекс говорит «не одновременно», контекст — «долго не надо». Все инструменты вместе — замки, каналы, WaitGroup, контекст — складываются в конструктор конкурентных схем всего курса: разучив по одному за урок, дальше вы только комбинируете.

Карточки урока

Термин

sync.Mutex — что это

нажмите, чтобы перевернуть
Определение

Замок взаимного исключения: Lock — войти и держать, Unlock — освободить. Нулевое значение готово к работе. Защищает участок кода (критическую секцию), а не переменную: атомарность — следствие дисциплины расстановки.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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, чтобы увидеть пустой отчёт детектора — лучший вид отчёта.

Личные заметкиПусто
Хранятся только в вашем браузере (Go Pro · Мьютексы и общее состояние)

Вопросы и ответы по уроку

Чем мьютекс отличается от канала?

Мьютекс — замок на общее состояние: стучаться могут многие, внутри секции один. Канал — труба передачи владения: «не разделяйте память, передавайте её». Защитить переменную — мьютексом; передать работу и результаты — каналом; в воркер-пуле они работают в связке.

Что будет, если вызвать 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 постоянно. В песочнице флаг недоступен — это локальный инструмент.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Fiber25 уроков~13 чпрактика25 уроков уже доступны

Fiber: экспресс-подобный веб-фреймворк на Fasthttp

Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.

  • Поднимать HTTP-сервер на Fiber за минуты
  • Проектировать маршруты, группы и версии API
  • Писать middleware: логи, CORS, лимиты, recovery
  • Принимать и отдавать JSON с валидацией