Проблема масштабирования: почему select и poll не справляются с нагрузкой
Высоконагруженные VPN-концентраторы операторского класса и корпоративные шлюзы удаленного доступа регулярно обслуживают десятки и сотни тысяч одновременных клиентских сессий на одном физическом сервере. В современных сетевых архитектурах каждый активный туннель поддерживает состояние сокета UDP или TCP, криптографические ключи сессии и таймеры периодических пакетов проверки активности (keepalive).
В ранних сетевых демонах мультиплексирование ввода-вывода строилось на классических системных вызовах BSD: select() и poll(). Несмотря на концептуальную простоту, оба интерфейса обладают линейной алгоритмической сложностью $O(N)$, что делает их непригодными для производительных шлюзов:
- Линейное сканирование при каждой итерации цикла событий:
select()иpoll()при каждом вызове передают массивы дескрипторов из пользовательского пространства в пространство ядра. Ядро вынуждено последовательно опрашивать каждый дескриптор из списка, даже если только на пяти сокетах из 50 000 есть входящие данные. По мере роста числа сессий процессор тратит почти все вычислительное время на сканирование неактивных сокетов. - Постоянное выделение памяти и копирование структур: При каждом обращении к
select()илиpoll()происходит повторное выделение памяти и передача объемных битовых масок через границу пользовательского пространства и ядра. Для шлюза на 100 000 клиентов пересылка мегабайтов метаданных десятки раз в секунду перегружает шину памяти и вытесняет кэш-линии процессора. - Фиксированный лимит дескрипторов: Стандарт POSIX ограничивает
select()константойFD_SETSIZE(как правило, 1024 дескриптора), вынуждая разработчиков создавать неэффективные многопроцессные архитектуры исключительно ради распределения дескрипторов.
Для обработки сотен тысяч соединений с детерминированной эффективностью $O(1)$ в Linux разработана специализированная подсистема event poll: epoll.
Архитектура подсистемы Linux epoll
Появившись в ядре Linux 2.6, механизм epoll устранил узкие места масштабирования благодаря разделению процесса наблюдения на две независимые фазы: регистрацию состояния дескриптора и получение готовых событий. Вместо передачи полного списка дескрипторов на каждой итерации ядро хранит структуру мониторинга в собственной памяти.
+-------------------------------------------------------------------------+
| Цикл событий VPN (User-Space Event Loop) |
+-------------------------------------------------------------------------+
| ^
| epoll_ctl(EPOLL_CTL_ADD / MOD / DEL) | epoll_wait()
v |
+-------------------------------------------------------------------------+
| Контекст ядра epoll |
| |
| +-----------------------------------------------------------------+ |
| | Красно-черное дерево отслеживания (rbr) | |
| | - Вставка, поиск и удаление сокетов за O(log N) | |
| | - Сохранение состояния между вызовами опроса | |
| +-----------------------------------------------------------------+ |
| | |
| v [Прерывание сетевой карты / Wakeup]|
| +-----------------------------------------------------------------+ |
| | Двусвязный список готовых событий (rdllist) | |
| | - Извлечение активных дескрипторов за O(1) | |
| +-----------------------------------------------------------------+ |
+-------------------------------------------------------------------------+
| |
v DMA сетевой кадр v Передача пакетов
+-------------------------------------------------------------------------+
| Физическая сетевая карта / Устройство TUN/TAP |
+-------------------------------------------------------------------------+
Три базовых системных вызова
Работа подсистемы опирается на три компактных вызова:
epoll_create1(int flags): Создает дескриптор epoll в пространстве ядра. ФлагEPOLL_CLOEXECгарантирует автоматическое закрытие дескриптора при вызовеexecve().epoll_ctl(int epfd, int op, int fd, struct epoll_event *event): Управляет красно-черным деревом (rbr), отслеживающим сокеты. Операции добавления (EPOLL_CTL_ADD), изменения (EPOLL_CTL_MOD) и удаления (EPOLL_CTL_DEL) выполняются за время $O(\log N)$ без полного копирования данных из пользовательского пространства.epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout): Приостанавливает поток до появления событий. Вызов возвращает массив, содержащий только те дескрипторы, на которых произошли события, гарантируя истинную масштабируемость $O(1)$ относительно общего числа подключений.
Сравнение режимов Level-Triggered и Edge-Triggered (EPOLLET)
По умолчанию epoll функционирует в режиме Level-Triggered (LT). Однако для шлюзов с высоким сетевым трафиком критически важно задействовать режим Edge-Triggered (ET) через флаг EPOLLET.
Level-Triggered (LT):
Данные доступны в буфере сокета ───────────────────────────────────────────>
epoll_wait() возвращает событие [ДА] [ДА] [ДА] [ДА] ...
(Срабатывает непрерывно, пока буфер не опустеет до 0 байт)
Edge-Triggered (ET - EPOLLET):
Поступление данных (смена состояния: пусто -> есть данные) ────────────────>
epoll_wait() возвращает событие [ДА] [НЕТ] [НЕТ] [НЕТ] ...
(Срабатывает только при смене состояния; буфер вычитывается полностью)
Недостатки Level-Triggered в многопоточных серверах
В режиме Level-Triggered системный вызов epoll_wait() продолжает генерировать уведомления до тех пор, пока в буфере сокета остаются непрочитанные байты. Если рабочий поток прочитал лишь часть зашифрованного пакета, последующие циклы продолжают пробуждать потоки. В многопоточных архитектурах это приводит к избыточным переключениям контекста и борьбе за блокировки.
Принципы работы Edge-Triggered (EPOLLET)
При установке флага EPOLLET ядро отправляет уведомление только в момент изменения состояния готовности сокета (например, при переходе из нечитаемого состояния в читаемое). Это снижает частоту генерации событий ядром более чем на 60% при интенсивном потоке трафика.
При этом режим Edge-Triggered накладывает строгие требования к коду:
- Обязательные неблокирующие сокеты: Все отслеживаемые дескрипторы должны быть переведены в неблокирующий режим с флагом
O_NONBLOCKчерезfcntl(). Если поток заблокируется на синхронномrecv(), весь цикл обработки событий остановится. - Циклическое чтение до
EAGAINилиEWOULDBLOCK: Поскольку повторное событие не поступит до прихода новой порции сетевых данных, рабочий поток обязан считывать данные в цикле до тех пор, пока системный вызов не вернет ошибкуEAGAIN.
// Шаблон вычитки сокета в режиме Edge-Triggered на C
while (1) {
ssize_t count = recv(client_fd, buffer, sizeof(buffer), 0);
if (count == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// Буфер полностью опустошен, возврат в epoll_wait
break;
}
// Обработка непредвиденной ошибки соединения
close(client_fd);
break;
} else if (count == 0) {
// Клиент закрыл соединение
close(client_fd);
break;
}
// Обработка расшифрованного пакета туннеля
process_vpn_packet(buffer, count);
}
Управление многопоточностью с флагом EPOLLONESHOT
При проектировании архитектур VPN-шлюзов с пулом рабочих потоков несколько потоков нередко разделяют общий экземпляр epoll. В такой схеме возникают две классические угрозы: проблема thundering herd и состояние гонки между потоками.
Риск состояния гонки между потоками
Представим, что Поток A и Поток B ожидают событий на общем дескрипторе epoll. На сокет 4 поступает порция UDP-пакетов. Поток A просыпается и начинает читать буфер. Пока Поток A занят расшифровкой заголовков, на сокет 4 приходит следующая пачка пакетов. Без дополнительной изоляции epoll_wait() разбудит Поток B для того же самого сокета.
В результате Поток A и Поток B начнут читать из одного сокета одновременно. Это приведет к нарушению порядка пакетов в криптографическом протоколе, сбою скользящего окна повторов и повреждению состояния сессии.
Решение через EPOLLONESHOT
Для предотвращения состояния гонки без тяжелых мьютексов на уровне приложения в Linux предусмотрен флаг EPOLLONESHOT.
1. epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, EPOLLIN | EPOLLET | EPOLLONESHOT)
- Сокет регистрируется с однократным уведомлением.
2. Приходят пакеты -> Поток A просыпается через epoll_wait().
- Ядро автоматически отключает последующие уведомления для sock_fd.
3. Приходят новые пакеты, пока Поток A выполняет дешифрование.
- Поток B не просыпается; sock_fd защищен от конкурентного доступа.
4. Поток A завершает обработку и полностью вычитывает сокет.
- Поток A вызывает: epoll_ctl(epfd, EPOLL_CTL_MOD, sock_fd, ... | EPOLLONESHOT)
- Сокет снова ставится на мониторинг для будущих событий.
Благодаря флагу EPOLLONESHOT ядро гарантирует, что в любой момент времени данный сокет обрабатывается ровно одним рабочим потоком, устраняя конкуренцию без потери производительности на многоядерных процессорах.
Масштабирование многопоточных концентраторов RiceGuard
В распределенной архитектуре VPNRice движок RiceGuard сочетает механизмы EPOLLET и EPOLLONESHOT с ускорением пакетной обработки на уровне ядра Linux, обеспечивая неблокирующее масштабирование на высоконагруженных узлах связи.
+-------------------------------------------------------------------+
| Рабочий шлюз сети VPNRice |
| |
| +-----------------------------------------------------------+ |
| | 100 000+ одновременных клиентских сессий | |
| +-----------------------------------------------------------+ |
| | |
| v |
| +-----------------------------------------------------------+ |
| | Главный диспетчер epoll (дерево rbr) | |
| | Конфигурация EPOLLET + EPOLLONESHOT | |
| +-----------------------------------------------------------+ |
| | | | |
| v v v |
| +-------------+ +-------------+ +-------------+ |
| | Поток 0 | | Поток 1 | | Поток 2 | |
| | Ядро CPU 0 | | Ядро CPU 1 | | Ядро CPU 2 | |
| | Дешифрование| | Дешифрование| | Дешифрование| |
| | ChaCha20 | | ChaCha20 | | ChaCha20 | |
| +-------------+ +-------------+ +-------------+ |
| | | | |
| v Повтор v Повтор v Повтор |
| epoll_ctl(MOD) epoll_ctl(MOD) epoll_ctl(MOD) |
+-------------------------------------------------------------------+
1. Распределение сессий без глобальных блокировок
RiceGuard связывает клиентские потоки с выделенными ядрами процессора. Изоляция контекстов соединений внутри независимых экземпляров epoll или пулов с EPOLLONESHOT позволяет криптографическим потокам работать без спинлоков. Согласование рукопожатий, проверка доступности и расшифровка трафика выполняются с субмиллисекундными задержками диспетчеризации.
2. Пакетная вычитка с помощью recvmmsg
Для снижения накладных расходов на системные вызовы при резких всплесках трафика RiceGuard объединяет краевые уведомления с вызовом recvmmsg(). При сигнале готовности от epoll_wait() рабочий поток считывает до 64 датаграмм за один системный переход, передавая пакетные векторы сразу в оптимизированные векторные инструкции SIMD (AVX-512 / NEON) для дешифрования ChaCha20-Poly1305.
3. Изоляция сетевых пространств имен
Каждый сегмент в архитектуре RiceGuard изолирован в выделенном сетевом пространстве имен Linux network namespace. Маршрутизация пакетов через epoll координируется строго внутри границ изоляции, исключая непреднамеренную утечку незашифрованного трафика на внешние физические интерфейсы. В сочетании с локальными шифрованными DNS-прокси эта событийно-ориентированная архитектура обеспечивает надежную защиту данных и предсказуемую пропускную способность под высокими сетевыми нагрузками.



