Сервер, держащий десятки тысяч одновременных соединений, не может позволить себе отдельный поток на каждого клиента. Память кончится, планировщик задохнётся на переключениях контекста. Поэтому вся высоконагруженная сетевая разработка под Linux крутится вокруг одного вопроса: как одному потоку обслуживать тысячи соединений сразу, не простаивая и не опрашивая каждое вхолостую. На этот вопрос есть два принципиально разных ответа, и разделяет их не скорость, а сама философия работы с вводом-выводом.

Первый ответ зовётся epoll и царствует с начала двухтысячных. Второй, io_uring, появился в ядре 5.1 в 2019 году и до сих пор вызывает споры о том, действительно ли он быстрее. Понять разницу между ними значит понять разницу между уведомлением о готовности и выполненной за тебя работой.

Почему select и poll задыхались, а epoll решил проблему десяти тысяч соединений

Чтобы оценить epoll, нужно вспомнить, от чего он избавил. Старые механизмы select и poll работали по принципу полной переописи. На каждый вызов приложение передавало ядру весь список наблюдаемых дескрипторов целиком, ядро линейно обходило их все, проверяя готовность, и возвращало результат. При сотне дескрипторов это терпимо. При десяти тысячах ядро на каждый вызов перебирает десять тысяч записей, и расходы растут линейно с числом соединений. Именно это упиралось в знаменитую проблему десяти тысяч соединений, проблему C10K.

epoll перевернул схему через регистрацию состояния. Приложение один раз сообщает ядру список интересующих дескрипторов, и ядро запоминает его, поддерживая внутреннюю структуру. При вызове ожидания ядру не нужно перебирать всё заново, оно возвращает только те дескрипторы, на которых действительно что-то изменилось. Список не пересылается каждый раз, регистрация происходит однажды. Благодаря этому epoll остаётся эффективным даже на тысячах соединений и сегодня лежит в основе таких серверов, как Nginx и HAProxy. Базовый цикл строится на трёх вызовах:

int ep = epoll_create1(0);

struct epoll_event ev;
ev.events = EPOLLIN;               /* интересует готовность к чтению */
ev.data.fd = listen_fd;
epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, &ev);   /* регистрируем один раз */

struct epoll_event events[64];
for (;;) {
    int n = epoll_wait(ep, events, 64, -1);     /* спим до событий */
    for (int i = 0; i < n; i++) {
        handle_ready_fd(events[i].data.fd);     /* обрабатываем готовые */
    }
}

В чём подвох уровневого и перепадного режимов срабатывания

У epoll есть два режима уведомления, и непонимание разницы между ними порождает самые коварные баги высоконагруженных серверов. Уровневый режим, level-triggered, работает по умолчанию: пока в сокете есть непрочитанные данные, epoll_wait будет сообщать о готовности снова и снова при каждом вызове. Это прощает ошибки. Прочитал не всё, не страшно, в следующий раз напомнят.

Перепадной режим, edge-triggered, включается флагом EPOLLET и ведёт себя жёстче. Он сообщает о готовности лишь однажды, в момент изменения состояния, при появлении новых данных. Если приложение прочитало не всё, что было в буфере, повторного уведомления не будет, и остаток данных зависнет невостребованным, а соединение словно застрянет. Документация ядра прямо предупреждает об этой типичной ловушке. Правило перепадного режима железное: получив уведомление, нужно читать из сокета в неблокирующем цикле до тех пор, пока чтение не вернёт ошибку о том, что данных больше нет. Только полное вычерпывание буфера за один раз делает перепадной режим корректным.

Зачем тогда вообще нужен более опасный режим? Ради меньшего числа лишних пробуждений в высоконагруженных сценариях и более тонкого контроля над логикой повторной постановки на наблюдение. Опытные серверы комбинируют перепадной режим с флагами вроде EPOLLONESHOT для одноразового уведомления и EPOLLEXCLUSIVE для борьбы с эффектом стада, когда одно событие на слушающем сокете будит сразу множество ожидающих потоков. Типичная архитектура высоконагруженного сервера ставит по одному экземпляру epoll на ядро процессора и распределяет приём соединений через опцию переиспользования порта.

Перепадной режим обязывает держать все сокеты неблокирующими. Иначе цикл вычерпывания буфера до конца упрётся в блокирующее чтение и остановит весь поток обработки на одном соединении, заморозив тысячи других. Неблокирующий режим превращает попытку чтения из пустого буфера в мгновенный возврат ошибки о временной недоступности данных, и это ровно тот признак, по которому цикл понимает, что вычерпал всё и пора возвращаться к ожиданию. Связка перепадного режима, неблокирующих сокетов и цикла чтения до исчерпания образует единый неделимый паттерн: выбросишь любой элемент, и сервер начнёт либо терять данные, либо зависать.

Сколько системных вызовов стоит один запрос при подходе epoll

Главная скрытая цена epoll кроется в самой его природе. epoll не выполняет ввод-вывод. Он лишь уведомляет, что операцию теперь можно выполнить без блокировки. Сама операция остаётся за приложением и требует отдельного системного вызова. Проследим путь одного запроса в типичном эхо-сервере: пробуждение на epoll_wait по готовности к чтению, затем recv для чтения данных, затем send для отправки ответа, и при использовании одноразового режима ещё epoll_ctl для повторной постановки на наблюдение. Итого три-четыре системных вызова на единственный логический запрос.

Каждый системный вызов это переход из пользовательского режима в режим ядра и обратно, а такой переход не бесплатен. На обычных файлах ситуация ещё показательнее: для них epoll практически бесполезен, потому что готовность к чтению сообщается немедленно, а реальные данные всё равно подтягиваются последующим синхронным вызовом чтения. Объединить разнородные операции в пакет epoll не умеет, ядерного опроса, обнуления копирований и встроенных таймеров у него нет. Эти ограничения и стали почвой, на которой вырос io_uring.

Как io_uring заменяет уведомление о готовности выполненной операцией

io_uring строится на совершенно другой идее. Вместо того чтобы сообщать приложению, когда операцию можно сделать, ядро делает операцию само и сообщает, когда она уже завершена. Это переход от модели готовности к модели завершения, и в нём вся суть.

Технически io_uring это два кольцевых буфера в разделяемой памяти между приложением и ядром. Очередь подачи, куда приложение кладёт запросы на операции, и очередь завершения, откуда забирает результаты выполненных. Поскольку буферы общие, во многих случаях передать ядру пачку запросов и забрать пачку результатов можно вообще без системных вызовов, просто записав в разделяемую память. Один универсальный интерфейс покрывает сетевой ввод-вывод, файловый ввод-вывод, таймеры и отмену операций, тогда как epoll требует разных механизмов под разные задачи. Минимальная отправка чтения через библиотеку liburing выглядит так:

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, client_fd, buf, sizeof(buf), 0);  /* готовим recv */
io_uring_submit(&ring);                                    /* отправляем пачку */

struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);          /* ждём завершения */
int bytes = cqe->res;                    /* res это результат операции */
io_uring_cqe_seen(&ring, cqe);           /* помечаем как обработанное */

Поле res в записи завершения это и есть результат самой операции, число прочитанных байт или код ошибки, словно приложение само вызвало recv, только сделало это ядро асинхронно. Дальше начинаются режимы, ради которых io_uring и затевался. Режим опроса очереди подачи, SQPOLL, запускает в ядре отдельный поток, который непрерывно сам вычитывает новые запросы из очереди подачи. В установившемся состоянии приложение не входит в ядро вообще, ноль системных вызовов на отправку работы. Ещё io_uring умеет сцеплять операции в цепочку: прочитать из файла, затем записать в сокет, затем сбросить на диск, и всё это подаётся единым связанным набором, который ядро выполняет по порядку, не возвращаясь в пользовательское пространство между шагами.

Почему быстрее не всегда означает лучше для конкретной нагрузки

Здесь начинается отрезвление, которое индустрия проходила на своих ошибках. Само сообщество io_uring так и не пришло к единому мнению о превосходстве над epoll. Одни разработчики утверждают, что io_uring заметно быстрее, другие что разница нулевая, третьи что в отдельных сценариях io_uring даже проигрывает. Аккуратный количественный разбор расставил всё по местам через тип нагрузки.

На нагрузке типа пинг-понг, где запрос и ответ чередуются короткими порциями, io_uring стабильно обгоняет epoll, экономя на системных вызовах. А вот на потоковой нагрузке, где данные льются непрерывным потоком, io_uring неожиданно может оказаться медленнее epoll. Причина в накладных расходах самой асинхронной машинерии, которая на сплошном потоке не успевает себя окупить. Отсюда практический парадокс: разработчики библиотек асинхронного ввода-вывода нередко проверяли io_uring и обнаруживали отсутствие выигрыша на малых масштабах, отчего оставались на epoll.

Известна поучительная история команды, переписавшей шлюз с обработки на epoll на io_uring ради обещанного сорокапроцентного прироста. Полгода работы и сотни тысяч строк переписанного кода спустя выяснилось то, о чём громкие тесты умалчивали: они меряют изолированные искусственные сценарии, а не живую нагрузку. Реальный выигрыш оказался куда скромнее обещанного. Урок прост: io_uring силён там, где приложение умеет пакетировать запросы, регистрировать горячие буферы заранее и аккуратно управлять жизненным циклом колец и буферов. Использовать io_uring как простую замену epoll на нагрузке, не упирающейся в ввод-вывод, означает получить нулевой или отрицательный эффект.

Какие границы и риски стоит держать в голове при выборе

Помимо производительности есть соображения зрелости и безопасности. epoll проще и переносимее, его семантика готовности предсказуема и работает на огромном спектре ядер и дистрибутивов. io_uring моложе и завязан на относительно свежие ядра, а его сложность обернулась длинной чередой уязвимостей. Это привело к тому, что в ряде окружений интерфейс отключают целиком из соображений безопасности, и приложение должно уметь откатиться на epoll, если io_uring недоступен.

Есть и тонкость многопоточности. io_uring не поддерживает разделение на уровневый и перепадной режимы, его модель ввода-вывода принципиально иная, и потому он не вытесняет epoll, а сосуществует с ним как отдельный инструмент. Разработчики ряда библиотек прямо отмечают, что заменить epoll на io_uring внутри существующей архитектуры нельзя именно из-за разной семантики, и логичнее строить отдельную реализацию.

Стоит сказать и о доступности в реальных окружениях. epoll есть на любом сколько-нибудь современном ядре и нигде не блокируется. io_uring же завязан на свежие ядра и из соображений безопасности отключён в ряде сред: его выпилили из ChromeOS и боевых серверов Google, заблокировали по умолчанию в контейнерах Docker. Причина в том, что io_uring выполняет операции в обход классической фильтрации системных вызовов через seccomp, делая песочницу дырявой. Сетевому серверу, который должен работать в произвольном контейнере, на это нельзя закрывать глаза: код обязан уметь определить недоступность io_uring и откатиться на epoll, иначе в защищённом окружении он просто не стартует. Это смещает баланс: epoll выигрывает не только простотой, но и тем, что работает везде без оговорок.

Сведём выбор к практике. Если нагрузка сетевая, соединений много, а операции укладываются в модель готовности, проверенный epoll с грамотным перепадным режимом и распределением по ядрам остаётся отличным выбором. Если же приложение упирается в файловый ввод-вывод, умеет пакетировать запросы и нуждается в цепочках операций или ядерном опросе без системных вызовов, io_uring раскрывает свою силу. А универсального ответа нет, и любая миграция начинается не с переписывания всего кода, а с замера на реальной нагрузке самого горячего пути. Цифры с чужих тестов тут плохой советчик, потому что меряют они не вашу систему.