GraphLMS

Go
0/32 решеноНачать
Глава 05 · Основы~15 мин чтения

sync: Mutex, RWMutex, Once, Cond, WaitGroup, Pool

О чём глава

Пакет sync — это набор «низкоуровневых» примитивов синхронизации: мьютексы, условные переменные, однократная инициализация, ожидание группы горутин, пул объектов. Каналы из предыдущей главы — это про передачу данных между горутинами. sync — это про защиту общего состояния на месте: когда несколько горутин трогают одну и ту же переменную, карту или поле структуры.

Здесь мы разберём каждый примитив: что он гарантирует, когда он уместен и где о него обычно спотыкаются. По ходу — интерактивные примеры, которые можно запустить прямо в браузере. Сразу честная оговорка: настоящего параллелизма и race-детектора в браузере нет, поэтому «живые гонки» мы показываем статичным кодом с разбором, а запускаем то, что даёт детерминированный результат.

Когда мьютекс, а когда канал

Это первый вопрос, который стоит задать себе, прежде чем тянуться к любому примитиву. Эвристика самой команды Go простая:

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

Не насилуйте каналы там, где естественнее мьютекс, и наоборот. Защитить map от конкурентного доступа проще одним sync.Mutex, чем гонять её через канал к выделенной горутине-владельцу. А вот «раздать задачи воркерам и собрать результаты» — это работа для каналов, мьютекс тут будет неуклюжим.

Если сомневаетесь — спросите: «мне нужно подвинуть данные или по очереди потрогать общую переменную?» Ответ почти всегда подсказывает инструмент.

sync.Mutex

Mutex (mutual exclusion, взаимное исключение) гарантирует, что в критической секции в любой момент находится не больше одной горутины. Классический способ защитить разделяемое состояние.

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. То есть всё, что одна горутина записала под мьютексом, гарантированно видно следующей горутине, которая этот мьютекс захватит. Мьютекс защищает не только от «двух одновременных записей», но и от того, что другой процессор увидит устаревшее значение из своего кэша.

Несколько правил, которые экономят часы отладки:

  • Mutex нельзя копировать после первого использования. Если скопировать структуру со встроенным мьютексом по значению, получатся две независимые блокировки, и защита развалится. Поэтому методы вешают на указатель (*Counter), а go vet ловит копирование.
  • Держите критическую секцию короткой. Никаких сетевых вызовов или дискового I/O под Lock — иначе вы сериализуете всю систему через одну блокировку и убиваете пропускную способность.
  • defer Unlock — страховка от забытого разблокирования при раннем return или панике. Привыкайте писать Lock и сразу defer Unlock следующей строкой.

Запустим пример: тысяча инкрементов из горутин под мьютексом всегда даёт ровно 1000. (В браузере это исполняется однопоточно, но логика блокировки та же — нам важно, что код корректен.)

package main
 
import (
	"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)
}

А вот как выглядит та же программа без мьютекса — это уже гонка данных. Такой код в браузере мы не запускаем (тут нет настоящего параллелизма и race-детектора), но на реальной машине под go run -race он и упадёт с предупреждением, и иногда даст результат меньше 1000:

func (c *Counter) Inc() {
	c.n++ // ГОНКА: чтение-инкремент-запись не атомарны
}
// $ go run -race main.go
// ==================
// WARNING: DATA RACE
// ...
// итог: 973   // потерянные инкременты

c.n++ — это на самом деле три операции (прочитать, прибавить, записать). Две горутины могут прочитать одно и то же старое значение и затереть инкремент друг друга. Мьютекс делает эту тройку атомарной относительно других горутин.

sync.RWMutex

RWMutex разделяет читателей и писателей: либо много читателей одновременно, либо один писатель и больше никого. Имеет смысл при read-heavy нагрузке, где чтений сильно больше, чем записей, а критическая секция не микроскопическая.

type Cache struct {
    mu sync.RWMutex
    m  map[string]string
}
 
func (c *Cache) Get(k string) string {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.m[k]
}
 
func (c *Cache) Set(k, v string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.m[k] = v
}

RLock/RUnlock для чтения, Lock/Unlock для записи. Несколько горутин могут держать RLock параллельно — это и есть выигрыш на чтениях.

Но осторожно: RWMutex тяжелее обычного Mutex, потому что внутри ведёт учёт читателей. Если критическая секция крошечная (например, прочитать один int), contention на внутреннем счётчике читателей может сделать RWMutex медленнее простого Mutex или атомика. Правило: RWMutex оправдан, когда чтения действительно частые И заметно дороже накладных расходов на сам замок. Меряйте бенчмарком, не угадывайте.

sync.Once

Once гарантирует, что переданная функция выполнится ровно один раз, даже если Do вызвали из множества горутин одновременно. Тело функции happens-before возврата любого Do — то есть все вызывающие увидят полностью завершённую инициализацию. Идеально для ленивого создания синглтона.

var (
    once sync.Once
    conn *DB
)
 
func GetDB() *DB {
    once.Do(func() { conn = connect() }) // connect ровно один раз
    return conn
}

Даже если сто горутин одновременно вызовут GetDB, connect отработает один раз, а остальные дождутся и получат готовый conn. Частый второй сценарий — однократное закрытие канала, когда отправителей несколько: closeOnce.Do(func(){ close(ch) }) спасает от паники «close of closed channel».

Запустим: пять горутин рвутся инициализировать, но init печатается один раз.

package main
 
import (
	"fmt"
	"sync"
)
 
func main() {
	var once sync.Once
	var wg sync.WaitGroup
	var calls int
 
	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)
}

sync.WaitGroup

WaitGroup ждёт, пока завершится группа горутин. Внутри — счётчик: Add(n) увеличивает его, Done уменьшает на единицу, Wait блокируется, пока счётчик не дойдёт до нуля.

var wg sync.WaitGroup
for _, job := range jobs {
    wg.Add(1)
    go func(j Job) {
        defer wg.Done()
        process(j)
    }(j)
}
wg.Wait() // здесь все горутины точно завершились

Два правила, которые нарушают чаще всего:

  • Add вызывайте до запуска горутины, в коде вызывающего, а не внутри самой горутины. Если сделать wg.Add(1) уже внутри go func(){...}, то Wait может проскочить раньше, чем горутина успела стартовать и увеличить счётчик.
  • Done — всегда через defer, чтобы счётчик уменьшился даже при панике или раннем return внутри горутины. Забытый Done — это вечно висящий Wait.

С точки зрения памяти: каждый wg.Done() happens-before возврата Wait, поэтому после wg.Wait() результаты, записанные горутинами, гарантированно видны.

В Go 1.25 появился метод wg.Go(func(){ ... }), который сам делает Add(1), запускает горутину и вызывает Done по завершении — меньше шансов ошибиться:

var wg sync.WaitGroup
for _, job := range jobs {
    wg.Go(func() { process(job) }) // Add/Done под капотом
}
wg.Wait()

Запустим классику: запускаем воркеры, ждём всех, суммируем результаты. Wait возвращает управление только после того, как все горутины отписались.

package main
 
import (
	"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)
}

Обратите внимание: total защищён мьютексом, потому что в него пишут из разных горутин. WaitGroup отвечает за «дождись всех», но не за защиту общей переменной — это две разные задачи, и каждая решается своим инструментом.

sync.Cond

Cond — условная переменная: горутины засыпают в ожидании какого-то условия и просыпаются по сигналу. Берут её, когда нужно «жди, пока состояние не станет таким-то», а каналом это выразить неудобно — например, ждущих много и у каждого своё условие.

type Queue struct {
    mu   sync.Mutex
    cond *sync.Cond
    data []int
}
 
func (q *Queue) Pop() int {
    q.mu.Lock()
    defer q.mu.Unlock()
    for len(q.data) == 0 { // именно цикл, не if!
        q.cond.Wait()      // атомарно отпускает mu и паркует; при пробуждении снова берёт Lock
    }
    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 всегда внутри цикла проверки условия, не под if. Причины две — возможны ложные пробуждения (spurious wakeup), и пока разбуженная горутина снова берёт Lock, другой потребитель мог уже утащить событие. Цикл перепроверяет условие после пробуждения и не даёт работать с пустой очередью.

Signal будит одного ждущего, Broadcast — всех. Cond создают через sync.NewCond(&mu), привязывая к мьютексу, который защищает само условие.

Запустим маленький пример «производитель-потребитель» на Cond. Потребитель ждёт, пока в очереди появятся данные:

package main
 
import (
	"fmt"
	"sync"
)
 
func main() {
	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("готово")
}

Честно говоря, многие задачи, где напрашивается Cond, на практике чище решаются каналами. Cond берут, когда ждущих много и нужен точечный Signal ровно одному или массовый Broadcast всем сразу.

sync.Pool

Pool — кэш переиспользуемых объектов, чтобы снизить нагрузку на сборщик мусора. Главное, что нужно понять сразу: это не connection pool. Объекты в Pool могут быть удалены в любой момент, в частности при сборке мусора. Нельзя рассчитывать, что положенное туда там и останется.

// фрагмент: подразумевается import "bytes"
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 кладёт объект обратно. Польза появляется только при высокой частоте аллокаций короткоживущих объектов одного типа — классика — буферы в HTTP-хендлерах под нагрузкой. На редких аллокациях Pool не окупится и только усложнит код.

Два момента: всегда сбрасывайте состояние объекта (как b.Reset() выше) — иначе получите данные от прошлого использования. И никогда не храните в Pool то, что должно жить гарантированно: соединения, открытые файлы, что угодно с ресурсами-владельцами.

Pool мы намеренно не даём go play: его эффект — про GC и параллельную нагрузку, а в браузере он просто отработает без наблюдаемой разницы и ничему не научит. Смысл примитива — в бенчмарках на реальной машине.

Частые ошибки

  • Копирование Mutex/WaitGroup по значению. Передали структуру с замком в функцию по значению — получили две независимые блокировки, защита не работает. Ловится go vet; держите такие структуры за указателем.
  • Add внутри горутины вместо вызывающего кода — гонка с Wait, который может вернуться раньше времени.
  • cond.Wait() под if вместо for. Ложные пробуждения и похищенные события приведут к работе с непроверенным состоянием.
  • Долгий I/O под Lock. Сетевой вызов или дисковая операция под мьютексом сериализует всю систему через одну блокировку и убивает пропускную способность.
  • RWMutex на крошечной секции, где обычный Mutex или atomic банально быстрее из-за меньших накладных расходов.

Мысленная модель

Каждый примитив отвечает на свой вопрос:

  • Mutex — «дай эксклюзивный доступ к состоянию».
  • RWMutex — «пусти многих читать или одного писать».
  • WaitGroup — «дождись, пока все закончат».
  • Once — «сделай это ровно один раз».
  • Cond — «спи, пока не наступит условие».
  • Pool — «переиспользуй временный мусор, чтобы не грузить GC».

Выбор примитива — это ответ на вопрос «какую именно синхронизацию я хочу», а не «какой инструмент я лучше знаю». Сначала формулируете задачу синхронизации, потом подбираете под неё примитив.

Что дальше

Дальше стоит посмотреть на atomic — для совсем простых счётчиков и флагов атомики обходятся дешевле мьютекса. Понять, почему Unlock/Lock, Done/Wait и Once.Do дают видимость записей, помогает глава модель памяти. А каналы и context — это вторая половина инструментария: оркестрация и отмена, которые в реальных сервисах комбинируются с примитивами sync.

В тренажёре это рабочая лошадка топика 2 (5–8) и топика 5 (16–20): счётчики, кэши, очереди, пулы — всё про корректный выбор и применение этих примитивов. В топике 7 (23–32) мьютексы и WaitGroup сплетаются с каналами и context в полноценные сервисные конструкции.