Моей первой большой самостоятельной работой программиста была инвентаризация celery-задач. Нам с товарищем по бэкенду достался легаси-проект товарно-учетного приложения. С горем пополам перевезли его из Hetzner в родное «облако» и поняли, что срочно необходимо всё документировать, очищать и структурировать. Пока коллега упаковывал приложение в контейнеры, я занялся Celery, так как на этой библиотеке было завязано много бизнес-логики.
Пока группировал и отлаживал задачи, определял им очереди, нашел в документации загадочное autoscale. Кажется, что в тот момент этот параметр светился золотом. Вот оно! То, что нужно! Сейчас там как всё наладится и заработает без сучка и задоринки. Ровно, чинно, благородно.
Мне повезло, что так и случилось, очереди стали более спокойными. Причин тому несколько:
-
было достаточно ресурсов сервера;
-
задачи были, в основном, I\O-bound;
-
в процессе работы избавился от некоторых утечек памяти и ограничил время исполнения самых «отмороженных» задач.
Спустя время я решил отрефлексировать и пересмотреть тот опыт и был удивлен тому, как autoscale работает в действительности на разного рода очередях. Если у вас есть сомнения, стоит ли читать статью, то предлагаю решить загадку:
1. Запускаем воркер: `-A celery_app worker --autoscale=4,0 --worker_prefetch_multiplier=1`2. Запускаем скрипт: `for idx_task in range(1, 601): io_task.delay() if idx_task % 4 == 0: time.sleep(1.7)`3. Который генерирует такие задачи: `@celery.task(name='io') def io_task() -> int: result = 0 for i in range(10**7): result += i**2 time.sleep(1) return result`Вопрос: сколько всего процессов будет создано за время обработки 600 задач?Варианты ответа:1. 312. 43. 1504. 0
Если выбрали второй вариант — 4 процесса, то мне есть, чем вас удивить. Правильный ответ будет позже.
О понятиях
Воркер — экземпляр Celery, включающий в себя процесс-супервизор и все дочерние процессы.
Супервизор — ведущий процесс воркера, устанавливающий соединение с брокером и порождающий и управляющий прочими рабочими процессами. Сам задачи не обрабатывает.
Рабочий процесс — дочерний процесс супервизора внутри воркера для обработки задач. Создан или при старте воркера(concurrency), или динамически(autoscale).
Очередь по расписанию — очередь, в которой показатель Total(RabbitMQ) в любой момент времени не превышает количество рабочих процессов.
Нагруженная очередь — очередь, которая может иметь задачи в статусе Ready(RabbitMQ) и Total больше количества рабочих процессов.
Prefork autoscale
Для тех, кто не знаком с Python, Celery — распределенная очередь задач, работающая через брокер сообщений. Использует собственную библиотеку(billiard, форк от стандартной питоновской) для механизма мультипроцессинга. Prefork — это пул по умолчанию и по совместительству самый распространенный режим, к которому применим autoscale. Хорош для CPU-bound да и для прочих, так как не требует сторонних библиотек, в отличие от многопоточных.
Параметр autoscale задаётся при старте воркера --autoscale=max,min и весь жизненный цикл проходит между этих границ. Воркеру каждую секунду необходимо проверять всем ли условиям на настоящий момент он соответствует. Механизм autoscale[1] запускается в двух местах:
-
Это собственный цикл body() с методом maybe_scale() класса Autoscaler, который крутится и смотрит:
-
есть ли что в очереди?
-
можно ли добавить рабочих процессов?
-
-
Это callback при получении сообщения от брокера.
А вот демасштабирование работает по расписанию и лишь когда с последнего scale_up прошло более 30 секунд, значение по умолчанию для AUTOSCALE_KEEPALIVE.
В чем подвох загадки? В AUTOSCALE_KEEPALIVE и минимальном количестве процессов(их вовсе нет). Каждые 1.7 секунд четыре задачи(по штуке на рабочий процесс) попадают в очередь. Воркер их подхватывает, создает дочерние процессы и раздает им задачи. Важно, что AUTOSCALE_KEEPALIVE отсчитывается от последнего scale_up(). Время на обработку задачи примерно от 1.6 до 2.2 секунды, то есть пауза в цикле(1.7) настроена так, чтобы воркер закончил обработку, взял новую партию задач и очередь была +/- пустой. Плюс, казалось бы, пренебрежительные миллисекунды на публикацию сообщений и IPC.
Но по итогу нам это дает следующее: каждый рабочий процесс подходит к порогу AUTOSCALE_KEEPALIVE, выполнив примерно 15 задач(значение взято из логов) и очень может быть, что сейчас ждет новую. Но цикл maybe_scale(), глядя на пустую очередь и простаивающий процесс, считает его лишним и «сворачивает».
Схематично это выглядит так(тайминги условны).

Но следом поступают задачи. И мы вынуждены снова создавать рабочие процессы. И так повторяется практически каждые 30 секунд.
В логе выглядит так
[2026-04-21 09:52:14,481: INFO/MainProcess] Task cpu_intensive[f30f03c9-5c03-4f9a-a28b-6c5624549b7c] received[2026-04-21 09:52:14,482: INFO/MainProcess] Scaling up 1 processes.[2026-04-21 09:52:14,524: INFO/MainProcess] Task memory_intensive[c88643c5-d9b2-4014-a79e-bb1564b481c7] received[2026-04-21 09:52:14,525: INFO/MainProcess] Scaling up 1 processes.[2026-04-21 09:52:14,571: INFO/MainProcess] Task cpu_intensive[47e0d170-ad7c-44fb-a401-40f3a1be46bb] received[2026-04-21 09:52:14,571: INFO/MainProcess] Scaling up 1 processes.[2026-04-21 09:52:14,635: INFO/MainProcess] Task cpu_intensive[abedebc8-05a6-400e-bb98-4062f3633ddf] received[2026-04-21 09:52:14,636: INFO/MainProcess] Scaling up 1 processes....[2026-04-21 09:52:57,543: INFO/MainProcess] Scaling down 1 processes....[2026-04-21 09:52:57,543: INFO/MainProcess] Task cpu_intensive[2ff56d89-ca89-4edf-b557-a9465b8d4f72] received[2026-04-21 09:52:57,543: INFO/MainProcess] Scaling up 1 processes.
Здесь и кроется причина, что ответ не четыре, а 31. Четыре процесса за полный цикл работы в описанных условиях возможны при AUTOSCALE_KEEPALIVE=600.
Правильный ответ — 31.
Отмечу, что разброс может быть до 34 процессов: зависит от «железа» и момента. У меня задача считалась от 1.58 до 1.92 сек
Рассчитать максимум рабочих процессов можно так:
(общая длительность работы / AUTOSCALE_KEEPALIVE) * кол-во динамических процессов + минимальный порог
По условию загадки общая длительность работы равна 255 секунд((600(всего задач) / 4(размер пачки)) * 1.7 секунд сна)
Подставив в формулу значения (255/30) * 4 + 0 получим 34. Если бы половина процессов была статической, то вышло бы (255/30) * 2 + 2 = 19. Почему: а) два любых процесса всегда живы; б) два процесса могут заменяться, но не чаще чем раз в полминуты.
На графике в брокере это может выглядеть так:
-
левая сторона с пиками(пример нагруженной очереди), здесь autoscale будет трудно хулиганить с порождением процессов;
-
для правой же стороны(пример очереди по расписанию) ситуация становится обратной.

Казалось бы! А вона оно как.
На эту тему есть открытый вопрос в репозитории фреймворка[2]. Возможно, прунинг процессов будет отталкиваться не от последнего скейла, а от последнего сообщения. Стоит ли менять значение по умолчанию? Для микрозадач, длительностью до 2 секунд, и не нагруженной очереди, скорее да. Но при прочих условиях вы сделаете concurrency.
Prefork concurrency
В сравнении с autoscale concurrency — «скучная» технология: какой лимит задан при запуске столько рабочих процессов и будет работать всю жизнь воркера(если лимит не задан, то по умолчанию использует кол-во ядер). У него нет специальных циклов для проверки всем ли параметрам он соответствует, его ценят таким, какой он есть. И в том же количестве. Исключением из этого правила могут стать специальные аргументы, используемые при настройке инстанса Celery. Или форс-мажоры типа падения воркера. Впрочем, эти же факторы распространяются и на autoscale
Я не буду подробно останавливаться на всем многообразии параметров, упомяну лишь те, что непосредственно влияют на тему статьи. Это --max-tasks-per-child и --max-memory-per-child. Если с первым из названия можно понять, что это ограничение на общее количество выполненных задач дочерним процессом, то у второго параметра есть любопытная особенность, на мой взгляд, неочевидная: --max-memory-per-child накладывает ограничения по затраченной памяти не в рантайме, а на «жизнь» самого процесса, то есть если вы настроили 100 MB, а задача потребила 120 MB, то после выполнения процесс будет заменён новым[3]. Причем память все равно может утекать, так как исчерпание предела проверяется после выполнения задачи; внутри процесса же — можно доработаться и до OOM Killer. Поэтому эта настройка может выйти боком и процессы будут пересоздаваться чаще, чем требуется.
Runtime
Давайте же посмотрим, как с подобными загадке задачами будет справляться celery в обозначенных режимах. Я подготовил сводные таблицы с фиксированными и динамическими рабочими процессами, отображающую производительность, за которую мы можем побороться этими инструментами. Увидим сколько рабочих процессов будет обслуживать один цикл и как меняются скорость обработки одной задачи от постановки до результата и общая пропускная способность. И есть ли вообще разница.
Что такое производительность?
В настоящем контексте мы можем рассматривать 4 вида:
-
Пропускная способность(throughput), кол-во задач в секунду;
-
Обработка одной задачи(latency), сек;
-
Обработка N задач (общее время), сколько времени займет разбор фиксированной пачки;
-
«Шумный» сосед, отъём ресурсов и, как следствие, снижение работоспособности других процессов.
Упор будет на throughput и latency, так как а) фиксировано время скрипта(для 3 пункта не подойдет); б) нужно отдельно контролировать какой-то важный процесс(для соседа), что увеличивает сложность эксперимента.
Рабочее окружение
Все замеры проведены с версией celery 5.6.3, на ноутбуке с процессором AMD Ryzen 5 5500U with Radeon Graphics × 6 в консоли Linux Mint 22.3 — Cinnamon 64-bit.
С помощью cgroups и cpuset ограничил эксперимент двумя ядрами(первые потоки): 5 и 6. Некая имитация отдельного 2х-ядерного сервера.
Брокер: RabbitMQ. Для некоторых брокеров, к примеру, старых версий Redis механизм получения задач может работать иначе.
Методика
Каждые 1.6(по расписанию — 10) секунд в брокере публикуются задачи(I/O и memory-bound, нормальный runtime которых 1.7 сек) в количестве равным максимальному кол-ву рабочих процессов. Для autoscale нижним порогом является половина от максимума. К примеру, если concurrency=4, то в паре будет autoscale=4,2. Каждый рабочий процесс ограничен worker_prefetch_multiplier=1, то есть может за раз взять одну задачу. Длительность одного замера 300 секунд с перезапуском воркера. Каждый вариант(2 очереди Х 2 режима Х 3 кол-в процессов) по 20 раз.
Все 240 замеров были проведены в случайном порядке.
Нагруженная очередь
|
Режим |
Ср. процессов* |
Длительность (с) |
Задач |
Throughput (з/с) |
Сред. latency (с) |
CV latency |
95% ДИ для среднего |
Медиана (с) |
90-й перц. (с) |
95-й перц. (с) |
|---|---|---|---|---|---|---|---|---|---|---|
|
autoscale(2,1) |
2.90 |
303.94 |
363.8 |
1.20 |
6.33 |
74.0% |
[4.14 – 8.51] |
5.44 |
8.13 |
8.90 |
|
concurrency(2) |
2.00 |
303.93 |
366.3 |
1.21 |
5.22 |
4.5% |
[5.11 – 5.33] |
5.22 |
7.78 |
8.15 |
|
autoscale(4,2) |
7.50 |
303.99 |
716.0 |
2.36 |
8.65 |
65.5% |
[6.00 – 11.29] |
7.57 |
12.52 |
14.56 |
|
concurrency(4) |
4.00 |
303.91 |
726.0 |
2.39 |
6.63 |
14.0% |
[6.20 – 7.06] |
6.61 |
9.91 |
10.85 |
|
autoscale(8,4) |
11.40 |
302.71 |
1281.2 |
4.23 |
22.42 |
4.1% |
[22.00 – 22.85] |
21.97 |
39.15 |
41.52 |
|
concurrency(8) |
8.00 |
302.73 |
1281.2 |
4.23 |
22.55 |
9.7% |
[21.53 – 23.57] |
22.02 |
38.71 |
41.27 |
Очередь по расписанию
|
Режим |
Ср. процессов* |
Длительность (с) |
Задач |
Throughput (з/с) |
Сред. latency (с) |
CV latency |
95% ДИ для среднего |
Медиана (с) |
90-й перц. (с) |
95-й перц. (с) |
|---|---|---|---|---|---|---|---|---|---|---|
|
autoscale(2,1) |
11.00 |
302.65 |
60.0 |
0.20 |
1.67 |
0.2% |
[1.67 – 1.67] |
1.65 |
1.78 |
1.79 |
|
concurrency(2) |
2.00 |
302.65 |
60.0 |
0.20 |
1.65 |
0.2% |
[1.65 – 1.66] |
1.65 |
1.68 |
1.72 |
|
autoscale(4,2) |
22.00 |
302.68 |
120.0 |
0.40 |
1.69 |
0.7% |
[1.69 – 1.70] |
1.68 |
1.76 |
1.88 |
|
concurrency(4) |
4.00 |
302.68 |
120.0 |
0.40 |
1.66 |
0.2% |
[1.66 – 1.66] |
1.65 |
1.69 |
1.74 |
|
autoscale(8,4) |
43.75 |
302.74 |
240.0 |
0.79 |
1.87 |
0.5% |
[1.87 – 1.88] |
1.85 |
2.07 |
2.15 |
|
concurrency(8) |
8.00 |
302.75 |
240.0 |
0.79 |
1.89 |
7.9% |
[1.82 – 1.95] |
1.80 |
2.09 |
2.28 |
* - среднее количество процессов созданных одним воркером за один прогон 300 секунд
Малые выводы о prefork autoscale
Когда я впервые взялся за эту тему, то первым тезисом, который собирался отстаивать, был «Остановите autoscale! Он убивает систему и latency». И таблицы выше на первый взгляд это подтверждают.
-
Autoscale в нагруженной очереди может быть нестабилен. Его ДИ выходит за «приемлемые» пределы.
-
Средний latency на 30%, а медианный на 14% хуже concurrency для 4-процессного воркера.
-
Чем больше динамических процессов в очереди по расписанию, тем заметнее разница медианного latency между autoscale и concurrency в пользу второго. Хоть она и не критична.
А с другой стороны множество форков не сильно влияет на пользовательский опыт, что демонстрирует autoscale в очереди по расписанию или нагруженной очереди с 8 процессами.
Стоит ли отказываться от autoscale? Если у вас есть постоянно нагруженная очередь, то concurrency скорее всего будет лучшим решением. По крайней мере вы можете себя избавить от лишних раздумий и поисков скрытой угрозы при неконтролируемом обновлении процессов. С другой же стороны для задач по расписанию можно настроить «призрачный» воркер. Есть задачи — есть процессы.
Надеюсь, статья станет для вас хорошим подспорьем для проведения инвентаризации собственных celery-задач.
В качестве заключения
Изначально статья называлась Что в лоб — то по лбу и точка. Но при сборе метрик я сменил гнев и поспешные выводы на милость и обоснованные решения. Поэтому заменил точку на вопросительный знак.
Я признаю ограничения, которые мешают делать широкие и далеко идущие выводы. Для ЦПУ-цепких задач, где процессор всегда много занят, форки могут оказать негативное влияние. Для двух потоков на одном ядре, я получал обратные данные, и размах был велик. А когда я не ограничивал ядра и процессы кочевали по всему железу, тогда autoscale проигрывал на 5-10% и в latency, и в пропускной способности. Но истина всегда конкретна: был выбран конкретный пример с конкретными ограничениями и данные изменились.
Я не стал описывать принципы работы Celery с архитектурными особенностями, иначе бы вышло громоздко и отвлекло от темы. Многообразие настроек, распределение по очередям, предвыборка и «голод» процессов — это всё влияет скорость и качество обработки. Если будет интересно — следующей разберу архитектуру фреймворка; также планирую охватить темы Canvas Workflows, масштабирования для gevent/eventlet и docker/K8s. А может быть пройтись по очередям и принципам их создания, ведь порой проблемы не в том, как работает механизм, а с чем и в какой последовательности.
Источники
-
Celery Autoscaling: Scaling Decision Flowchart — https://deepwiki.com/celery/celery/5.6-autoscaling#scaling-decision-flowchart
-
Issue #8943: Autoscale should only scale down if no tasks were accepted during keepalive period — https://github.com/celery/celery/issues/8943
-
Billiard Worker Process Lifecycle — https://deepwiki.com/celery/billiard/2.2-worker-process-lifecycle#the-work-loop
Исходный код эксперимента: https://github.com/okolobackend/Celery-Architecture-and-Scaling
ссылка на оригинал статьи https://habr.com/ru/articles/1030838/