Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 20 из 25 ~20 мин +50 XP

Конкурентность и масштабирование

Сервис живёт спокойно, пока очередь короткая, а в час пик письма едут минутами: мощности одного воркера перестало хватать. Ответ лежит в двух вопросах: сколько задач процесс берёт одновременно — это опция Concurrency — и как добавить воркеров, не сломав один общий Redis. В уроке — механика пула, подбор под CPU-bound и IO-bound задачи, масштабирование воркеров процессами и машинами, разделение очередей по типу нагрузки и диагностика лагов в asynqmon.

Содержание урока · 10 разделов

Дальше по порядку. Сначала устройство пула: семафор, горутина на задачу, дефолт по ядрам — то, что происходит внутри одного процесса при каждом заборе задачи. Затем выбор числа: почему для рендера и для письма оно разное и где стоит настоящий потолок. Потом горизонталь: второй и третий процесс, другие машины — и почему Redis не станет узким местом, а задачи не начнут исполняться дважды. Финал — эксплуатация: разделение очередей на отдельные инстансы Server с разной Concurrency и чтение лагов по счётчикам asynqmon. Базу по очередям и весам берём из седьмого урока — здесь только масштаб. Названия дальше важнее формул: слот, забор, реплика — за каждым словом стоит конкретное место в коде asynq, и путать их дорого: половина разбора лагов начинается с того, что двое в тикете говорят о разном «воркере».

Asynq concurrency: пул слотов одного процесса#

Опция Concurrency в asynq.Config отвечает на вопрос, сколько задач один процесс берёт на исполнение одновременно. Это не число горутин в программе и не размер буфера, а ёмкость пула хендлеров. Внутри сервер работает просто: процессор в цикле берёт задачу из Redis, запускает для неё горутину с вашим хендлером, а прежде каждого забора кладёт токен в канальный семафор ёмкостью ровно в Concurrency; горутина держит токен до самого конца исполнения и возвращает его, когда хендлер закончил работу. Новый слот не откроется, пока не освободится один из занятых, — так максимальное число одновременных обработок держится точно на заданном значении. Дефолт продуман: если поле оставить нулём или отрицательным, NewServer подставит число ядер CPU, доступных текущему процессу. Очереди при этом опрашиваются не блокирующей операцией, а обычным забором: когда все очереди пусты, процессор засыпает на пол-интервала проверки плюс случайный джиттер, чтобы не долбить Redis холостыми запросами; интервал настраивается полем TaskCheckInterval и по умолчанию равен секунде. Из этого следует практичный вывод: Concurrency — потолок, а не обещание. Процесс с Concurrency 30 при пустых очередях не держит тридцать горутин — они рождаются под задачи; зато при их изобилии активных хендлеров будет ровно тридцать, а остальные задачи честно подождут в Redis.

Конфиг с ёмкостью пула

GO
srv := 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 задаётся при старте процесса: изменение — это новый деплой воркеров.

Масштабирование воркеров: процессы и машины против одной 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; тяжёлое душит быстрое — разделяйте инстансы; процессы живы, а очередь стоит — проверяйте, ту ли очередь слушает воркер. Наблюдаемость в метриках и алертах — отдельная тема двадцать третьего урока.

Соберите конфиг масштаба

Допишите ёмкость пула и флаг строгого приоритета.

srv := asynq.NewServer(redisConnOpt, asynq.Config{ : 30, // тридцать задач одновременно Queues: map[string]int{"critical": 6, "default": 3}, Priority: true, })

Соберите запуск рендер-воркера

Расставьте строки запуска рендер-процесса: коннект, конфиг с узким пулом, маршруты, старт.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: конкурентность и масштаб

Термин

Config.Concurrency

нажмите, чтобы перевернуть
Определение

Ёмкость пула одного процесса: максимум одновременных исполнений задач. Ноль или минус — число доступных процессу ядер CPU.

1 / 6

Конкурентность и масштаб разобраны до винтиков: пул слотов на семафоре, дефолт по ядрам, подбор по профилю задач, процессы и машины против одного 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; песочница сайта покажет только семафор на каналах.

Личные заметкиПусто
Хранятся только в вашем браузере (Asynq · Конкурентность и масштабирование)

Вопросы и ответы по уроку

Какое значение Concurrency выбрать на старте?

Начните с дефолта — числа ядер: для смешанной нагрузки это честная база. Дальше по числам: если active постоянно упирается в потолок, а задачи в основном ждут сеть, поднимайте и замеряйте. Если хендлеры жгут CPU, прибавка выше ядер ничего не даст. Пороги диктует ваш профиль задач, а не таблица из документации.

Можно ли менять Concurrency без перезапуска?

Нет: поле читается при создании сервера и живёт в процессе до его конца. Изменение — это новый деплой воркеров, и делать его надо через плавную остановку из девятнадцатого урока: дожим текущих задач, затем старт новых процессов. На лету масштабируется число процессов, а не их конфигурация.

Несколько воркеров на одной очереди не исполнят задачу дважды?

Забор задачи атомарен: сообщение переносится из pending в active одним Lua-скриптом, и второму воркеру оно не достанется. Повторное исполнение возможно по другой причине: воркер умер посреди задачи, lease истёк, и работа вернулась в очередь — поэтому хендлеры остаются идемпотентными при любом масштабе.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Kafka25 уроков~12 чпрактика25 уроков уже доступны

Kafka в Go: продюсеры и консьюмеры на kafka-go

Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.

  • Понимать топики, партиции, оффсеты и группы консьюмеров
  • Писать продюсеры с ключами, батчами и ретраями
  • Читать сообщения группой с коммитами оффсетов
  • Выбирать гарантии: at-most-once, at-least-once