
Цикл событий: один поток, миллион соединений
Летом 1999 года Дэн Кегель, инженер из Caltech, опубликовал на своей домашней странице небольшое эссе. Заголовок был провокационным: «The C10K problem» — «Проблема десяти тысяч клиентов». Кегель ставил вопрос, который тогда казался фантастикой: что нужно изменить в операционных системах и серверных программах, чтобы одна машина могла одновременно обслуживать десять тысяч клиентов?
В 1999 году типичный веб-сервер обслуживал десятки, в лучшем случае — сотни одновременных соединений. Десять тысяч звучало как заведомо непосильная задача. Но Кегель показал, что ограничения не в железе: процессор простаивает, память не кончается, сеть не загружена. Всё упирается в архитектуру — в то, как программа ждёт данные из сети.
Это эссе оказалось пророческим. Через несколько лет вокруг него выросла целая инженерная культура: новые системные вызовы, новые шаблоны проектирования, новые языки и среды. Сегодня одна машина обслуживает миллионы соединений, и для этого инженерам пришлось переизобрести фундаментальную идею: как программа разговаривает с сетью.
В этом посте мы пройдём путь от наивной модели «поток на клиента» к одному потоку, держащему миллион соединений. Посмотрим на системные вызовы select, poll, epoll, kqueue и io_uring, на шаблоны реактора и проактора, на рантаймы libuv и Tokio. А ещё будет история того, как nginx тихо вытеснил Apache из лидеров и взгляд на следующий рубеж — C10M.
Поток на клиента: простая идея, которая не масштабируется
Представьте веб-сервер начала 90-х. Приходит запрос — операционная система создаёт новый процесс, который читает данные из сокета, формирует ответ, записывает его обратно и завершает работу. Следующий запрос — новый процесс. Так был устроен оригинальный Apache в режиме prefork: пул процессов, каждый обрабатывает одно соединение целиком, от первого байта до закрытия.
Логика проста до примитивности. Код синхронный: вызвал read — поток заснул, дождался байтов, продолжил. Никаких обратных вызовов, никаких состояний, никаких конечных автоматов. Один процесс — один разговор. Программисту не нужно думать о параллельности: ядро само переключает контексты, и каждое соединение живёт в своей собственной маленькой вселенной.
Проблема в цене. Каждый процесс в Linux расходует несколько мегабайт памяти (стек, страницы кода, структуры ядра). Создание процесса — это fork, копирование таблиц страниц, выделение дескрипторов. Переключение между процессами — это сброс кэшей процессора, перезагрузка TLB, прыжок в режим ядра и обратно. Когда процессов несколько десятков, это незаметно. Когда их тысячи, машина начинает захлёбываться: 90 % времени уходит не на работу, а на переключение между бесполезно ждущими процессами.
Apache попытался смягчить проблему режимом worker (потоки вместо процессов) и event (один поток обслуживает несколько keep-alive соединений). Но фундаментальная модель — поток на запрос — оставалась той же. И для проблемы C10K она была обречена на провал.
Нужен был другой подход — один поток, обслуживающий множество соединений одновременно. Чтобы это работало, ядру требовалось дать программе способ сказать: «разбуди меня, когда любое из этих ста соединений будет готово». Такой способ к началу 90-х уже существовал — он появился ещё в 4.2BSD, но массовое сетевое программирование до него только-только начинало дотягиваться.
select: первый способ ждать сразу всех
В 1983 году в составе 4.2BSD появился системный вызов select. Идея простая: программа передаёт ядру список файловых дескрипторов, которые её интересуют, и говорит — «разбуди меня, когда хотя бы один из них будет готов к чтению или записи». Ядро блокирует поток, а когда что-то произошло, возвращает управление вместе со списком дескрипторов, которые готовы.
Это было революцией. Один поток мог теперь ждать сразу сотню соединений: программа собирала их в пачку, вызывала select, проходила по результату и обрабатывала те, что готовы. Появилась возможность писать серверы, в которых количество соединений никак не связано с количеством потоков.
Но у select были два врождённых ограничения, которые в эпоху C10K стали смертельными.
Первое — жёсткий лимит. Набор дескрипторов в select представлен битовой маской фиксированного размера — FD_SETSIZE. На большинстве систем это значение составляло 1024. Хочешь обслуживать больше тысячи соединений — пересобирай ядро или приложение с другим значением. Это раздражало, но было решаемо.
Второе ограничение убийственное — линейная сложность. На каждый вызов select программа передаёт весь набор дескрипторов в ядро, ядро просматривает каждый из них, чтобы понять, готов ли он, и возвращает обратно весь набор. Если из 10 000 соединений активны 10, ядро всё равно перебирает все 10 000. Программа после возврата тоже перебирает все 10 000, чтобы найти активные. Каждое срабатывание стоит O(n), и это на каждый вызов, тысячи раз в секунду.
При тысяче соединений select ещё справляется. При десяти тысячах он съедает 30–50 % процессорного времени на себя, не делая никакой полезной работы. C10K на select была недостижима.
poll: то же самое, без жёсткого лимита
В 1986 году в System V Release 3 появился poll — улучшенная версия select с близкой семантикой. Вместо битовой маски фиксированного размера он принимал массив структур произвольной длины: «вот дескриптор, вот события, которые мне интересны».
Это сняло ограничение в 1024 соединения. Теоретически poll мог следить за десятками тысяч. Но фундаментальная проблема осталась: каждый вызов передавал всё состояние целиком и стоил O(n). Ядро всё так же перебирало весь массив, программа всё так же искала активные дескрипторы линейным поиском.
poll был лучше, чем select, но не решал основную проблему. Нужно было что-то принципиально другое: интерфейс, в котором ядро само помнило, за чем следить, и сообщало программе только об изменениях. Только что-то изменилось, только об этом и сказать.
К концу 90-х индустрия упёрлась именно в этот предел. Эссе Кегеля было реакцией на коллективное осознание: дальше с select/poll дороги нет.
kqueue и epoll: прорыв к O(1)
Решение пришло параллельно из двух мест.
В 2000 году в FreeBSD 4.1 Джонатан Лемон представил kqueue — новый механизм уведомлений. Идея блестящая: программа один раз регистрирует в ядре список интересующих её событий и получает «дескриптор очереди». Дальше она просто читает из этой очереди — ядро присылает туда только произошедшие события. Никакого перебрасывания списка туда-сюда, никакого линейного перебора. И — что особенно элегантно — kqueue единообразно обрабатывает не только сетевые сокеты, но и таймеры, сигналы, изменения в файловой системе, события процессов. Это был первый универсальный механизм событий на уровне ядра.
В Linux 2.5.45 (октябрь 2002) Давиде Либенци добавил epoll — функционально близкий механизм, реализованный иначе и заточенный под сетевой ввод-вывод. Три системных вызова: epoll_create создаёт в ядре отдельный объект-очередь и возвращает его дескриптор, epoll_ctl добавляет к этому объекту наблюдаемые сокеты, epoll_wait забирает накопившиеся события. Сложность каждой операции — O(1) или O(log n) от количества активных событий, а не от общего количества соединений.
Разница оказалась колоссальной. Сервер на epoll, обслуживающий 10 000 idle keep-alive соединений с тысячей активных, тратит время только на эту тысячу. Машина перестаёт захлёбываться. Проблема C10K решена. На Windows аналог появился ещё раньше — I/O Completion Ports (IOCP) в Windows NT 3.5 (1994), но с другой философией, о которой поговорим чуть позже.
С появлением epoll и kqueue высокопроизводительные сетевые серверы стали технической реальностью. Но недостаточно дать программистам новый системный вызов — нужны были шаблоны, как правильно строить вокруг него код. Эти шаблоны появились почти одновременно с самими механизмами.
Реактор и проактор: два способа строить событийную программу
В 1995 году Дуглас Шмидт из Вашингтонского университета опубликовал статью «Reactor: An Object Behavioral Pattern for Concurrent Event Demultiplexing and Dispatching». Он описал шаблон, который позже стал каноническим: реактор.
Идея реактора такова. Есть один цикл — назовём его циклом событий — который опрашивает ядро через select/epoll/kqueue. Когда ядро говорит «сокет 42 готов к чтению», реактор находит зарегистрированный обработчик и вызывает его. Обработчик читает данные, делает что нужно, при необходимости регистрирует следующее событие. И снова возвращается в цикл.
Программа — это набор небольших обработчиков, которые реагируют на готовность дескрипторов. Никаких потоков на соединение. Никакой блокировки. Всё работает в одном потоке или в небольшом пуле потоков, по одному на ядро.
У реактора есть близкий, но иначе устроенный родственник — проактор, описанный позже теми же исследователями. Разница тонкая, но важная. В реакторе ядро говорит: сокет 42 можно читать без блокировки», а саму операцию чтения выполняет программа. В проакторе программа просит ядро: «выполни чтение из сокета 42 в этот буфер» — и ядро присылает уведомление, когда чтение уже завершено и данные лежат в буфере.
Реактор это «скажи, когда можно». Проактор — «сделай и скажи, когда сделал». Реактор естественен для Linux с epoll. Проактор — родная модель Windows с IOCP. Долгое время в этом была главная философская разница между мирами Linux и Windows.
С 2019 года эта разница начала стираться.
io_uring: настоящий асинхронный ввод-вывод в Linux
В мае 2019 года Йенс Аксбо, инженер из Facebook1 и автор подсистемы блочного ввода-вывода Linux, представил io_uring — механизм, который наконец принёс настоящий проактор в Linux.
Идея радикальная. Между ядром и программой создаются два кольцевых буфера в общей памяти: очередь представлений (submission queue) и очередь завершений (completion queue). Программа кладёт запрос в первую очередь — «прочитай вот это, запиши вот то, прими соединение». Ядро в фоне выполняет операции и кладёт результаты во вторую. Системные вызовы не нужны вообще, данные передаются через общую память.
Это меняет правила игры. Каждый системный вызов в Linux стоит сотни наносекунд: переключение в режим ядра, проверка прав, возврат. На тяжёлых сетевых серверах эти наносекунды складываются в значимый процент процессорного времени. io_uring позволяет в пакетном режиме выполнять десятки операций за один поход в ядро, а с включённым SQPOLL-режимом ядро само опрашивает очередь, и системных вызовов нет совсем.
В апреле 2025 года команда Linux продемонстрировала HTTPS-сервер, обрабатывающий запросы с нулём системных вызовов на запрос: io_uring принимает соединение, читает запрос, шифрует ответ через kTLS и отправляет — всё без единого перехода между пространством пользователя и ядром. Это то, что ещё пять лет назад казалось технически невозможным.
io_uring стремительно проникает в высокопроизводительный код. PostgreSQL 18 (сентябрь 2025) использует его для асинхронного чтения страниц. Tokio (об этом ниже) предлагает экспериментальный бэкенд на io_uring. Современные базы данных, прокси-серверы и хранилища постепенно переписываются под эту модель.
Но теория и системные вызовы — это половина истории. Чтобы цикл событий стал массовой технологией, кому-то нужно было сделать его удобным.
libuv и Node.js: цикл событий уходит в массы
В 2009 году Райан Даль, разработчик, который сильно интересовался сетевыми серверами, выступил на конференции JSConf в Берлине с презентацией нового проекта. Он назвал его Node.js — JavaScript-среда на основе движка V8 от Google, но не для браузера, а для серверов. Главная идея: весь ввод-вывод в Node.js асинхронный по умолчанию. Программист пишет не «прочитай файл», а «прочитай файл и потом вызови вот эту функцию».
Под капотом Node.js использовал библиотеку, которую Даль написал специально для этого: libuv. Это слой абстракции над механизмами цикла событий разных операционных систем — epoll в Linux, kqueue в macOS и BSD, IOCP в Windows. Программе всё равно, на какой системе она работает: libuv предоставляет единый интерфейс «зарегистрируй обратный вызов, я разбужу его, когда событие произойдёт».
Гениальность Node.js была не в технической новизне — реакторы и циклы событий существовали к тому моменту 25 лет. Гениальность была в доступности. JavaScript уже знали миллионы веб-разработчиков. И вдруг они получили инструмент, в котором одна-единственная команда node server.js запускала событийный сервер, способный держать тысячи соединений. Без потоков. Без select. Без C.
Цена этой простоты — пресловутый callback hell: вложенные на десять уровней обратные вызовы, в которых легко запутаться. Решением стали обещания (Promises) и потом — синтаксис async/await, который превратил асинхронный код во внешне последовательный. Но за всей этой синтаксической косметикой по-прежнему вращается тот же самый цикл событий libuv, дёргающий epoll_wait.
Node.js породил целую индустрию: Express, потом Koa, потом Fastify, потом Deno (того же Даля, его признание, что он сделал не всё правильно), потом Bun. Все они работают по одной и той же модели: один поток, цикл событий, асинхронный ввод-вывод.
Tokio: тот же принцип, но без сборщика мусора
Через несколько лет после Node.js аналогичная революция случилась в мире системных языков. В 2016 году Карл Лерче, инженер из стартапа Tilde, запустил проект Tokio — асинхронную среду исполнения для Rust.
Rust предлагал то, чего не мог дать JavaScript: ноль накладных расходов на сборку мусора, статические гарантии безопасности памяти, прямой доступ к системным вызовам. Tokio объединил это с моделью цикла событий: одно соединение — это не поток ОС, а задача (task), лёгкая структура в несколько сотен байт, мультиплексируемая на потоки рантайма.
Главная техническая идея Rust — async-функции компилируются в конечные автоматы. Когда программист пишет let data = socket.read().await, компилятор превращает это в структуру, у которой есть состояние «жду данные из сокета». Tokio регистрирует эту структуру в epoll, и когда данные приходят, продвигает автомат к следующему состоянию. Никакой аллокации в куче для большинства задач, никаких накладных расходов сверх минимально необходимых.
Tokio стал стандартом де-факто в Rust-экосистеме: на нём построены Hyper (HTTP-стек), Tonic (gRPC), reqwest (HTTP-клиент), большая часть прокси Cloudflare, движки баз данных вроде Datadog или TiKV. Когда нужен максимальный throughput на ядро, выбирают Tokio.
Идея, изобретённая для решения C10K в конце 90-х, дошла до зрелости и стала фундаментом нового поколения системного ПО. Но самый яркий пример её триумфа на десять лет старше Tokio и из совершенно другой страны.
Nginx против Apache: как идея победила в продакшене
В 2002 году Игорь Сысоев, системный администратор компании «Рамблер» в Москве, столкнулся с проблемой: ему нужно было обслуживать высоконагруженный портал, и Apache на этой нагрузке захлёбывался. Идея prefork — процесс на запрос — на трафике «Рамблера» не работала, машины уходили в swap, латентность росла.
Сысоев решил написать веб-сервер с нуля. Принципы, на которых он его строил, были прямой противоположностью Apache:
- Один процесс — множество соединений. Цикл событий на
epoll(Linux) илиkqueue(FreeBSD). - Никаких потоков на запрос — только небольшой пул рабочих процессов (worker), по одному на ядро процессора.
- Всё, что можно сделать неблокирующе, — сделано неблокирующе. Файлы отправляются через
sendfileбез копирования через пользовательское пространство.
Первая публичная версия — nginx 0.1.0 — вышла 4 октября 2004 года. Долгое время это был «русский веб-сервер для тех, кто понимает» — известный в узких кругах, использовавшийся на «Рамблере», «Яндексе», во ВКонтакте.
Решающим моментом стало распространение Nginx за пределы России. В 2008 году его уже использовали WordPress.com, GitHub и FastMail. К 2011 году по данным Netcraft Nginx обогнал Microsoft IIS и стал вторым по популярности веб-сервером в мире. К 2019 году он обогнал Apache и стал первым.
Это не было поражением Apache из-за плохого кода. Apache по-прежнему отличный сервер. Это было поражение архитектурной модели: поток-на-запрос проиграл циклу событий. Когда инженеры Cloudflare выбирали базу для своих edge-серверов в начале 2010-х, они выбрали Nginx. Когда Netflix строил свою CDN, он построил её поверх Nginx. Современный интернет в значительной мере работает на коде Сысоева.
В 2019 году Сысоев продал компанию Nginx Inc. компании F5 Networks за 670 миллионов долларов. Идея, спасшая «Рамблер» от падения в 2002 году, превратилась в инфраструктуру, через которую проходит существенная часть мирового веб-трафика.
C10M: следующий горизонт
В 2013 году инженер по сетевой безопасности Роберт Грэм выступил на конференции Shmoocon с провокационной презентацией: «C10M Defending The Internet At Scale». Если C10K (десять тысяч соединений) был достигнут в нулевых, рассуждал Грэм, то теперь пора решать C10M — десять миллионов одновременных соединений на одной машине.
Грэм утверждал: классический сетевой стек ядра Linux — это узкое место. Каждый пакет проходит через массу проверок, копирований, блокировок. На скоростях 10 и 40 гигабит в секунду ядро не успевает обрабатывать пакеты, даже если железо способно. Решение, которое он предложил, было радикальным: обойти ядро вообще.
Этот подход называется kernel bypass. Сетевая карта через библиотеки вроде DPDK (Intel) или Netmap отдаёт пакеты прямо в пользовательское пространство, минуя стек TCP/IP ядра. Программа сама реализует TCP, сама управляет буферами, сама принимает решения о повторных передачах. Никаких системных вызовов, никаких переключений контекста — голое железо и максимум контроля.
Это сложно, но работает. На kernel-bypass-фреймворках построены биржевые системы, продвинутые DDoS-фильтры, программные маршрутизаторы. С одного современного сервера можно теперь снимать десятки миллионов пакетов в секунду — если есть достаточно мотивации, чтобы переписать половину стека.
Параллельный путь к той же цели — io_uring и его развитие. В отличие от kernel bypass, который выкидывает ядро, io_uring пытается сделать ядро настолько эффективным, что обходить его перестанет иметь смысл. Демонстрации насыщения 200 гигабит в секунду с одного ядра процессора через io_uring показывают: цель C10M достижима и без обхода ядра.
Эпилог: одна идея, пройденная до конца
Если посмотреть на эту историю с высоты, видно одну сквозную линию.
В 1983 году появился select — первый способ ждать сразу несколько источников данных. В 2002 году epoll и kqueue дали возможность делать это эффективно. В 2009 году Node.js вручил эту модель массовому программисту. В 2019 году io_uring довёл её до предела, исключив системные вызовы. А кто-то на этом пути — Сысоев в Москве, Даль в Сан-Хосе, Лерче в Нью-Йорке — переплавил инженерные идеи в продакшен-системы, на которых сегодня держится интернет.
И всё это — ответ на простой вопрос Дэна Кегеля 1999 года: «А что, если десять тысяч?»
Кегель сам уже давно не активен в этой теме — его страница с эссе про C10K жива и пополняется, но в основном как исторический памятник. А индустрия, для которой он сформулировал задачу, прошла этот путь до конца: от десяти тысяч соединений к миллионам, от прерываний ядра к программируемому железу, от потока-на-запрос к одному потоку и нулю системных вызовов.
Цикл событий — это уже не оптимизация. Это новый способ думать о работе с сетью. Программа не читает, она подписывается на готовность. Не пишет — планирует запись. Не ждёт — регистрирует обратный вызов. Звучит непривычно, но это и есть язык, на котором современный сервер разговаривает с миром.
Источники
- The C10K Problem (Dan Kegel, 1999) — оригинальное эссе, с которого всё началось
- Reactor: An Object Behavioral Pattern for Concurrent Event Demultiplexing and Dispatching (Douglas C. Schmidt, 1995) — каноническое описание шаблона
- kqueue: A Generic and Scalable Event Notification Facility (Jonathan Lemon, 2001) — FreeBSD
- Efficient IO with io_uring (Jens Axboe, 2019) — техническая статья автора
- C10M Defending The Internet At Scale (Robert Graham, 2013) — презентация на Shmoocon
- Inside NGINX: How We Designed for Performance & Scale (NGINX Blog) — архитектура nginx
- The Node.js Event Loop (официальная документация) — как устроен цикл событий в Node
- Tokio Tutorial: Async in Depth — модель задач в Rust
- Netcraft Web Server Survey — статистика популярности веб-серверов
Facebook принадлежит компании Meta Platforms Inc., признанной в России экстремистской организацией и запрещённой на территории РФ. ↩︎