Високонавантажені транзитні ретранслятори VPN, проксі-сервери маскування трафіку та корпоративні граничні маршрутизатори регулярно виконують роль проміжних ланок між віддаленими клієнтськими тунелями та внутрішніми сервісними кластерами. У класичній архітектурі програмного забезпечення пересилання транзитного потоку змушує процес простору користувача виконувати безперервні цикли читання та запису через файлові дескриптори сокетів. Під час обробки агрегованого трафіку від 20 до 40 Гбіт/с на десятках тисяч паралельних сесій така традиційна схема спричиняє швидке насичення системної шини пам'яті та дефіцит процесорних ресурсів.

Кожен традиційний виклик read(2) із вхідного сокета змушує ядро Linux дублювати байти корисного навантаження через межу привілеїв із сокетних буферів ядра (sk_buff) в оперативну пам'ять процесу. Одразу після цього проксі-демон виконує write(2) або send(2) для передавання даних у вихідний сокет тунелю, спонукаючи операційну систему здійснювати друге копіювання назад у простір ядра. При безперервному мультигігабітному навантаженні процесорні ядра витрачають до 40% своїх робочих тактів на виконання інструкцій копіювання (copy_user_generic_string), попутно витісняючи активні криптографічні ключі зі швидкісного кешу L1 та L2 процесора.

Операційна система Linux пропонує надійне архітектурне рішення через системні виклики зв'язування каналів: splice(2) та vmsplice(2). Скеровуючи дані мережевих пакетів через проміжне кільце сторінок каналу всередині ядра без виходу в простір користувача, ці механізми дозволяють створювати ефективну архітектуру Zero-Copy для ретрансляторів VPN.

Системні обмеження традиційного проксі-пересилання

Щоб зрозуміти масштаби падіння продуктивності при звичайному пересиланні даних, розглянемо шлях зашифрованого транзитного пакета:

Традиційне пересилання через простір користувача (2 копіювання, 4 зміни контексту):
[ Вхідна мережева карта ] ---> [ Вхідний sk_buff ]
                                      |
                               copy_to_user (Етап 1: Ядро -> Користувач)
                                      v
                        [ Буфер в оперативній пам'яті ]
                                      |
                              copy_from_user (Етап 2: Користувач -> Ядро)
                                      v
[ Вихідна мережева карта ] <--- [ Вихідний sk_buff ]

Ця класична схема породжує три критичні системні проблеми:

  1. Багаторазове зростання навантаження на шину пам'яті: Кожен байт транзитних даних проходить через системну шину пам'яті 4 рази: DMA від мережевої карти в RAM, читання з RAM у регістри CPU, запис із регістрів у пам'ять користувача та зворотний запис у буфер сокета. При швидкості 40 Гбіт/с навантаження на шину сягає 160 Гбіт/с.
  2. Вимивання кешу процесора: Безперервне перекачування мегабайтів мережевих пакетів через регістри процесора очищає кеш L1/L2, стираючи таблиці перетворень та структури шифрування (наприклад, ChaCha20-Poly1305 або AES-GCM).
  3. Витрати на зміну контексту: Постійне чергування системних викликів read(2) та write(2) провокує мільйони перемикань між режимами ядра та користувача, марнуючи такти на скидання буферів трансляції адрес (TLB).

Архітектура splice(2): Кільцеві буфери каналів

Системний виклик splice(2) перенаправляє потік даних між двома файловими дескрипторами (з яких хоча б один має бути каналом — pipe) без дублювання байтів між ядром і простором користувача.

Внутрішньоядерна передача Zero-Copy через кільце сторінок:
[ Вхідний сокет ] ═════════════════════════════════════════╗
        |                                                  ║
 splice(sock_in -> pipe)                                   ║
        v                                                  ║
[ struct pipe_inode_info ]                                 ║  Нульове дублювання пам'яті
  * Кільце struct pipe_buffer (Вказівники сторінок і зсув) ║  (Лише обмін посиланнями)
        |                                                  ║
 splice(pipe -> sock_out)                                  ║
        v                                                  ║
[ Вихідний сокет ] ════════════════════════════════════════╝

Модель буферів каналу в ядрі

Основою splice(2) є внутрішня підсистема каналів Linux (fs/pipe.c). Канал становить собою не просто безперервний байтовий потік, а кільцевий масив дескрипторів struct pipe_buffer, що посилаються на фізичні сторінки пам'яті (struct page *):

  • Обмін вказівниками на сторінки: Коли splice(2) зчитує дані із сокета в канал, ядро не дублює байти. Воно отримує посилання на фізичні сторінки, де розміщені фрагменти мережевого пакета (skb_frag_t), і додає їхні адреси в комірки кільцевого буфера каналу.
  • Збільшення лічильника посилань: Ядро збільшує лічильник використання сторінки (page_ref_inc). Пакет залишається у фізичній пам'яті, тоді як початкові структури сокета безпечно звільняються.
  • Скерування у вихідний інтерфейс: При наступному виклику splice(2) з каналу у вихідний сокет мережевий стек приєднує ті самі сторінки пам'яті безпосередньо до таблиці розсіювання/збирання (scatter-gather list) для передавання мережевою картою через DMA.

Інтеграція з простором користувача через vmsplice(2)

Якщо splice(2) відповідає за передавання даних виключно всередині ядра, то в низці практичних сценаріїв проксі застосунку потрібно додати службовий заголовок, вставити мітку сесії або перевірити метадані маршрутизації. Для цього призначений виклик vmsplice(2).

Виклик vmsplice(2) відображає масив векторних буферів користувача (struct iovec) безпосередньо в канал ядра:

ssize_t vmsplice(int fd, const struct iovec *iov, unsigned long nr_segs, unsigned int flags);

За умови передавання прапорця SPLICE_F_GIFT простір користувача передає право власності на сторінки пам'яті ядру. Якщо сторінки вирівняні за межею пам'яті, ядро додає їх у кільце дескрипторів каналу без копіювання. Наступний виклик splice(2) спрямовує ці сторінки прямо до фізичного адаптера мережі.

Практична реалізація: Побудова ретранслятора Zero-Copy

Високопродуктивний транзитний проксі виділяє двоспрямовану пару каналів для кожного активного тунелю клієнта. Цикл передавання виконує неблокуючі виклики splice(2) під керуванням epoll(7):

#define _GNU_SOURCE
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>

#define RELAY_CHUNK_SIZE 65536

// Пересилання пакетів між вхідним та вихідним сокетами
int relay_socket_pair(int fd_in, int fd_out, int pipefd[2]) {
    // Етап 1: Перенесення даних із сокета в проміжний канал ядра
    ssize_t spliced_in = splice(fd_in, NULL, pipefd[1], NULL,
                                RELAY_CHUNK_SIZE,
                                SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
    
    if (spliced_in <= 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) return 0;
        return -1; // Сокет закрито або сталася помилка
    }

    // Етап 2: Перенесення даних із каналу у вихідний сокет
    ssize_t spliced_out = 0;
    while (spliced_out < spliced_in) {
        ssize_t written = splice(pipefd[0], NULL, fd_out, NULL,
                                 spliced_in - spliced_out,
                                 SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
        
        if (written <= 0) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // Дані залишилися в каналі; epoll сповістить про готовність
                break;
            }
            return -1;
        }
        spliced_out += written;
    }

    return spliced_out;
}

Налаштування розміру каналу

За замовчуванням Linux виділяє 16 сторінок (64 КБ) на кожен канал. При мультигігабітному навантаженні цей обсяг може створювати зворотний тиск. Адміністраторам рекомендується збільшувати розмір каналу за допомогою функції fcntl з опцією F_SETPIPE_SZ:

// Збільшення розміру буфера каналу до 1 МБ (256 сторінок)
fcntl(pipefd[0], F_SETPIPE_SZ, 1048576);

Системні ліміти каналів налаштовуються через параметри sysctl:

# Встановлення максимального ліміту місткості каналу до 4 МБ
sysctl -w fs.pipe-max-size = 4194304

Застосування в архітектурі RiceGuard VPN

Сетевий рушій RiceGuard від VPNRice використовує можливості внутрішньоядерного передавання даних на всій розподіленій інфраструктурі ретрансляції:

  1. Додавання заголовків через vmsplice: При інкапсуляції транзитних потоків RiceGuard формує службові заголовки в просторі користувача та передає їх через vmsplice(2) із прапорцем SPLICE_F_GIFT, виключаючи зайве дублювання буферів.
  2. Розвантаження магістральних ліній: На магістралях між дата-центрами, де зашифрований потік передається між точками присутності, обробники RiceGuard з'єднують клієнтські тунелі з магістральними каналами безпосередньо через splice(2).
  3. Зростання апаратної продуктивності: Під час стрес-тестів із навантаженням 40 Гбіт/с на 12 000 паралельних потоків заміна стандартного циклу recv(2)/send(2) на splice(2) зменшила завантаження процесора з 68% до 22%, одночасно скоротивши наскрізну затримку на 32%.

Поширені запитання

Чи може splice(2) передавати дані безпосередньо між двома сокетами без каналу?

Ні. В архітектурі ядра Linux щонайменше один із дескрипторів у виклику splice(2) має бути каналом (pipe). При цьому витрати на створення пари каналів мінімальні, а кільце буферів каналу працює як ефективна внутрішньоядерна черга.

Чи сумісний splice(2) із шифруванням TLS або WireGuard?

Так. Якщо криптографічні операції виконуються на рівні ядра (наприклад, Kernel TLS / kTLS або тунелі WireGuard у просторі ядра), виклик splice(2) взаємодіє напряму із сокетними потоками даних без перенесення пакетів у простір користувача.

Що відбувається в разі фрагментації мережевого пакета в буфері сокета?

Ядро Linux обробляє фрагментовані сокетні буфери цілком прозоро. Алгоритм splice(2) обробляє кожен фрагмент у списку scatter-gather і послідовно заповнює комірки кільцевого буфера pipe_buffer, повністю гарантуючи цілісність пакетів.