Сколько потоков имеет смысл: ищем точку, после которой воркеры только вредят
«Приложение тормозит — накиньте воркеров» — вроде бы логичный первый шаг, и часто он действительно помогает. Но график пропускной способности от числа потоков не растёт вечно по прямой: у него есть пик, а после него — спад. Причём спад настоящий: та же нагрузка на том же железе с бо́льшим числом воркеров начинает отвечать медленнее, чем с меньшим. Разберём, откуда берётся этот перегиб, почему он не всегда совпадает с числом ядер CPU, и как найти свою точку нагрузочным тестированием, а не гаданием по формулам из интернета.
Содержание
Почему график не растёт по прямой
Интуиция подсказывает: один поток обрабатывает N запросов в секунду, значит десять потоков обработают 10×N. Это верно только в одном частном случае — когда потоки не делят между собой ничего, кроме операционной системы, и работа идеально параллелится без взаимного ожидания. В реальности почти всегда есть общий ресурс: физические ядра CPU, память и её пропускная способность, диск, сетевая карта, блокировки в приложении или в базе данных.
Пока потоков меньше, чем свободных ресурсов, добавление нового потока действительно почти линейно добавляет пропускной способности — простаивающее ядро наконец начинает работать. Но как только потоков становится больше, чем система может параллельно обслужить, каждый новый поток не столько добавляет работы, сколько отбирает время у существующих: планировщику ОС приходится чаще переключать контекст, потоки чаще стоят в очереди за одним и тем же ядром или блокировкой, растут накладные расходы на синхронизацию. Кривая пропускной способности после какого-то числа потоков перестаёт расти, выходит на плато, а затем — это и есть точка перегиба в узком смысле — начинает падать: система тратит на административные накладные расходы больше, чем выигрывает от параллелизма.
Важно сразу разделить два похожих, но разных эффекта. Первый — насыщение (saturation): пропускная способность перестаёт расти, но и не падает, просто плато. Второй — деградация (thrashing): пропускная способность реально снижается с ростом числа воркеров, задержки растут быстрее, чем линейно. Второй случай болезненнее в проде, потому что «добавили ресурсов — стало хуже» ломает базовую интуицию и обычно замечается уже после того, как кто-то в панике накрутил воркеров вдвое.
CPU-bound и I/O-bound: разная арифметика потоков
Прежде чем считать оптимальное число воркеров, нужно понять, чем именно занят каждый поток большую часть времени — это меняет весь расчёт.
CPU-bound нагрузка — поток реально считает: парсинг, сжатие, шифрование, рендеринг, сложные агрегации в памяти. Такой поток почти всё время держит ядро занятым, iowait низкий, время в D-состоянии минимально. Для CPU-bound задач верхний предел полезного параллелизма близок к числу логических ядер (nproc) — по сути, число потоков, которые физически могут одновременно исполнять инструкции. Чуть больше потоков, чем ядер, иногда оправдано (запас на планировщик, на фоновые задачи ОС), но кратно больше — почти всегда путь к context switching вместо полезной работы.
I/O-bound нагрузка — поток бо́льшую часть времени не считает, а ждёт: ответ от базы данных, чтение с диска, ответ от внешнего API по сети. Пока поток ждёт, он не занимает CPU — значит, на одном ядре может «параллельно» ждать гораздо больше потоков, чем ядер в системе, и это не приведёт к перегреву планировщика. Классический пример — веб-сервер, который в основном ждёт базу или внешние сервисы: у него разумное число воркеров может быть в разы больше числа ядер, потому что каждый воркер большую часть жизни просто спит в ожидании ответа.
Проблема в том, что почти любое приложение — смесь обоих режимов, причём соотношение может меняться от эндпоинта к эндпоинту и от часа к часу. Обработчик, который на 90% ждёт базу, но иногда сериализует большой JSON, ведёт себя как I/O-bound в среднем, но периодически выжирает CPU не хуже вычислительной задачи. Отсюда правило: формула «воркеров = ядра × 2» или подобная — это стартовая точка для итерации, а не результат расчёта. Она может оказаться и заниженной, и завышенной в зависимости от реального профиля вашей нагрузки.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверЧто именно происходит после точки перегиба
Деградация после оптимума — это не одна причина, а обычно комбинация нескольких, которые усиливают друг друга.
- Context switching. Переключение контекста между потоками не бесплатно: сохранить регистры и состояние одного потока, загрузить состояние другого, обновить таблицы страниц памяти, частично инвалидировать кэши процессора. Пока потоков немного, эти расходы малозаметны. Когда потоков в разы больше числа ядер и все активны одновременно, переключений становится так много, что заметная доля CPU-времени уходит не на задачи приложения, а на административную работу планировщика.
- Contention на блокировках. Многопоточный код почти всегда где-то синхронизируется — мьютекс вокруг общей структуры, блокировка строки в базе, семафор на пул соединений. Пока потоков мало, конкуренция за блокировку редкая. С ростом их числа растёт вероятность, что несколько потоков одновременно хотят одну и ту же блокировку — часть простаивает в ожидании вместо полезной работы, а сама блокировка становится узким местом независимо от того, сколько ещё ядер простаивает рядом.
- Конкуренция за кэши процессора и память. CPU сильно выигрывает, когда данные потока остаются в L1/L2/L3-кэше между обращениями. Когда потоков больше, чем физических ядер, планировщик вынужден чаще переключать разные потоки на одно ядро — вместе с потоком «выселяются» из кэша и его данные. Каждый следующий такт чаще идёт за данными в оперативную память, которая на порядки медленнее кэша, — это cache thrashing, и виден он не напрямую, а только в возросшем времени выполнения той же работы.
- Память и её пропускная способность. У каждого потока свой стек и часто свой буфер под задачу — рост их числа увеличивает суммарное потребление памяти вплоть до давления на аллокатор, обращений к свопу или упора в лимит cgroup контейнера. У многоядерных систем есть и предел совокупной пропускной способности шины памяти: при очень большом числе активных потоков они начинают буквально стоять в очереди за доступом к RAM.
- Внешние ограничения ниже приложения. Даже если само приложение отлично масштабируется, у него есть соседи по пределу: число соединений, которое держит база данных, лимит файловых дескрипторов ОС, размер пула на стороне upstream. Рост воркеров сверх этого предела не увеличивает пропускную способность — он просто переносит очередь на уровень ниже, где она менее заметна и сложнее диагностируется.
Как найти свою точку перегиба нагрузочным тестированием
Формулы дают стартовую точку, но конкретное число для конкретного приложения на конкретном железе находится только измерением. Методика простая по шагам, но требует терпения — перебор в одну итерацию редко даёт правильный ответ.
- Зафиксируйте всё, кроме числа воркеров. Один и тот же сценарий нагрузки, профиль запросов, железо, одинаково холодный или прогретый кэш перед каждым прогоном. Если генератор нагрузки сам упирается в свой CPU раньше сервера — результат врёт, его лучше запускать на отдельной машине.
- Снимайте не только throughput, но и латентность по перцентилям. Средняя задержка обманчива: пропускная способность может расти, а p95/p99 уже деградировать — это ранний сигнал приближения к точке перегиба.
- Перебирайте число воркеров по сетке, а не бинарным поиском по одной метрике. Возьмите несколько значений вокруг предполагаемого оптимума (число ядер, половина от него, полтора, два, четыре раза) и постройте график throughput и p99-латентности от числа воркеров. Форма графика — рост, плато, спад — важнее одной конкретной цифры.
- Мониторьте систему во время теста, а не только результат на выходе. Полезные инструменты:
$ mpstat -P ALL 1 # загрузка каждого логического ядра отдельно
$ vmstat 1 # колонка r — очередь на CPU, cs — context switches/сек
$ pidstat -w 1 # переключения контекста по конкретному процессу
$ perf stat -e context-switches,cpu-migrations -p <PID>
Резкий рост колонки cs в vmstat или context-switches в perf stat при добавлении очередной порции воркеров без соразмерного роста throughput — прямой признак, что вы уже за точкой перегиба, а не до неё.
- Повторите тест минимум дважды на каждой точке. Один аномальный прогон (фоновая задача ОС, соседний процесс, случайный всплеск GC) легко спутать с реальной тенденцией — единичному измерению доверять нельзя.
Конкретные числа с прогона — throughput в запросах в секунду, значения задержки — сильно зависят от железа, версии рантайма, профиля запросов и сети, поэтому ниже сознательно нет придуманных цифр: на вашем сервере оптимум может оказаться и заметно ниже, и заметно выше числа ядер. Полезный побочный эффект теста — видно, во что реально упирается система на пике: если во время роста воркеров load average улетает заметно выше числа ядер при низком %CPU, ищите не процессор, а диск или сеть — подробнее в статье load average: что число значит на самом деле.
Практика: где выставляется число воркеров
Настройка числа потоков/воркеров редко делается в одном месте — обычно это несколько независимых параметров на разных уровнях стека, и они должны быть согласованы между собой, а не выбраны порознь.
| Компонент | Параметр | Стартовая точка для итерации |
|---|---|---|
| Nginx | worker_processes | обычно auto (по числу ядер), редко нужно больше |
| Nginx | worker_connections | тысячи на воркер — это не потоки ОС, а событийный цикл |
| Gunicorn (Python) | --workers | (2 × ядра) + 1 как стартовая формула, дальше — по тесту |
| uWSGI | processes / threads | зависит от режима: prefork под CPU-bound, threads под I/O-bound |
| Node.js | cluster воркеры | обычно по числу ядер — event loop и так однопоточный на воркер |
| PostgreSQL | max_connections | не «побольше», а под реальный пул + запас, лучше через PgBouncer |
| Java-приложения | размер thread pool | отдельно под CPU-bound и I/O-bound пулы, не один общий |
Ключевой момент, который часто упускают: в связке «сервер приложений → база данных» именно база — самый чувствительный участок к перебору воркеров. Каждое новое соединение к PostgreSQL или MySQL — это отдельный процесс или поток на стороне базы со своим потреблением памяти, и рост числа воркеров приложения без пулера соединений (PgBouncer, ProxySQL) может упереть в лимит раньше, чем CPU веб-сервера успеет деградировать. Если делаете сайзинг сервера под нагрузку с нуля, есть смысл сначала прикинуть конфигурацию по методике из статьи как рассчитать конфигурацию сервера под нагрузку, а параллелизм воркеров настраивать уже поверх выбранного железа.
Отдельно стоит NUMA на многосокетных серверах: если приложение запущено с большим числом потоков без привязки (taskset, numactl), часть потоков может обращаться к памяти, физически подключённой к другому сокету, — с заметно бо́льшей задержкой, чем к «своей». На таких серверах точка перегиба может наступить раньше числа логических ядер именно из-за этого эффекта, а не из-за дефицита CPU-времени.
Типичные ошибки: «воркеров побольше, на всякий случай»
Самая частая ошибка звучит невинно: если 4 воркера — хорошо, то 40 — наверняка ещё лучше, а лишние просто немного полежат без дела. На практике простаивающих воркеров без дела не бывает — даже воркер без активных задач требует памяти под стек и структуры, а событийный планировщик ОС всё равно периодически его учитывает. Дальше несколько конкретных проявлений этой ошибки:
- Воркеры считают «про запас на пиковую нагрузку». Логика понятна — вдруг будет всплеск трафика. Но если потоков намного больше, чем система реально может параллельно обслуживать, всплеск не будет обработан быстрее — он просто быстрее упрётся в конкуренцию за CPU и уйдёт в деградацию раньше, чем при более скромном числе воркеров с очередью перед ними. Очередь запросов перед разумным числом воркеров почти всегда предсказуемее толпы воркеров, дерущихся за одно ядро.
- Копируют число из чужого конфига без переноса контекста. Формула «(2 × ядра) + 1» родом из рекомендаций Gunicorn для конкретного профиля нагрузки — она не универсальна для любого приложения на любом железе. Число из чужого стека с другими пропорциями CPU-bound/I/O-bound работы и другой базой данных — источник конфигураций, которые «работали у кого-то в блоге», но не подходят вашей задаче.
- Не разделяют пулы под разные типы работы. Один общий thread pool обслуживает и быстрые I/O-bound запросы, и редкие тяжёлые CPU-bound задачи (генерация отчёта, обработка изображения). Пока тяжёлая задача выполняется, она занимает поток, который мог бы обработать десяток лёгких запросов, — на графике это видно как редкие, но заметные всплески p99-латентности без видимой причины в среднем throughput.
- Меняют число воркеров в проде «на глаз» без повторного теста. Оптимум, найденный на предыдущей версии кода и прежнем профиле трафика, не гарантированно остаётся оптимумом после рефакторинга или роста базы данных — то, что раньше было I/O-bound, могло стать CPU-bound из-за добавленной в код логики, и наоборот.
- Путают деградацию воркеров с нехваткой ресурсов и покупают железо вместо тюнинга. Если пропускная способность падает именно от роста числа воркеров при неизменном железе, более мощный сервер может даже не помочь — проблема в конфигурации параллелизма, а не в дефиците сырой мощности. Показательный случай из практики, когда один зависший воркер утянул за собой всю очередь запросов — разобран в статье один воркер завис и утянул за собой всю очередь: деградация там была не про число воркеров, но механика «один плохой участник ломает всю систему параллелизма» узнаваема.
Понять, как именно планировщик Linux решает, какому потоку сейчас отдать процессорное время и почему это небесплатно, поможет разбор как ядро выбирает, кому отдать процессор — это ровно механика, которая стоит за словом «context switching» выше.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
С чего начать, если приложение уже в проде и непонятно, сколько воркеров сейчас оптимально?
С измерения текущей ситуации: снимите vmstat 1 и mpstat -P ALL 1 под реальной нагрузкой на пике часа. Если cs (context switches) высокий, а прирост throughput при последнем увеличении числа воркеров был почти нулевым — вы, скорее всего, уже рядом с плато или за ним.
Можно ли просто взять число ядер и не мучиться с тестами?
Как стартовая точка для CPU-bound нагрузки — разумно. Для смешанной или I/O-bound нагрузки число ядер почти наверняка будет заниженным, потому что не учитывает время, которое потоки проводят в ожидании I/O. Это отправная точка итерации, а не готовый ответ.
Точка перегиба — это всегда про CPU?
Нет. Часто первым упирается не CPU, а лимит соединений к базе, файловых дескрипторов ОС, пропускная способность диска или сети. Внешне это выглядит так же — рост воркеров перестаёт помогать или начинает вредить, — но лечится снятием ограничения ниже по стеку, а не изменением числа воркеров.
Как отличить насыщение (плато) от настоящей деградации на графике?
По наклону кривой throughput после пика: плато — линия почти горизонтальна при дальнейшем росте воркеров, деградация — линия идёт вниз. Второй случай требует немедленного отката числа воркеров.
Нужно ли учитывать гипертрединг (SMT) при расчёте числа потоков?
Да: логические потоки от Hyper-Threading/SMT делят исполнительные блоки физического ядра, а не добавляют полноценные новые. Для сильно CPU-bound нагрузки точка перегиба может наступить раньше числа, которое показывает nproc.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →