Паттерны конкурентности
Знать примитивы — goroutine, channel, sync.Mutex, context — это лишь половина дела. На собеседовании по Go редко спрашивают «что такое канал»; чаще просят раздать работу по горутинам, ограничить их число, собрать результаты и аккуратно остановиться при ошибке. Это и есть паттерны конкурентности — устоявшиеся способы сложить примитивы так, чтобы схема не текла горутинами, не паниковала на close и не клала процесс под нагрузкой.
Каждый паттерн здесь решает конкретную боль. Неограниченный fan-out — по горутине на элемент огромного среза — выглядит просто и легко взрывает память и дескрипторы; пул воркеров и семафор ставят этому предел. errgroup снимает с WaitGroup ручную возню с первой ошибкой и отменой. Пул HTTP-соединений живёт внутри одного http.Client, и клиент на каждый запрос молча убивает переиспользование. А выбор «канал против мьютекса» — не вопрос моды: для состояния на месте мьютекс бьёт канал и по скорости, и по простоте. Тема разбирает эти приёмы по слоям — от fan-out до ловушки захвата переменной цикла.
Карта темы
- Fan-out — по горутине на задачу, сбор результатов в буферизованный канал и безопасное закрытие отдельной горутиной после
WaitGroup. - Пул воркеров — фиксированные N воркеров тянут задачи из общего канала; для CPU-bound размер по
runtime.NumCPU(). - Ограничение конкурентности — буферизованный канал как счётный семафор ограничивает число одновременных операций.
- errgroup — как
WaitGroup, но ловит первую ошибку, отменяет контекст для fail-fast и ограничивает конкурентность черезSetLimit. - Каналы против мьютекса —
sync.Mutexдля изменения состояния на месте,channelдля передачи владения; оба дают happens-before, выбор по цене. - Пул HTTP-соединений — переиспользование одного
http.Clientи тюнингTransport, чтобы всплеск горутин не исчерпал соединения. - Захват переменной цикла — почему горутины в цикле до Go 1.22 делили одну переменную, а с 1.22 каждая итерация своя.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
| Запускать по горутине на каждый элемент огромного среза | Взрыв памяти и исчерпание файловых дескрипторов и соединений |
| Закрывать канал результатов из воркера-отправителя | Двойной close паникует, close до конца других — «send on closed channel» |
| Закрывать канал до завершения всех отправителей при fan-out | Паника «send on closed channel»; закрывать должна отдельная горутина после WaitGroup |
| Слать в небуферизованный канал, когда потребитель ушёл | Отправители виснут навсегда — утечка горутин; нужен буфер или select по ctx.Done() |
| Не закрывать канал задач воркер-пула | Воркеры навсегда виснут в range, wg.Wait() не вернётся — дедлок |
Брать runtime.NumCPU() * 10 воркеров для CPU-bound работы | Лишние переключения контекста вместо ускорения; размер ≈ числу ядер |
Считать, что errgroup.Wait вернёт все ошибки | Возвращается только первая, остальные молча отбрасываются |
Думать, что g.SetLimit собирает все сбои | SetLimit лишь ограничивает параллелизм; ошибка по-прежнему первая |
Создавать http.Client на каждый запрос | У каждого свой пустой пул — пулинг убит, соединения текут |
Считать, что MaxIdleConnsPerHost по умолчанию велик | Дефолт — 2; под нагрузкой тёплые соединения постоянно пересоздаются |
Закрыть resp.Body, но не дочитать его | Соединение могут не вернуть в пул — переиспользование срывается |
| Захватывать переменную цикла горутиной на Go ≤ 1.21 | Все горутины читают одну и ту же переменную — типичный вывод 3 3 3 |
| Гонять горячий счётчик через канал «ради идиоматичности» | Лишний слой; для состояния на месте мьютекс или atomic быстрее и проще |
Значение для собеседований
Паттерны конкурентности — любимый формат практических Go-задач: «раздай работу по горутинам и собери результат», «ограничь число одновременных запросов», «останови всех при первой ошибке». Интервьюер проверяет не знание примитивов, а умение собрать из них схему, которая не течёт и не паникует.
Что обычно проверяют:
- Как безопасно закрыть канал результатов при fan-out — отдельная горутина-закрывальщик после
WaitGroup, а не сам отправитель. - Чем пул воркеров отличается от fan-out и как выбрать число воркеров — по числу ядер для CPU-bound, больше для IO-bound.
- Как ограничить число одновременных операций без пула — буферизованный канал как счётный семафор.
- Что
errgroupдаёт сверхWaitGroup— первая ошибка, отмена контекста для fail-fast, лимит черезSetLimit. - Когда брать
channel, а когдаsync.Mutex— передача владения против защиты состояния на месте. - Почему один
http.Clientна сервис обязателен и что тюнить вTransportпод всплеск горутин. - Что выведет цикл с горутинами на старой и новой версии Go — ловушка захвата переменной цикла до 1.22.
Типичный неверный ответ: «просто запусти горутину на каждый элемент — Go же дешёвый». Это запускает разбор того, что неограниченный fan-out взрывает память и дескрипторы, и что число одновременных операций ограничивают пулом воркеров или семафором на буферизованном канале.