Десятигигабитный канал способен упереться не в провод и не в диск, а в одно ядро процессора: по умолчанию все пакеты интерфейса обрабатывает тот CPU, на который приходит прерывание, пока соседние ядра скучают. Очередь приема переполняется, ядро начинает выбрасывать пакеты, соединения ловят таймауты, а утилита top показывает ровную половину простоя. Ядро Linux решает эту задачу связкой механизмов: аппаратный RSS раскладывает пакеты по очередям сетевой карты, программный RPS пересылает их между ядрами, RFS и ускоренный aRFS подводят каждый поток к процессору, где живет обслуживающее приложение, а XPS наводит порядок на передаче. Все четыре управляются командами ethtool, файлами sysfs и параметрами sysctl, без пересборки ядра и перезапуска служб. Дальше по порядку: что делает каждый механизм, какие числа подставлять и как убедиться, что распределение работает.

Многоочередные сетевые карты и аппаратное распределение пакетов

Серверные сетевые карты давно умеют держать несколько очередей приема и передачи, типичные модели работают с 8, 16 или 64 парами. При входе пакет карта считает хеш по четырем полям заголовков, адресам и портам источника и назначения, чаще всего алгоритмом Toeplitz. Младшие биты хеша служат индексом в таблице косвенности, где для каждого индекса записан номер очереди, и таблица размазывает трафик по очередям почти ровно. Длину таблицы советуют делать минимум в четыре раза больше числа очередей, при таком соотношении перекос нагрузки между очередями укладывается в порядка 16 процентов и для большинства сервисов незаметен. У каждой очереди собственное прерывание MSI-X, и раскладка этих прерываний по ядрам решает, какой процессор возьмется за обработку. Текущая таблица смотрится одной командой:

ethtool -x eth0

Вывод для карты с восемью очередями выглядит так:

RX flow hash indirection table for eth0 with 8 RX ring(s):
    0:     0     1     2     3     4     5     6     7
    8:     0     1     2     3     4     5     6     7

Каждая строка отправляет свой кусок таблицы в очереди по кругу, поэтому при ровном потоке каналы загружаются одинаково. Список прерываний с номерами ядер, где они уже отработали, лежит в /proc/interrupts, там же видно, какая строка принадлежит какой очереди. Если потоков мало, хеш может собрать весь трафик на пару очередей, и тогда выравниванием занимаются механизмы из следующих разделов.

Число очередей, веса и контексты RSS в командах ethtool

Прежде чем что-то перекладывать, стоит узнать предел карты, он печатается командой ethtool -l eth0:

Channel parameters for eth0:
Pre-set maximums:
RX:             8
TX:             8
Other:          1
Combined:       8
Current hardware settings:
RX:             4
TX:             4
Other:          1
Combined:       4

Здесь карта умеет восемь пар очередей, а работает с четырьмя. Число очередей подгоняется под число ядер без перезагрузки:

ethtool -L eth0 combined 8
ethtool -X eth0 equal 8

Первая команда поднимает пары приема и передачи до восьми, вторая переписывает таблицу косвенности на равномерное распределение. Если одна очередь хронически перегружена, ей задают вес: команда ethtool -X eth0 weight 4 2 2 2 отправит в первую очередь вдвое больше трафика, чем в каждую из трех остальных. Отдельная способность современных карт состоит в именованных контекстах RSS, где под конкретный порт собирается собственная раскладка:

ethtool -X eth0 hfunc toeplitz context new
ethtool -X eth0 equal 2 context 1
ethtool -N eth0 flow-type tcp6 dst-port 22 context 1

Здесь создан контекст с номером 1, в нем выделены очереди 0 и 1, и весь трафик SSH по IPv6 уведен туда через ntuple фильтр. Ядро ответит строкой "Added rule with ID 1023", и запомнить стоит именно этот номер: правило снимается командой ethtool -N eth0 delete 1023, а контекст удаляется командой ethtool -X eth0 context 1 delete. Прием держит служебный трафик отдельно от основного и позволяет закрепить за портом именно те ядра, которые свободны.

Привязка прерываний очередей к ядрам и служба irqbalance

Красивая раскладка очередей обесценивается, если все прерывания достаются ядру 0. Аффинность каждого прерывания задается через /proc/irq/N/smp_affinity_list, а удобнее всего разложить их циклом:

grep eth0 /proc/interrupts
i=0
for irq in $(awk '/eth0/ {gsub(":", ""); print $1}' /proc/interrupts); do
  echo $i > /proc/irq/$irq/smp_affinity_list
  i=$((i+1))
done

Первая команда перечисляет прерывания интерфейса с номерами, цикл раскладывает их по ядрам начиная с нуля. Файл smp_affinity_list принимает десятичный номер ядра и избавляет от шестнадцатеричных масок, которыми живет соседний файл smp_affinity. В двухсокетной системе очереди привязывают к ядрам того узла, где стоит карта: файл /sys/class/net/eth0/device/numa_node напечатает номер узла, а numactl --hardware перечислит ядра каждого узла. Дальше прячется ловушка: служба irqbalance перекидывает прерывания динамически и через несколько минут после загрузки затирает ручные настройки. Ее либо останавливают командой systemctl disable --now irqbalance, либо ограничивают через конфигурацию, перечислив в ней запрещенные ядра, тогда служба оставит сетевые прерывания в покое.

Программное распределение RPS для карт с одной очередью

Картам с единственной очередью аппаратное распределение недоступно, и тут вступает программный аналог RPS. Механизм включается записью битовой маски процессоров в файл rps_cpus: пакет после прерывания ставится в очередь отложенной обработки выбранного ядра, и протокольный стек выполняется уже там. По умолчанию файл содержит ноль, то есть механизм выключен. Для шестнадцати ядер полная маска записывается как ffff, для второй половины сокета, ядер с 8 по 15, как ff00:

echo ffff > /sys/class/net/eth0/queues/rx-0/rps_cpus
cat /sys/class/net/eth0/queues/rx-0/rps_cpus

Расчет маски простой: каждому ядру соответствует свой бит, ядро 0 весит 1, ядро 1 весит 2, ядро 2 весит 4 и так далее, ядра с 0 по 7 занимают первый байт, с 8 по 15 второй, поэтому ff00 означает именно вторую половину. Если локальность памяти не критична, ставят все ядра сразу, а при высоком темпе прерываний ядро-обработчик из маски исключают, чтобы не мешать самому себе. Пересылка пакета чужому ядру стоит межпроцессорного прерывания IPI, поэтому там, где очередей хватает по одной на каждый CPU, RPS избыточен: аппаратный RSS выполняет ту же работу без лишних расходов. Виртуальные карты virtio на VPS часто показывают одну очередь, и RPS там единственный инструмент, а число очередей virtio поднимается той же командой ethtool -L eth0 combined N, если хост разрешает.

Механизм RFS и ускоренный aRFS для привязки потоков к приложениям

Равномерность не гарантирует попадания в нужное ядро: RSS и RPS могут отправить пакет туда, где приложение, читающее этот пакет, никогда не запускалось, и данные проедут через кэш соседнего узла. Механизм RFS закрывает разрыв: хеш потока служит индексом в глобальной таблице, где для каждого потока записан процессор, на котором поток обрабатывается в пользовательском пространстве. Размер таблицы задается параметром sysctl:

sysctl -w net.core.rps_sock_flow_entries=32768

Для умеренно загруженного сервера стартовым значением считается 32768, крупным узлам требуется 1048576 и больше, а любое значение округляется вверх до степени двойки. Каждая аппаратная очередь получает собственную долю через файл rps_flow_cnt, и сумма долей должна совпадать с общим размером:

echo 2048 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt

При шестнадцати очередях и общем размере 32768 каждой достается ровно по 2048 записей. На NUMA-хосте большую таблицу выделяют сразу на всех узлах, иначе распределение памяти получается перекошенным:

numactl --interleave=all bash -c "echo 1048576 > /proc/sys/net/core/rps_sock_flow_entries"

Переезд потока на новое ядро откладывается, пока в очереди старого остались его пакеты: ядро сверяет счетчики глобальной и локальной таблиц и не допускает перепутанного порядка пакетов. Аппаратный вариант aRFS доводит идею до конца: ядро само перепрограммирует карту, и пакет уходит сразу в очередь рядом с приложением. Условия три: поддержка со стороны драйвера, ядро с включенной опцией CONFIG_RFS_ACCEL и активные фильтры ntuple:

ethtool -K eth0 ntuple on

После включения карта следит за таблицей сама, а привязка CPU к очередям выводится из раскладки прерываний, поэтому порядок из прошлого раздела остается обязательным.

Исходящий трафик и маски xps_cpus для очередей передачи

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

for q in $(seq 0 7); do
  printf '%x\n' $((1 << q)) > /sys/class/net/eth0/queues/tx-$q/xps_cpus
done

Маска 1 означает ядро 0, маска 2 ядро 1, и при равенстве числа очередей и ядер получается чистая пара без конкуренции. Когда очередей меньше, чем ядер, очередь отдают ядрам, соседним по кэшу с тем, где обрабатывается прерывание завершения передачи. Выбранная для соединения очередь запоминается в структуре сокета, поэтому пакеты одного соединения не разъезжаются по разным очередям и не приходят получателю в перепутанном порядке. Есть и привязка на уровне очередей приема через файлы xps_rxqs: RX-очередь отправляет через соответствующую TX-очередь, это удобно многопоточным приложениям с активным опросом. Отдельно карта умеет ограничивать скорость каждой очереди передачи записью в файл tx_maxrate, значение задается в Мбит/с, ноль означает отсутствие лимита.

Диагностика распределения нагрузки и защита от доминирующего потока

Настроенные механизмы требуют проверки. Команда mpstat -P ALL 1 раз в секунду печатает загрузку каждого ядра, и здоровая картина выглядит как примерно равные доли softirq по всем задействованным ядрам. Потери из-за переполнения очереди видны в /proc/net/softnet_stat: во втором столбце каждой строки копятся пакеты, не поместившиеся в очередь отложенной обработки, а порог задает параметр net.core.netdev_max_backlog со значением по умолчанию порядка тысячи, для нагруженных серверов его поднимают в десять раз:

mpstat -P ALL 1 5
sysctl -w net.core.netdev_max_backlog=10000

Третий столбец считает срывы выделения процессорного времени на обработку, и его рост говорит, что ядрам не хватает квантов даже при пустых очередях. Раскладку по очередям проверяют напрямую: файл rps_cpus печатает активную маску, а прерывания за последнюю секунду видно в /proc/interrupts:

cat /sys/class/net/eth0/queues/rx-0/rps_cpus
watch -n1 "grep eth0 /proc/interrupts"

Отдельная угроза для сервера с тысячами соединений: один поток, забирающий половину трафика, перекашивает распределение, потому что RPS и RFS держат поток на одном ядре ради порядка пакетов. Для такой ситуации в ядре предусмотрен flow limit: когда очередь ядра доходит до половины порога netdev_max_backlog, ядро ведет счет последних 256 пакетов и выбрасывает пакеты потока, забравшего больше половины из них, а соединение при этом не рвется. Включается защита битовой маской по ядрам, таблица хешей по умолчанию держит 4096 ячеек и регулируется параметром net.core.flow_limit_table_len, новая длина подхватывается при очередном выделении таблицы:

echo ff > /proc/sys/net/core/flow_limit_cpu_bitmap

Порядок внедрения на типичном сервере с восемью ядрами и картой на восемь очередей укладывается в короткий чеклист:
1. выровнять число очередей под число ядер командой ethtool -L eth0 combined 8;
2. проверить таблицу косвенности через ethtool -x и при необходимости переписать командой ethtool -X eth0 equal 8;
3. разложить прерывания очередей по ядрам через /proc/irq/N/smp_affinity_list и остановить irqbalance;
4. включить RFS, задав net.core.rps_sock_flow_entries=32768 и по 4096 записей на каждую очередь;
5. заполнить xps_cpus каждой TX-очереди, добившись взаимно однозначной привязки;
6. через сутки снять метрики mpstat и заглянуть в /proc/net/softnet_stat, чтобы убедиться в отсутствии потерь.

Числа здесь стартовые, а не окончательные: узел с сотнями тысяч коротких соединений потребует таблицы RFS побольше, а машина с одним активным потоком и широким каналом покажет пользу от выделенных очередей через ntuple фильтры. Принцип же неизменен: у каждой очереди свой процессор, у каждого потока ядро поблизости, и сетевой стек перестает быть узким местом раньше, чем канал.