Go, Together: визуальный гид по конкурентности в Go

Go, Together: mutex, semaphore, channel, select

Конкурентность проще, когда её видно. Запустите эти маленькие модели: горутины спорят за общее состояние, ждут разрешений, передают значения через каналы и реагируют на ту операцию, которая готова первой.

Эксклюзивный доступ

По одному.

Мьютекс защищает общее состояние. Горутина вызывает Lock, меняет данные, затем Unlock. Остальные ждут снаружи критической секции.

Эксперимент 01 / Общий счётчик
Скорость
G₁
goroutine A
0общий счётчик
G₂
goroutine B
Мьютекс разблокирован
var mu sync.Mutex
count := 0

func increment() {
    mu.Lock()          // enter
    count++            // protected
    mu.Unlock()        // leave
}

Когда использовать

  • Несколько горутин меняют одно и то же значение.
  • Защищённая операция должна быть атомарной.
  • Держите критическую секцию короткой. Не забывайте Unlock.
Ограниченный доступ

По несколько.

Семафор - это счётчик разрешений. Здесь одновременно могут работать только три задачи. Так ограничивают нагрузку на БД, API, CPU или любой дефицитный ресурс.

Эксперимент 02 / Лимит воркеров
Скорость
3/3
свободных
J1
J2
J3
J4
J5
J6
Все разрешения доступны
sem := make(chan struct{}, 3)

for _, job := range jobs {
    sem <- struct{}{}   // acquire
    go func() {
        defer func() { <-sem }() // release
        work(job)
    }()
}

Когда использовать

  • Нужна конкурентность, но не безлимитная.
  • Ёмкость - сколько задач могут войти одновременно.
  • В больших проектах x/sync/semaphore умеет взвешенные разрешения.
Типизированная связь

Передай значение.

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

Эксперимент 03 / Буферизованный пайплайн
Скорость
SEND
производитель
КАНАЛ ЗАКРЫТ
RECV
потребитель
Буфер пуст
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. Таймер или канал контекста делают ожидание отменяемым.

Эксперимент 04 / Fan-in + таймаут
Скорость
apiResults
events
timeout
select
{ ... }
Ожидание 3 каналов
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 по значению даёт копии свой лок - и две горутины спокойно входят в одну критическую секцию.

Фикс: pointer receivers; запускайте 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.