Сервис держит в памяти кэш: карту «ключ → значение». Её читают сотни горутин-обработчиков,
и изредка какая-нибудь из них обновляет запись. Передавать карту из рук в руки через
канал здесь неудобно: данные не движутся, они лежат на месте, и к ним по очереди
обращаются многие. Для такого случая в пакете sync есть примитивы, которые защищают
общее состояние на месте.
В главе разберём каждый из них: Mutex, RWMutex, Once, WaitGroup, Cond и Pool,
а заодно sync.Map. Для каждого — что он гарантирует, когда уместен и где на нём обычно
ошибаются.
Нужны структуры, методы с получателем-указателем, карты, defer и горутины. Полезно
помнить правило из модели памяти: если хотя бы одна горутина
пишет в переменную, все обращения к ней из разных горутин должны быть синхронизированы.
Для sync.Pool понадобится приведение типа x.(T) из главы
«Интерфейсы».
Два термина. Общее состояние — данные, к которым обращаются несколько горутин.
Критическая секция — участок кода, который работает с общим состоянием и поэтому
должен выполняться под одной блокировкой.
На первом проходе изучите Mutex, WaitGroup и Once, затем RWMutex. Cond
понадобится к задаче 08. Pool — оптимизация, к которой приходят после того, как код
уже корректен и измерения показали, что аллокации мешают.
Перед выбором примитива полезно спросить себя: мне нужно передать данные из одной
горутины в другую или дать многим горутинам по очереди поработать с одними и теми же
данными?
Канал — когда данные движутся: раздать задачи воркерам, собрать результаты,
сообщить о событии.
Мьютекс — когда данные лежат на месте: счётчик, кэш, карта, поля структуры, к
которым обращаются многие.
Обратный выбор обычно получается неуклюжим. Защищать карту через выделенную
горутину-владельца, которой по каналу присылают запросы «прочитай» и «запиши», можно,
но это больше кода и медленнее, чем один sync.Mutex. А раздавать задачи воркерам через
срез под мьютексом с флагами «взято» хуже, чем через канал.
Mutex (mutual exclusion, взаимное исключение) гарантирует, что в критической секции
одновременно находится не больше одной горутины. Остальные, вызвавшие Lock, ждут,
пока владелец не вызовет Unlock.
type Counter struct { mu sync.Mutex n int}func (c *Counter) Inc() { c.mu.Lock() defer c.mu.Unlock() c.n++}
Мьютекс защищает не только от одновременных записей. Unlock happens-before следующего
Lock, поэтому всё, что одна горутина записала под мьютексом, следующая горутина,
захватившая его, гарантированно увидит.
package mainimport ( "fmt" "sync")type Counter struct { mu sync.Mutex n int}func (c *Counter) Inc() { c.mu.Lock() defer c.mu.Unlock() c.n++}func main() { var wg sync.WaitGroup c := &Counter{} for i := 0; i < 1000; i++ { wg.Add(1) go func() { defer wg.Done() c.Inc() }() } wg.Wait() fmt.Println("итог:", c.n)}
итог: 1000
Без мьютекса c.n++ содержал бы гонку данных: это три шага (прочитать, прибавить,
записать), и две горутины могут прочитать одно и то же старое значение. Мьютекс делает
эти три шага неделимыми относительно других горутин. Как выглядит отчёт race detector
для такой ошибки, показано в главе «Утечки горутин и гонки».
Три правила, которые экономят часы отладки:
Мьютекс нельзя копировать после первого использования. Если передать структуру с
мьютексом по значению, у копии будет свой независимый мьютекс, и защита перестанет
работать без единой ошибки компиляции. Поэтому методы объявляют на указателе
(*Counter). Команда go vet находит такие копирования (проверка copylocks).
Критическая секция должна быть короткой. Сетевой вызов или запись на диск под
Lock заставляет всех остальных ждать, пока идёт ввод-вывод. Сначала подготовьте
данные, потом коротко захватите мьютекс, чтобы их сохранить.
Unlock через defer сразу после Lock. Тогда мьютекс освободится при любом
return и при панике.
RWMutex различает читателей и писателей. Одновременно мьютекс могут держать либо
сколько угодно читателей (RLock/RUnlock), либо один писатель (Lock/Unlock).
type Cache struct { mu sync.RWMutex m map[string]string}func (c *Cache) Get(k string) (string, bool) { c.mu.RLock() defer c.mu.RUnlock() v, ok := c.m[k] return v, ok}func (c *Cache) Set(k, v string) { c.mu.Lock() defer c.mu.Unlock() c.m[k] = v}
Выигрыш появляется, когда чтений намного больше, чем записей, и чтение занимает
заметное время: читатели не ждут друг друга. Но RWMutex сложнее устроен: он ведёт
учёт читателей, и этот учёт сам становится точкой, за которую соревнуются ядра. Если
критическая секция крошечная (прочитать одно поле), обычный Mutex или
atomic часто оказываются быстрее. Решают это бенчмарком
(go test -bench), а не на глаз.
Ещё одна деталь: если писатель ждёт Lock, новые RLock тоже ждут, пока писатель не
отработает. Это защищает писателей от голодания, но означает, что рекурсивный RLock в
той же горутине может привести к дедлоку.
Для кэша есть и готовая потокобезопасная карта sync.Map с методами Load, Store,
LoadOrStore, Delete, Range. Она хорошо подходит для двух случаев, описанных в
документации: ключ записывается один раз и потом много раз читается, либо разные
горутины работают с непересекающимися наборами ключей. Для остальных случаев обычная
map под Mutex или RWMutex проще и часто быстрее. У sync.Map нет типов: значения
хранятся как any, и при чтении нужно приведение типа.
Once выполняет функцию ровно один раз, сколько бы горутин ни вызвали Do
одновременно. Выполнение функции happens-before возврата из любого Do, поэтому все
вызывающие видят полностью завершённую инициализацию.
var ( once sync.Once conn *DB)func GetDB() *DB { once.Do(func() { conn = connect() }) // connect выполнится один раз return conn}
Если сто горутин одновременно вызовут GetDB, connect выполнится один раз, а остальные
99 дождутся его окончания и получат тот же conn.
package mainimport ( "fmt" "sync")func main() { var once sync.Once var wg sync.WaitGroup calls := 0 for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() once.Do(func() { calls++ fmt.Println("инициализация выполнена") }) }() } wg.Wait() fmt.Println("тело Do отработало раз:", calls)}
инициализация выполненатело Do отработало раз: 1
Подвох: если функция в Do запаниковала или вернула ошибку через замыкание, Once всё
равно считает её выполненной. Повторной попытки не будет. С Go 1.21 есть удобные
обёртки: sync.OnceValue(f) возвращает функцию, которая один раз вызывает f и потом
отдаёт сохранённый результат, а sync.OnceValues(f) делает то же для пары
«значение, ошибка»:
var loadConfig = sync.OnceValues(func() (*Config, error) { return readConfig("app.yaml")})cfg, err := loadConfig() // при любом числе вызовов readConfig выполнится один раз
Ошибка здесь тоже запоминается: все вызывающие получат одну и ту же ошибку. Если нужен
повтор после неудачи, Once не подходит, понадобится мьютекс и флаг.
WaitGroup ждёт завершения группы горутин. Внутри у неё счётчик: Add(n) увеличивает
его, Done() уменьшает на единицу, Wait() ждёт, пока счётчик не станет нулём.
var wg sync.WaitGroupfor _, job := range jobs { wg.Add(1) go func(j Job) { defer wg.Done() process(j) }(job)}wg.Wait() // здесь все горутины точно завершились
Два правила, которые нарушают чаще всего:
Add вызывают до go, в запускающем коде. Если вызвать Add(1) внутри горутины,
Wait может выполниться раньше, увидеть ноль и вернуться, пока горутины ещё не
начались.
Done вызывают через defer, чтобы счётчик уменьшился и при раннем return, и
при панике. Пропущенный Done — это Wait, который не вернётся никогда.
С Go 1.25 у WaitGroup есть метод Go: он сам делает Add(1), запускает функцию в
новой горутине и вызывает Done, когда она вернётся. Ошибиться с порядком Add при
этом невозможно. Функция, переданная в wg.Go, не должна паниковать.
var wg sync.WaitGroupfor _, job := range jobs { wg.Go(func() { process(job) })}wg.Wait()
WaitGroup отвечает только за «дождаться всех». Если горутины пишут в общую переменную,
её всё равно нужно защищать:
package mainimport ( "fmt" "sync")func main() { var wg sync.WaitGroup var mu sync.Mutex total := 0 for i := 1; i <= 5; i++ { wg.Add(1) go func(n int) { defer wg.Done() mu.Lock() total += n * n mu.Unlock() }(i) } wg.Wait() fmt.Println("сумма квадратов 1..5:", total)}
Cond (условная переменная) позволяет горутинам спать, пока не выполнится условие над
общим состоянием, и просыпаться по сигналу. Её создают через sync.NewCond(&mu) и
привязывают к мьютексу, который защищает это состояние.
type Queue struct { mu sync.Mutex cond *sync.Cond // создаётся как sync.NewCond(&q.mu) data []int}func (q *Queue) Pop() int { q.mu.Lock() defer q.mu.Unlock() for len(q.data) == 0 { // цикл, а не if q.cond.Wait() } v := q.data[0] q.data = q.data[1:] return v}func (q *Queue) Push(v int) { q.mu.Lock() q.data = append(q.data, v) q.mu.Unlock() q.cond.Signal() // разбудить одного ждущего}
Wait делает три вещи: отпускает мьютекс, усыпляет горутину и, проснувшись, снова
захватывает мьютекс. Отпускать мьютекс нужно, иначе Push никогда бы не смог добавить
данные.
Почему цикл, а не if? В Go Wait не возвращается сам по себе, без Signal или
Broadcast. Но между сигналом и моментом, когда проснувшаяся горутина снова захватит
мьютекс, другая горутина может успеть забрать данные. Цикл заново проверяет условие под
мьютексом и не даёт работать с пустой очередью.
Signal будит одного ждущего, Broadcast — всех. Производитель и потребитель на Cond:
package mainimport ( "fmt" "sync")func main() { var mu sync.Mutex cond := sync.NewCond(&mu) queue := []int{} done := false var wg sync.WaitGroup wg.Add(1) go func() { // потребитель defer wg.Done() for { mu.Lock() for len(queue) == 0 && !done { cond.Wait() } if len(queue) == 0 && done { mu.Unlock() return } v := queue[0] queue = queue[1:] mu.Unlock() fmt.Println("получено:", v) } }() for i := 1; i <= 3; i++ { // производитель mu.Lock() queue = append(queue, i) mu.Unlock() cond.Signal() } mu.Lock() done = true mu.Unlock() cond.Broadcast() // разбудить, чтобы потребитель увидел done и вышел wg.Wait() fmt.Println("готово")}
получено: 1получено: 2получено: 3готово
Порядок значений здесь гарантирован: очередь одна, потребитель один, и он забирает
значения с начала среза. Потребитель выходит, только когда очередь пуста и выставлен
done, поэтому ни одно значение не теряется.
У Cond есть серьёзное ограничение: ожидание Wait нельзя поставить веткой в select,
значит, его нельзя совместить с таймаутом или отменой через контекст. Поэтому многие
задачи, где напрашивается Cond, чище решаются каналами. Cond уместен, когда ждущих
много, у них общее состояние под мьютексом и нужно будить то одного (Signal), то всех
(Broadcast). Задача 08 (своя WaitGroup) как раз такая.
Pool — набор временных объектов для повторного использования. Он снижает число
выделений памяти и нагрузку на сборщик мусора там, где одни и те же короткоживущие
объекты создаются тысячи раз в секунду, например буферы в HTTP-обработчиках.
var bufPool = sync.Pool{ New: func() any { return new(bytes.Buffer) },}func handle() { b := bufPool.Get().(*bytes.Buffer) b.Reset() // объект мог вернуться с данными прошлого использования defer bufPool.Put(b) // ... используем b как временный буфер}
Get достаёт объект из пула или вызывает New, если пул пуст. Put возвращает объект
в пул. Рантайм может выбросить объекты из пула в любой момент, обычно при сборке мусора,
поэтому Pool — не пул соединений и не кэш: класть туда то, что обязано сохраниться
(соединения, открытые файлы), нельзя.
Два правила: сбрасывайте состояние объекта после Get (как b.Reset()) и не добавляйте
Pool без измерений. На редких выделениях он ничего не даст, а код усложнит. Запускаемого
примера здесь нет: эффект Pool виден только в бенчмарках под нагрузкой.
Добавьте к Counter метод Value() int, который читает n под тем же мьютексом, что и
Inc. Напишите тест: 10 горутин делают по 100 вызовов Inc, тест ждёт всех и проверяет
Value. Запустите go test -race .. Какие обращения нужно защищать, если Value
вызывают, пока горутины ещё работают?
func TestCounter(t *testing.T) { c := &Counter{} var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < 100; j++ { c.Inc() } }() } wg.Wait() if got := c.Value(); got != 1000 { t.Fatalf("Value() = %d; хотим 1000", got) }}
Тесту нужны импорты testing и sync. Защищать нужно и запись в Inc, и чтение в
Value, одним и тем же мьютексом. Wait в тесте определяет момент итоговой проверки,
но не заменяет защиту отдельных обращений.
Для одиночного счётчика или флага мьютекс избыточен: следующая глава,
atomic, показывает, как обойтись одной атомарной операцией и где
эта экономия заканчивается.
В задачах курса этой главе соответствует блок 2 (задачи 05–08: счётчик, кэш с RWMutex,
однократная инициализация, своя WaitGroup) и блок 5 (шардированная карта,
singleflight). В блоке 7 мьютексы и WaitGroup работают вместе с каналами и
context внутри пулов воркеров.