Корпоративные шлюзы VPN и распределенные пограничные узлы функционируют на высокоплотных многоядерных серверах с 64, 128 или 256 физическими потоками CPU. При обработке непрерывного совокупного трафика в десятки гигабит в секунду через сотни тысяч одновременных зашифрованных туннелей сетевая пропускная способность редко упирается в тактовую частоту процессора. Снижение производительности происходит из-за узких мест в подсистеме памяти: переброски кэш-линий между ядрами (cache-line bouncing), перегрузки межпроцессорных шин в архитектуре NUMA и накладных расходов на межпроцессорные прерывания (IPI).

При стандартных настройках ядра Linux сетевые пакеты, поступающие на многоочередные сетевые карты (multi-queue NIC), проходят обработку softirq на одном процессорном ядре, после чего передаются рабочему потоку в пространстве пользователя, выполняющемуся на совершенно другом ядре. Такое архитектурное разделение аннулирует кэши L1 и L2 для каждого пакета, заставляя системную шину непрерывно синхронизировать память между кластерами ядер.

Для достижения линейного масштабирования и стабильной задержки менее одной миллисекунды высокопроизводительные прокси-серверы задействуют специализированные опции сокетов Linux: SO_INCOMING_CPU и SO_INCOMING_NAPI_ID. Связывая диспетчеризацию сокетов напрямую с аппаратными очередями приема и ядрами, выполняющими softirq ядра, эти инструменты гарантируют локализацию данных в кэше без межпроцессного взаимодействия, превращая многоядерные серверы в параллельные неблокирующие конвейеры передачи данных.


Барьер масштабируемости многоядерных систем: Конфликты кэша и межъядерные издержки

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

  1. Аппаратный прием (Ingress): Зашифрованный пакет поступает на сетевой адаптер. Механизм Receive-Side Scaling (RSS) вычисляет хэш Toeplitz по полям заголовков и направляет кадр в конкретную аппаратную очередь приема (например, RX Queue 3).
  2. Прерывание и цикл NAPI: Сетевая карта инициирует прерывание MSI-X, передавая выполнение Ядру 3. В цикле NAPI ядра внутри потока ksoftirqd/3 разбираются заголовки пакета, проверяются криптографические параметры и определяется целевой сокет.
  3. Выборка из сокета и диспетчеризация: Если процесс в пространстве пользователя использует стандартный опрос epoll без закрепления ядер, случайный рабочий поток на Ядре 11 просыпается для чтения пакета через вызов recvmsg().
Проблема вымывания кэша (при отсутствии привязки к ядрам):

Сетевой адаптер (Очередь RSS 3)
         │ (Прерывание MSI-X)
         ▼
[ Ядро CPU 3: Цикл Softirq / NAPI ]    <── Данные пакета записаны в кэш L1/L2 Ядра 3
         │
         │ (Межъядерная передача / Пробуждение Epoll)
         ▼
[ Ядро CPU 11: Рабочий поток процесса ] <── Холодный промах кэша! Принудительное чтение
                                            по шине памяти из Ядра 3 или общего L3/RAM.

Когда дескрипторы и полезная нагрузка, сформированные Ядром 3, запрашиваются Ядром 11, процессорная подсистема вынуждена запускать протоколы когерентности кэша (MESI или MOESI) через шины связи или интерфейсы AMD Infinity Fabric / Intel UPI. На сервере, обрабатывающем 5 миллионов пакетов в секунду, перекрестная инвалидация кэша исчерпывает пропускную способность внутренних шин, увеличивая задержку p99 более чем на 400%.


Глубокий анализ: SO_INCOMING_CPU и привязка к Softirq ядра

Опция сокета SO_INCOMING_CPU (появившаяся в ядре Linux 3.19) предоставляет приложениям возможность детального контроля над распределением соединений по ядрам. Она функционирует в двух режимах: опрос сокета и целенаправленное направление входящих соединений.

1. Направление соединений на ядра, обрабатывающие сетевые прерывания

В сочетании с флагом SO_REUSEPORT механизм SO_INCOMING_CPU обеспечивает детерминированный выбор слушающего сокета. Вместо равномерного распределения новых рукопожатий TCP или потоков UDP между всеми сокетами приложение создает изолированные слушающие сокеты под каждое физическое ядро CPU:

int cpu_id = 4;
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);

int enable_reuseport = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &enable_reuseport, sizeof(enable_reuseport));

/* Указание ядру направлять соединения от softirq Ядра 4 в данный сокет */
setsockopt(listen_fd, SOL_SOCKET, SO_INCOMING_CPU, &cpu_id, sizeof(cpu_id));

bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr));
listen(listen_fd, 1024);

В момент поступления пакета поисковая процедура ядра проверяет, соответствует ли идентификатор текущего ядра (smp_processor_id()) сокету в массиве SO_REUSEPORT. При совпадении входящее соединение помещается сразу в очередь приема именно этого сокета.

2. Устранение межпроцессорных прерываний и миграции строк кэша L3

Когда потоки пользовательского пространства фиксируются на соответствующих ядрах процессора через pthread_setaffinity_np(), они считывают данные исключительно из локальных очередей:

  • Поток 4 работает строго на Ядре CPU 4.
  • Ядро 4 обрабатывает сетевые пакеты в рамках подсистемы softirq.
  • Поток 4 извлекает кадры из сокета, настроенного с параметром SO_INCOMING_CPU = 4.

Поскольку структуры данных остаются в локальных кэшах L1 и L2 Ядра 4, задержка доступа к памяти сокращается с ~60 наносекунд (при выборке из удаленного L3 или оперативной памяти другого NUMA-узла) до менее чем 2 наносекунд. Сигналы межпроцессорных прерываний (IPI) полностью исчезают из тракта передачи данных.


Синхронизация с аппаратными очередями: Запрос SO_INCOMING_NAPI_ID

Если SO_INCOMING_CPU осуществляет привязку на уровне вычислительных ядер, то сетевые адаптеры распределяют поток данных по физическим очередям. Каждой аппаратной очереди соответствует отдельный экземпляр структуры NAPI в ядре.

1. Сопоставление очередей сетевой карты с рабочими потоками приложения

В ядре Linux 4.12 появилась опция сокета SO_INCOMING_NAPI_ID. С ее помощью процесс пользовательского пространства может точно определить идентификатор аппаратной очереди, принявшей пакеты данного соединения:

unsigned int napi_id = 0;
socklen_t optlen = sizeof(napi_id);

if (getsockopt(client_fd, SOL_SOCKET, SO_INCOMING_NAPI_ID, &napi_id, &optlen) == 0) {
    /* Передача client_fd потоку, закрепленному за данным кольцом NAPI */
    assign_socket_to_napi_worker(client_fd, napi_id);
}

Запрашивая NAPI ID, высоконагруженные пограничные шлюзы создают однозначную связь 1:1 между физическими очередями сетевого адаптера и программными циклами событий. Если адаптер на 100 Гбит/с содержит 32 очереди RX, прокси запускает 32 рабочих потока, каждый из которых закреплен за своим номером очереди.

2. Симметричные двунаправленные пути пакетов с интеграцией XPS

Высокая скорость передачи требует симметрии как на приеме (RX), так и на передаче (TX). Если шлюз принимает пакет, расшифровывает его и отправляет во внутренний сетевой интерфейс с несогласованного ядра, это порождает блокировки на очередях отправки TX.

Симметричный аппаратный конвейер передачи данных:

[ Очередь RX NIC 2 ] ──► [ Softirq Ядра 2 ] ──► [ Поток 2 (SO_INCOMING_CPU) ]
                                                            │
                                                            ▼ (Обработка и расшифровка)
[ Очередь TX NIC 2 ] ◄── [ Очередь XPS Ядра 2 ] ◄── [ Буфер отправки (Без блокировок) ]

Благодаря объединению SO_INCOMING_NAPI_ID на входе и механизма Transmit Packet Steering (XPS), привязанного к тому же ядру на выходе, пакет проходит изолированный тракт от физического кабеля до пространства пользователя и обратно без пересечения границ процессорных ядер.


Архитектура развертывания: Закрепленные рабочие циклы и разделение сокетов

Внедрение SO_INCOMING_CPU в асинхронный пограничный шлюз требует четкой последовательности инициализации:

  1. Определение топологии: Чтение каталогов /sys/devices/system/cpu/ и /sys/class/net/<iface>/device/numa_node для выявления узлов NUMA и физических ядер.
  2. Создание слушающих сокетов: Формирование массива сокетов с флагами SO_REUSEPORT и SO_INCOMING_CPU, сопоставленных с каждым ядром CPU.
  3. Привязка потоков: Для каждого рабочего процесса вызов pthread_setaffinity_np() для ограничения выполнения выделенным ядром.
  4. Регистрация в Epoll: Каждый поток подключает к своему локальному экземпляру epoll_create1() исключительно собственный слушающий сокет и связанные с ним соединения.
/* Точка входа рабочего потока */
void *worker_loop(void *arg) {
    int core_id = (int)(intptr_t)arg;
    
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(core_id, &cpuset);
    pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);
    
    /* Изолированный epoll-цикл для текущего ядра */
    int epoll_fd = epoll_create1(EPOLL_CLOEXEC);
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listeners[core_id], &ev);
    
    while (running) {
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        for (int i = 0; i < nfds; i++) {
            /* Быстрая обработка в локальном кэше L1/L2 */
            handle_active_socket(events[i].data.fd);
        }
    }
    return NULL;
}

Анализ микробенчмарков: Стабильность задержки и прирост пропускной способности

Сравнительные тесты на 128-ядерных серверах AMD EPYC при непрерывной нагрузке в 20 Гбит/с наглядно демонстрируют преимущества сокетной привязки к ядрам:

Сравнение характеристик при многоядерной нагрузке:

Параметр                      Стандартный Epoll    SO_INCOMING_CPU + Привязка NAPI
────────────────────────────────────────────────────────────────────────────────
Пропускная способность (Mpps) 3.4 Mpps             5.8 Mpps (+70%)
Промахи кэша L3 (Misses)      28.4%                6.2% (-78%)
Прерывания IPI                142,000 / сек        1,800 / сек (-98%)
Хвостовая задержка (p99)      14.2 мс              1.1 мс (-92%)
Системная нагрузка (IPI/Lock) 34%                  7%

Благодаря устранению межъядерной инвалидации кэша снижается паразитная нагрузка на системную шину памяти. Сервер стабильно справляется с резкими всплесками трафика без потерь пакетов в кольцевых буферах сетевого драйвера.


Тракт данных RiceGuard: Изоляция ядер для телеметрии и высокоскоростная передача

VPNRice интегрирует механизмы SO_INCOMING_CPU и SO_INCOMING_NAPI_ID непосредственно в тракт обработки данных узлов RiceGuard. При обслуживании скоростных корпоративных соединений и пограничных точек поддержание стабильной скорости обработки пакетов критически важно для защиты инфраструктуры.

На узлах RiceGuard входящие пакеты проходят фильтрацию XDP перед передачей в процессы пространства пользователя с привязкой к узлам NUMA. Согласование аппаратных очередей с рабочими потоками устраняет взаимные блокировки и накладные расходы на переключение контекста. Такая архитектурная изоляция гарантирует, что интенсивные волны запросов на подключение не нарушат работу активных туннелей пользователей, сохраняя высокую безопасность, низкую задержку и надежную цифровую конфиденциальность.