Что такое пул воркеров в Go

Реализуя очередной пет-проект, наткнулся на типичную задачу — нужно ограничить конкурентное выполнение. Что-то вроде флага --concurrency 10, как у npm, docker build или terraform. Горутины-то лёгкие, но без ограничений можно легко уронить внешний API или заполнить всю память.

Пул — это N долгоживущих воркеров, которые тянут задачи из канала. Семафор — это стоп-кран, который разрешает горутинам работать, но не создаёт их. Для моей задачи с единой очередью задач пул отлично подходит. // concurrency — значение флага --concurrency // targets — слайс адресов workers := min(concurrency, len(targets)) jobs := make(chan int)

var wg sync.WaitGroup for range workers { wg.Go(func() { for i := range jobs { // Выполняем работу, например, http вызов } }) }

enqueue(jobs, len(targets)) wg.Wait()

Задач иногда меньше, чем --concurrency. Тогда и воркеров меньше: незачем держать горутины, которым задача так и не достанется. Это достигается за счет использования min для определения числа воркеров.

В Go 1.25+ WaitGroup.Go сам делает Add и Done, поэтому код создания горутин упрощается.

Здесь виден классический fan-out: несколько функций читают один канал, пока его не закроют, и каждое значение достаётся ровно одной из них. Так одна очередь разъезжается на N воркеров. Подробнее можно почитать тут Pipelines and cancellation.

Отправка задач и закрытие канала происходит так: func enqueue(jobs chan<- int, n int) { defer close(jobs) for i := range n { jobs <- i } } Канал небуферизированный, поэтому jobs <- i блокируется пока кто-то из воркеров не будет готов и прочитает индекс из канала. Пока все воркеры внутри задачи, читать из jobs некому, и enqueue стоит на отправке. Новая задача не стартует, пока одна из горутин не вернётся к каналу. Одновременно выполняется не больше N задач. Это и есть --concurrency.

Если интересно, как это выглядит на настоящих HTTP-запросах, вот ping-scanner.