Бар'єр паралелізму: чому select та poll вичерпують ресурси на масштабі
Високонавантажені концентратори VPN операторського рівня та корпоративні шлюзи віддаленого підключення часто підтримують десятки й сотні тисяч одночасних клієнтських з'єднань на одному фізичному сервері. У сучасних мережевих топологіях кожен робочий тунель зберігає активний стан сокета UDP або TCP, криптографічні ключі сесії та таймери перевірки зв'язку (keepalive).
На початкових етапах розвитку мережевих служб мультиплексування введення-виведення спиралося на стандартні системні виклики сімейства BSD: select() та poll(). Попри простоту реалізації, обидва механізми мають лінійну обчислювальну складність $O(N)$, через що вони виявляються непридатними для високонавантажених шлюзів:
- Лінійний перебір на кожній ітерації циклу подій:
select()таpoll()щоразу копіюють масиви дескрипторів із простору користувача в простір ядра. Ядро змушене послідовно перевіряти кожен дескриптор зі списку спостереження, навіть якщо лише п'ять сокетів із 50 000 мають готові до читання зашифровані кадри. Із ростом кількості підключень процесор витрачає переважну частину тактів на опитування пасивних сокетів. - Постійне виділення пам'яті та дублювання метаданих: Кожен виклик
select()абоpoll()вимагає повторного виділення пам'яті та передачі масивних бітових масок через системну межу. Для шлюзу зі 100 000 клієнтів пересилання мегабайтів інформації десятки разів на секунду перевантажує шину оперативної пам'яті та вимиває кеш-рядки CPU. - Жорсткі обмеження на дескриптори файлів: Стандарт POSIX обмежує
select()константоюFD_SETSIZE(зазвичай 1024 дескриптори), що змушувало створювати громіздкі багатопроцесні архітектури лише для розподілу файлових дескрипторів.
Для стабільного опрацювання сотень тисяч одночасних потоків із детермінованою швидкодією $O(1)$ операційна система Linux пропонує механізм event poll: epoll.
Архітектура підсистеми Linux epoll
Підсистема epoll, додана в ядро Linux 2.6, долає проблему масштабування за рахунок розмежування моніторингу на два незалежні процеси: реєстрацію дескрипторів та отримання подій готовності. Замість передавання повного списку сокетів під час кожного опитування, ядро утримує постійне дерево станів у власній пам'яті.
+-------------------------------------------------------------------------+
| Цикл подій 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: Оскільки epoll не надішле нове сповіщення до появи наступної партії пакетів, потік повинен зчитувати дані в циклі до отримання помилки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_wait() на спільному epoll-дескрипторі. До сокета 4 надходить порція UDP-пакетів. Потік A активується та розпочинає читання буфера. Поки Потік A займається розшифруванням криптографічного заголовка, до сокета 4 прибуває наступна партія пакетів. Без додаткових обмежень epoll_wait() розбудить Потік B для того самого сокета.
У результаті Потік A та Потік B почнуть одночасно читати з сокета 4, що зруйнує послідовність криптографічних блоків, пошкодить вікно захисту від повторів (replay window) та порушить цілісність стану клієнтської сесії.
Вирішення проблеми через 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+ одночасних клієнтських сесій (UDP / TCP) | |
| +-----------------------------------------------------------+ |
| | |
| 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 розподіляє вхідні потоки даних між закріпленими за ядрами CPU потоками. Закріплення контекстів сесій за незалежними інстансами epoll або спільними пулами з EPOLLONESHOT дозволяє потокам дешифрування обробляти трафік без використання глобальних спінлоків. Встановлення з'єднань, контроль активності та дешифрування пакетів відбуваються з субмілісекундною затримкою.
2. Пакетна вичитка за допомогою recvmmsg
Для зниження накладних витрат на системні переходи під час спалахів трафіку RiceGuard застосовує режим Edge-Triggered разом із викликом recvmmsg(). Отримавши сигнал готовності від epoll_wait(), робочий потік зчитує до 64 датаграм за один виклик і одразу передає вектори у векторні інструкції SIMD (AVX-512 / NEON) для обробки криптографічним алгоритмом ChaCha20-Poly1305.
3. Ізоляція мережевих просторів імен
Кожен сегмент мережі RiceGuard функціонує в окремому просторі імен Linux network namespace. Маршрутизація пакетів засобами epoll виконується виключно в межах визначеного простору, що запобігає витоку незашифрованих даних на фізичні зовнішні інтерфейси. Разом із локальними зашифрованими проксі-службами DNS ця архітектура забезпечує максимальну конфіденційність, надійність з'єднання та стабільну продуктивність корпоративних систем.



