---
title: "Конкурентность и масштабирование"
description: "Asynq concurrency: сколько задач берёт воркер-процесс, дефолт по числу CPU, подбор под CPU-bound и IO-bound, масштабирование воркеров и разделение очередей."
course: "Asynq в Go: очередь фоновых задач на Redis"
url: "https://golangmaster.ru/courses/asynq/lessons/20-masshtabirovanie/"
published: "2026-09-19"
updated: "2026-09-23"
minutes: 20
---

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

*Курс «Asynq в Go: очередь фоновых задач на Redis», урок 20 из 25. ~20 минут. Обновлено 23.09.2026. Источник: https://golangmaster.ru/courses/asynq/lessons/20-masshtabirovanie/*

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

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

- Единицу: задачи будут обрабатываться строго по одной
- ✅ Число ядер CPU, доступных процессу
- Двадцать пять — встроенная константа библиотеки
- Сервер вернёт ошибку конфигурации

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

### Как подобрать Concurrency: CPU-bound и IO-bound задачи

Подбор начинается с вопроса: что делает хендлер между стартом и возвратом — считает или ждёт. CPU-bound задачи пережимают картинки, строят отчёты, шифруют: горутина почти всё время жжёт ядро, и Concurrency заметно выше числа ядер даёт только переключения контекста без прироста — держитесь ядер или чуть выше. IO-bound задачи ходят в HTTP, базы, почтовые сервисы: горутина большую часть жизни ждёт ответ, и процессор простаивает; здесь потолок ставит форму «пропускная способность, умноженная на среднее время задачи»: при десяти задачах в секунду и двух секундах на задачу нужно около двадцати слотов. Для IO-bound Concurrency в разы выше числа ядер — норма, и дефолт по CPU зря простаивает. Вторая ловушка — настоящий лимит почти всегда снаружи: пул соединений Postgres, лимит внешнего API, память машины. Concurrency 100 при пуле БД 20 означает очередь из восьмидесяти горутин внутри вашего процесса — она живёт в памяти, невидима для asynqmon и внезапно превращается в таймауты. И последнее: Concurrency читается при старте сервера — изменить его можно только новым деплоем воркеров, а гасить старые процессы надо по-честному, через дожим задач из [девятнадцатого урока](/courses/asynq/lessons/19-graceful-shutdown/). Подбирают число измерением: нагрузочный стенд, Latency и Processed — ваши показания, интуиция молчит.

### Правила подбора пула

- Считайте профиль задач: сколько из них ждут сеть, а сколько жгут CPU.
- CPU-bound: стартуйте от числа ядер; запас выше ядер даёт только переключения контекста.
- IO-bound: Concurrency по формуле «пропускная способность × среднее время задачи».
- Настоящий потолок часто снаружи: пул соединений БД, лимиты внешних API, память.
- Подбирайте измерением: Latency и Processed — показания, а не вкусовщина.
- Concurrency задаётся при старте процесса: изменение — это новый деплой воркеров.

> **Живое место: Concurrency 100 против пула БД 20.** Команда подняла Concurrency до сотни «чтобы очередь наконец проедалась», и воркеры действительно стали резвее — до первой волны нагрузки. Хендлеры пошли в Postgres, а пул соединений на двадцать подключений выстроил горутины в очередь ожидания: таймауты соединений порождали ретраи, ретраи умножали поток задач, и система легла сама на себя без единой ошибки в конфиге asynq. Пул Concurrency растёт вместе с тем, что стоит за хендлером, — базой, кэшем, внешним API; иначе слоты лишь выкупают место в чужой очереди ожидания.

### Масштабирование воркеров: процессы и машины против одной Redis

Когда один процесс уже набит, следующий слот добавляется новым процессом — и здесь устройство asynq играет на вас. Запустите тот же бинарник второй раз: оба процесса слушают одни очереди, и задачу при заборе уносит ровно один из них. Атомарность гарантирует Redis: Dequeue выполняется Lua-скриптом, который атомарно переносит сообщение из pending в active конкретной очереди, поэтому даже сотня одновременных заборов не выдаст задачу дважды. Пулы при этом независимы: у каждого процесса свой семафор, и два воркера с Concurrency 25 дают пятьдесят слотов суммарно. Redis остаётся координатором, но не узким местом: атомарный забор — одна короткая операция на задачу, и брокер выдерживает тысячи таких в секунду; что происходит внутри самого Redis — тема [отдельного курса по go-redis](/courses/go-redis/). На нескольких машинах схема не меняется: воркер — дешёвый stateless-процесс с доступом к Redis, раскладывайте его по инстансам как обычный сервис. Побочный бонус — устойчивость деплоев: пока новая реплика стартует, старые продолжают забор и очередь не замирает; один процесс на время дожима замораживает мощность, а их несколько — нет. Каждая реплика сердцебиением пишет в Redis информацию о себе: хост, PID, Concurrency и карту очередей — эти данные отдаёт Inspector методом Servers(), а вкладка серверов asynqmon рисует из того же источника, так что «кто вообще обрабатывает очередь и с какой мощностью» — вопрос одного взгляда. Смерть процесса задачу не теряет: lease истекает, и работа вернётся в очередь — но повторное исполнение никто не отменял, и идемпотентность из восемнадцатого урока остаётся обязательной при любом масштабе.

### Варианты масштабирования

| Вариант | Что даёт | Когда выбирать |
| --- | --- | --- |
| Поднять Concurrency | больше слотов в одном процессе | задачи IO-bound, лимиты снаружи далеко |
| Больше процессов на машине | суммарный пул, изоляция рестартов | процессу тесно в CPU или памяти |
| Воркеры на других машинах | свой CPU и RAM, отказоустойчивость | CPU-bound задачи и растущая нагрузка |
| Разные инстансы Server | физически отдельные пулы | тяжёлое мешает быстрым задачам |

### Два процесса, одна очередь

```go
// один и тот же бинарник запущен дважды на двух машинах;
// конфиг у обоих процессов один:
//   Concurrency: 25, Queues: map[string]int{"default": 1}
//
// в очереди default сейчас 60 задач
```

**Что выведет программа?**

- 25: два процесса разделят один пул на двоих
- ✅ 50: у каждого процесса свой пул на 25 слотов
- 60: очередь обслужится целиком мгновенно
- 0: два воркера на одной очереди запрещены

Семафор пула живёт внутри каждого процесса: два запуска — два независимых семафора по 25. Забор атомарен, поэтому задача уходит ровно в один из процессов: дублей не будет, а суммарная мощность равна 50. Шестидесятая задача останется в Redis до освобождения очередного слота.

### Разделение очередей и отдельные инстансы Server

Очереди из [седьмого урока](/courses/asynq/lessons/07-ocheredi-prioritety/) делят общий пул слотов пропорционально весам — и это их честная граница: рендер на десять минут и письмо на миллисекунду всё равно конкурируют за одни и те же слоты одного процесса. Взвешенный режим делает делёж справедливым, 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 задач. Что делает процессор почтового процесса?

- Заберёт рендеры с самым низким приоритетом
- Вернёт ошибку и перезапустит воркер
- ✅ Опросит свои очереди, не найдёт задач и заснёт до следующей проверки
- Перенесёт задачи из renders в default

Процессор опрашивает только очереди из своей карты Queues: на пустых своих очередях Dequeue отвечает ErrNoProcessableTask, и процессор засыпает на интервал проверки с джиттером. Renders для него не существует — как и для любого воркера, у которого эта очередь не настроена.

### Лаги и asynqmon: что смотреть, когда очередь растёт

Тикет «задачи выполняются с задержкой» диагностируется по трём числам, и все три видны в [asynqmon](/courses/asynq/lessons/16-asynqmon-ui/). Первое — пара pending и active: рост pending при плоском active означает, что задачи копятся, но не забираются — процессов мало или их пулы заняты. Когда active держится на уровне суммарного Concurrency всех живых воркеров, потолок достигнут: слоты не простаивают, и обрабатываемая мощность ограничена ровно тем, что настроено. Второе — Latency, возраст самой старой задачи в ожидании: большой pending бывает и у здоровой системы, а вот растущий Latency — прямой сигнал, что конкретная задача ждёт слишком долго; порог задаёт бизнес. Третье — список живых процессов: вкладка серверов asynqmon или Servers() из Inspector показывает каждую реплику с её Concurrency и очередями; исчезнувший из списка процесс — первая причина внезапных лагов после ночи. Сравните профиль pending с вчерашним пиком: если график повторяет прошлые дни, а Latency всё равно растёт, дело не во всплеске постановок — мощности объективно перестало хватать. Если pending плоский, а задержки есть, смотрите scheduled и retry: задачи, возможно, вообще не доходят до очереди ожидания. Диагноз диктует лекарство: пул занят — добавляйте процесс или поднимайте Concurrency; тяжёлое душит быстрое — разделяйте инстансы; процессы живы, а очередь стоит — проверяйте, ту ли очередь слушает воркер. Наблюдаемость в метриках и алертах — отдельная тема двадцать третьего урока.

> **Про песочницу: пул на канале без Redis.** Пул Concurrency — это канальный семафор, и он прекрасно живёт без asynq: запустите в песочнице программу, где горутины берут задачи из общего канала, держа токен на время работы, — если увеличите ёмкость канала, увидите, как меняется темп обработки. А вот второй воркер против того же Redis в песочнице не заведётся: сети и брокера там нет — для этого нужен локальный Go и Redis в Docker.

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

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

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

Подсказка: Первое поле — ёмкость пула процесса, второй флаг включает строгий порядок опроса очередей.

Правильные ответы в пропусках: `Concurrency`, `Strict`

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

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

Правильный порядок строк:

1. `redisOpt := asynq.RedisClientOpt{Addr: "localhost:6379"}`
2. `srv := asynq.NewServer(redisOpt, asynq.Config{`
3. `    Concurrency: 4,`
4. `    Queues: map[string]int{"renders": 1},`
5. `})`
6. `mux := asynq.NewServeMux()`
7. `mux.HandleFunc(tasks.TypeRenderReport, tasks.HandleRenderReport)`
8. `srv.Run(mux)`

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

- **Config.Concurrency** — Ёмкость пула одного процесса: максимум одновременных исполнений задач. Ноль или минус — число доступных процессу ядер CPU.
- **Семафор пула** — Внутренняя механика: канальный семафор ёмкостью Concurrency; горутина задачи держит токен от забора до конца хендлера.
- **Подбор для IO-bound** — Concurrency примерно равен пропускной способности, умноженной на среднее время задачи. Потолок ставят зависимые сервисы: пул БД, лимиты API.
- **Второй процесс воркера** — Тот же бинарник против одного Redis: Dequeue атомарен, задача уходит ровно в один процесс, пулы слотов суммируются.
- **Разные инстансы Server** — Разные Queues и Concurrency на процесс: тяжёлые рендеры физически отделены от быстрых писем. Клиенту без изменений — Queue на постановке.
- **Диагностика лагов** — Рост pending при полном active — воркеры не успевают; Latency — возраст старейшей задачи; Servers() покажет живые реплики с их мощностью.

Конкурентность и масштаб разобраны до винтиков: пул слотов на семафоре, дефолт по ядрам, подбор по профилю задач, процессы и машины против одного Redis, разделение тяжёлого и быстрого по разным инстансам Server. Мини-эксперимент: поднимите воркер с Concurrency 5, насыпьте в очередь двадцать задач, увидите active на пятёрке, запустите второй процесс — active станет десять, а pending проестся вдвое быстрее. Формат прежний: локально с Go и Redis в Docker, песочница сайта — тематическая программа на чистом Go без сети. Дальше по курсу: двадцать первый урок соберёт фоновые задачи в полноценное веб-приложение, а двадцать четвёртый покажет грабли продакшена, где масштабирование и мониторинг встречаются с реальностью.

## Коротко: что запомнить

- 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 истёк, и работа вернулась в очередь — поэтому хендлеры остаются идемпотентными при любом масштабе.
