Паттерны конкурентности
Fan-out, пулы воркеров, ограничение конкурентности, errgroup, пул HTTP-соединений и выбор между каналом и мьютексом в Go.
14 вопросов
JuniorТеорияОчень частоЧто такое worker pool и как его реализовать на Go?
Что такое worker pool и как его реализовать на Go?
worker pool — это фиксированный набор goroutine, которые все читают из одного канала задач и выполняют их параллельно. Запускают N goroutine, каждая в цикле range по каналу; производитель отправляет задачи и закрывает канал, из-за чего цикл range у каждого воркера завершается.
Типичные ошибки
- ✗Порождать новую goroutine на каждую задачу вместо переиспользования фиксированного набора — это не пул
- ✗Забыть закрыть канал задач, из-за чего воркеры навсегда блокируются на приёме и утекают
- ✗Считать, что воркеры остановятся сами, без закрытия канала или отмены через context
Уточняющие вопросы
- →Как собрать результаты от воркеров без гонки данных?
- →Как остановить worker pool досрочно, до обработки всех задач?
MiddleТеорияЧастоКак ограничить число одновременных операций с помощью буферизованного канала?
Как ограничить число одновременных операций с помощью буферизованного канала?
Используют буферизованный канал ёмкости N как счётный семафор: перед началом работы отправляют в него токен (sem <- struct{}{}), а по завершении — принимают. Отправка блокируется, когда в работе уже N токенов, поэтому одновременно выполняется не более N goroutine.
Типичные ошибки
- ✗Путать семафор (ограничивает конкурентность) с worker pool (фиксированные воркеры разгребают очередь)
- ✗Брать небуферизованный канал, который сериализует работу до одной за раз вместо
N - ✗Забыть освободить токен на пути ошибки, постепенно исчерпывая все слоты
Уточняющие вопросы
- →Чем семафор отличается от worker pool при ограничении конкурентности?
- →Как сделать освобождение токена безопасным, если операция может паниковать?
MiddleТеорияЧастоКогда защищать состояние через Mutex, а когда передавать его по каналу?
Когда защищать состояние через Mutex, а когда передавать его по каналу?
Берите sync.Mutex, чтобы защищать изменяемое состояние, обновляемое на месте — счётчик, кэш или map — он дёшев для коротких критических секций. Берите channel, чтобы передавать владение и координировать goroutine. Оба дают happens-before; выбор — это цена, а не безопасность.
Типичные ошибки
- ✗Понимать «share memory by communicating» как абсолютный запрет на мьютекс
- ✗Считать, что канал не устанавливает happens-before и потому не может безопасно публиковать данные
- ✗Полагать, что канал всегда дешевле мьютекса для простого общего счётчика
Уточняющие вопросы
- →Почему канал может быть избыточным для простого общего счётчика?
- →Как разблокировка мьютекса и отправка в канал создают ребро happens-before?
MiddleТеорияЧастоЧто добавляет вспомогательный пакет errgroup поверх WaitGroup?
Что добавляет вспомогательный пакет errgroup поверх WaitGroup?
errgroup.Group похож на WaitGroup, но Go запоминает первую ненулевую ошибку, а Wait её возвращает. WithContext отменяет свой context на этой ошибке, чтобы соседи остановились раньше, а SetLimit ограничивает параллелизм. Возвращается только первая ошибка.
Типичные ошибки
- ✗Ожидать, что
Waitвернёт ошибки всех goroutine, а не только первую ненулевую - ✗Забывать, что отмену на первой ошибке даёт именно
WithContext— обычная группа ничего не отменяет - ✗Считать, что
errgroupограничивает параллелизм по умолчанию, тогда какSetLimitопционален, а иначе предела нет
Уточняющие вопросы
- →Как
SetLimitсочетает ограничение параллелизма с поведением ошибки и отмены? - →Как собрать все ошибки, а не только первую — например через
errors.Join?
MiddleКодЧастоВерните самый быстрый из N поисковиков, запущенных конкурентно
Верните самый быстрый из N поисковиков, запущенных конкурентно
Запускают по одной горутине на имя, каждая измеряет testSearcher(ctx, name) и шлёт результат {name, dur, err} в буферизованный канал, который закрывающая горутина close-ит после WaitGroup. Проходят range по каналу, держат результат с наименьшим dur среди успешных и пробрасывают ctx, чтобы отмена останавливала пробы в полёте. Если все упали — возвращают последнюю ошибку.
Типичные ошибки
- ✗Возвращать первый ответивший поисковик, а не тот, у кого наименьшая сообщённая длительность
- ✗Обновлять общие
name/respTimeиз горутин без синхронизации — это data race - ✗Закрывать канал результатов из отправителя или не закрывать вовсе, из-за чего
rangeзависает навсегда
Уточняющие вопросы
- →Как вернуться раньше, как только поисковик обгонит целевую задержку, отменив остальных?
- →Почему канал должен быть буферизован (или отправки защищены), чтобы не течь горутинами при раннем выходе?
MiddleТеорияЧастоКак ограничить исходящие соединения, когда Go-сервис делает много HTTP-вызовов к одному хосту?
Как ограничить исходящие соединения, когда Go-сервис делает много HTTP-вызовов к одному хосту?
Переиспользуйте один http.Client (никогда не по одному на запрос) и настройте его Transport: MaxConnsPerHost ограничивает соединения к хосту, MaxIdleConnsPerHost держит тёплые для переиспользования, а IdleConnTimeout закрывает простаивающие. Без настройки всплеск горутин открывает неограниченные сокеты и исчерпывает эфемерные порты. Сочетайте с семафором, чтобы ограничить и число запросов в полёте.
Типичные ошибки
- ✗Создавать новый
http.Clientна каждый запрос, ломая переиспользование и теряя сокеты - ✗Полагать, что число соединений автоматически ограничено числом горутин или
GOMAXPROCS - ✗Оставлять
MaxConnsPerHostна значении по умолчанию (без ограничения) при всплеске конкурентных вызовов
Уточняющие вопросы
- →Почему нужно прочитать и закрыть
resp.Body, чтобы соединение вернулось в пул? - →Как
MaxIdleConnsPerHostвзаимодействует сMaxConnsPerHostпри устойчивой нагрузке?
MiddleДебаггингИногдаКонкурентные загрузчики утекают HTTP-соединениями. В чём ошибка с resp.Body.Close()?
Конкурентные загрузчики утекают HTTP-соединениями. В чём ошибка с resp.Body.Close()?
defer resp.Body.Close() стоит после io.ReadAll, а ранний if err != nil возвращает управление, оставляя body незакрытым на пути ошибки. Перенесите defer resp.Body.Close() сразу после проверки ошибки Get, чтобы каждый успешный Get закрывал body и освобождал соединение.
Типичные ошибки
- ✗Ставить
deferClose после проверки ошибки чтения body, из-за чего ошибка чтения возвращает управление с открытым body - ✗Считать, что body запроса не нужно закрывать, если он маленький или уже прочитан
- ✗Считать порядок
deferкосметикой — он устраняет утечку, только если зарегистрирован до любого раннего return
Уточняющие вопросы
- →Почему незакрытый
resp.Bodyмешает переиспользовать нижележащее TCP-соединение? - →Когда
resp.Bodyможет бытьnil, и нужна ли там защита передClose?
MiddleКодИногдаЧто выведет этот цикл с горутинами на Go 1.22+ и на Go ≤1.21?
Что выведет этот цикл с горутинами на Go 1.22+ и на Go ≤1.21?
На Go 1.22+ каждая итерация получает свой v, поэтому горутины печатают 1 2 3 в некотором порядке. На Go ≤1.21 они все делили один v, изменяемый циклом, и обычно печатали 3 3 3. Переносимое исправление — v := v внутри цикла или передача v аргументом.
Типичные ошибки
- ✗Считать, что
go func(){...}()копирует переменную цикла в момент вызова — он захватывает переменную, а не её значение - ✗Принимать поведение
3 3 3за data race, а не за детерминированный захват одной общей переменной - ✗Думать, что скоупинг Go 1.22 ещё и упорядочивает горутины в
1 2 3— порядок по-прежнему не определён
Уточняющие вопросы
- →Как изменение в Go 1.22 скоупит переменную цикла и стоит ли это аллокации на итерацию?
- →Почему передача
vаргументом функции исправляет ошибку на любой версии Go?
MiddleКодИногдаОбработка []int через worker pool для CPU-bound задачи
Обработка []int через worker pool для CPU-bound задачи
Для CPU-bound работы размер пула — runtime.NumCPU(): лишние goroutine сверх числа ядер дают лишь накладные расходы планировщика. Раздавайте индексы через канал задач; каждый воркер пишет out[i] = heavyCompute(nums[i]) в свой индекс — блокировка не нужна, порядок сохраняется. sync.WaitGroup ждёт завершения всех воркеров.
Типичные ошибки
- ✗Запускать по goroutine на элемент для CPU-bound работы — тысячи goroutine забивают планировщик вместо насыщения ядер
- ✗Собирать результаты через
appendв общий срез под mutex — это сериализует воркеров и путает порядок вывода - ✗Брать размер пула по числу элементов или хардкод-константой вместо
runtime.NumCPU()
Уточняющие вопросы
- →Почему воркеров больше, чем ядер CPU, не ускоряет CPU-bound работу?
- →Как заменить этот ручной пул на пакет
errgroupсSetLimit?
SeniorКодИногдаДве горутины печатают 1..N по порядку, чередуя нечётные и чётные
Две горутины печатают 1..N по порядку, чередуя нечётные и чётные
Используйте два сигнальных канала как пинг-понг: горутина odd ждёт <-odd, печатает, затем сигналит even <- struct{}{}; горутина even делает зеркально. main запускает всё через odd <- struct{}{} и ждёт канал done, который even-горутина закрывает после N. Передача эстафеты обеспечивает строгий порядок без общего состояния.
Типичные ошибки
- ✗Хвататься за общий счётчик или mutex вместо передачи эстафеты через канал
- ✗Считать, что один FIFO-канал заставит двух потребителей детерминированно чередоваться
- ✗Думать, что
WaitGroupупорядочивает выполнение горутин
Уточняющие вопросы
- →Почему
struct{}{}— хорошее сигнальное значение нулевого размера на каналах? - →Как закрытие
doneиз even-горутины чисто останавливаетmain?
SeniorКодИногдаСделайте fan-out RPC по поставщикам с дедлайном context и агрегацией
Сделайте fan-out RPC по поставщикам с дедлайном context и агрегацией
Запускают по одной goroutine на поставщика, каждая вызывает SearchRPC(ctx, …) и шлёт результаты в буферизованный канал; sync.WaitGroup и закрывающая goroutine делают close после завершения всех. Агрегируют, проходя range по каналу, и пробрасывают ctx, чтобы таймаут отменял все RPC в полёте, а не ждал их.
Типичные ошибки
- ✗Создавать context через
context.WithTimeout, но не вызывать егоcancel— таймер течёт до дедлайна - ✗Закрывать канал результатов из goroutine-отправителя, а не после
wg.Wait, что даёт отправку в закрытый канал - ✗Не пробрасывать
ctxвSearchRPC, из-за чего дедлайн так и не отменяет медленного поставщика
Уточняющие вопросы
- →Почему
closeканала должен идти послеwg.Wait, и почему в отдельной goroutine? - →Как пакет
errgroupсWithContextсократил бы этот код fan-out и агрегации?
SeniorКодИногдаЗапустить сервис с graceful shutdown и агрегированием ошибок очистки
Запустить сервис с graceful shutdown и агрегированием ошибок очистки
Паттерн func Main() error: main лишь делает if err := Main(); err != nil { log.Fatal(err) }. Внутри Main после каждой успешной инициализации регистрируйте defer, выполняющий очистку и вкладывающий её ошибку в именованный возврат через errors.Join. Defer-ы идут в LIFO, поэтому Service.Stop раньше SendQueue.Close. Это работает, ведь log.Fatal вызывает os.Exit, пропускающий defer-ы, поэтому они должны жить в Main, не в main.
Типичные ошибки
- ✗Класть defer-ы в
main, гдеos.Exitизlog.Fatalих пропускает - ✗Глотать ошибки очистки вместо вкладывания их в возврат
- ✗Выполнять очистку в порядке инициализации, а не в обратном LIFO
Уточняющие вопросы
- →Почему
log.Fatalвmainпропускает отложенную очистку, а возврат ошибки — нет? - →Как
errors.Joinдаёт одному завершению показать и runtime-ошибку, и ошибку закрытия?
SeniorТеорияРедкоКак goroutine при fan-out утекает на отправке в канал и как это предотвратить?
Как goroutine при fan-out утекает на отправке в канал и как это предотвратить?
goroutine, делающая ch <- result после того, как получатель уже вернулся (например, по ранней ошибке или дедлайну), навсегда блокируется на отправке и утекает. Чините через select по отправке и ctx.Done() либо буфер канала по числу отправителей, чтобы отправка не блокировалась после отмены.
Типичные ошибки
- ✗Ранний возврат получателя, пока отправители всё ещё заблокированы на
ch <-, навсегда их застревая - ✗Буфер меньше числа отправителей, из-за чего лишние отправители всё равно блокируются
- ✗Закрывать канал со стороны получателя ради разблокировки отправителей — отправка в закрытый канал паникует
Уточняющие вопросы
- →Почему закрытие канала не разблокирует отправителя безопасно, в отличие от заблокированного получателя?
- →Как обнаружение утечек goroutine в
go testвыявляет такого застрявшего отправителя?
SeniorТеорияРедкоКак отменить выполняющуюся работу, ограниченную семафорным каналом?
Как отменить выполняющуюся работу, ограниченную семафорным каналом?
Захватывают слот через select сразу по отправке в семафор и по ctx.Done(), чтобы отменённый контекст прерывал ожидание. Каждая работающая goroutine делает select на ctx.Done(), чтобы завершиться раньше, и освобождает токен через defer, поэтому отмена не теряет слоты.
Типичные ошибки
- ✗Блокироваться только на отправке в семафор, из-за чего отменённый контекст всё равно ждёт свободный слот
- ✗Освобождать токен только на успешном пути, теряя слот всякий раз при отмене работы
- ✗Считать, что отмена контекста сама останавливает goroutine, а не проверяется явно
Уточняющие вопросы
- →Куда поместить освобождение токена, чтобы паника в работе всё равно освободила слот?
- →Как вспомогательный пакет
errgroupи егоSetLimitсочетают ограничение и отмену?