11. CPU Affinity и NUMA

Управление процессами и памятью на уровне ядра

«Современные многосокетные серверы — это не одиночные компьютеры; это небольшие распределенные сети, маскирующиеся под единую материнскую плату».
— Архитектурный закон высоконагруженных систем

В раннюю эпоху Unix мир был плоским и простым: в сервере стоял один процессор, соединенный общей шиной с банком оперативной памяти. Любой байт памяти находился ровно на одинаковом физическом расстоянии от кремниевых регистров процессора.

Сегодня эта уютная иллюзия разрушена. Современный двухсокетный сервер на базе AMD EPYC или Intel Xeon — это не монолитный компьютер, а миниатюрная распределенная система. Ядра разделены на кристаллы (чиплеты) и сокеты, объединенные скоростными шинами межпроцессорного соединения (Intel UPI или AMD Infinity Fabric).

В предыдущей статье 31. NUMA. Non Uniform Memory Access мы подробно изучили физическую топологию серверов. Однако операционная система не может оставаться пассивным наблюдателем: она ежесекундно решает, на каком именно физическом ядре будет исполняться машинный код и из каких физических планок памяти ядро будет аллоцировать страницы RAM.

DIAGRAM
       СОКЕТ 0 (NUMA Node 0)                    СОКЕТ 1 (NUMA Node 1)
 ┌─────────────────────────────┐        ┌─────────────────────────────┐
 │ Ядра CPU 0..31              │        │ Ядра CPU 32..63             │
 │ Кэш L3 (Горячий, ~10-15 нс) │        │ Кэш L3 (Горячий, ~10-15 нс) │
 └──────────────┬──────────────┘        └──────────────┬──────────────┘
                │                                      │
                ▼                                      ▼
 ┌─────────────────────────────┐  Шина UPI/IF   ┌─────────────────────────────┐
 │ Локальная RAM Node 0        │◄══════════════►│ Локальная RAM Node 1        │
 │ Доступ: ~70-100 нс          │   (дольше!)    │ Доступ: ~70-100 нс          │
 └─────────────────────────────┘                └─────────────────────────────┘

Если поток исполняется на Ядре 0 и читает память из планки своего сокета (Node 0), задержка составляет порядка 70–100 нс (обращение к DRAM; цифры ~20–30 нс относятся скорее к кэшу L3). Но если планировщик Linux бездумно перебросит этот поток на Ядро 32 в соседний сокет (Node 1), каждое обращение к этой же памяти превратится в удаленный запрос через межсокетную шину, и задержка заметно вырастет (насколько именно, зависит от платформы: измерять нужно на своём железе)!

Для Go-разработчика понимание механизмов ядра Linux — CPU Affinity и NUMA-политик — это водораздел между кодом, который «просто компилируется», и высоконагруженными сервисами, способными держать миллионы RPS с предсказуемым p99 latency.


CPU Affinity: Жесткая привязка к ядрам

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

Это не мягкая рекомендация, а абсолютный запрет. Если процессу назначена маска ядер [0, 1, 2], планировщик Linux никогда не выделит ему ядро 3 (даже если в режиме по умолчанию SCHED_OTHER ядра 0–2 будут раскалены до 100%, а ядро 3 будет полностью простаивать).

Как это работает под капотом

В исходном коде ядра Linux процессорное сродство хранится внутри структуры task_struct в битовой маске cpus_mask (тип cpumask_t).

Когда приложение или администратор вызывает системный вызов sched_setaffinity(pid, mask, sizeof(mask)), ядро выполняет следующую последовательность:

  1. Валидирует битовую маску, сверяя ее со списком реально подключенных ядер (cpu_online_mask).
  2. Проверяет права доступа: процессу разрешено менять affinity только себе или дочерним задачам (для произвольных процессов требуется привилегия CAP_SYS_NICE).
  3. Активирует флаг SCHED_AFFINITY в дескрипторе задачи.
  4. Если задача в данный момент исполняется на ядре, не входящем в новую маску, ядро запускает системный поток миграции (migration/N), который принудительно переносит задачу на разрешенное ядро и исключает ее из глобальной межъядерной балансировки нагрузки (load_balance).

Утилиты и управление

В Linux-окружении управлять affinity можно как до старта сервиса, так и в рантайме:

  • taskset -c 0-3 ./myapp — запуск приложения с жесткой привязкой к логическим ядрам 0, 1, 2 и 3.
  • ps -T -p <PID> — просмотр всех потоков процесса и их идентификаторов TID.
  • taskset -cp <PID> — просмотр текущей шестнадцатеричной маски affinity процесса.
  • cgroups v2 cpuset — промышленный стандарт контейнерной изоляции в современных дата-центрах (подробно см. в 52. Ограничения ОС. ulimit, cgroups, quotas).

Как планировщик ОС работает с Affinity

Планировщик Linux CFS (Completely Fair Scheduler / EEVDF) стремится к идеальной справедливости, распределяя процессорное время пропорционально весу задачи (nice).

Когда на задачу накладывается маска affinity, планировщик переводит ее в категорию Sticky Task («прилипшая задача»):

  1. При каждом вызове функции диспетчеризации schedule() ядро проверяет поле cpus_mask.
  2. Если текущее ядро входит в разрешенную маску, поток возобновляется без миграции, сохраняя кэши L1/L2 разогретыми.
  3. Если текущее ядро перегружено, балансировщик ищет свободное ядро строго внутри разрешенной маски.
  4. Если все ядра маски заняты, задача не может уйти на свободные ядра за пределами маски. Она ставится в локальную очередь выполнения, создавая искусственное бутылочное горлышко на физическом уровне. В случае блокировок по системным ресурсам поток может перейти в состояние TASK_UNINTERRUPTIBLE (ожидание освобождения ресурсов).

Влияние на Go-рантайм:
При инициализации рантайм Go вызывает sched_getaffinity и автоматически устанавливает значение runtime.NumCPU() и начальный GOMAXPROCS равным числу ядер в разрешенной маске аффинити (а не общему числу ядер хоста). Однако если маска аффинити изменяется динамически после запуска, либо если переменная GOMAXPROCS была принудительно переопределена вручную выше размера маски (например, оставлена равной 64 при маске из 4 ядер), Go создаст избыточное число логических процессоров P, которые начнут бороться за физические ядра, сжигая процессорное время на переключениях контекста.


NUMA-ориентированное управление памятью в Linux

Ядро Linux не просто отображает топологию NUMA через виртуальную файловую систему /sys/devices/system/node/. Оно активно управляет физическим размещением памяти, стремясь свести обращения к удаленным узлам (Remote Access / Remote Memory Access) к минимуму.

flowchart TD
    A["Запрос аллокации памяти (mmap / malloc)"] --> B["Проверка политики потока (mempolicy)"]

    B --> C{"Политика MPOL_BIND?"}
    C -- "Да" --> D["Выделение памяти СТРОГО в указанном узле"]
    D --> D1{"Есть свободная RAM?"}
    D1 -- "Да" --> E["Успешная локальная аллокация"]
    D1 -- "Нет" --> F["Возврат ошибки ENOMEM (без fallback)"]

    C -- "Нет (MPOL_PREFERRED)" --> G["Попытка аллокации в локальном узле"]
    G --> G1{"Хватило памяти?"}
    G1 -- "Да" --> E
    G1 -- "Нет" --> H["Fallback на удаленные NUMA-узлы (Remote RAM)"]

    classDef ok fill:#064e3b,stroke:#10b981,stroke-width:2px,color:#ecfdf5;
    classDef err fill:#4c0519,stroke:#f43f5e,stroke-width:2px,color:#ffe4e6;
    classDef flow fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    class E ok;
    class F err;
    class A,B,C,D,D1,G,G1,H flow;
  

Системные вызовы управления памятью

Ядро предоставляет специализированные системные интерфейсы для прямого контроля над размещением физических страниц:

  1. set_mempolicy(mode, nodemask, maxnode) — устанавливает политику аллокации памяти для всех будущих операций текущего системного потока.
  2. mbind(addr, len, mode, nodes, maxnode, flags) — привязывает уже выделенный диапазон виртуальной памяти к конкретным физическим NUMA-узлам.

В Linux поддерживаются следующие ключевые режимы (mode):

  • MPOL_PREFERRED: Политика по умолчанию. Ядро старается выделить память на локальном NUMA-узле потока. Если узел переполнен, память тихо выделяется на соседних узлах.
  • MPOL_BIND: Жесткая привязка. Память выделяется только на перечисленных узлах. Если там нет свободных страниц, процесс не уходит на удаленные узлы.
  • MPOL_INTERLEAVE: Постраничное чередование. Физические страницы распределяются по очереди между узлами (страница 0 на Node 0, страница 1 на Node 1 и т.д.). Идеально для разделяемой памяти, к которой обращаются потоки с разных сокетов.

Автоматическое NUMA-балансирование

Начиная с ядра Linux 2.6.38 и 3.8+, по умолчанию включен механизм Automatic NUMA Balancing (kernel.numa_balancing = 1).

Как работает этот демон:

  1. Ядро периодически сбрасывает флаг присутствия страниц в таблицах страниц (помечая их как PROT_NONE), искусственно провоцируя программные прерывания Page Fault при обращении.
  2. Перехватывая эти Page Faults, ядро измеряет долю удаленных обращений (Remote Page Faults против Local).
  3. При обнаружении аномалий ядро запускает фоновый процесс миграции страниц migratepages (и дедупликацию через ksm), копируя 4-килобайтные страницы из одного сокета в другой.

Инженерная проблема: Фоновая миграция страниц ядра блокирует доступ к страницам памяти, сбрасывает кэш трансляций (TLB) и порождает чудовищные всплески latency (хвостовые задержки p99 прыгают с 2 мс до 150 мс!).
Поэтому в высокопроизводительных production-системах автоматическое балансирование всегда отключают:

Bash
sysctl -w kernel.numa_balancing=0
# Или напрямую через procfs:
echo 0 > /proc/sys/kernel/numa_balancing

Интеграция с cgroups и оркестраторами

В современных дата-центрах и облаках инженеры не прописывают taskset руками на боевых хостах. Все управление изоляцией делегировано cgroups v2 и оркестратору Kubernetes.

cgroups v2: cpuset и memory

Контроллер cpuset в подсистеме cgroups v2 предоставляет исчерпывающий инструментарий изоляции:

  • cpuset.cpus: Список разрешенных ядер (например, 0-15).
  • cpuset.mems: Список разрешенных узлов оперативной памяти (например, 0).
  • cpuset.cpus.partition: превращает группу в «раздел» с изолированными ядрами (root / isolated). Старые параметры cgroups v1 cpuset.sched_load_balance и cpuset.mem_hardwall в cgroups v2 отсутствуют.
Bash
# Пример создания строго изолированной cgroup на узле 0
mkdir -p /sys/fs/cgroup/high_perf_service
echo "0-7" > /sys/fs/cgroup/high_perf_service/cpuset.cpus
echo "0" > /sys/fs/cgroup/high_perf_service/cpuset.mems
echo $$ > /sys/fs/cgroup/high_perf_service/cgroup.procs

Kubernetes CPU Manager и Topology Manager

Kubernetes транслирует архитектурные требования железа через два ключевых механизма kubelet:

  1. CPU Manager (cpuManagerPolicy: static):
    Для подов класса Guaranteed с целочисленными запросами ядер (например, cpu: "4") kubelet выделяет эксклюзивные физические ядра, убирая их из общего пула хоста. Ни один другой контейнер не сможет использовать эти ядра!
  2. Topology Manager (topologyManagerPolicy: restricted или single-numa-node):
    Координирует выделение CPU, памяти и PCIe-устройств (например, сетевых карт SR-IOV и GPU). Если ресурсы пода невозможно выделить в рамках одного NUMA-узла, Topology Manager отклонит запуск пода (Pod Admission Failure), защищая приложение от штрафа за межсокетный трафик.

Mechanical Sympathy: Влияние на Go-рантайм

Рантайм языка Go исторически проектировался как универсальная среда с единой общей кучей. Он не обладает встроенной NUMA-осведомленностью (NUMA-awareness) «из коробки»: планировщик Go не привязывает горутины к конкретным сокетам.

Ключевые точки взаимодействия:

  1. Инициализация рантайма:
    При старте Go вызывает системный вызов sched_getaffinity. Если процесс запущен через утилиту numactl или в cgroup с ограничением ядер, рантайм автоматически увидит правильное количество доступных процессоров.
  2. Выделение кучи аренами (arenas):
    Go выделяет виртуальную память непрерывными аренами по 64 МБ через системный вызов mmap. Реальные физические страницы оперативной памяти привязываются ядром в момент первого обращения (First Touch Policy). Если горутина, создавшая объект, работала на сокете 0, объект ляжет в Node 0. Если позже горутину украдет поток с сокета 1, возникнет Remote TLB Miss и доступ через межсокетную шину.
  3. Мониторинг распределения памяти по NUMA-узлам:
    Флага GODEBUG=gcresidency в Go нет (неизвестные ключи GODEBUG рантайм молча игнорирует), поэтому смотрите внешними инструментами ОС, например numastat -p <pid> и /proc/<pid>/numa_maps. Если вы видите резкий перекос (например, GC выполняет 90% работы на ядрах 0–7 и лишь 10% на 8–15), это верный признак того, что куча осела на одном NUMA-узле, а горутины размазаны по разным.

Итог

  1. CPU Affinity (sched_setaffinity) — инструмент жесткой привязки потоков к ядрам, устраняющий миграции задач и сохраняющий кэши L1/L2/L3 горячими.
  2. NUMA-политики ядра (mbind, set_mempolicy) управляют физическим размещением памяти. Политика MPOL_PREFERRED используется по умолчанию, а автоматическую миграцию страниц (kernel.numa_balancing) на highload-хостах отключают.
  3. В контейнерах Kubernetes управление делегировано cgroups v2 (cpuset), CPU Manager (static) и Topology Manager (restricted).
  4. Рантайм Go масштабируется лучше всего, когда размер одного экземпляра приложения укладывается в границы одного NUMA-узла.
  5. Проверяйте распределение памяти по узлам внешними инструментами (numastat, /proc/<pid>/numa_maps) и синхронизируйте GOMAXPROCS с маской выделенных ядер.

Мы завершили фундаментальный первый блок: от старта ядра и жизненного цикла процессов до планировщика, стоимости context switch и привязки к кристаллам процессора.

Но как ядро изолирует эти процессы на уровне адресов, заставляя каждый из них верить, что вся память компьютера принадлежит только ему? Во втором блоке мы погрузимся в архитектуру виртуальной памяти: 12. Виртуальная память. Почему процессу кажется, что у него своя память.