Пакет sync: Mutex, WaitGroup, Once, Map, Pool, Cond, errgroup, atomic
Третья часть конспекта по конкурентности — весь пакет sync по порядку. У каждого примитива своя причина существовать: сначала показываем проблему, которую он решает, потом сам механизм, потом код, который можно запустить и увидеть результат своими глазами.
sync.Mutex и sync.RWMutex
Начнём с самой сути проблемы, а не с API. Когда несколько горутин одновременно читают И пишут одну и ту же переменную, результат становится непредсказуемым — не потому что Go «плохо работает», а потому что операция вроде counter++ на самом деле состоит из трёх шагов (прочитать значение, прибавить единицу, записать обратно), и эти шаги двух разных горутин могут перемежаться как угодно.
Запусти несколько раз — результат будет РАЗНЫМ (например 960, потом 978), и почти всегда меньше 1000. Это не баг компилятора и не "иногда так бывает" — это гарантированное следствие незащищённого одновременного доступа: часть инкрементов теряется, потому что две горутины успевают прочитать одно и то же старое значение до того, как любая из них запишет новое.
sync.Mutex решает это ровно так, как решают очередь в одну дверь: только одна горутина одновременно может находиться между Lock() и Unlock() — все остальные ждут своей очереди.
Этот код при любом числе запусков даёт ровно 1000 — потому что Lock()/Unlock() превращают три «расползающихся» шага в одну неделимую операцию с точки зрения остальных горутин. var mu sync.Mutex уже готов к использованию сразу после объявления — конструктор не нужен, zero value рабочий.
RWMutex — тот же принцип, но с послаблением для чтения. Если данные читают в 100 раз чаще, чем меняют, полный эксклюзивный Mutex — расточительство: два одновременных чтения друг другу не мешают, блокировать их друг от друга незачем. RWMutex даёт два режима: RLock()/RUnlock() пускает СРАЗУ НЕСКОЛЬКО читателей одновременно, а Lock()/Unlock() для записи по-прежнему эксклюзивен и ждёт, пока уйдут все читатели.
Важная оговорка, которую часто упускают: RWMutex не всегда быстрее обычного Mutex. Внутри он устроен сложнее — нужно вести подсчёт активных читателей, — и на коротких критических секциях или при частых записях эти накладные расходы могут перевесить выгоду от параллельного чтения. Оправдан он именно там, где чтений кардинально больше записей, а не «на всякий случай везде вместо Mutex».
Две ловушки, обе стоит показать на коде, а не просто запомнить как факт.
Первая — забыть Unlock() при раннем return без defer. Захваченный и не освобождённый лок означает, что ЛЮБОЙ следующий Lock() на этом же мьютексе зависнет навсегда — это deadlock, а не гонка данных, и go run -race его не найдёт (race detector ловит гонки, а не зависания).
func withdraw(mu *sync.Mutex, balance *int, amount int) error { mu.Lock() if *balance < amount { return errors.New("недостаточно средств") // Unlock не вызван — лок висит навсегда } *balance -= amount mu.Unlock() return nil}func withdraw(mu *sync.Mutex, balance *int, amount int) error { mu.Lock() defer mu.Unlock() // сработает при ЛЮБОМ выходе из функции, включая этот return if *balance < amount { return errors.New("недостаточно средств") } *balance -= amount return nil}Вторая — передать структуру, у которой ЕСТЬ поле sync.Mutex, по значению (не по указателю). Присваивание/передача структуры копирует ВСЕ её поля, включая мьютекс — получается два независимых мьютекса вместо одного общего, и защита данных исчезает, хотя код выглядит так, будто блокировка есть. go vet умеет ловить эту ошибку статически (сообщение вида passes lock by value), поэтому её стоит воспринимать не как теорию, а как то, что реально всплывает в CI.
sync.WaitGroup
Пример выше уже использовал WaitGroup — самое время объяснить, зачем он вообще нужен. Без него main() не станет ждать запущенные горутины сама по себе: как только выполнение доходит до конца main, вся программа завершается немедленно, даже если горутины ещё работают и ничего не успели напечатать.
WaitGroup — это просто счётчик с тремя операциями: Add(n) прибавляет n к счётчику, Done() (эквивалент Add(-1)) уменьшает на 1, Wait() блокируется, пока счётчик не станет 0. Правильный порядок: Add(1) вызывается ПЕРЕД запуском горутины, в той же горутине, что и сам go, а Done() — обычно через defer первой строкой внутри запущенной горутины, чтобы сработать при любом выходе, включая панику.
Важно понимать границы: WaitGroup синхронизирует только САМ ФАКТ «все завершились» — он ничего не знает про данные, которые горутины меняют внутри, и никак их не защищает. Если горутины пишут в общую переменную, WaitGroup не заменяет Mutex/atomic — это два разных инструмента для двух разных задач (дождаться завершения vs защитить данные), и часто нужны оба одновременно, как в примере с counter выше.
Главная ловушка — Add(1) ВНУТРИ самой горутины вместо перед go func(). Это гонка на самом планировщике: между запуском горутины и тем моментом, когда она реально начнёт выполняться и вызовет Add(1), основной поток уже может дойти до Wait() и увидеть счётчик 0 — то есть Wait() вернётся раньше, чем горутина вообще успела зарегистрироваться, и часть работы окажется не дождавшейся.
Отдельно: если Done() вызвать больше раз, чем было Add(), счётчик уйдёт в отрицательное значение и рантайм запаникует с sync: negative WaitGroup counter — это тоже частая опечатка при копипасте кода между функциями с разным числом горутин.
sync.Once
Классическая задача: код инициализации (открыть соединение, прочитать конфиг, создать синглтон) должен выполниться РОВНО ОДИН РАЗ, даже если к нему одновременно обратятся 50 горутин. Наивное решение с проверкой if instance == nil { instance = create() } без синхронизации — гонка: две горутины могут ОБЕ прочитать nil до того, как любая успеет записать результат, и обе вызовут create(), получив два разных экземпляра там, где должен быть один.
sync.Once решает это одним методом: once.Do(f) гарантирует, что f реально выполнится только у первого вызова, а все остальные (даже одновременные, даже если передать другую функцию) немедленно вернутся, ничего не сделав.
Печатается "инициализация выполнена" один раз и calls == 1, сколько бы горутин ни соревновались. Внутри Once устроен как оптимизация под частый случай: после первого раза быстрая проверка идёт через atomic-флаг (дёшево на каждый следующий вызов), а полноценный Mutex используется только у самих конкурирующих на старте горутин, пока флаг ещё не установлен.
Классический вопрос на собеседовании: «реализуйте потокобезопасный ленивый синглтон» — правильный ответ в реальном коде почти всегда sync.Once. Если просят реализовать это вручную через double-checked locking (без Once) — там нужен именно atomic.Pointer[T] для самого указателя на экземпляр, а не просто Mutex на быстром пути чтения, иначе возникает гонка уже на самом чтении указателя между потоками.
sync.Map
sync.Map — специализированная структура, а НЕ универсальная замена «map + Mutex» на все случаи жизни. Она оптимизирована ровно под два сценария: (1) ключ пишется один раз и потом только читается очень часто (write-once, read-many — например, кэш конфигурации), (2) разные горутины стабильно работают с непересекающимися наборами ключей, почти не конкурируя друг с другом.
По официальным бенчмаркам самого Go, обычная map под Mutex часто ПРЕВОСХОДИТ sync.Map именно в сценарии частых записей вперемешку с чтением одних и тех же ключей — то есть выбор sync.Map "потому что она про конкурентность" без проверки паттерна доступа может дать более медленный код, а не более быстрый.
var cache sync.Map
// LoadOrStore — атомарное "прочитать или записать одним действием"
v, loaded := cache.LoadOrStore("user:42", computeExpensiveValue())
if loaded {
fmt.Println("значение уже было в кэше:", v)
} else {
fmt.Println("вычислили и сохранили новое значение:", v)
}Без LoadOrStore пришлось бы делать Load, а при промахе отдельный Store — и между этими двумя шагами другая горутина могла бы успеть вставить своё значение, то есть Load+Store раздельно снова была бы гонкой.
Важный нюанс: LoadOrStore решает, какое значение ОСТАНЕТСЯ в карте, но не мешает нескольким горутинам параллельно ВЫЧИСЛИТЬ значение до того, как решение принято, — в примере выше computeExpensiveValue() может реально вызваться несколько раз впустую, просто в карте останется только один результат. Если нужна гарантия «функция вычисления вызвана ровно один раз» — внутрь ячейки кладут sync.Once, а не полагаются на LoadOrStore саму по себе.
Range безопасен для использования при конкурентных изменениях карты (не паникует, не портит данные), но НЕ даёт снапшот-консистентности — если во время обхода другая горутина что-то добавит или удалит, Range может это увидеть частично или не увидеть вовсе, гарантий порядка и полноты снимка нет.
У sync.Map намеренно нет метода Len(). Узнать количество элементов можно только вручную пройдя Range и посчитав — это осознанное решение авторов, а не недосмотр: консистентный счётчик потребовал бы дополнительной синхронизации, которая противоречила бы самой идее оптимизации под lock-free чтение. Ещё деталь: значения хранятся как any, поэтому при Load всегда нужен type assertion — компилятор тип не проверяет, ошибиться легко.
sync.Pool и sync.Cond
sync.Pool — пул временных объектов для переиспользования вместо постоянных новых аллокаций. Типичный пример — буфер под тело каждого HTTP-запроса: без пула на каждый запрос создаётся новый []byte, который тут же становится мусором для GC; с пулом одни и те же буферы используются повторно.
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) }, // вызывается, только если пул пуст
}
func handle() {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // ОБЯЗАТЕЛЬНО: буфер может содержать данные ПРЕДЫДУЩЕГО использования
defer bufPool.Put(buf)
buf.WriteString("...")
// использовать buf
}Get() достаёт объект из пула или вызывает New(), если пул сейчас пуст; Put() возвращает объект обратно после использования. Забыть сбросить состояние объекта после Get() (в примере — buf.Reset()) — частая практическая ошибка: старые данные предыдущего пользователя утекают в код, который считает, что получил чистый объект.
Отдельно стоит держать в голове, что Pool — это ЯВНО эфемерное хранилище: сборщик мусора может опустошить его полностью между циклами GC без каких-либо гарантий и предупреждений. Это оптимизация для снижения давления на аллокатор, а не надёжный кэш — полагаться на то, что объект переживёт в пуле долгое время, нельзя.
sync.Cond — примитив ожидания условия поверх Mutex, для случая «горутина должна спать, пока некое условие не станет истинным, и её должен разбудить кто-то другой». Wait() атомарно освобождает захваченный лок и усыпляет горутину одновременно (без атомарности между «отпустить лок» и «уснуть» было бы окно для гонки), пока её не разбудят через Signal() (будит одну ожидающую горутину) или Broadcast() (будит все).
cond.L.Lock()
for !conditionIsTrue() { // ЦИКЛ, не if
cond.Wait()
}
// условие точно истинно здесь
cond.L.Unlock()Условие внутри Cond всегда проверяется в цикле for, а не в if. Между тем моментом, когда кого-то разбудили через Signal/Broadcast, и моментом, когда разбуженная горутина реально снова захватила лок и продолжила выполнение, другая горутина может успеть «украсть» условие первой (например, забрать последний элемент из очереди) — тогда после пробуждения условие уже снова ложно, и без цикла код продолжил бы работу с неверным предположением.
В идиоматичном Go для сигнализации между горутинами чаще всё же выбирают каналы — они проще композируются через select и не требуют отдельного разбора этой ловушки. Cond встречается реже и обычно там, где логика ожидания сложнее, чем один канал может выразить удобно.
errgroup
golang.org/x/sync/errgroup формально не часть стандартной библиотеки (отдельный модуль под golang.org/x), но на практике — фактически стандартный инструмент для одной конкретной задачи: запустить несколько горутин, каждая из которых может вернуть ошибку, дождаться всех, и если хоть одна упала — отменить остальные, не дожидаясь их естественного завершения.
errgroup.WithContext(ctx) возвращает саму группу и производный gctx. Как только ЛЮБАЯ функция, запущенная через g.Go(func() error), вернёт ненулевую ошибку, gctx автоматически отменяется — все остальные горутины, которые слушают именно gctx.Done(), узнают об этом почти мгновенно, не дожидаясь своего собственного таймаута. В примере выше сервис B ждал бы секунду впустую, если бы не отмена — вместо этого он завершается почти сразу после падения соседа.
g.Wait() дожидается завершения всех запущенных функций и возвращает ПЕРВУЮ полученную ошибку — остальные ошибки (если было несколько) отбрасываются: это fail-fast поведение, а не сбор всех ошибок в список.
Частая ошибка — использовать внутри функций, переданных в g.Go, исходный ctx вместо gctx. Тогда отмена по первой ошибке физически не долетает до остальных операций — они продолжают ждать исходный таймаут или отмену родителя, как будто errgroup вообще не участвовал, и всё главное преимущество перед голым WaitGroup (быстрая совместная отмена при первой ошибке) теряется.
Атомарные операции
sync/atomic даёт операции над ОДНИМ значением без блокировок — они реализованы напрямую аппаратной инструкцией процессора (compare-and-swap), поэтому для простых счётчиков быстрее Mutex: не нужно приостанавливать и будить горутину, весь механизм — одна машинная инструкция.
CompareAndSwap(old, new) — центральная операция: запись происходит, только если текущее значение всё ещё равно old; если кто-то успел его изменить между чтением и записью — операция проваливается, и это нужно обработать самому, обычно перечитав значение и попробовав снова в цикле. Это строительный блок всех лок-free алгоритмов:
Каждая горутина в цикле: читает текущий максимум, и если её значение больше — пытается записать через CompareAndSwap. Если попытка провалилась (кто-то другой обновил значение первым), горутина не сдаётся, а перечитывает актуальный максимум и пробует снова — цикл гарантированно закончится, потому что каждая успешная запись увеличивает максимум, а конечное число горутин не может бесконечно друг друга опережать.
Типизированные обёртки (atomic.Int64, atomic.Bool, доступны с Go 1.19) предпочтительнее голых функций вроде atomic.AddInt64(&x, ...) над обычным int64 — они инкапсулируют значение внутри структуры, физически не позволяя случайно сделать обычное x = 5 в обход атомарности где-то в другом месте кода. С голым int64, к которому иногда применяют atomic-функции, а иногда просто присваивают напрямую, компилятор такую ошибку не поймает.
Важное ограничение: атомарными операциями по отдельности нельзя защитить ДВА связанных поля как единый инвариант (например, «баланс» и «версия записи», которые должны меняться вместе). Между атомарным изменением первого поля и атомарным изменением второго другая горутина может успеть прочитать структуру и увидеть несогласованное промежуточное состояние — по отдельности каждое поле атомарно, а вместе как пара — нет. Если два и более полей должны меняться согласованно, нужен один Mutex на всю группу полей, а не отдельные atomic на каждое.