Практическая ситуация: сервис запускает много независимых задач, но одновременно разрешено выполнять только две; как применить буферизованный канал для ограничения числа активных горутин?
Буферизованный канал можно использовать как семафор: свободный слот представляется одним элементом в канале. Перед запуском задачи программа отправляет маркер в канал, а после завершения горутина извлекает его обратно. Если буфер заполнен, следующая отправка блокируется, поэтому одновременно выполняется не больше заданного числа задач.
Семафор — классический примитив синхронизации для ограничения числа участников, одновременно получающих доступ к ресурсу. В Go отдельного семафора в стандартном наборе примитивов нет, поэтому для этой задачи часто используют блокирующий обмен через буферизованный канал.
Такой подход соответствует модели Go, где каналы применяются не только для передачи данных, но и для координации выполнения. Он решает проблему неограниченного создания горутин при массовой обработке задач.
Если запускать отдельную горутину для каждой задачи без ограничения, число одновременно выполняемых операций может превысить пропускную способность базы данных, внешнего API, файловой системы или CPU. Это приводит к росту потребления памяти, конкуренции за ресурсы, тайм-аутам и каскадным отказам.
Ограничение должно учитывать именно активные задачи. Недостаточно просто ограничить размер входного канала: его размер регулирует количество ожидающих элементов, но не гарантирует число одновременно работающих горутин.
Создаётся канал ёмкостью N, где N — максимальное число одновременных задач. Отправка маркера занимает один слот. Когда все слоты заняты, отправка блокируется до завершения одной из уже запущенных задач.
После работы горутина обязана освободить слот. Для этого освобождение обычно регистрируют через defer, чтобы оно произошло и при обычном завершении, и при раннем выходе из функции.
В примере канал содержит не данные, а два разрешения. Цикл блокируется на sem <- struct{}{}, когда обе горутины уже работают; после извлечения маркера следующая задача получает возможность стартовать.
Важно выполнять захват разрешения до запуска горутины. Если сначала создать горутину, а затем заблокировать её на отправке в семафор, число горутин всё равно может стать огромным: ограничивается активная работа, но не количество ожидающих горутин.
У этого решения есть компромиссы. Слишком маленький лимит снижает пропускную способность, слишком большой возвращает перегрузку. Если отправка разрешения может ждать долго, её стоит сделать отменяемой через context.Context; иначе остановка сервиса способна оставить часть управляющих горутин заблокированными.
Семафор ограничивает количество операций, но не распределяет задачи между фиксированным числом работников. Для постоянного потока задач, требующего контроля очереди и равномерного распределения нагрузки, может быть предпочтительнее пул воркеров.
Сервис получил 10 000 заданий на обращение к внешнему API, которое разрешает не более 20 параллельных запросов. Неограниченный запуск горутин быстро создаёт всплеск соединений и приводит к ответам «слишком много запросов».
Рассматривались два варианта. Пул из 20 воркеров даёт явный контроль очереди и ограничивает число горутин, но требует отдельного жизненного цикла очереди, воркеров и завершения. Семафор проще встроить в уже существующий цикл обработки, однако ожидающие задачи нужно дополнительно ограничивать или отменять, иначе они могут накапливаться в вызывающей горутине.
Выбран семафор вместимостью 20 вместе с контекстом запроса и ограниченной очередью входных заданий. Разрешение захватывалось до запуска рабочей горутины, а освобождение выполнялось через defer. В результате одновременно выполнялось не более 20 API-вызовов, а при остановке сервиса ожидание новых разрешений прекращалось по отмене контекста.
Что произойдёт, если разрешение захватывается внутри уже запущенной горутины?
Ограничится число активных операций, но не число созданных горутин. При большом потоке задач каждая из них может зависнуть на отправке маркера в полный канал. Это расходует память и планировочные ресурсы, поэтому для жёсткого ограничения количества горутин разрешение обычно получают до go.
Почему освобождение разрешения нужно связывать с завершением работы через defer?
Если удалить маркер только в обычной ветке завершения, любой ранний return, ошибка или паника могут оставить слот занятым. После этого доступное число разрешений постепенно уменьшится, а новые задачи начнут блокироваться навсегда. defer повышает надёжность освобождения, хотя паника, не перехваченная внутри горутины, всё равно завершает процесс; он не заменяет обработку паник и корректное завершение приложения.
Чем ограничение конкурентности отличается от ограничения скорости запросов?
Семафор ограничивает число операций, которые выполняются одновременно, но не задаёт интервал между их запусками. Если задачи завершаются мгновенно, через систему может пройти очень много запросов за секунду при малом числе активных операций. Для ограничения частоты нужен отдельный механизм rate limiting, а семафор может применяться вместе с ним, если требуется контролировать и параллелизм, и скорость.