Каждый раз, когда операционная система решает, на каком ядре выполнять очередной поток, она делает выбор между "достаточно хорошим" и "наилучшим возможным". В большинстве случаев выбирает первое. Не потому что планировщик плохо написан, а потому что у него нет достаточно информации о том, что именно делает конкретное приложение и какие данные ему нужны прямо сейчас. Именно здесь на сцену выходят CPU affinity и NUMA topology.
Эти два механизма существуют не как академическая тонкость, а как рабочий инструмент, который при правильном применении даёт измеримый прирост производительности. Речь порой не о процентах, а о кратных улучшениях задержки и пропускной способности, и понять, почему это так, можно только разобравшись с тем, как именно устроена связь между ядрами и памятью внутри современного сервера.
Что такое CPU affinity и почему планировщик не всегда прав
CPU affinity, или "привязка процессора", позволяет явно указать операционной системе, на каком ядре или наборе ядер должен выполняться конкретный процесс. По умолчанию ядро Linux перемещает потоки между ядрами с учётом загрузки, тепловыделения и ряда других параметров. Это разумно для общей многозадачной среды, но разрушительно для задержкочувствительных приложений.
Каждое такое перемещение влечёт за собой сброс горячего кэша L1 и L2, физически привязанных к конкретному ядру. Данные, которые поток читал и записывал, нужно заново загружать из общего L3 или, что хуже, из оперативной памяти. Планировщик видит равномерную нагрузку. Приложение видит непредсказуемые всплески задержки.
Явная привязка устраняет эту проблему: поток остаётся на одном ядре, его рабочий набор данных живёт в горячем кэше, и каждый следующий запрос обрабатывается быстрее предыдущего. В системах обработки высокочастотных торговых заявок или в ядрах телекоммуникационных платформ разница между "поток гуляет сам по себе" и "поток зафиксирован на ядре" измеряется десятками микросекунд задержки.
Технически аффинность в Linux реализована через битовую маску, где каждый бит соответствует одному логическому ядру. Если бит установлен, планировщик может использовать это ядро для процесса. Не установлен, значит запрещено.
Инструменты управления привязкой
Управлять аффинностью можно несколькими способами, и каждый из них занимает своё место в зависимости от задачи.
Самый прямолинейный инструмент, taskset, умеет читать и менять маску аффинности на лету, без перезапуска процесса. Привязать уже работающий процесс к конкретным ядрам можно одной командой:
# Привязать процесс к ядрам 4, 5 и 6 по PID
taskset -cp 4,5,6 <PID>
# Запустить новое приложение сразу с нужной привязкой
taskset -c 2-7 ./my_application
# Прочитать текущую маску аффинности процесса
taskset -cp <PID>
Однако у taskset есть принципиальное ограничение: она управляет только тем, на каких ядрах выполняется код, но никак не контролирует, из какого региона памяти выделяются данные. На многосокетных серверах это критичный разрыв.
Здесь задачу берёт на себя numactl. Она понимает топологию системы и позволяет привязать процесс одновременно и к ядрам, и к региону памяти:
# Привязать процесс к ядрам NUMA-узла 0 и к его локальной памяти
numactl --cpunodebind=0 --membind=0 ./my_application
# Запустить с предпочтением локальной памяти, но без жёсткого ограничения
numactl --cpunodebind=1 --preferred=1 ./my_application
# Чередовать выделение страниц между всеми узлами
numactl --interleave=all ./my_application
Для серверных служб, которые должны стартовать с правильной привязкой после каждой перезагрузки, правильное место, это unit-файл systemd:
[Service]
CPUAffinity=0 2 4 6
ExecStart=/usr/bin/my_service
После этого служба каждый раз запускается именно на указанных ядрах, без ручного вмешательства.
Архитектура NUMA и цена удалённой памяти
Чтобы понять, почему привязка к ядрам без привязки к памяти решает лишь половину задачи, нужно разобраться с тем, как устроена память в современных многопроцессорных системах. Архитектура NUMA возникла как ответ на физическое ограничение: одна шина памяти не способна эффективно обслуживать десятки ядер одновременно.
В типичном двухсокетном сервере каждый процессорный сокет подключён к своему контроллеру памяти и своим планкам DRAM. Когда ядро на сокете 0 обращается к памяти, физически расположенной на сокете 1, запрос проходит через межсокетный коммутационный канал. Intel называет его Ultra Path Interconnect, AMD использует Infinity Fabric. Канал обеспечивает связность, но неизбежно добавляет задержку.
Цифры говорят сами за себя. Измерения с помощью Intel Memory Latency Checker на типичных двухсокетных Xeon-платформах показывают задержку локального доступа к памяти около 85 нс. Задержка доступа к памяти соседнего сокета составляет 128–142 нс, то есть полуторакратный прирост. При многопоточной нагрузке с конкуренцией за межсокетный канал задержки вырастают до 1200 циклов против 300 циклов при локальном доступе. Это уже четырёхкратный разрыв.
Если не контролировать размещение памяти явно, Linux по умолчанию придерживается политики "first-touch": память выделяется на том узле, где живёт поток, впервые обратившийся к этой странице. Если процесс был запущен с одного узла, а планировщик позже переместил его потоки на другой, получается разрыв: данные на узле 0, вычисления на узле 1, каждый запрос к памяти идёт через UPI. Приложение замедляется, а диагностировать это без специальных инструментов нелегко.
Как читать топологию системы перед настройкой
Прежде чем что-либо привязывать, разумно понять, с какой именно топологией приходится работать. Настройка "вслепую" здесь почти всегда приводит к тому, что инженер создаёт новые проблемы вместо того, чтобы решать старые.
Команда numactl --hardware выдаёт полную картину узлов, перечень ядер на каждом из них, объём локальной памяти и матрицу расстояний между узлами:
numactl --hardware
# Пример вывода:
# available: 2 nodes (0-1)
# node 0 cpus: 0 1 2 3 4 5 6 7 16 17 18 19 20 21 22 23
# node 0 size: 64276 MB
# node 1 cpus: 8 9 10 11 12 13 14 15 24 25 26 27 28 29 30 31
# node 1 size: 64505 MB
# node distances:
# node 0 1
# 0: 10 21
# 1: 21 10
Расстояние нормировано: локальный доступ обозначен как 10, удалённый как 21. Чем выше число, тем дольше путь запроса к памяти.
Для более детальной картины, где видны кэши, Hyper-Threading и подключённые устройства, используется lstopo из пакета hwloc. Именно здесь обнаруживается практически важная деталь: если сетевая карта физически подключена к сокету 0, а процесс обработки её пакетов зафиксирован на ядрах сокета 1, каждый пакет дважды пересекает межсокетный канал. Избежать этого можно только прочитав топологию заранее.
# Установить hwloc и визуализировать топологию
sudo apt install hwloc
lstopo --output-format txt
Изоляция ядер и тонкая настройка политики памяти
Один из распространённых запросов звучит так: выделить несколько ядер исключительно под конкретное приложение и не пускать туда системные процессы. Добиться этого силами одного taskset нельзя, потому что его привязка носит рекомендательный характер для планировщика, но не запрещает другим процессам использовать те же ядра.
Параметр ядра Linux isolcpus, передаваемый через загрузчик, исключает указанные ядра из пула балансировки планировщика полностью:
# В файле /etc/default/grub добавить в GRUB_CMDLINE_LINUX:
GRUB_CMDLINE_LINUX="isolcpus=4-11 nohz_full=4-11 rcu_nocbs=4-11"
# Применить изменения
sudo update-grub
После перезагрузки ни один обычный процесс не получит эти ядра без явного указания через taskset. Это решение, к которому обращаются разработчики телекоммуникационных платформ и систем обработки пакетов. Но у него есть изъян: изолированные ядра выпадают из балансировки полностью, и планировщик туда не заходит.
Более гибкий подход предлагает механизм cpuset. Создаётся "щит" из набора ядер, все чужие процессы из него выдворяются, а приложение запускается внутри щита. При этом балансировка внутри выделенного пула сохраняется, и настройка делается без перезагрузки:
# Создать изолированный пул из ядер 4-7
cset shield --cpu=4-7 --kthread=on
# Запустить приложение внутри щита
cset shield --exec ./my_application
Мониторинг и диагностика NUMA-эффектов
Настроить аффинность и никогда больше не проверять результат означает не настроить ничего. Производительность многосокетных систем требует постоянного наблюдения.
numastat показывает статистику выделения памяти по каждому NUMA-узлу. Когда столбец numa_miss растёт, это прямой сигнал о том, что приложению не хватает памяти на своём узле и оно регулярно обращается через UPI:
# Посмотреть статистику NUMA-промахов по всем процессам
numastat -p <PID>
# Общая картина по узлам
numastat
Для атомарных операций между потоками NUMA добавляет особый сюрприз: задержка сравнительно-обменных инструкций между ядрами разных сокетов может быть вдвое выше, чем на одном сокете. Многопоточные приложения, активно использующие разделяемые атомарные счётчики или мьютексы, получают этот штраф при каждой операции синхронизации. Диагностировать это позволяет perf:
# Мониторинг NUMA-событий в реальном времени
perf stat -e numa:* -p <PID>
Правильно выстроенная аффинность, совмещённая с осознанным управлением памятью, превращает многосокетный сервер из источника нестабильных задержек в предсказуемую и управляемую платформу. Это не разовая настройка, а непрерывный процесс наблюдения и тонкировки, который окупается тем точнее, чем выше требования приложения к стабильности отклика.