Сколько ядер реально использует приложение: ищем потолок масштабирования замером
Вы взяли сервер на 32 vCPU специально под нагрузку, но htop во время пиковой обработки показывает: четыре ядра в красной зоне, остальные двадцать восемь простаивают. Приложение формально «видит» все 32 — nproc их честно покажет, — но реально использует малую часть, и добавление ещё ядер ничего не ускорит. Ниже — как измерить, сколько ядер утилизируется на самом деле, откуда берётся этот потолок и что с ним делать.
Содержание
- Формальный лимит и реальная утилизация — два разных числа
- Закон Амдала: почему доля последовательного кода определяет потолок
- Как измерить реальную загрузку по ядрам
- Профилировщики: где именно в коде находится узкое место
- Типичные причины недоиспользования ядер
- Как поднять реальную утилизацию: практические шаги
Формальный лимит и реальная утилизация — два разных числа
nproc, cat /proc/cpuinfo, панель тарифов хостинга — все они отвечают на вопрос «сколько ядер выделено», а не «сколько ядер реально работает». Это разные измерения, и путаница между ними стоит денег: апгрейд с 8 до 32 vCPU не ускорит однопоточный обработчик ни на процент, потому что 24 новых ядра ему физически нечем занять.
«Использовать ядро» означает, что на нём в данный момент реально исполняются инструкции процесса — не просто выделено время планировщиком, а ядро не простаивает в idle и не ждёт ввода-вывода. Приложение может держать 32 потока, но если 31 из них всё время спит в ожидании блокировки или ответа от базы, а активно считает только один — по факту работает одно ядро, вне зависимости от того, сколько их доступно. Разница между «доступно» и «занято» — не вина железа: код физически не способен занять то, что ему нечем параллелить.
Быстрая проверка перед тем, как разбираться глубже: смотрите на распределение по ядрам, а не на агрегированный процент CPU. Агрегированное «CPU: 15%» на 32-ядерной машине может означать как ровные 15% на каждом ядре, так и одно ядро на 100% и 31 ядро на 0% — цифры одинаковые, картина принципиально разная, и только разбивка по ядрам её различает.
Закон Амдала: почему доля последовательного кода определяет потолок
Даже идеально написанное многопоточное приложение почти никогда не параллелится на 100% — всегда остаётся кусок кода, который обязан выполняться последовательно: инициализация, сборка результата из потоков, запись в общий лог, блокировка на общем ресурсе. Закон Амдала формализует, как этот последовательный остаток режет теоретический предел ускорения:
S(N) = 1 / ((1 - P) + P / N)
где P — доля кода, которую можно распараллелить (от 0 до 1), N — число ядер, S(N) — во сколько раз ускоряется выполнение относительно одного ядра. Ключевой и контринтуитивный вывод закона: при N, стремящемся к бесконечности, ускорение стремится не к бесконечности, а к пределу 1 / (1 - P) — и упирается в него довольно быстро.
Таблица показывает, насколько жёстко даже небольшая последовательная доля режет пользу от дополнительных ядер:
| Доля параллельного кода (P) | Ускорение на 8 ядрах | Ускорение на 32 ядрах | Ускорение на 128 ядрах | Теоретический предел |
|---|---|---|---|---|
| 50% | 1.6x | 1.9x | 2.0x | 2x |
| 75% | 2.9x | 3.7x | 3.9x | 4x |
| 90% | 4.7x | 7.8x | 9.2x | 10x |
| 95% | 5.9x | 11.6x | 16.8x | 20x |
| 99% | 7.5x | 24.4x | 56.7x | 100x |
При 90% параллельного кода переход с 32 на 128 ядер даёт прирост всего с 7.8x до 9.2x — почти четырёхкратное увеличение числа ядер отрабатывает менее чем в полтора раза по скорости. Это не оценка конкретного вашего приложения (реальная доля P меряется отдельно, цифры в таблице — иллюстрация формулы), но принцип держится железно: чем больше в коде последовательных участков — блокировок, единой точки записи, синхронного шага перед стартом параллельной части, — тем быстрее дополнительные ядра перестают окупаться. Тот же закон, только применительно к тысячам ядер видеокарты, разобран в статье о том, почему тысячи ядер GPU не дают тысячекратного ускорения — механика идентична, различается только масштаб N.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКак измерить реальную загрузку по ядрам
Прежде чем что-то чинить, нужна честная картина: какие именно ядра заняты, чем именно и насколько равномерно. Три инструмента покрывают почти все случаи.
mpstat -P ALL — построчная загрузка каждого ядра в реальном времени, из пакета sysstat:
mpstat -P ALL 1
Вывод обновляется раз в секунду и показывает по каждому CPU отдельно %usr (пользовательский код), %sys (ядро ОС), %iowait (ожидание диска/сети), %idle (простой). Если под нагрузкой вы видите одно-два ядра с %usr под 90-100 и остальные с %idle за 95 — это прямое подтверждение однопоточного или почти однопоточного узкого места, и дальше нужно искать не «мало ядер», а «почему код не использует остальные».
top в потоковом режиме — стандартный top, но с раскрытием потоков процесса, чтобы увидеть, какие именно нити внутри приложения нагружают процессор:
top -H -p $(pgrep -f myapp | head -1)
Флаг -H разворачивает процесс на отдельные строки по каждому потоку — так видно, один поток ест 100% CPU, а остальные простаивают, или нагрузка размазана по десяткам потоков равномерно. В интерактивном top без флагов клавиша 1 переключает сводную строку CPU в разбивку по ядрам — быстрый способ увидеть перекос без отдельной команды.
pidstat -t — то же самое, что top -H, но в виде лога, удобного для сохранения и последующего анализа за период, а не только текущего снимка:
pidstat -t -p $(pgrep -f myapp) 1 30
Собирает данные попоточно, с указанием, на каком ядре (CPU) выполнялся каждый снимок каждого потока — так видно не только «один поток загружен», но и «поток мигрирует между ядрами каждую секунду», что само по себе стоит процессорного времени на переключение контекста и промахи кэша.
Отдельно проверьте /proc/<pid>/status, поле Cpus_allowed_list — иногда приложению физически доступна только часть ядер из-за taskset, cgroup-лимитов в контейнере или cpuset в Kubernetes, и тогда «недоиспользование» ядер — не баг кода, а настройка окружения. Если общая загрузка по всем ядрам стабильно невысока, но сервис всё равно тормозит — прежде чем профилировать код, свериться с общей очередью процессов через load average и что это число значит на самом деле: возможно, дело не в CPU вовсе, а в ожидании диска или сети, которое mpstat покажет как %iowait, а не как занятость ядра.
Профилировщики: где именно в коде находится узкое место
Метрики по ядрам отвечают «сколько», но не «почему». Следующий шаг — увидеть, какая конкретно функция или блокировка держит единственный загруженный поток, пока остальные простаивают.
Для нативного кода и в целом на уровне ОС — perf:
sudo perf record -F 99 -p $(pgrep -f myapp) -g -- sleep 30
perf report
Флейм-график по результатам сразу покажет, если 80% времени одного горячего потока уходит в одну функцию — сериализацию, регулярное выражение, синхронный вызов внешнего API — а не размазано по коду ровно.
Для Python — py-spy, который дополнительно умеет показывать состояние всех потоков и держателя GIL одновременно:
py-spy dump --pid $(pgrep -f myapp)
Вывод покажет по каждому потоку его текущий стек вызовов и статус — что особенно полезно именно для вопроса «почему заняты не все ядра», потому что часто ответ виден прямо в дампе: несколько потоков висят в состоянии ожидания одной и той же блокировки.
Для Java — async-profiler в wall-clock режиме (не CPU-time), который в отличие от обычного CPU-профилирования показывает и потоки, которые не грузят процессор, а просто блокированы:
./profiler.sh -d 30 -e wall -f wall.html $(pgrep -f java)
Для Node.js — встроенный --prof или clinic bubbleprof, которые визуализируют цепочки асинхронных вызовов и показывают, где event loop одного процесса простаивает в ожидании, вместо того чтобы отдавать работу.
Общий принцип не завязан на язык: нужен снимок стеков *всех* потоков за интервал, а не только суммарная загрузка CPU — иначе видно симптом («ядра простаивают»), но не причину. Как безопасно профилировать рабочий прод без остановки сервиса — отдельно разобрано в материале про профилирование приложения на проде.
Типичные причины недоиспользования ядер
За перекосом «мало ядер загружено при доступных многих» почти всегда стоит одна из нескольких повторяющихся причин.
| Причина | Как проявляется | Что смотреть |
|---|---|---|
| GIL в CPython | Много Python-потоков, но CPU-bound код исполняется по факту на одном ядре за раз | py-spy dump, держатель GIL в стеке |
| Однопоточный event loop (Node.js, один процесс) | Один процесс держит одно ядро под 100%, остальные простаивают, даже если код асинхронный | top -H, число процессов node в ps aux |
| Неверно настроенный worker/thread pool | Пул на 4 воркера при 32 доступных ядрах — явное наследие дефолта или старого конфига | Конфиг сервера приложений (workers, threads, max_connections) |
| Блокировки на общем ресурсе (mutex, глобальный лок) | Несколько потоков активны, но по очереди, суммарная загрузка ниже ожидаемой | perf lock record, дамп стеков в момент пика |
| Пул соединений к БД меньше пула воркеров | Воркеры простаивают в ожидании свободного соединения, а не считают | Метрики пула (pool_size, checked_out, время ожидания) |
| I/O-wait маскируется под «CPU-bound» | Задача выглядит вычислительной, но реально ждёт диск или сеть | %iowait в mpstat, отдельно от %usr |
| Оверсабскрипшн потоков | Потоков сильно больше, чем ядер — рост нагрузки на переключение контекста без прироста throughput | vmstat 1, столбец cs (context switches) растёт непропорционально полезной работе |
GIL (Global Interpreter Lock) в стандартном CPython — самая частая причина именно для Python-сервисов: он гарантирует, что байткод интерпретатора исполняет только один поток одновременно, поэтому многопоточность в Python реально ускоряет только I/O-bound код (пока один поток ждёт сеть, GIL освобождается для другого), но не CPU-bound вычисления внутри чистого Python — для них нужны отдельные процессы (multiprocessing), а не потоки. Похожая по симптому, но другая по механике история уже разбиралась на конкретном примере в материале о том, почему Ollama не использует все ядра CPU — там причина не GIL, а параметр числа потоков вычислительной библиотеки, выставленный по умолчанию куда ниже фактического числа ядер сервера.
Node.js в классической конфигурации — один процесс, один поток JavaScript (воркер-треды и libuv-пул для части I/O — отдельная история). Один процесс Node.js физически не может занять больше одного ядра под сам JS-код, сколько бы ядер ни было доступно на сервере — это архитектурное решение платформы, а не баг конкретного приложения.
Оверсабскрипшн — обратная по знаку проблема: если потоков в разы больше числа ядер (скажем, 200 потоков на 8 ядрах при CPU-bound нагрузке), система тратит заметную долю времени на переключение контекста между потоками, что на графике mpstat выглядит как высокая загрузка %sys, а не %usr, — ядра формально заняты, но не продуктивной работой.
Как поднять реальную утилизацию: практические шаги
После того как замер и профиль указали на конкретную причину, дальше — прицельное исправление, а не наращивание ресурсов сервера.
Python: процессы вместо потоков для CPU-bound задач.
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool:
results = list(pool.map(cpu_heavy_function, chunks))
ProcessPoolExecutor обходит GIL, потому что каждый процесс — отдельный интерпретатор со своей памятью; для I/O-bound кода вместо этого чаще выгоднее asyncio или обычные потоки — GIL там не мешает, потому что во время ожидания сети он и так освобождён. Для WSGI/ASGI-серверов (gunicorn, uvicorn) число воркеров обычно стартуют считать от (2 × число_ядер) + 1 — это отправная точка для подбора, а не универсальная формула.
Node.js: cluster-режим вместо одного процесса.
const cluster = require('cluster');
const os = require('os');
if (cluster.isPrimary) {
for (let i = 0; i < os.cpus().length; i++) cluster.fork();
} else {
require('./server.js');
}
Модуль cluster поднимает по процессу на ядро, каждый со своим event loop, а балансировку входящих соединений берёт на себя сам Node.js — единственный штатный способ занять больше одного ядра для CPU-задач в чистом Node.js без переписывания логики под worker_threads.
Число воркеров/потоков должно соответствовать реально доступным ядрам, особенно в контейнерах — приложение внутри Docker может видеть через nproc число ядер хоста, но реально быть ограничено cpuset/--cpus на уровень ниже, и тогда конфиг «воркеров по числу ядер», взятый из nproc внутри контейнера, окажется завышенным. Сверяйте с cat /sys/fs/cgroup/cpu.max (cgroup v2) или лимитом, заданным при запуске контейнера.
Пул соединений к БД не меньше пула воркеров — иначе воркеры простаивают в очереди за соединением, а не считают, и рост ядер не помогает вообще, потому что узкое место не в CPU.
И трезвая граница применимости всего перечисленного: если профиль и закон Амдала показывают, что доля последовательного кода велика (единая точка записи в лог, общий лок на критической секции), рефакторинг под многопоточность может дать заметно меньше, чем кажется на бумаге — иногда правильнее не наращивать ядра на одном сервере, а горизонтально масштабироваться: несколько независимых инстансов приложения за балансировщиком вместо одного гиганта, где большая часть ядер простаивает по той же причине.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Стоит ли брать сервер с максимальным числом ядер, если профиль показывает низкую долю параллельного кода?
Не всегда — по закону Амдала прирост за пределами теоретического потолка 1 / (1 - P) становится незначительным. Часто выгоднее сервер с меньшим числом более быстрых ядер (выше частота на ядро) для последовательной части кода, чем максимизировать число ядер, которые всё равно не займутся.
Как отличить GIL от блокировки ввода-вывода как причину?
По mpstat: если ядро активно (%usr высокий) на одном потоке, а остальные простаивают из-за GIL — CPU-время реально тратится, просто на одном ядре последовательно. Если дело в I/O, суммарная загрузка CPU низкая по всем ядрам — процесс не грузит процессор, он ждёт ответа извне.
Может ли контейнер искусственно ограничивать видимые ядра, хотя хост их использует?
Да: nproc внутри контейнера без явного cgroup-лимита обычно показывает все ядра хоста, но реальный лимит CPU-времени может быть ниже — проверяйте cpu.max в cgroup v2 или флаг --cpus при запуске.
Несколько worker-процессов (gunicorn, PM2 cluster) полностью решают проблему недоиспользования ядер?
Решают проблему одного процесса на одном ядре — каждый воркер способен занять отдельное ядро. Но если внутри процесса остаются свои блокировки или узкое место в общем ресурсе (одна БД, один Redis без пула), утилизация упрётся в новый потолок — просто на уровень выше.
Стоит ли ставить число потоков/воркеров сильно больше числа ядер «про запас»?
Для CPU-bound нагрузки — нет: избыточные потоки не ускоряют вычисления, а добавляют накладные расходы на переключение контекста (столбец cs в vmstat). Для I/O-bound нагрузки, где потоки в основном ждут, разумный избыток может быть оправдан — но это стоит проверять замером, а не выставлять на глаз.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →