Высоконагруженные транзитные ретрансляторы 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, гарантируя целостность передаваемых пакетов.