Введение
Сокеты как фундамент сетевых приложений
Эволюция методов чтения сокетов
VPN service
Сетевая обработка
Синхронное многозадачное чтение
Многоканальное чтение
Распределение пакетов по каналам
eBPF
Специфические оптимизации и ограничения
Модифицированная архитектура
Заключение
Введение
В условиях стремительного роста объёмов интернет‑трафика особое значение приобретают высоконагруженные системы. Под этим термином понимают подкласс систем массового обслуживания, предназначенных для обработки большого числа запросов при минимальном времени отклика. Стабильность таких систем определяется их способностью сохранять работоспособность при увеличении нагрузки и обеспечивать предсказуемый рост задержек.
Для достижения требуемого уровня производительности применяется масштабирование. Его принято разделять на два основных подхода: вертикальное масштабирование, при котором увеличиваются вычислительные ресурсы отдельных узлов, и горизонтальное масштабирование, предполагающее добавление в систему дополнительных сервисов, обслуживающих запросы. Однако независимо от выбранного подхода сетевой уровень остаётся критическим фактором, влияющим на производительность. Это связано с тем, что сетевую часть невозможно масштабировать столь же гибко, как остальные компоненты системы.
Так, при вертикальном масштабировании увеличение числа процессоров ускоряет обработку запросов, но количество сетевых интерфейсов остаётся ограниченным: клиенты продолжают взаимодействовать с сетевым адресом, привязанным к конкретной сетевой карте. При горизонтальном масштабировании все запросы проходят через балансировщик нагрузки, который остаётся единственной точкой входа. В результате именно эффективность сетевой обработки может стать узким местом, нивелирующим преимущества масштабирования.
Как следствие, при проектировании высоконагруженных систем необходимо уделять особое внимание организации сетевой обработки. В моей практике эта задача особенно остро проявилась при разработке VPN‑сервиса, который можно рассматривать как типичный пример высоконагруженной системы. В статье будет показано, как применение нестандартных технических решений позволяет существенно повысить скорость сетевой обработки и, как следствие, улучшить общую производительность системы.
Сокеты как фундамент сетевых приложений
В современных вычислительных системах сетевое взаимодействие осуществляется посредством сокетов. Сокет представляет собой программный интерфейс, обеспечивающий обмен данными между узлами сети. Каждый узел идентифицируется уникальным сетевым адресом, а приложения, работающие с сетевым трафиком, дополнительно различаются по номерам портов. Для организации сетевого взаимодействия приложение создаёт сокет, связывает его с определённым портом и принимает входящие сообщения, адресованные этому порту.
Существуют два основных класса сокетов: потоковые (TCP) и датаграммные (UDP). Их ключевые отличия заключаются в следующем.
Установка соединения. UDP-сокет не требует предварительного установления соединения: данные можно передавать сразу после его создания. TCP-сокет, напротив, требует установления соединения перед началом передачи.
Передача данных. Сокет UDP передает данные в виде дататаграмм, т. е. независимых пакетов фиксированного размера. TCP передает данные как непрерывную последовательность байт, а их интерпретация возлагается на приложение, принимающее данные.
Доставка сообщений. UDP не гарантирует доставку: датаграмма либо доставляется целиком, либо теряется полностью. TCP обеспечивает гарантированную доставку всех переданных данных.
Скорость передачи. UDP является «легковесным» протоколом. Размер заголовка UDP сообщения составляет всего 8 байт, в то время как для TCP минимальный размер заголовка составляет 20 байт (но может быть и больше, в зависимости от опций протокола – вплоть до 60 байт). Поэтому в UDP объём передаваемых служебных данных минимальный, в результате чего нагрузка на сеть оказывается значительно меньше. Отсутствие механизмов гарантированной доставки снижает задержки, в результате чего скорость передачи данных в UDP сокетах быстрее по сравнению с UDP.
Интерфейс сокетов предлагает следующие базовые операции:
socket – создание нового сокета.
bind – связывание сокета с портом. После этой операции на соответствующий сокет будет поступать предназначенный ему сетевой трафик.
send – пересылка данных. В UDP cокетах адрес получателя задается дополнительным параметром, в TCP – используется сетевой адрес предварительно установленного соединения.
receive – получение данных. В UDP сокетах, помимо самих данных, возвращается сетевой адрес отправителя.
listen – ожидание входящих подключений. Применяется только для TCP сокетов.
connect – установление соединения. Используется только для TCP сокетов.
accept – прием запросов на установление подключения. Используется только для TCP сокетов. Для каждого подключения сервер создает отдельный через который будут поступать данные от клиента, установившего соединение.
close – закрытие сокета. Сокет перестает существовать; для TCP при этом разрывается соединение.
Эволюция методов чтения сокетов
Синхронное чтение
Наиболее простой способ чтения данных – это синхронное чтение, при котором используется блокирующий системный вызов. В функцию чтения данных передается дескриптор созданного сокета и указатель на буфер для приема данных Функция остается заблокированной до тех пор, пока в сокет не поступят данные. После их получения система записывает их в переданный буфер и возвращает управление. Операция повторяется циклически, пока сокет остается открытым.
Пример реализации синхронного чтения приведен в Листинг 1. В данном и последующих примерах опущено описание кода, связанного с созданием сокета, его привязкой к порту, подключением к серверу, а также созданием сокета при приеме подключения и аналогичных операций. Эти действия являются стандартными и в рассматриваемом контексте не представляют интереса. В центре внимания находится исключительно реализация чтения данных из сокетов при условии, что сокет уже создан и функционирует корректно.
Листинг 1. Синхронное чтение сокетов
nt fd = socket(AF_INET, SOCK_STREAM, 0); //Create the socket while (1) //Socket polling { char buf[1024]; //Declare data buffer ssize_t n = recv(fd, buf, sizeof(buf), 0); //Read data if (n < 0 ) //Error detected { //Close the socket and break the polling close(fd); break; } handle_data(fd, buf, n); //Process of received data }
Основным недостатком описанного подхода является необходимость создания отдельного потока для каждого сокета. Это обусловлено тем, что в одном потоке невозможно одновременно читать данные из группы сокетов: при чтении конкретного сокета функция не возвращает управление до поступления данных, хотя в это время они могут приходить в другие сокеты. В случае фиксированного числа сокетов, например при работе в режиме клиента или при использовании ограниченного числа UDP-соединений, допустимо создание отдельного потока для каждого сокета. Однако если количество сокетов заранее неизвестно, как, например, в случае TCP-сервера, обслуживающего тысячи клиентов, подобное решение становится практически неприменимым.
Неблокируемое чтение
Для обработки большого числа сокетов может применяться неблокирующий режим, в который они переводятся сразу после создания. Для получения данных используется тот же системный вызов, что и при синхронном чтении. Однако, поскольку сокет переведен в неблокирующий режим, данный вызов возвращает ошибку при отсутствии данных, после чего возможен переход к чтению из другого сокета.
Пример реализации неблокируемого чтения приведен в Листинг 2
Листинг 2. Неблокируемое чтение сокетов
unsigned int n_sockets = 0; //A number of opened sockets const unsigned int INITIAL_SIZE = 10; //Initial size of socket array int* fd = (int*)malloc(INITIAL_SIZE * sizeof(int)); //Declare socket array for (int i=0; i < n_sockets; ++i) { //Set non-blocking mode. We suppose sockets have been created fcntl(fd[i], F_SETFL, O_NONBLOCK); } bool operate = true; //Working sign while (operate) //Read cycle must be interrupted somewhere outside { char buf[1024]; //Declare data buffer for (int i=0; i < n_sockets; ++i) { ssize_t n = recv(fd[i], buf, sizeof(buf), 0); //Read data if (n < 0) //Error occured { if (errno == EAGAIN) ; //The kernel didn't have any data to read else handle_error(fd[i], errno); //Error, it is necessary to close appropriate socket } else handle_data(fd[i], buf, n); //Process of received data } }
Существенным недостатком описанного подхода является необходимость непрерывного циклического опроса всех сокетов. При этом для каждого сокета приходится выполнять системные вызовы, что приводит к крайне неэффективному использованию процессорного времени.
Чтение из выборки
Уменьшение числа системных вызовов может быть достигнуто посредством предварительного формирования выборки сокетов, в которые поступили данные. Для этой цели применяется системный вызов select, после чего для выбранных сокетов выполняется чтение данных стандартным способом, аналогичным синхронному вводу.
Пример реализации чтения выборки приведен в Листинг 3.
Листинг 3. Чтение сокетов из выборки
unsigned int n_sockets = 0; //A number of opened sockets const unsigned int INITIAL_SIZE = 10; //Initial size of socket array int* fd = (int*)malloc(INITIAL_SIZE * sizeof(int)); //Declare socket array fd_set readset; // A set of sockets bool operate = true; //Working sign while (operate) //Read cycle must be interrupted somewhere outside { int maxfd = -1; //Maximum value of opened socket descriptors FD_ZERO(&readset); //Initialization of socket set for (i=0; i < n_sockets; ++i) { if (fd[i] > maxfd) maxfd = fd[i]; //Correct maximum descriptor value FD_SET(fd[i], &readset); //Add descriptor to set } select(maxfd+1, &readset, NULL, NULL, NULL); //Wait until one or more fds are ready to read for (i=0; i < n_sockets; ++i) //Process sockets that are set in readset { if (FD_ISSET(fd[i], &readset)) //This socket are presented in set { ssize_t n = recv(fd[i], buf, sizeof(buf), 0); //Read data If (n < 0) // Error detected handle_error(fd[i], errno); else handle_datafd[i], buf, n); //Pocess data } }
Основным недостатком описанного подхода является ограниченная масштабируемость. В каждом цикле требуется заново инициализировать множество дескрипторов, выполнить системный вызов select, пройти по всем элементам множества, проверяя наличие данных, и осуществить операции чтения. При увеличении числа сокетов время обработки множества пропорционально возрастает. Дополнительно следует учитывать, что структура fd_set реализована в виде битовой маски фиксированного размера. Вследствие этого максимальное количество дескрипторов в наборе ограничено системной константой FD_SETSIZE, стандартное значение которой в Linux составляет 1024.
Событийно-ориентированное чтение
И, наконец, рассмотрим событийно-ориентированное чтение, которое фактически стало стандартом в современных системах. Принцип данного подхода заключается в том, что приложение не осуществляет опрос сокетов теми или иными способами, а подписывается на события, связанные с их состоянием. При возникновении соответствующего события, например поступления данных, система уведомляет приложение, после чего оно выполняет необходимые операции.
Операционные системы реализуют событийно-ориентированную работу с сокетами различными средствами: в Linux применяются механизмы epoll и io_uring, в macOS — kqueue, в Solaris — evports, в Windows — IOCP. Все перечисленные решения в той или иной форме следуют общей концепции. В качестве примера рассмотрим реализацию с помощью библиотеки epoll, приведенную в Листинг 4.
Листинг 4. Событийно-ориентированное чтение сокетов
#define MAX_EVENTS 10 //Declare maximum number of events to be returned for one cycle bool operate = true; //Working sign int epoll_fd = epoll_create(0); //Create epoll queue struct epoll_event events[MAX_EVENTS]; //Declare array of stored events while (operate) //Read cycle must be interrupted somewhere outside { int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); //Wait for events for (int i = 0; i < n; i++) //Handle of n received events { char buf[1024]; //Declare read data buffer int count = read(events[i].data.fd, buf, sizeof(buf)); //Read data. Socket descriptor is stored inside of event if (count <= 0) //Error occured close(events[i].data.fd); else handle_data(events[i].data.fd, buf, n); //Process received data } }
Следует отметить, что в настоящее время при разработке приложений системные библиотеки напрямую используются сравнительно редко. Вместо них, как правило, применяются кроссплатформенные фреймворки, предоставляющие высокоуровневые абстракции (например, ASIO, libevent, ACE и др.). Их внутренняя реализация опирается на системные механизмы конкретной платформы.
VPN service
Итак, мы рассмотрели различные подходы к чтению данных из сокетов. Теперь обратимся к тому, как эти механизмы используются в высоконагруженных системах. В качестве примера рассмотрим VPN‑сервис – типичный представитель подобных систем. В нашем случае он реализован на базе протокола WireGuard, который на сегодняшний день считается одним из самых производительных и эффективных решений для организации защищённых туннелей.
Модель туннелирования WireGuard изображена на Рисунок 1

WireGuard использует механизм маршрутизации по криптоключам (Cryptokey Routing): каждому публичному ключу сопоставляется список разрешённых IP‑адресов. Ключ одновременно идентифицирует участника и определяет, какие пакеты ему можно отправлять и принимать, связывая криптографическую аутентификацию с сетевой адресацией.
Две подсети соединяются защищённым VPN‑туннелем, построенным поверх публичной сети. Туннель формируется интерфейсами WireGuard, которые инкапсулируют IP‑пакеты в UDP‑сообщения. Интерфейс может выступать как клиент или сервер – роли симметричны и определяются инициатором соединения и маршрутизацией.
Работа протокола сводится к следующему. При получении IP‑пакета интерфейс ищет публичный ключ пира, которому разрешён адрес отправителя. Если ключ найден, пакет шифруется и отправляется на EndPoint пира. На принимающей стороне пакет расшифровывается, проверяется соответствие публичного ключа и допустимых IP‑адресов, после чего либо передаётся дальше, либо отбрасывается.
Таким образом, списки разрешённых IP‑адресов выполняют роль таблицы маршрутизации при отправке и механизма контроля доступа при приёме.
На практике протокол включает предварительный handshake (процедура установки соединения), в ходе которого стороны вырабатывают сессионные ключи и идентификаторы сессии. Эти детали выходят за рамки статьи; за дополнительной информацией можно обратиться к официальной документации WireGuard.
Архитектура сервиса изображена на Рисунок 2.

Клиентами VPN выступают компоненты, обеспечивающие сетевой обмен: приложения, браузеры, сетевые карты и другие. Передача информации осуществляется в виде IP‑пакетов, где указывается адрес получателя.
Точкой приёма пакетов от клиентов служит TUN‑интерфейс — виртуальный сетевой интерфейс, не связанный с физическим оборудованием и создаваемый посредством системных вызовов. VPN‑клиенты могут направлять пакеты в этот интерфейс различными способами: настроить маршрутизацию для определённых адресов, создать сокет и привязать его к TUN либо открыть устройство TUN и записывать пакеты напрямую.
С TUN‑интерфейсом взаимодействует приложение WG Agent, которое считывает поступающие пакеты. Далее они шифруются компонентом Crypto Engine в соответствии с правилами, описанными ранее, и через UDP‑сокет отправляются в сеть. На противоположной стороне туннеля работает аналогичное приложение: оно принимает пакеты из UDP‑сокета, расшифровывает и валидирует их с помощью Crypto Engine, а затем передаёт в собственный TUN‑интерфейс, откуда клиент получает предназначенные ему данные.
Мы отметили, что WG Agent выполняет операции шифрования и дешифрования пакетов, однако его функциональность этим не ограничивается. В реальности он также обеспечивает установление соединений, управление сессиями, проверку разрешений, обработку команд и другие задачи. Тем не менее, для упрощения дальнейшего изложения будем считать, что приложение занимается исключительно криптографической обработкой пакетов.
Сетевая обработка
Как мы видели, центральным звеном в организации сервиса является WG Agent. Рассмотрим, как в нём может быть реализована сетевая подсистема.
Для работы нам необходимы три компонента:
1) TUN интерфейс – для коммуникации с клиентами;
2) UDP сокет – для передачи пакетов между точками туннеля;
3) HTTPs сервер – для обфускации пакетов.
Последний компонент требует пояснения. Дело в том, что в публичных сетях UDP протоколы зачастую заблокированы, и единственным разрешенным протоколом в них является HTTP и HTTPs. Для работы VPN в этих условиях используется техника обфускации пакетов, т. е. передача пакетов через HTTPs запросы.
Обфускация работает следующим образом. Предварительно, VPN компоненты на обеих концах туннеля устанавливают HTTPs соединение. Сам IP пакет формируется точно таким же образом, как и при использовании UDP, но для его передачи UDP сокеты не используются. Cформированный пакет помещается в тело HTTPs запроса, этот запрос отправляется в другой конец туннеля. На другом конце пакет извлекается из запроса, обрабатывается, и отдается получателю. При указанном способе скорость передачи данных много ниже, чем при использовании UDP, зато VPN остается работоспособным.
Архитектура сетевой подсистемы изображена на Рисунок 3.

Для каждого сетевого компонента создается поток сетевой обработки, задачей которого является чтение входящих IP пакетов. Чтение осуществляется событийно-ориентированным способом: при поступлении пакета операционная система генерирует соответствующее событие, по которому поток читает пакет из компонента и отправляет его в очередь. С очередью работает набор потоков-воркеров, которые извлекают пакеты из очереди, обрабатывают их и отправляют в очередь на отправку. Отправка осуществляется отдельным потоком, который извлекает пакеты из очереди и в зависимости от назначения записывает пакеты либо в TUN, либо в сокет.
Как можно заметить из анализа схемы на Рисунок 3, в процессе обработки пакета выполняется множество операций. Совокупность действий, выполняемых от момента получения пакета из сети до его передачи получателю, образует так называемый критический путь (Рисунок 4). Его длина определяется количеством операций, а время прохождения – суммарным временем их выполнения. Эти параметры напрямую влияют на пропускную способность системы.

В нашем случае замеры, выполненные с помощью IPerf3, показали, что пропускная способность сети составляет 4.69 Гбит/с, тогда как пропускная способность нашего агента – лишь 246 Мбит/с. Разумеется, она по определению будет ниже сетевой, поскольку в процессе передачи пакеты проходят через ряд криптографических операций, требующих значительных вычислительных ресурсов. Тем не менее, полученное значение чрезвычайно мало, что указывает на наличие дополнительных узких мест.
Чтобы понять природу этих ограничений, рассмотрим результаты анализа производительности. Исследование показало, что низкая пропускная способность обусловлена следующими факторами:
1) Сетевой компонент обслуживается только одним потоком, поэтому пакеты читаются строго последовательно: пока текущий пакет не обработан, следующий ожидает своей очереди.
2) Длина критического пути составляет 9 операций, что является довольно большим значением.
3) Для каждого входящего пакета выполняется выделение памяти. Хотя сама операция быстрая, при непрерывном потоке данных её совокупные издержки становятся заметными.
4) Существенную задержку вносит очередь. Доступ к ней осуществляется в монопольном режиме, поэтому на время выполнения операций очередь блокируется.
5) Механизмы блокировки очереди приводят к переключениям контекста ядра, что дополнительно увеличивает задержку.
В совокупности эти факторы формируют значительные накладные расходы, и в итоге применение типового конструкторского решения – выделенного сетевого потока с очередью для параллельной обработки – приводит к неудовлетворительной производительности всей системы.
Синхронное многозадачное чтение
Очевидно, требуется пересмотр архитектуры сетевой подсистемы. Можно выделить следующие направления оптимизации:
1) Организовать многопоточное чтение;
2) Избавиться от очереди;
3) Сократить критический путь.
Организовать многопоточное чтение представляется возможным с помощью синхронного многозадачного чтения. Иными словами, создаётся набор потоков, которые читают сетевые компоненты (TUN и UDP) посредством синхронных системных вызовов. В иных условиях пришлось бы предусматривать синхронизацию между потоками, однако в нашем случае её обеспечивает сама операционная система: операции чтения данных из UDP‑сокетов и TUN‑интерфейса являются атомарными, то есть синхронизация выполняется на уровне ОС.
Примерная реализация синхронного многозадачного чтения приведена в Листинг 5.
Листинг 5. Синхронное многозадачное чтение
void* receive_data(void* arg) //Read data thread function { int sockfd = *(int*)arg; while (1) //Reading cycle { struct sockaddr_in client_addr; char buffer[BUFFER_SIZE]; //Data buffer ssize_t bytes_received = recvfrom(sockfd, buffer, BUFFER_SIZE, 0, (struct sockaddr*)&client_addr, &sizeof(client_addr)); //Read UDP socket if (bytes_received < 0) //Some error (socket might be closed) break; //Process the packet } } void ReadSocket() //Start read cycle { int sockfd = socket(AF_INET, SOCK_DGRAM, 0); //Create UDP socket struct sockaddr_in server_addr; /* You need to fill server_addr here */ bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)); //Bind the socket for (int i = 0; i < THREAD_COUNT; ++i) pthread_create(&threads[i], NULL, receive_data, &sockfd) //Create read thread
Отказ от очереди возможен за счёт обработки пакета в том же потоке, который выполняет чтение сетевого компонента. Таким образом, организация очереди перекладывается на сетевой стек ОС. При высокой нагрузке это потенциально может привести к потере пакетов, но проблема решается горизонтальным масштабированием агентов. Помимо устранения задержек, связанных с очередью, достигается дополнительный эффект: память для хранения пакета выделяется один раз и затем переиспользуется. Кроме того, операционная система не тратит время на формирование событий: при поступлении нового пакета он сразу размещается в области памяти, переданной в системный вызов, после чего последний возвращает управление.
Пересмотр архитектуры привел к трехкратному сокращению критического пути (Рисунок 5).

Использование синхронного многозадачного чтения может показаться шагом назад: как отмечалось в соответствующей главе, эволюция методов работы с сокетами стремилась уйти от синхронного режима, а мы к нему возвращаемся. Однако, как видно, инструменты, считающиеся устаревшими, при правильном применении могут обрести «второе дыхание».
Многоканальное чтение
В реализации синхронного многозадачного чтения существует узкое место: потоки обрабатывают пакеты последовательно, то есть каждый следующий поток ждёт, пока предыдущий завершит чтение очередного пакета. При небольшом числе потоков это почти незаметно, однако на высокопроизводительных серверах с десятками ядер такой эффект становится ощутимым. Операционная система предоставляет механизм, позволяющий избежать этих задержек, — использование каналов.
Для организации каналов создаётся набор сокетов, привязанных к одному и тому же порту, а также набор TUN‑интерфейсов с одинаковым именем. Для каждого экземпляра ОС формирует отдельный канал и самостоятельно распределяет входящие данные между ними.
Рассмотрим многоканальное чтение на примере сокетов (Листинг 6).
Листинг 6. Многоканальное чтение
void* receive_data(void* arg) //Read data thread function { int sockfd = socket(AF_INET, SOCK_DGRAM, 0); struct sockaddr_in server_addr; /* Fill server_addr */ int reuse = 1; //Assign option value //Assign multichannel mode setsockopt(sockfd, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse); //Bind the socket bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)); while (1) //Reading cycle { char buffer[BUFFER_SIZE]; struct sockaddr_in client_addr; ssize_t bytes_received = recvfrom(sockfd, buffer, BUFFER_SIZE, 0, (struct sockaddr*)&client_addr, &sizeof(client_addr)); //Read UDP socket if (bytes_received < 0) //Some error (socket might be closed) break; //Process the packet } void ReadSocket() { for (int i = 0; i < THREAD_COUNT; ++i) pthread_create(&threads[i], NULL, receive_data, &sockfd); }
Здесь запускаются несколько потоков, каждый из которых читает свой сокет. В отличие от предыдущего примера, где сокет создавался один раз и передавался в потоковую функцию, здесь каждый поток создаёт собственный сокет и устанавливает для него флаг SO_REUSEPORT. После этого все сокеты привязываются к одному порту, и операционная система формирует отдельную очередь для каждого из них.
Работа с TUN‑интерфейсами организуется аналогично, за исключением того, что для создания интерфейса и операций чтения/записи используются другие системные вызовы. Однако применение каналов для TUN снижает переносимость решения: поддержка многоканального чтения реализована только в Linux, тогда как другие операционные системы этот механизм не предоставляют.
Возникает вопрос: в какой канал следует отправлять пакеты, если все они связаны с одним и тем же интерфейсом? В общем случае отправку можно выполнять в любой из созданных каналов. Однако, чтобы избежать перегрузки отдельных экземпляров, рекомендуется циклически распределять пакеты по каналам (алгоритм Round Robin).
Но здесь важный нюанс: некоторые логические протоколы требуют строгого соблюдения очередности запросов и ответов. В таких случаях необходимо запоминать, из какого канала был получен очередной пакет, и, если требуется отправить ответ, направлять его через тот же сокет.
Распределение пакетов по каналам
Как операционная система распределяет пакеты по каналам? Механизм достаточно прямолинейный. ОС анализирует заголовок входящего пакета и вычисляет хэш по четырём полям: адрес отправителя, порт отправителя, адрес получателя и порт получателя. Разрядность хэша выбирается так, чтобы соответствовать количеству созданных каналов. Полученное значение напрямую определяет номер канала, в который будет направлен пакет.
Судя по всему, такой алгоритм распределения выбран для того, чтобы сохранять порядок доставки пакетов между конкретными отправителями и получателями. Однако в нашем случае пакеты на VPN‑сервер приходят из одной и той же точки туннеля. Это полностью нивелирует преимущества многоканального чтения: заголовки всех пакетов содержат одинаковые адреса отправителя и получателя, поэтому хэш всегда будет одинаковым. В итоге все пакеты будут попадать в один и тот же канал.
Один из способов обойти эту проблему – использовать несколько сокетов‑отправителей. Приложение создаёт набор сокетов с разными портами и поочерёдно отправляет пакеты через них. Поскольку у каждого сокета свой порт, заголовки пакетов различаются, и хэш распределения тоже будет разным.
Несмотря на простоту реализации, описанный подход имеет определенные недостатки. Во‑первых, усложняется протокол: сервер начинает получать пакеты от одного и того же приложения, но с разных портов. Во‑вторых, диапазон возможных значений хэша очень мал (он ограничен числом каналов), поэтому вероятность коллизий остаётся высокой – разные заголовки могут давать одинаковый хэш. К счастью, существует альтернативный способ распределения пакетов по каналам – использование eBPF‑программ, которые позволяют гибко управлять логикой выбора канала на уровне ядра.
eBPF
В Linux изначально существовал механизм BPF – акроним от Berkeley Packet Filter. Он предназначался для фильтрации пакетов на уровне ядра и представлял собой встроенную виртуальную машину, способную загружать и выполнять простой пользовательский байт‑код.
Однако возможности классического BPF были сильно ограничены: нельзя было хранить состояние, набор инструкций был минимальным, а логика фильтрации – примитивной. Чтобы обойти эти ограничения, был разработан eBPF (extended BPF). Со временем он превратился в полноценный универсальный инструмент, позволяющий запускать пользовательские программы прямо в контексте ядра.
Рабочий цикл eBPF выглядит так: основная программа загружает eBPF‑код в ядро, привязывает его к определённому хуку (событию, которое должно запускать eBPF), и при возникновении этого события ядро вызывает соответствующую eBPF‑функцию.
eBPF‑программы обычно пишутся на ограниченном подмножестве языка C и компилируются с помощью компилятора clang. Поддержка разработки eBPF‑программ также существует в языке Rust, однако она пока остаётся ограниченной и недостаточно зрелой. Сами eBPF‑программы выполняются в контексте ядра, а управление ими осуществляется из пользовательского пространства. В этом отношении практически все современные языки программирования предоставляют средства для загрузки и управления eBPF‑программами.
Нужно отдать должное, как разработчики технологии решили проблему хранения состояния, под которым понимается содержимое памяти программы. Ведь eBPF программа работает в контексте ядра, а здесь некорректные операции с памятью могут привести к фатальным последствиям, вплоть до краха системы. Так вот, состояние eBPF хранится в пользовательском пространстве, а обмен переменными осуществляется через карты формата «ключ-значение». В этом случае при некорректной работе с памятью произойдет аварийное завершение пользовательского процесса, но операционная система не повреждается.
Уникальность eBPF проявляется в том, что она позволяет модифицировать поведение ядра ОС без изменения его кода или загрузки новых модулей. Эта технология находит применение в самых различных областях: фильтрация сетевого трафика, балансировка нагрузки, профилирование, отладка, и т. п. Рассмотрим, как она может быть реализована для выбора канала сокета (Листинг 7).
Листинг 7. eBPF программа для чтения сокетов
//Declare map struct { __uint(type, BPF_MAP_TYPE_ARRAY); //A key is a number __uint(max_entries, 1); //Map contains only one value __type(key, __u32); //Type of key is 32-bit number __type(value, __u32); //Type of value is 32-bit number } rr_index SEC(".maps"); SEC("sk_reuseport/select") //Declare hook: it will be called on packet receiving int select_sock(struct sk_reuseport_md *ctx) //Packet info is passed as input parameter { //Initialize a key __u32 key = 0; //Load the last stored channel index from map __u32 *index = bpf_map_lookup_elem(&rr_index, &key); //In case a map is not initialized if (!index) return 0; //Calculate the following index. reuseport_array_size is a number of created sockets __u32 selected = (*index + 1) % ctx->reuseport_array_size; //Store new calculated index in map bpf_map_update_elem(&rr_index, &key, &selected, BPF_ANY); //Return selected socket index to kernel return selected; } char LICENSE[] SEC("license") = "GPL";
В результате компиляции исходного кода формируется объектный файл, содержащий байт‑код eBPF‑программы. Основное приложение загружает этот файл в ядро и привязывает программу к нужному сокету или другому доступному хуку. Для этих операций используется библиотека libbpf, предоставляющая удобный набор функций для работы с eBPF‑объектами, их инициализации и привязки к соответствующим точкам в подсистемах ядра (Листинг 8).
Листинг 8. Загрузка eBPF и привязка к сокетам
//Declare a number of channels #define NUM_SOCKS 4 //Open eBPF file struct bpf_object *obj =bpf_object__open_file("reuseport_rr.o", NULL); //Load eBPF to kernel bpf_object__load(obj); //Find our program that we declared in eBPF code struct bpf_program *prog =bpf_object__find_program_by_name(obj,"select_sock"); //Get program descriptor int prog_fd = bpf_program__fd(prog); //Find map by name that we declared in eBPF code struct bpf_map *map =bpf_object__find_map_by_name(obj, "rr_index"); //Get map descriptor int map_fd = bpf_map__fd(map); //Initialize key uint32_t key = 0; //Initialize value (the very first socket index in group) uint32_t initial_value = 0; //Write value to the map bpf_map_update_elem(map_fd, &key, &initial_value, BPF_ANY); // Create sockets for (int i = 0; i < NUM_SOCKS; i++) { //Create socket int fd = socket(AF_INET, SOCK_DGRAM, 0); //Initialize option value int opt = 1; //Set socket multi-channel mode setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &opt,sizeof(opt)); //Attach eBPF program to the created socket setsockopt(fd, SOL_SOCKET,SO_ATTACH_REUSEPORT_EBPF, &prog_fd, sizeof(prog_fd)); } //Socket with attached selector is created. Further, bind it to the port and run listen threads
Как видно, модули eBPF‑программы, загружаемые при старте приложения, необходимо где‑то хранить. Это не всегда удобно, особенно если таких модулей несколько. В качестве альтернативы их можно встроить непосредственно в приложение. Рассмотрим, как это делается.
Сначала при помощи утилиты xxd преобразуем содержимое скомпилированного объектного файла в заголовочный файл C, содержащий объявление массива с данными этого файла. Например, команда
xxd -i reuseport_rr.o > reuseport_rr.o.h
сгенерирует заголовочный файл reuseport_rr.o.h, показанный в Листинг 9.
Листинг 9. Заголовочный файл содержимого скомпилированного объектного файла после выполнения xxd
unsigned char reuseport_rr_o[] = { 0x7f, 0x45, 0x4c, 0x46, ....... }; unsigned int reuseport_rr_o_len = 1234;
Далее в коде приложения, в месте загрузки eBPF‑модуля, подключаем этот файл:
#include "reuseport_rr.o.h”
После этого вместо загрузки программы из файла, то есть вызова
bpf_object__open_file("reuseport_rr.o", NULL)
мы загружаем ее из памяти:
bpf_object__open_mem(reuseport_rr_o, reuseport_rr_o_len, NULL)
Таким образом, объектные файлы eBPF‑программы больше не требуется хранить отдельно: их содержимое включается в итоговый исполняемый модуль на этапе компиляции.
В заключение кратко обозначим особенности реализации eBPF для TUN‑интерфейсов. Концептуально она основана на тех же принципах, что и для сокетов, однако практическая реализация оказывается сложнее. В eBPF‑программу не передаётся общее число созданных TUN-интерфейсов, поэтому их количество необходимо дополнительно передавать через карту вместе с индексом текущего канала. Кроме того, если при настройке сокетов для привязки eBPF достаточно указать соответствующую опцию, то в случае TUN после загрузки программы в ядро требуется вручную устанавливать хук, что связано с дополнительными системными вызовами.
Следует подчеркнуть, что поддержка многоканального чтения TUN реализована исключительно в Linux; другие операционные системы такой возможности не предоставляют.
Специфические оптимизации и ограничения
До настоящего момента внимание уделялось оптимизациям, связанным преимущественно с сетевой подсистемой. Теперь рассмотрим аспекты, обусловленные спецификой функционирования протокола WireGuard.
В архитектуре WireGuard различают два класса пакетов: транспортные и управляющие. Первые обеспечивают передачу пользовательских данных, вторые — реализацию служебных функций протокола. К управляющим, в частности, относятся пакеты handshake, которыми обмениваются клиент и сервер при установлении соединения. Их обработка существенно более ресурсоёмка, поскольку требует выполнения большого числа криптографических операций, тогда как транспортные пакеты обрабатываются значительно быстрее.
Для поддержания стабильной пропускной способности критически важно минимизировать задержки при обработке транспортных пакетов. В случае управляющих пакетов требования к задержкам менее жёсткие: обмен ими происходит относительно редко, и пользователю допустимо ожидание при установлении соединения. Однако следует учитывать возможность кратковременных всплесков нагрузки, например, когда множество клиентов одновременно инициируют процедуру подключения, что приводит к необходимости параллельной обработки значительного числа управляющих пакетов.
Исходя из этих особенностей, рационально разграничить механизмы обработки транспортных и управляющих пакетов. Потоки, принимающие данные, определяют тип пакета: транспортные обрабатываются немедленно, тогда как управляющие помещаются в очередь. Последующая обработка очереди осуществляется потоками-воркерами, которым назначается пониженный приоритет относительно потоков, непосредственно принимающих данные из сокетов.
Следует отметить, что рассмотренные в предыдущих разделах методы оптимизации относились к работе с UDP-сокетами. Однако в нашем сервисе для обфускации пакетов используется протокол HTTPS, функционирующий поверх TCP. В этом случае описанные подходы неприменимы по ряду причин.
Во-первых, TCP является протоколом с установлением соединения: для каждого соединения создаётся отдельный сокет. Следовательно, невозможно заранее предсказать количество одновременно устанавливаемых соединений и, соответственно, число потоков, необходимых для чтения данных.
Во-вторых, TCP реализует потоковую модель передачи: данные поступают не в виде дейтаграмм, а в виде последовательных порций, причём их разделение определяется внутренними механизмами протокола. Это означает, что сообщения должны собираться из полученных порций строго в том порядке, в котором они были отправлены.
По указанным причинам многопоточное чтение TCP-сокетов невозможно. Для них применяется классическая схема обработки данных через очередь, что обеспечивает корректность сборки сообщений и согласованность передачи.
Модифицированная архитектура
Архитектура, модифицированная с учётом рассмотренных методов оптимизации, представлена на Рисунок 6.

Для обработки входящих данных создаётся набор потоков, осуществляющих чтение из сокета и TUN‑интерфейса. Количество потоков должно быть согласовано с числом доступных ядер процессора, чтобы избежать избыточного переключения контекста и деградации производительности.
При поступлении пакета выполняется его классификация: транспортные пакеты немедленно передаются на обработку и доставку конечному потребителю, тогда как управляющие пакеты помещаются в очередь. Обслуживание очереди осуществляется пулом потоков‑воркеров, что обеспечивает масштабируемость и равномерное распределение нагрузки. Аналогичным образом пакеты, поступающие от HTTPS‑сервера, также направляются в очередь для дальнейшей обработки.
Что у нас теперь с производительностью? Замеры, выполненные с помощью IPerf3 для обновленной архитектуры, демонстрируют следующие результаты:
– пропускная способность сети составляет 4.69 Гбит/с;
– пропускная способность агента на базе исходной архитектуры – 246 Мбит/с;
– пропускная способность агента на базе оптимизированной архитектуры – 1564 Мбит/с.
Как видим, применение рассмотренных методов оптимизации обеспечило рост производительности более чем в шесть раз! Потрясающий результат, подтверждающий эффективность предложенных решений.
Заключение
Итак, какие выводы можно сделать из полученных результатов?
Использование типовых решений, таких как события и очереди, может приводить к неоптимальным результатам. Поэтому необходимо учитывать конкретные особенности функционирования системы и подбирать инструменты, наиболее подходящие для данных условий. В нашем случае такой особенностью является обработка датаграмм, что позволяет применять синхронное чтение и отказаться от очередей. Кроме того, наличие различных типов пакетов открывает возможность организовать их обработку дифференцированными способами.
Особое значение имеет критический путь. Минимизация его длины является ключевым фактором увеличения пропускной способности. В нашем примере скорость обработки пакетов осталась неизменной, однако сокращение критического пути обеспечило значительный прирост производительности.
Что касается выбора сетевого протокола, то решение между UDP и TCP определяется прежде всего требованиями, предъявляемыми к системе. При этом следует учитывать, что в вопросах оптимизации сетевого взаимодействия инструментарий UDP значительно богаче. В ряде случаев целесообразно усложнить логический протокол обмена ради использования UDP‑сокетов.
Таким образом, рассмотренный пример наглядно подтверждает, что выбор архитектурного решения для сетевой обработки оказывает определяющее влияние на производительность всей системы. Представленные подходы могут быть применены при проектировании как систем реального времени, так и высоконагруженных сервисов, где критически важны скорость отклика и устойчивость к нагрузкам. В конечном счёте речь идёт не столько о выборе отдельных инструментов, сколько о формировании целостной архитектурной стратегии, способной адаптироваться к изменяющимся условиям и обеспечивать устойчивое развитие системы.