Содержание урока · 10 разделов
- Asynq concurrency: пул слотов одного процесса
- Как подобрать Concurrency: CPU-bound и IO-bound задачи
- Правила подбора пула
- Масштабирование воркеров: процессы и машины против одной Redis
- Варианты масштабирования
- Разделение очередей и отдельные инстансы Server
- Лаги и asynqmon: что смотреть, когда очередь растёт
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Дальше по порядку. Сначала устройство пула: семафор, горутина на задачу, дефолт по ядрам — то, что происходит внутри одного процесса при каждом заборе задачи. Затем выбор числа: почему для рендера и для письма оно разное и где стоит настоящий потолок. Потом горизонталь: второй и третий процесс, другие машины — и почему Redis не станет узким местом, а задачи не начнут исполняться дважды. Финал — эксплуатация: разделение очередей на отдельные инстансы Server с разной Concurrency и чтение лагов по счётчикам asynqmon. Базу по очередям и весам берём из седьмого урока — здесь только масштаб. Названия дальше важнее формул: слот, забор, реплика — за каждым словом стоит конкретное место в коде asynq, и путать их дорого: половина разбора лагов начинается с того, что двое в тикете говорят о разном «воркере».
Asynq concurrency: пул слотов одного процесса#
Опция Concurrency в asynq.Config отвечает на вопрос, сколько задач один процесс берёт на исполнение одновременно. Это не число горутин в программе и не размер буфера, а ёмкость пула хендлеров. Внутри сервер работает просто: процессор в цикле берёт задачу из Redis, запускает для неё горутину с вашим хендлером, а прежде каждого забора кладёт токен в канальный семафор ёмкостью ровно в Concurrency; горутина держит токен до самого конца исполнения и возвращает его, когда хендлер закончил работу. Новый слот не откроется, пока не освободится один из занятых, — так максимальное число одновременных обработок держится точно на заданном значении. Дефолт продуман: если поле оставить нулём или отрицательным, NewServer подставит число ядер CPU, доступных текущему процессу. Очереди при этом опрашиваются не блокирующей операцией, а обычным забором: когда все очереди пусты, процессор засыпает на пол-интервала проверки плюс случайный джиттер, чтобы не долбить Redis холостыми запросами; интервал настраивается полем TaskCheckInterval и по умолчанию равен секунде. Из этого следует практичный вывод: Concurrency — потолок, а не обещание. Процесс с Concurrency 30 при пустых очередях не держит тридцать горутин — они рождаются под задачи; зато при их изобилии активных хендлеров будет ровно тридцать, а остальные задачи честно подождут в Redis.
Конфиг с ёмкостью пула
GOsrv := asynq.NewServer(redisConnOpt, asynq.Config{
Concurrency: 30, // до тридцати задач одновременно
Queues: map[string]int{
"critical": 6,
"default": 3,
"low": 1,
},
})Concurrency задаёт ёмкость пула процесса: столько хендлеров может исполняться параллельно, остальным задачам придётся ждать в Redis. Ноль или отрицательное значение заменяется числом доступных процессу ядер CPU — осмысленный дефолт для смешанной нагрузки. Веса очередей здесь из седьмого урока: они делят общий пул тридцати слотов, но сами слоты одни и те же для всех трёх очередей.
Проверка: дефолт Concurrency
В asynq.Config не задано поле Concurrency. Какую ёмкость пула получит сервер?
Как подобрать Concurrency: CPU-bound и IO-bound задачи#
Подбор начинается с вопроса: что делает хендлер между стартом и возвратом — считает или ждёт. CPU-bound задачи пережимают картинки, строят отчёты, шифруют: горутина почти всё время жжёт ядро, и Concurrency заметно выше числа ядер даёт только переключения контекста без прироста — держитесь ядер или чуть выше. IO-bound задачи ходят в HTTP, базы, почтовые сервисы: горутина большую часть жизни ждёт ответ, и процессор простаивает; здесь потолок ставит форму «пропускная способность, умноженная на среднее время задачи»: при десяти задачах в секунду и двух секундах на задачу нужно около двадцати слотов. Для IO-bound Concurrency в разы выше числа ядер — норма, и дефолт по CPU зря простаивает. Вторая ловушка — настоящий лимит почти всегда снаружи: пул соединений Postgres, лимит внешнего API, память машины. Concurrency 100 при пуле БД 20 означает очередь из восьмидесяти горутин внутри вашего процесса — она живёт в памяти, невидима для asynqmon и внезапно превращается в таймауты. И последнее: Concurrency читается при старте сервера — изменить его можно только новым деплоем воркеров, а гасить старые процессы надо по-честному, через дожим задач из девятнадцатого урока. Подбирают число измерением: нагрузочный стенд, Latency и Processed — ваши показания, интуиция молчит.
Правила подбора пула#
- Считайте профиль задач: сколько из них ждут сеть, а сколько жгут CPU.
- CPU-bound: стартуйте от числа ядер; запас выше ядер даёт только переключения контекста.
- IO-bound: Concurrency по формуле «пропускная способность × среднее время задачи».
- Настоящий потолок часто снаружи: пул соединений БД, лимиты внешних API, память.
- Подбирайте измерением: Latency и Processed — показания, а не вкусовщина.
- Concurrency задаётся при старте процесса: изменение — это новый деплой воркеров.
Команда подняла Concurrency до сотни «чтобы очередь наконец проедалась», и воркеры действительно стали резвее — до первой волны нагрузки. Хендлеры пошли в Postgres, а пул соединений на двадцать подключений выстроил горутины в очередь ожидания: таймауты соединений порождали ретраи, ретраи умножали поток задач, и система легла сама на себя без единой ошибки в конфиге asynq. Пул Concurrency растёт вместе с тем, что стоит за хендлером, — базой, кэшем, внешним API; иначе слоты лишь выкупают место в чужой очереди ожидания.
Масштабирование воркеров: процессы и машины против одной Redis#
Когда один процесс уже набит, следующий слот добавляется новым процессом — и здесь устройство asynq играет на вас. Запустите тот же бинарник второй раз: оба процесса слушают одни очереди, и задачу при заборе уносит ровно один из них. Атомарность гарантирует Redis: Dequeue выполняется Lua-скриптом, который атомарно переносит сообщение из pending в active конкретной очереди, поэтому даже сотня одновременных заборов не выдаст задачу дважды. Пулы при этом независимы: у каждого процесса свой семафор, и два воркера с Concurrency 25 дают пятьдесят слотов суммарно. Redis остаётся координатором, но не узким местом: атомарный забор — одна короткая операция на задачу, и брокер выдерживает тысячи таких в секунду; что происходит внутри самого Redis — тема отдельного курса по go-redis. На нескольких машинах схема не меняется: воркер — дешёвый stateless-процесс с доступом к Redis, раскладывайте его по инстансам как обычный сервис. Побочный бонус — устойчивость деплоев: пока новая реплика стартует, старые продолжают забор и очередь не замирает; один процесс на время дожима замораживает мощность, а их несколько — нет. Каждая реплика сердцебиением пишет в Redis информацию о себе: хост, PID, Concurrency и карту очередей — эти данные отдаёт Inspector методом Servers(), а вкладка серверов asynqmon рисует из того же источника, так что «кто вообще обрабатывает очередь и с какой мощностью» — вопрос одного взгляда. Смерть процесса задачу не теряет: lease истекает, и работа вернётся в очередь — но повторное исполнение никто не отменял, и идемпотентность из восемнадцатого урока остаётся обязательной при любом масштабе.
Варианты масштабирования#
| Вариант | Что даёт | Когда выбирать |
|---|---|---|
| Поднять Concurrency | больше слотов в одном процессе | задачи IO-bound, лимиты снаружи далеко |
| Больше процессов на машине | суммарный пул, изоляция рестартов | процессу тесно в CPU или памяти |
| Воркеры на других машинах | свой CPU и RAM, отказоустойчивость | CPU-bound задачи и растущая нагрузка |
| Разные инстансы Server | физически отдельные пулы | тяжёлое мешает быстрым задачам |
Два процесса, одна очередь
Прочитайте код и выберите, что он выведет:
// один и тот же бинарник запущен дважды на двух машинах;
// конфиг у обоих процессов один:
// Concurrency: 25, Queues: map[string]int{"default": 1}
//
// в очереди default сейчас 60 задачРазделение очередей и отдельные инстансы Server#
Очереди из седьмого урока делят общий пул слотов пропорционально весам — и это их честная граница: рендер на десять минут и письмо на миллисекунду всё равно конкурируют за одни и те же слоты одного процесса. Взвешенный режим делает делёж справедливым, StrictPriority — строгим, но оба управляют порядком опроса, а не мощностями: отдельные мощности начинаются там, где появляются разные инстансы Server. Поднимите второй процесс с собственным конфигом: почтовый воркер держит очереди critical и default с Concurrency 30, рендерный — очередь renders с Concurrency 4. Теперь тяжёлая задача физически не может занять слоты быстрых: у рендеров свой семафор, своя машина с запасом памяти, свой темп рестартов. Клиенту ничего не меняется: постановка по-прежнему опция Queue, а исполняет задачу тот воркер, у которого очередь в конфиге; правило молчаливого ожидания из седьмого урока становится ещё важнее — очередь renders обязана жить в конфиге живого процесса. ServeMux у каждого инстанса свой: почтовый процесс регистрирует только типы писем, рендерный — только рендеры, и чужой хендлер физически не вызовется не там. Цена решения операционная: два деплоя, два набора конфигов и метрик. Взамен вы получаете класс обслуживания, который не проседает от чужой нагрузки ни при каком потоке задач.
Два Server: письма и рендеры
GO// процесс 1: быстрые задачи, широкий пул
emailSrv := asynq.NewServer(redisConnOpt, asynq.Config{
Concurrency: 30,
Queues: map[string]int{
"critical": 6,
"default": 3,
},
})
// процесс 2: тяжёлые рендеры, узкий пул, отдельная машина
renderSrv := asynq.NewServer(redisConnOpt, asynq.Config{
Concurrency: 4,
Queues: map[string]int{"renders": 1},
})Два инстанса Server живут в разных процессах и делят один Redis: у каждого свой семафор и свой список очередей. Письменный пул в тридцать слотов никогда не встретит рендер — очереди renders в его конфиге нет. Клиент по-прежнему ставит задачи опцией Queue, а забирает их тот процесс, который слушает очередь.
Проверка: чужая очередь
Почтовый процесс не имеет очереди renders в конфиге. В critical и default пусто, в renders накопилось 500 задач. Что делает процессор почтового процесса?
Лаги и asynqmon: что смотреть, когда очередь растёт#
Тикет «задачи выполняются с задержкой» диагностируется по трём числам, и все три видны в asynqmon. Первое — пара pending и active: рост pending при плоском active означает, что задачи копятся, но не забираются — процессов мало или их пулы заняты. Когда active держится на уровне суммарного Concurrency всех живых воркеров, потолок достигнут: слоты не простаивают, и обрабатываемая мощность ограничена ровно тем, что настроено. Второе — Latency, возраст самой старой задачи в ожидании: большой pending бывает и у здоровой системы, а вот растущий Latency — прямой сигнал, что конкретная задача ждёт слишком долго; порог задаёт бизнес. Третье — список живых процессов: вкладка серверов asynqmon или Servers() из Inspector показывает каждую реплику с её Concurrency и очередями; исчезнувший из списка процесс — первая причина внезапных лагов после ночи. Сравните профиль pending с вчерашним пиком: если график повторяет прошлые дни, а Latency всё равно растёт, дело не во всплеске постановок — мощности объективно перестало хватать. Если pending плоский, а задержки есть, смотрите scheduled и retry: задачи, возможно, вообще не доходят до очереди ожидания. Диагноз диктует лекарство: пул занят — добавляйте процесс или поднимайте Concurrency; тяжёлое душит быстрое — разделяйте инстансы; процессы живы, а очередь стоит — проверяйте, ту ли очередь слушает воркер. Наблюдаемость в метриках и алертах — отдельная тема двадцать третьего урока.
Пул Concurrency — это канальный семафор, и он прекрасно живёт без asynq: запустите в песочнице программу, где горутины берут задачи из общего канала, держа токен на время работы, — если увеличите ёмкость канала, увидите, как меняется темп обработки. А вот второй воркер против того же Redis в песочнице не заведётся: сети и брокера там нет — для этого нужен локальный Go и Redis в Docker.
Соберите конфиг масштаба
Допишите ёмкость пула и флаг строгого приоритета.
Соберите запуск рендер-воркера
Расставьте строки запуска рендер-процесса: коннект, конфиг с узким пулом, маршруты, старт.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: конкурентность и масштаб
Конкурентность и масштаб разобраны до винтиков: пул слотов на семафоре, дефолт по ядрам, подбор по профилю задач, процессы и машины против одного Redis, разделение тяжёлого и быстрого по разным инстансам Server. Мини-эксперимент: поднимите воркер с Concurrency 5, насыпьте в очередь двадцать задач, увидите active на пятёрке, запустите второй процесс — active станет десять, а pending проестся вдвое быстрее. Формат прежний: локально с Go и Redis в Docker, песочница сайта — тематическая программа на чистом Go без сети. Дальше по курсу: двадцать первый урок соберёт фоновые задачи в полноценное веб-приложение, а двадцать четвёртый покажет грабли продакшена, где масштабирование и мониторинг встречаются с реальностью.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Concurrency — максимум одновременных исполнений задач одним процессом; ноль или минус — число доступных ядер CPU.
- Пул работает канальным семафором: горутина задачи держит слот от забора до конца хендлера.
- CPU-bound: около числа ядер; IO-bound: выше — ограничение снаружи, в БД и внешних API.
- Dequeue атомарен: сколько бы процессов ни слушало очередь, задача уедет ровно в один.
- Пулы слотов суммируются: два процесса с Concurrency 25 дают пятьдесят слотов против одной Redis.
- Разные инстансы Server с разными Queues и Concurrency отделяют тяжёлое от быстрого физически.
- StrictPriority упорядочивает опрос очередей, но не добавляет мощностей.
- Лаги читаются по паре pending и active и по Latency: рост pending при полном active — воркеров не хватает.
Домашнее задание
Выпишите пять типов задач своего сервиса и разметьте каждый как CPU-bound или IO-bound; для IO-bound посчитайте Concurrency по формуле «пропускная способность × среднее время» и сравните с текущим значением. Поднимите два процесса одного воркера против одной Redis, насыпьте в очередь пару десятков задач и по логам с ID убедитесь, что ни одна не исполнилась дважды. Добавьте рендер-процесс с очередью renders и Concurrency 4, откройте в Inspector Servers() — там должны быть обе реплики с их конфигами. Всё локально: Go и Redis в Docker; песочница сайта покажет только семафор на каналах.
Личные заметкиПусто
Вопросы и ответы по уроку
Какое значение Concurrency выбрать на старте?
Начните с дефолта — числа ядер: для смешанной нагрузки это честная база. Дальше по числам: если active постоянно упирается в потолок, а задачи в основном ждут сеть, поднимайте и замеряйте. Если хендлеры жгут CPU, прибавка выше ядер ничего не даст. Пороги диктует ваш профиль задач, а не таблица из документации.
Можно ли менять Concurrency без перезапуска?
Нет: поле читается при создании сервера и живёт в процессе до его конца. Изменение — это новый деплой воркеров, и делать его надо через плавную остановку из девятнадцатого урока: дожим текущих задач, затем старт новых процессов. На лету масштабируется число процессов, а не их конфигурация.
Несколько воркеров на одной очереди не исполнят задачу дважды?
Забор задачи атомарен: сообщение переносится из pending в active одним Lua-скриптом, и второму воркеру оно не достанется. Повторное исполнение возможно по другой причине: воркер умер посреди задачи, lease истёк, и работа вернулась в очередь — поэтому хендлеры остаются идемпотентными при любом масштабе.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Kafka в Go: продюсеры и консьюмеры на kafka-go
Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.
- Понимать топики, партиции, оффсеты и группы консьюмеров
- Писать продюсеры с ключами, батчами и ретраями
- Читать сообщения группой с коммитами оффсетов
- Выбирать гарантии: at-most-once, at-least-once