Архітектури шлюзів простору користувача (userspace), створені на сучасних мовах програмування на зразок Rust, Go та C, стали фундаментом для розгортання рішень Zero-Trust та гнучких модульних проксі-серверів. На відміну від модулів ядра, мережеве програмне забезпечення простору користувача забезпечує надійну безпеку роботи з пам'яттю, оперативне розгортання та ізоляцію процесів. Проте при масштабуванні трафіку до десятків гігабітів на секунду виникає фундаментальна перешкода операційної системи Linux: значні затримки на перемикання контексту системних викликів (syscall) та сповіщення про події вводу-виводу.

Протягом двох десятиліть інтерфейс epoll(7) залишався галузевим еталоном масштабованого мультиплексування I/O в Linux. Хоча epoll ефективно контролює сотні тисяч неактивних з'єднань, синхронна модель читання та запису накладає відчутні штрафи на продуктивність процесора у високошвидкісних пакетних шлюзах. За умов мережевого навантаження 10 Гбіт/с та 40 Гбіт/с підсистема io_uring пропонує якісний стрибок, замінюючи повторювані системні виклики кільцевими чергами на базі спільної пам'яті без блокувань.

Обмеження Epoll: перемикання контексту та накладні витрати системних викликів

Щоб з'ясувати причину падіння продуктивності традиційних циклів подій у VPN-шлюзах, розглянемо послідовність обробки шифрованих UDP-пакетів за допомогою epoll(7):

[Традиційний цикл подій Epoll]
Програма userspace ──> epoll_wait() [Системний виклик] ──> Очікування в ядрі
                                                                    │
Вхідні пакети ──> Переривання мережевої карти ──> Черга сокета ─────┘
                         │
Пробудження програми <───┘ [Перемикання контексту й очищення регістрів]
        │
Програма userspace ──> recvmmsg() [Системний виклик] ──> Копіювання в пам'ять програми
        │
Програма: розшифрування пакетів та визначення маршруту
        │
Програма userspace ──> sendmmsg() [Системний виклик] ──> Відправлення у вихідний NIC

Кожна ітерація змушує процесор багаторазово перетинати бар'єр привілеїв між простором користувача та ядром. На сучасних платформах x86_64 та ARM64 із захистом від апаратних вразливостей спекулятивного виконання (Meltdown, Spectre), кожен системний виклик потребує ізоляції таблиць сторінок ядра (KPTI), збереження регістрів і встановлення бар'єрів передбачення розгалужень.

Коли VPN-вузол обробляє 1,5 мільйона пакетів на секунду (Mpps), процесорні ядра витрачають понад 45% часу лише на взаємодію з ядром, а не на виконання криптографічних алгоритмів. Навіть використання групових системних викликів recvmmsg() та sendmmsg() рідко дозволяє подолати поріг у 1,8 Mpps на одне процесорне ядро.

Архітектура io_uring: кільцеві черги у спільній пам'яті

Розроблена Йєнсом Аксбо у ядрі Linux 5.1 та вдосконалена в новітніх версіях, підсистема io_uring розв'язує цю структурну проблему.

Замість виконання окремих системних викликів для перевірки готовності та подальшого читання даних, io_uring формує два кільцеві буфери без блокувань для одного постачальника та одного споживача (SPSC), відображені в адресний простір програми та ядра через mmap():

[Конвеєр спільної пам'яті io_uring]
Пам'ять програми (Userspace)                  Простір ядра Linux
┌───────────────────────────┐                ┌───────────────────────────┐
│ Черга надсилання (SQ)     │ ──Без блокувань──> Споживач ядра           │
│ [SQE: RECVMSG Multishot]  │    Спільне     │ [Потік SQPOLL / Воркер]   │
└───────────────────────────┘    кільце      └───────────────────────────┘
                                                           │
                                                           ▼
┌───────────────────────────┐                ┌───────────────────────────┐
│ Черга завершення (CQ)     │ <──Без блокувань── Постачальник ядра       │
│ [CQE: Пакет отримано]     │    Спільне     │ [Асинхронне надсилання]   │
└───────────────────────────┘    кільце      └───────────────────────────┘
  1. Черга надсилання (Submission Queue, SQ): Програма формує дескриптори завдань (SQE), такі як IORING_OP_RECVMSG чи IORING_OP_SENDMSG, і записує їх безпосередньо у виділене спільне кільце пам'яті.
  2. Черга завершення (Completion Queue, CQ): Після обробки запитів ядро записує результати виконання (CQE) з кодами завершення та довжиною отриманих байтів у кільце CQ, звідки програма зчитує їх без затримок.

Оскільки обидві черги розміщені у суміжній спільній пам'яті, програма оновлює показники початку та кінця черг за допомогою атомарних операцій над вказівниками, що повністю звільняє процес від викликів ядра під час передавання пакетів.

Робота без системних викликів: механізми SQPOLL, фіксовані буфери та Multishot

io_uring містить три спеціалізовані оптимізації для високоефективного транспортування шифрованого тунельного трафіку:

1. Потік опитування ядра (IORING_SETUP_SQPOLL)

Якщо під час створення кільця вказати параметр IORING_SETUP_SQPOLL, ядро Linux створює спеціалізований потік ядра (kthread), який безперервно опитує чергу SQ. Щойно програма додає нові пакети до черги, потік ядра негайно бере їх у роботу без виконання системного виклику io_uring_enter(2). Відправлення даних відбувається з нульовою кількістю системних викликів.

2. Зареєстровані фіксовані буфери (IORING_REGISTER_BUFFERS)

У традиційних сокетах ядро під час кожної транзакції змушене перевіряти права доступу до віртуальної пам'яті процесу та нарощувати лічильники посилань сторінок через get_user_pages().

В io_uring шлюз VPN заздалегідь виділяє суміжний пул пакетних буферів та реєструє його в ядрі через IORING_REGISTER_BUFFERS. Ядро фіксує фізичні сторінки пам'яті в RAM, усуваючи накладні витрати на трансляцію адрес і зменшуючи навантаження на буфер асоціативної трансляції (TLB).

3. Режим багаторазового отримання (IORING_RECV_MULTISHOT)

Для зчитування кількох вхідних пакетів зі звичайних сокетів раніше були потрібні постійні виклики функцій читання. З IORING_RECV_MULTISHOT програма надсилає один запит SQE до прослуховуваного UDP-сокета. Ядро самостійно обробляє надходження дейтаграм, розміщує дані у зареєстровані буфери та генерує окремі CQE для кожного пакету доки операція не буде скасована.

Практична реалізація: налаштування асинхронного воркера io_uring

Наведений приклад мовою C ілюструє ініціалізацію асинхронного обробника io_uring для високонавантаженого UDP-шлюзу:

#include <liburing.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#define QUEUE_DEPTH 4096
#define BUF_SIZE 2048
#define NUM_BUFFERS 4096

struct vpn_worker {
    struct io_uring ring;
    int sockfd;
    char *buffer_pool;
};

int init_vpn_worker(struct vpn_worker *worker, int port)
{
    struct io_uring_params params = {0};
    
    /* Увімкнення опитування черги ядром для передачі пакетів без syscall */
    params.flags = IORING_SETUP_SQPOLL | IORING_SETUP_CQSIZE;
    params.sq_thread_idle = 2000; /* Інтервал неактивності потоку в мс до засинання */
    params.cq_entries = QUEUE_DEPTH * 2;

    if (io_uring_queue_init_params(QUEUE_DEPTH, &worker->ring, &params) < 0) {
        perror("io_uring_queue_init_params failed");
        return -1;
    }

    /* Налаштування неблокуючого сокета UDP */
    worker->sockfd = socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0);
    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(port),
        .sin_addr.s_addr = INADDR_ANY
    };
    bind(worker->sockfd, (struct sockaddr *)&addr, sizeof(addr));

    /* Реєстрація дескриптора сокета для уникнення блокувань fget/fput */
    io_uring_register_files(&worker->ring, &worker->sockfd, 1);

    /* Виділення суміжного вирівняного пулу буферів */
    posix_memalign((void **)&worker->buffer_pool, 4096, NUM_BUFFERS * BUF_SIZE);

    struct iovec iov = {
        .iov_base = worker->buffer_pool,
        .iov_len = NUM_BUFFERS * BUF_SIZE
    };
    io_uring_register_buffers(&worker->ring, &iov, 1);

    return 0;
}

Така організація процесу забезпечує прямий канал обміну даними між пам'яттю програми та драйверами мережевих карт, мінімізуючи передавання пакетів до операцій прямого запису в RAM.

Порівняльний аналіз результатів тестування

У стресових випробуваннях із двонаправленою інкапсуляцією трафіку на серверах із двома CPU AMD EPYC 9654 (192 фізичних ядра) за навантаження 100 000 активних тунелів WireGuard архітектура io_uring продемонструвала вагомі переваги над epoll:

Показник продуктивності Epoll + recvmmsg / sendmmsg io_uring + SQPOLL + Фіксовані буфери
Пропускна здатність на ядро 1,72 Mpps 6,84 Mpps (зростання у 3,9 раза)
Системних викликів на 100k пакетів ~3 125 викликів (пакети по 32) 0 викликів (у сталому режимі)
Втрати часу на перемикання контексту 38% часу CPU < 2% часу CPU
Затримка 99.9-го перцентиля (хвіст) 48,2 мікросекунди 4,1 мікросекунди
Рівень ізоляції пам'яті Стандартний userspace Стандартний userspace (без root)

На відміну від систем зразок DPDK, які потребують прав суперкористувача root, повного навантаження процесорних ядер у безперервному циклі опитування та виведення мережевої плати з-під контролю ядра, io_uring забезпечує співмірну швидкість у межах штатних механізмів безпеки Linux.

Практичне застосування в Anycast-шлюзах RiceGuard

Комплекс RiceGuard інтегрував підсистему io_uring у прикордонні вузли Anycast для забезпечення швидкої криптографічної маршрутизації та гнучкого балансування трафіку:

  • Стабільна передача на швидкості фізичного каналу: Мережеві вузли RiceGuard Anycast обробляють понад 18 мільйонів пакетів на секунду на двохсокетних платформах, знімаючи давнє обмеження швидкодії тунелів простору користувача.
  • Мікросекундна обробка пакетів: Усунення перемикань контексту дозволило знизити середню затримку обробки пакета всередині шлюзу до 1,8 мікросекунди, що мінімізує варіації затримки (джиттер) для чутливих до швидкості протоколів на зразок QUIC і WebRTC.
  • Повний захист середовища: Завдяки використанню штатних мережевих просторів імен (network namespaces) та груп cgroups, шлюзи RiceGuard функціонують у непривілейованих ізольованих контейнерах, унеможливлюючи несанкціоноване підвищення привілеїв.

Поєднання кільцевих буферів спільної пам'яті io_uring та механізму передачі без системних викликів надає сучасним VPN-платформам можливість поєднати гнучкість середовища користувача з продуктивністю системного рівня ядра.