Go, Together: визуальный гид по конкурентности в Go
Конкурентность проще, когда её видно. Запустите эти маленькие модели: горутины спорят за общее состояние, ждут разрешений, передают значения через каналы и реагируют на ту операцию, которая готова первой.
По одному.
Мьютекс защищает общее состояние. Горутина вызывает Lock, меняет данные, затем Unlock. Остальные ждут снаружи критической секции.
var mu sync.Mutex count := 0 func increment() { mu.Lock() // enter count++ // protected mu.Unlock() // leave }
Когда использовать
- Несколько горутин меняют одно и то же значение.
- Защищённая операция должна быть атомарной.
- Держите критическую секцию короткой. Не забывайте
Unlock.
По несколько.
Семафор - это счётчик разрешений. Здесь одновременно могут работать только три задачи. Так ограничивают нагрузку на БД, API, CPU или любой дефицитный ресурс.
sem := make(chan struct{}, 3) for _, job := range jobs { sem <- struct{}{} // acquire go func() { defer func() { <-sem }() // release work(job) }() }
Когда использовать
- Нужна конкурентность, но не безлимитная.
- Ёмкость - сколько задач могут войти одновременно.
- В больших проектах
x/sync/semaphoreумеет взвешенные разрешения.
Передай значение.
Каналы передают типизированные значения между горутинами. Небуферизованный канал - это рандеву. Буферизованный может держать значения, пока получатель не будет готов.
jobs := make(chan int, 3) go func() { for i := 1; i <= 5; i++ { jobs <- i // send } close(jobs) // no more sends }() for job := range jobs { work(job) }
Запомните
- Отправка и приём могут блокироваться. Это координация, не баг.
- Закрывать канал должен только отправитель.
- Чтение из закрытого канала сначала отдаёт буфер, затем нулевые значения.
Кто готов первым.
select ждёт группу операций с каналами. Когда одна готова - выполняется её case. Таймер или канал контекста делают ожидание отменяемым.
{ ... }
select { case result := <-apiResults: handle(result) case event := <-events: dispatch(event) case <-time.After(2 * time.Second): return errors.New("timeout") }
Почему «группы каналов»?
selectсобирает несколько возможных операций с каналами.- Если готовы несколько, Go выбирает псевдослучайно.
- Ветка
defaultделает операцию неблокирующей.
Выбирайте по смыслу.
Не спрашивайте «какой примитив лучше?» Спросите: «какие отношения я моделирую?»
| Концепт | Моделирует | Лучше всего для | Осторожно |
|---|---|---|---|
| Mutex | Владение | Защита общего состояния | Дедлоки, длинные критические секции |
| Semaphore | Ёмкость | Ограничение параллельной работы | Утечка разрешений, голодание |
| Channel | Общение | Передача значений и владения | Заблокированные send, неясное закрытие |
| Select | Выбор | Мультиплексирование, отмена, таймауты | Busy loop с default |
Где обычно ломается.
Почти каждый мой баг конкурентности из этого списка.
Unlock, который никогда не вызывается
Ранний return, ветка ошибки или panic между Lock и Unlock оставляют мьютекс навсегда - и все остальные горутины встают за ним.
defer mu.Unlock() сразу после Lock.
Копирование значения с локом внутри
Передача структуры с sync.Mutex по значению даёт копии свой лок - и две горутины спокойно входят в одну критическую секцию.
go vet.
Горутины, которые никогда не завершаются
Горутина пишет в канал, который уже никто не читает - вызывающий вернулся раньше или по таймауту. Она блокируется навсегда, память живёт дальше.
Фикс: у каждой горутины должен быть выход:ctx.Done(), закрытый канал или слот в буфере.
Закрытие не с той стороны
Получатели закрывают канал, или два отправителя закрывают его - panic close of closed channel. Send в закрытый канал тоже паникует.
Фикс: один владелец - отправитель - закрывает один раз.Утечка разрешений
Слот семафора берут и отпускают только на happy path - ёмкость медленно сгорает, пул исчерпывается, всё встаёт.
Фикс: отпускайте черезdefer в той же функции, что брала слот.
wg.Add внутри горутины
Если Add(1) внутри горутины, Wait может увидеть ноль и вернуться до старта работы.
Add до go, внутри - defer wg.Done().
Sleep вместо синхронизации
time.Sleep(100 * time.Millisecond), чтобы «горутина успела», проходит на ноутбуке и падает на загруженном CI.
WaitGroup, канал или errgroup.
select с горячим default
default делает операцию неблокирующей, и select в for крутит CPU на 100%, ожидая ничего.
default или добавьте ticker/таймаут.
Игнорирование отмены
Вызывающий сдался две секунды назад, а воркер всё ещё ходит в БД. Контекст передали вниз и больше не проверяют.
Фикс: select наctx.Done() и return ctx.Err().
Локи в разном порядке
Один путь берёт A, потом B; другой - B, потом A. Под нагрузкой они встречаются посередине и ждут вечно.
Фикс: один глобальный порядок локов и короткие критические секции.nil-канал в select
Send и receive на nil-канале блокируются навсегда. Обычно случайный дедлок - иногда намеренный способ отключить ветку.
make до использования.
Тесты без -race
Гонки данных невидимы, пока не испортят что-то в проде. Зелёный suite ничего не доказывает, если детектор не запускали.
Фикс:go test -race ./... в CI.