MAATRIX / Блог / Сколько процессов заставят ядро тратить время на само себя: замер переключений

Сколько процессов заставят ядро тратить время на само себя: замер переключений

MAATRIX

На сервере с 8 ядрами крутится сервис с несколькими сотнями потоков «на всякий случай», и он ощутимо медленнее той же версии с полусотней. CPU в top показывает 100%, но ответы приходят медленнее, а колонка us (пользовательское время) не растёт пропорционально нагрузке — вместо неё ползёт вверх sy. Ядро не бездельничает: оно честно работает, просто не над вашими запросами, а над тем, чтобы каждые несколько миллисекунд снять с CPU один поток и посадить туда другой. Дальше — как эту цену измерить числом, а не ощущением «что-то тормозит», и какие изменения архитектуры её снижают.

Что происходит при переключении контекста

Переключение контекста (context switch) — это момент, когда планировщик Linux забирает CPU у одной задачи и отдаёт другой. Внешне это строчка в описании, но внутри — несколько отдельных операций, каждая со своей ценой.

Сначала ядро сохраняет состояние текущей задачи — регистры процессора, указатель инструкций, указатель стека — в структуру task_struct/thread_info. Планировщик (CFS, Completely Fair Scheduler, в новых версиях частично EEVDF) выбирает следующую задачу, и её сохранённое с прошлого раза состояние загружается обратно в регистры.

Если новая задача принадлежит другому процессу, а не другому потоку того же процесса, добавляется ещё один шаг — переключение адресного пространства: ядро перезагружает регистр CR3 (на x86-64) со ссылкой на таблицы страниц нового процесса. Часть записей буфера трансляции адресов (TLB) при этом становится невалидной, и последующие обращения к памяти вынуждены заново идти по таблицам страниц, пока TLB не прогреется. Современные CPU смягчают это тегами PCID/ASID, позволяющими не сбрасывать TLB целиком, но и это не бесплатно.

Есть и третья, менее заметная, но часто более дорогая составляющая — холодный кэш. Пока задача исполнялась, её рабочие данные осели в L1/L2/L3-кэше. На её месте запускается другая задача со своими данными — старые постепенно вытесняются. Когда снятая задача вернётся на CPU, часть обращений к памяти, раньше попадавших в кэш за единицы тактов, снова пойдёт в оперативную память — на порядки медленнее. Само переключение занимает единицы микросекунд, но просадка из-за холодного кэша размазывается по следующим миллисекундам исполнения и не видна отдельной строчкой ни в одном счётчике — она просто делает код медленнее, чем он «должен» быть.

Переключения делятся на два типа, и это различие пригодится дальше при чтении метрик:

  • Добровольное (voluntary). Задача сама уступает CPU — заблокировалась на чтении с диска, ждёт ответа сети, встала на мьютекс, вызвала sleep(). Нормальная часть жизни приложения, которое взаимодействует с внешним миром.
  • Вынужденное (involuntary). Планировщик снял задачу с CPU принудительно — истёк квант времени, а другая задача ждёт своей очереди. Рост доли именно вынужденных переключений — признак того, что задач на CPU претендует больше, чем система может обслужить без очереди.

Как замерить: vmstat, pidstat, perf stat

Первый и самый быстрый способ увидеть масштаб — системная метрика cs в vmstat:

$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 4  0        0 812340  98432 3210044    0    0     2     8  920 14300 62  9 28  1  0
 9  0        0 811980  98432 3210120    0    0     0     4  1310 41800 58 24  9  8  1

cs — число переключений контекста в секунду по всей системе, in — число аппаратных прерываний в секунду. Само по себе cs мало о чём говорит — на нагруженном многоядерном сервере тысячи переключений в секунду могут быть нормой. Показательна не абсолютная цифра, а динамика относительно sy и относительно роста полезной нагрузки: если при добавлении воркеров cs резко подскочил, а sy вырос заметнее, чем us, — часть добавленных ресурсов уходит не на работу приложения, а на планировщик. Колонка r — число задач, готовых к исполнению прямо сейчас (runnable); если она стабильно больше числа логических ядер, это ранний сигнал очереди за CPU.

Для конкретного процесса системная сводка бесполезна — нужен pidstat из пакета sysstat:

$ pidstat -w 1 -p <PID>
Linux 6.8.0 (server)   09/08/2026  _x86_64_  (8 CPU)

Time   UID   PID    cswch/s nvcswch/s  Command
14:20:01 1000  4821    850.00     120.00  worker
14:20:02 1000  4821    910.00     340.00  worker

cswch/s — добровольные переключения в секунду, nvcswch/s — вынужденные. Растущий nvcswch/s при неизменной или падающей полезной работе — прямой сигнал, что процесс всё чаще снимают с CPU не по его воле, а из-за очереди на это же ядро. Без -p pidstat -w 1 покажет топ процессов по переключениям по всей системе.

Точные счётчики за интервал даёт perf stat:

$ perf stat -e context-switches,cpu-migrations -p <PID> sleep 5

 Performance counter stats for process id '4821':

         4,612      context-switches
           187      cpu-migrations

       5.001234512 seconds time elapsed

cpu-migrations — сколько раз задачу передвигали не просто на паузу и обратно, а на другое физическое ядро, теряя прогретый кэш. Частые миграции при малой нагрузке — повод посмотреть на привязку процессов к CPU (taskset, numactl, cpuset в cgroups), особенно на NUMA-серверах. Без отдельной утилиты те же кумулятивные счётчики (с момента старта процесса) видны в grep ctxt_switches /proc/<PID>/status — снимите значение дважды с интервалом и вычтите, чтобы получить переключения именно за этот отрезок.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

/proc/interrupts: смежная, но другая метрика

/proc/interrupts — не про переключения контекста напрямую, а про аппаратные и программные прерывания, но природа у них общая: оба отбирают у CPU время от полезной работы, и обе метрики стоит смотреть вместе при диагностике «куда делся процессор».

$ cat /proc/interrupts
           CPU0       CPU1       CPU2       CPU3
  24:      98234      87621      91045      88900   IR-PCI-MSI 0-edge   eth0-TxRx-0
 LOC:    4021233    4019887    4022104    4020650   Local timer interrupts
 RES:     182034     179440     183210     180992   Rescheduling interrupts
 CAL:       3421       3390       3455       3402   Function call interrupts
 TLB:      21034      20887      21205      20904   TLB shootdowns

Пронумерованные строки вверху — обычные аппаратные прерывания от устройств (сетевая карта, диск). Псевдо-прерывания внизу файла на SMP-системах интереснее в контексте context switching:

  • LOC — прерывания локального таймера на каждом ядре, управляющие тиком планировщика (частота — параметр сборки ядра CONFIG_HZ, обычно 100–1000 Гц): именно они дают планировщику шанс решить, не пора ли снять текущую задачу с CPU.
  • RES — межпроцессорные прерывания (IPI), которыми одно ядро будит другое для проснувшейся задачи, назначенной планировщиком туда. Рост счётчика — свидетельство активного перераспределения задач между ядрами.
  • TLB — TLB shootdown, принудительная инвалидация записей TLB на других ядрах при изменении отображения памяти. Растёт вместе с интенсивностью работы памяти большого числа процессов и так же ворует CPU-время.

Если LOC и RES растут пропорционально росту cs в vmstat, а пропускная способность приложения не растёт — это независимое подтверждение, что узкое место именно в накладных расходах планировщика. Отдельная ситуация — когда основную нагрузку создают не переключения между процессами приложения, а сами аппаратные прерывания от сетевой карты или диска; как это диагностировать, разобрано в статье что такое прерывание и почему сетевая карта отбирает у вас целое ядро.

Порог: сколько процессов на ядро — это ещё нормально

Однозначного числа «после N процессов на ядро начинаются проблемы» не существует — оно зависит от того, чем заняты процессы, от размера их рабочего набора в памяти и от версии планировщика. Но общая логика воспроизводима на любом железе.

Пока число одновременно готовых к исполнению задач (runnable — это r в vmstat, а не общее число процессов в ps aux) не превышает число логических ядер, планировщику почти не с кем толкаться: у каждого ядра почти всегда есть готовая задача, и переключения идут в основном по добровольным причинам. Как только runnable-задач становится заметно больше числа ядер, планировщик делит время каждого ядра между несколькими претендентами — чем их больше, тем короче каждый кусок и тем чаще переключение: CFS распределяет условное «целевое время отклика» (sched_latency_ns, на EEVDF — sched_base_slice_ns) между всеми runnable-задачами на ядре, и при росте их числа частота переключений растёт почти линейно.

Практический ориентир для проверки на своём сервере:

  • Смотрите не на общее число процессов/потоков (ps -eLf | wc -l), а на runnable-нагрузку — колонку r в vmstat под реальным трафиком и через mpstat -P ALL 1 загрузку по каждому логическому ядру.
  • Если r стабильно держится в пределах числа логических ядер (nproc), запас есть. Если r в разы больше nproc, а %sy заметно выше, чем на более скромной конфигурации при том же трафике, — вы уже платите за переключения больше, чем нужно.
  • Дальше — не гадание, а измерение: увеличивайте число воркеров ступенчато, снимайте cs/nvcswch/s/пропускную способность на каждой ступени. Резкий рост переключений без соразмерного роста throughput — сигнал, что дальше двигаться некуда. Подробная методика перебора и построения графика разобрана в статье сколько потоков имеет смысл: точка перегиба.

Само решение планировщика, кому и когда отдать процессор, — не чёрный ящик: приоритеты через nice/cgroups и логика «справедливости» CFS подробно разобраны в статье как ядро решает, кому отдать процессор следующим — это ровно тот механизм, что стоит за каждым переключением выше.

Процессы против потоков: почему переключение стоит по-разному

Для планировщика ядра процесс и поток — разные экземпляры schedulable-сущности (task_struct), и с точки зрения решения «кого запустить следующим» они равноправны: у обоих есть приоритет, оба стоят в очереди на CPU и одинаково подвержены вытеснению. Разница не в том, планирует ли их ядро одинаково, а в том, что именно приходится переключать вместе с ними.

Что переключаетсяМежду потоками одного процессаМежду разными процессами
Регистры CPU, указатель стекаДаДа
Таблицы страниц / адресное пространство (mm_struct)Не переключается — общееПереключается — перезагрузка CR3
TLBВ основном сохраняетсяЧастично или полностью инвалидируется
Кэш процессора L1/L2Вытесняется данными другого потокаВытесняется данными другого процесса
Открытые файловые дескрипторы, сигналыОбщие на процессОтдельные

Потоки одного процесса создаются с общим адресным пространством (в Linux это технически тоже clone(), но с флагом CLONE_VM, разделяющим mm_struct), поэтому переключение между ними не требует перезагрузки таблиц страниц и меньше бьёт по TLB. Переключение между независимыми процессами всегда тяжелее именно на этот шаг — отсюда правило «потоки дешевле процессов при прочих равных».

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

Отдельно стоит цена создания — она отличается от цены переключения, но часто путается с ней. fork() создаёт новый процесс с собственным (изначально общим по copy-on-write) адресным пространством — заметно дороже, чем pthread_create()/clone(CLONE_VM), создающий поток внутри уже существующего адресного пространства. Если процессы или потоки создаются часто (модель «процесс на запрос» вместо пула воркеров), к цене переключений добавляется ещё и цена самого создания задачи.

Как снизить нагрузку на планировщик

Первый и самый прямой рычаг — снизить число OS-level сущностей, конкурирующих за CPU, до уровня, который реально нужен нагрузке, а не выставлен «с запасом». Направления, обычно в комбинации:

  • Пересчитать число воркеров по факту. Если runnable-задач стабильно в разы больше числа ядер без роста полезной работы — воркеров больше, чем система может обслуживать; лишние раньше упираются в конкуренцию за CPU. Методика подбора — в статье про точку перегиба, ссылка выше.
  • Перейти на событийную модель для I/O-bound работы. Вместо потока или процесса на каждое соединение — цикл событий (epoll, io_uring), обслуживающий тысячи соединений на паре системных потоков — так устроены nginx и event loop Node.js. Ту же идею реализуют горутины Go и async/await в Python (asyncio)/Rust (tokio) — многозадачность внутри процесса, не требующая от ядра переключаться на каждое ожидание.
  • Разделить пулы под CPU-bound и I/O-bound работу. Для вычислений — число потоков близко к числу ядер. Для ожидания I/O обработчиков может быть кратно больше — они почти всё время спят.
  • Привязать процессы к ядрам, где оправдано. taskset, numactl --cpubind, cpuset в cgroups снижают cpu-migrations — особенно заметно на NUMA-серверах.
  • Группировать мелкую работу. Пул воркеров с очередью задач обходится ядру дешевле, чем создание и уничтожение сущности на каждый мелкий таск.
  • Учитывать аппаратные прерывания отдельно. Если CPU-время уходит на IRQ, а не на переключения приложения, irqbalance/smp_affinity — отдельный рычаг.

Эффект от этих мер измеряется теми же метриками, что и в начале статьи, — до изменений и после, на одинаковой нагрузке.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Context switching — это всегда плохо и его нужно свести к нулю?

Нет, это нормальная часть многозадачности. Проблема не в факте переключений, а в их избыточной частоте относительно объёма полезной работы.

Высокий %sy в top всегда означает проблему с переключениями?

Не обязательно — системное время включает и системные вызовы, и обработку прерываний, и работу файловой системы. Растущий %sy — повод проверить cs/nvcswch/s, а не готовый диагноз; подробнее, куда уходит время CPU, — в статье user, system, iowait: куда на самом деле уходит время процессора.

Можно назвать число переключений в секунду, которое считается «много»?

Универсального порога нет — на слабом сервере заметна и тысяча в секунду, на мощном многоядерном десятки тысяч будут нормой. Значение имеет рост относительно роста полезной нагрузки и доля nvcswch/s в общем числе.

SMT (гипертрейдинг) увеличивает число ядер для расчёта порога?

Логические потоки SMT дают ОС дополнительные единицы планирования, но делят исполнительные блоки физического ядра — для CPU-bound нагрузки точка, после которой переключения начинают доминировать, может наступить раньше числа nproc.

CPU-квоты cgroups меняют картину с переключениями?

Да — cgroup с квотой (cpu.cfs_quota_us/cpu.max) может снять процесс с ядра ещё до истечения тайм-слайса, если квота на период исчерпана, добавляя переключения сверх вызванных конкуренцией с другими задачами.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →