Планировщик Linux старается держать поток на привычном ядре и без всяких подсказок, однако под нагрузкой он постоянно пересматривает решения и перебрасывает потоки с ядра на ядро. Каждый переезд обходится дорого: содержимое кэшей первого и второго уровня остается на старом месте, таблицы страниц вытесняются из буфера TLB, и поток сначала заново прогревает кэш, а уже потом работает в полную силу. Для кодировщика видео, сервера баз данных или торгового приложения такие потери складываются в проценты, которые можно забрать обратно почти бесплатно. Маска родства процессора, в английской терминологии CPU affinity, закрепляет поток за выбранными логическими ядрами и запрещает планировщику уводить его куда-то еще. Ниже разбираются утилита taskset, системный вызов sched_setaffinity, параметры ядра isolcpus и nohz_full, механизм cpuset в cgroup, настройки systemd и numactl, а также ловушка с гиперпоточностью, из-за которой неверная привязка замедляет систему вместо ускорения.

Маска родства потока и стоимость его переездов между ядрами

Маска родства это набор разрешенных ядер, записанный битами, причем атрибут принадлежит каждому потоку по отдельности, а не программе в целом. Младший бит соответствует первому логическому процессору: значение 0x1 разрешает только ядро 0, значение 0x3 открывает ядра 0 и 1, а 0x32 читается как двоичное 110010 и означает ядра 1, 4 и 5. Планировщик и без принуждения возвращал бы потоки на привычные ядра, но при конкуренции он решает в пользу общей пропускной способности. Принудительная маска делает правило жестким: поток физически не займет чужое ядро, даже когда оно свободно, зато его данные почти всегда греются в одном и том же кэше. Права простые: свои процессы пользователь переносит свободно, чужие требуют возможности CAP_SYS_NICE, читать же маску разрешено у любого процесса. Если среди разрешенных ядер не оказывается ни одного существующего, например запрошенный номер отдан другой группе cgroup, вызов вернет ошибку EINVAL, и привязка не применится. Текущий набор ядер виден в файле /proc/self/status в строке Cpus_allowed_list, а быстрая проверка из шелла выглядит так:

taskset -p $$
grep Cpus_allowed_list /proc/self/status
pid 8123's current affinity mask: ffffffff
Cpus_allowed_list:  0-31

Маска ffffffff означает 32 разрешенных логических процессора. Если поток в момент смены маски сидит на запрещенном ядре, ядро само немедленно перетащит его на разрешенное, перезагрузка не нужна. Обратный вызов sched_getaffinity возвращает действующий набор, и именно на нем построен весь вывод taskset.

Чтение топологии процессора в lscpu и отличие физических ядер от логических

Управлять привязкой вслепую нельзя, сначала нужна карта машины. Утилита lscpu печатает число логических процессоров, физических ядер и сокетов, а каталог /sys/devices/system/cpu выдает точные пары гиперпоточностей:

lscpu | egrep 'CPU\(s\)|Thread|Core|Socket|NUMA'
cat /sys/devices/system/cpu/cpu0/topology/thread_siblings_list
CPU(s):              32
Thread(s) per core:  2
Core(s) per socket:  16
Socket(s):           1
NUMA node(s):        1
0,1

Гиперпоточность добавляет ядру второй комплект регистров, но исполнительные порты, кэши L1 и L2 у пары общие, поэтому два горячих потока на соседних логических процессорах делят одно физическое ядро и тормозят друг друга. Из вывода видно, что пары здесь соседние: у ядра 0 напарник 1, у ядра 2 напарник 3, и горячие задачи лучше разводить по четным номерам 0, 2, 4, 6, оставляя нечетные для легкой работы. На серверах с NUMA добавляется второй слой: команда numactl -H покажет узлы с памятью и расстояния между ними, где локальный узел обычно имеет дистанцию 10, а соседний 20, и чтение чужой памяти идет примерно в полтора раза медленнее. Число ядер с учетом текущей маски печатает nproc, а lscpu -e=CPU,CORE,NODE выкладывает построчное соответствие номеров, удобное для расчета пар. Пары напарников зависят от платформы: где-то они соседние, 0 и 1, где-то разнесены на половину списка, 0 и 16, поэтому перед планировкой стоит заглянуть в thread_siblings_list каждого ядра, а не полагаться на привычку.

Утилита taskset для запуска команд и переноса работающих процессов

Самый быстрый инструмент для разовой работы входит в пакет util-linux и есть в любом дистрибутиве. Форма со списком ядер через --cpu-list читается человеком, форма с шестнадцатеричной маской удобна в скриптах:

taskset --cpu-list 2,3,6 ./encoder --preset slow
taskset -pc 0-15 4213
taskset -p 4213
pid 4213's current affinity list: 0-31
pid 4213's new affinity list: 0-15

Первая команда стартует кодировщик сразу на трех выбранных ядрах, вторая переносит работающий процесс с номером 4213 на первую половину машины, третья показывает текущую маску. Шестнадцатеричную запись несложно считать в уме: биты с 8 по 11 дают F00, поэтому команда taskset -p F00 4213 означает ядра 8, 9, 10 и 11. Список допускает шаг: запись 0-10:2 разворачивается в ядра 0, 2, 4, 6, 8 и 10, что удобно для отбора первых нитей всех физических ядер. Ключ -a переносит все потоки многопоточного процесса сразу. Не каждый процесс согласен переехать: у потоков ядра с жесткой привязкой к процессору, вроде ksoftirqd/0, маску изменить не получится, и taskset честно сообщит об ошибке Invalid argument, а служба kswapd примет новую маску, но миграция может задержаться до удобного момента. Такие отказы не поломка, а защита: часть потоков ядра обязана жить на своем процессоре.

Числовой эксперимент, показывающий цену неправильной привязки

Теорию легко проверить двумя процессами, каждый из которых делает сто миллионов системных вызовов getppid, на машине с двумя физическими ядрами и гиперпоточностью:

time -p ./loop 0 0 100000000
time -p ./loop 0 1 100000000
time -p ./loop 0 3 100000000
real 14.75
real 11.52
real 7.89

Первый запуск посадил оба процесса на одно ядро 0, и они делили его по очереди: системное время доходило до 11.73 секунды из 14.75, система захлебывалась в переключениях контекста. Второй развел процессы по паре гиперпоточностей одного ядра, и стало легче, но все еще медленно: исполнительные порты и кэши оставались общими. Третий разнес конкурентов по разным физическим ядрам 0 и 3, время упало до 7.89 секунды, почти вдвое против худшего варианта. Вывод для практики: горячие потоки размещают на разных физических ядрах, а пара гиперпоточностей одного ядра пригодна для одного горячего потока и одной фоновой задачи, но не для двух равноценных. Конкретные секунды зависят от процессора, однако порядок выигрыша воспроизводится почти везде. Для долгоживущих сервисов тот же эффект виден в perf stat: после закрепления счетчик миграций падает почти до нуля, а промахи кэша сокращаются на десятки процентов.

Системный вызов sched_setaffinity и привязка потоков из кода на C

Когда привязка нужна внутри самой программы, используют интерфейс glibc над системным вызовом sched_setaffinity, появившимся в ядре 2.5.8. Аргумент 0 означает вызывающий поток, идентификатор из gettid позволяет управлять отдельным потоком группы, а для pthread-потоков есть обертка pthread_setaffinity_np:

#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <pthread.h>

void *worker(void *arg) {
    cpu_set_t set;
    CPU_ZERO(&set);
    CPU_SET(2, &set);
    CPU_SET(6, &set);
    if (pthread_setaffinity_np(pthread_self(), sizeof(set), &set) != 0) {
        perror("pthread_setaffinity_np");
        return NULL;
    }
    printf("поток закреплен за ядром %d\n", sched_getcpu());
    return NULL;
}
gcc -O2 -o worker worker.c -lpthread
./worker
поток закреплен за ядром 2

Полезные мелочи, о которых стоит помнить заранее. Маска наследуется: дочерний процесс после fork получает родительский набор ядер, и он же переживает execve, поэтому привязку можно выставить один раз в обертке запуска. Привязку удобно задавать и на создании потока через pthread_attr_setaffinity_np, тогда поток рождается сразу на нужном ядре и не тратит время на стартовую миграцию. Структура cpu_set_t рассчитана на 1024 процессора, и для машин крупнее понадобится CPU_ALLOC с динамическим размером. Ошибки диагностируются по значению errno: EINVAL означает пустой или запрещенный набор, EPERM говорит о попытке изменить чужой процесс без CAP_SYS_NICE, ESRCH о несуществующем идентификаторе потока. Маску стоит воспринимать как обещание, а не гарантию: если закрепить четыре горячих потока на двух ядрах, конкуренция вернется уже внутри разрешенного набора.

Резервирование ядер параметрами ядра isolcpus и nohz_full

Перенос задач задним числом полумера: пока привязку не выставили, планировщик успевает заселить нужные ядра фоновыми процессами. Параметры ядра решают задачу радикально и прямо на старте системы. Строка в файле /etc/default/grub:

GRUB_CMDLINE_LINUX_DEFAULT="quiet isolcpus=1-3 nohz_full=1-3 rcu_nocbs=1-3"

После update-grub и перезагрузки ядра 1-3 исчезают из общего планирования: обычные процессы туда не попадут никогда, поселить задачу на них сможет только sched_setaffinity или механизм cpuset. Параметр nohz_full отключает на этих ядрах периодические прерывания таймера, пока там работает единственный поток, а rcu_nocbs переносит обработку RCU-обратных вызовов на остальные ядра. Проверка после загрузки занимает одну строку:

cat /sys/devices/system/cpu/isolated
1-3

Для веб-сервера или прокси, живущего сетью, стоит отлучить от зарезервированных ядер и обработчики прерываний, иначе сетевые события продолжат вторгаться на изолированные номера. Служба irqbalance делает это через запрет в файле /etc/default/irqbalance, а ручной способ пишет маску прямо в /proc/irq/24/smp_affinity, где значение F0 означает ядра с 4 по 7:

echo 'IRQBALANCE_BANNED_CPULIST=1-3' | sudo tee -a /etc/default/irqbalance
sudo systemctl restart irqbalance

Идеальной пустоты не получится: потоки ядра с привязкой к процессору, тот же ksoftirqd/1, остаются на своих номерах, но без внешних событий они почти не просыпаются. Зарезервированные ядра не должны составлять большинство машины: планировщику и службам нужен запас, типично под общую работу оставляют от четверти до половины ядер, а изолированные отдают одной прикладной задаче.

Постоянные правила закрепления через cpuset в cgroup, юниты systemd и numactl

Разовые опыты удобно переводить на постоянные рельсы тремя способами. Механизм cpuset в cgroup версии 2 ограничивает целую группу процессов:

cd /sys/fs/cgroup
echo '+cpuset' > cgroup.subtree_control
mkdir hotpool
echo '2-3' > hotpool/cpuset.cpus
echo '0' > hotpool/cpuset.mems
echo 4213 > hotpool/cgroup.procs

Файл cpuset.cpus задает разрешенные ядра, cpuset.mems привязывает группу к узлу памяти NUMA, а запись номера процесса в cgroup.procs переносит его вместе со всеми потоками. В первой версии cgroup тот же результат дают файлы cpuset.cpus и tasks в каталоге /sys/fs/cgroup/cpuset. Для служб systemd предлагает две записи. Параметр AllowedCPUs в юните опирается на cpuset и действует на все процессы службы:

[Service]
AllowedCPUs=2-3
systemctl daemon-reload
systemctl restart myapp.service

Строка CPUAffinity= в файле /etc/systemd/system.conf задает маску по умолчанию для самого менеджера и всех его потомков и применяется после daemon-reexec. Текущее значение показывает systemctl show -p AllowedCPUs myapp.service. Для контейнеров аналогичную роль играет ключ --cpuset-cpus у docker run. На NUMA-машине удобен numactl, который заодно решает вопрос памяти:

numactl --physcpubind=2,3 --membind=0 ./app --config app.conf
numactl --show

Команда --show печатает политику, узел памяти и итоговую маску процессоров, которую удобно сверять с планом размещения. Порядок внедрения на боевом сервере укладывается в пять шагов:
1. снять картину нагрузки по ядрам командой mpstat -P ALL 1 и найти горячие потоки через pidstat -t -p 4213 1;
2. выбрать свободные физические ядра, избегая соседних гиперпоточностей, и при необходимости зарезервировать их параметрами isolcpus и nohz_full;
3. закрепить процессы через taskset, юнит systemd или группу cpuset в cgroup;
4. на NUMA-системах добавить numactl с привязкой памяти к своему узлу;
5. сверить маски командой taskset -pc и сравнить время отклика до и после, доверяя числам, а не ощущениям.

Привязка к ядрам не заменяет тюнинг самого приложения, зато дает предсказуемость: латентность перестает плясать от настроения планировщика, кэши прогреваются один раз, а соседние службы больше не отбирают такты друг у друга.