Блог компании 3v-Hosting

Что такое epoll и почему Nginx такой быстрый

Общее

14 мин.


Когда говорят о производительности веб-серверов, то Nginx почти всегда вспоминают одним из первых. На нём работают интернет-магазины, стриминговые сервисы, API-платформы, CDN, корпоративные системы и миллионы обычных сайтов. И дело не только в архитектуре самого Nginx, но и в том, насколько эффективно он использует возможности Linux.

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

В этой статье мы разберёмся, как работает epoll, чем он отличается от select() и poll(), каким образом его использует Nginx и почему сам по себе epoll ещё не делает приложение быстрее.

 

 

 

 

Почему обслуживание тысяч соединений - это сложная задача

Представьте себе сервер, к которому одновременно подключены 50 000 клиентов. Звучит пугающе, но в реальности большинство из них ничего не делает. Кто-то уже получил страницу и ждёт следующего действия пользователя, кто-то держит открытым WebSocket, кто-то медленно скачивает файл. И лишь небольшая часть клиентов прямо сейчас отправляет данные или ждёт ответа.

Допустим, из 50 000 открытых TCP-соединений только около 500 требуют обработки в данный момент, а это значит, что если сервер будет постоянно проверять состояние всех 50 тысяч сокетов, процессор потратит большую часть времени впустую, ведь полезных событий почти нет, а проверить нужно каждое соединение.

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

Но с ростом нагрузки начали проявляться ограничения этого метода. Каждый поток занимает память, а операционной системе приходится постоянно переключать процессор между тысячами потоков. В какой-то момент заметная часть ресурсов начинает уходить уже не на непосредственную обработку запросов, а на обслуживание самой модели параллелизма.

Тогда понадобился другой подход - один процесс должен был научиться работать сразу с большим количеством соединений. Так появился системный вызов select(), используя который приложение передавало ядру список файловых дескрипторов и спрашивало, какие из них готовы к чтению или записи.

Для своего времени это было серьёзным шагом вперёд. Теперь один процесс мог обслуживать множество клиентов. Но при каждом вызове приходилось заново передавать ядру весь список дескрипторов, а затем полностью его просматривать. Кроме того, существовало ограничение FD_SETSIZE, которое плохо подходило для большого количества соединений.

Позже появился системный вызов poll(), который избавился от ограничения FD_SETSIZE, но принцип его работы почти не изменился в сравнении с select() - приложение всё так же передавало ядру массив дескрипторов и получало их состояние обратно.

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

Решением этой проблемы и стал механизм epoll, который кардинально изменил подход к обработке большого количества соединений.

 

 

 

Что такое epoll и чем он отличается от select() и poll()

epoll - это встроенный механизм Linux для отслеживания событий на файловых дескрипторах. В случае веб-серверов речь почти всегда идёт конкретно о сетевых сокетах. И главное отличие epoll от более ранних механизмов select() и poll() связано не со скоростью сети, а с тем, как приложение взаимодействует с ядром операционной системы.

При использовании select() и poll() приложение при каждом обращении передаёт ядру полный список дескрипторов, за которыми нужно следить, а затем заново просматривает результат.

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

В таблице ниже мы свели основные различия всех трёх упомянутых механизмов, чтобы эта информация была наглядной.

Механизм Работа с дескрипторами Ограничения Поведение при большом количестве соединений
select() список передаётся при каждом вызове ограничение FD_SETSIZE масштабируется плохо
poll() массив передаётся при каждом вызове нет ограничения FD_SETSIZE приходится каждый раз обрабатывать большой массив
epoll дескрипторы регистрируются один раз ограничение определяется ресурсами ОС хорошо подходит для десятков тысяч соединений

 

Иногда ещё их различия описывают одной фразой: select() и poll() работают за O(n), а epoll - за O(1). Такое объяснение помогает понять общую идею, хотя реальная работа ядра, конечно, устроена куда сложнее.

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

 

 

 

 

Как epoll работает внутри Linux

Для работы с epoll приложение обычно использует три системных вызова. Сначала создаётся экземпляр epoll с помощью системного вызова:

epoll_create1()

 

Затем приложение регистрирует нужные файловые дескрипторы:

epoll_ctl()

Например, можно сообщить ядру, что интересует момент, когда определённый сокет станет готов для чтения.

 

После регистрации начинается ожидание событий:

epoll_wait()

Этот вызов блокируется до появления события и возвращает только те дескрипторы, которые требуют обработки.

Упрощённо цикл выглядит так:

 

Что такое epoll

 

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

 

 

Какие события отслеживает epoll

Приложение может подписаться на разные типы событий. Их множество, но для понимания работы сетевого сервера достаточно нескольких основных:

Событие Значение
EPOLLIN сокет готов для чтения
EPOLLOUT сокет готов для записи
EPOLLERR произошла ошибка
EPOLLHUP соединение закрыто или больше не может использоваться обычным образом

Например, клиент отправил HTTP-запрос. Данные появились в сокете и в результате ядро сообщает о событии чтения. Приложение читает запрос и начинает формировать ответ.

Если сокет пока не готов принять весь объём данных, то сервер не обязан ждать и спокойно может переключиться на другие соединения, после чего может вернуться к этому клиенту позже, когда сокет снова станет доступен для записи. За счёт этого один процесс способен эффективно обслуживать огромное количество клиентов.

 

 

Level-triggered и edge-triggered режимы

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

 

Логика работы epoll

 

Edge-triggered работает иначе. Уведомление приходит только при изменении состояния, поэтому приложение должно использовать неблокирующие сокеты и читать или записывать данные до тех пор, пока операция не вернёт, например, EAGAIN.

Режим Поведение Особенность
Level-triggered уведомления приходят, пока условие сохраняется проще реализовать
Edge-triggered уведомление приходит только при изменении состояния требует более аккуратной реализации

 

 

 

 

 

Как Nginx использует epoll и обслуживает тысячи соединений

Архитектура Nginx построена вокруг событийной модели, когда вместо создания отдельного процесса или потока для каждого клиента Nginx запускает ограниченное количество worker-процессов.

Упрощённая схема этой модели выглядит так:

 

Событийная модель epoll

 

Master-процесс управляет workers, загружает конфигурацию и выполняет административные задачи. Клиентские соединения обрабатывают worker-процессы, каждый из которых способен одновременно работать с большим количеством сокетов.

Когда клиент подключается к серверу, его соединение попадает в событийную систему worker-процесса. Пока клиент ничего не отправляет, то worker не тратит на него процессорное время. Конечно, открытый сокет занимает память, файловый дескриптор и другие системные ресурсы, но отдельный поток для него не создаётся.

Но как только в сокете появляется событие, то worker обрабатывает его и переходит к следующему.

Предположим, Nginx поддерживает 50 000 соединений. Это вовсе не означает, что сервер одновременно выполняет 50 000 HTTP-запросов. Реальная картина может выглядеть примерно так, что в моменте из этих 50К соединений 47К ждут данные, периодически активны 2500, а тех, которые требуют обработки здесь и сейчас всего около 500. В этом случае worker работает прежде всего с теми 500 соединениями, которые действительно требуют внимания, а остальные хоть и могут оставаться открытыми, но процессор почти не тратит на них время.

Такая модель хорошо подходит для keep-alive, WebSocket, reverse proxy, API-шлюзов и других сценариев, где соединения могут оставаться открытыми долгое время.

Поэтому 50 000 открытых соединений и 50 000 одновременно выполняемых запросов - это совершенно разная нагрузка. Именно событийная архитектура вместе с epoll позволяет одному worker-процессу Nginx обслуживать тысячи и десятки тысяч клиентов.

 

 

 

 

Что такое worker_connections и от чего зависит количество соединений

Максимальное количество соединений для одного worker задаётся параметром worker_connections:

events {
    worker_connections 4096;
}

 

Если worker-процессов несколько, теоретический предел становится выше. Например:

worker_processes 4;
​
events {
    worker_connections 4096;
}

 

Получаем:

4 × 4096 = 16384

 

Но воспринимать это число как максимальное количество одновременно подключённых клиентов нельзя, так как если Nginx работает как reverse proxy, то он открывает соединения не только с клиентами, но и с upstream-серверами. Поэтому один запрос может занимать как минимум два соединения: входящее клиентское и исходящее к backend.

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

ulimit -n

Если процессу разрешено открыть только 1024 файловых дескриптора, то простое увеличение значения параметра worker_connections, например, до 50000 не позволит worker обслуживать 50 000 соединений.

Реальный же предел зависит от нескольких параметров:

  • worker_processes;
  • worker_connections;
  • лимитов файловых дескрипторов;
  • объёма оперативной памяти;
  • количества соединений с upstream-серверами;
  • характера нагрузки.

Это означает, что epoll позволяет Nginx эффективно работать с большим количеством дескрипторов, но не снимает ограничения Linux и самого сервера.

 

 

 

 

Почему epoll особенно важен сегодня и где он используется

Когда веб только появился, то большинство запросов были короткими, то есть браузер получал страницу, после чего соединение вскоре закрывалось.

Сегодня-же всё устроено намного сложнее, так как браузер может одновременно загружать десятки ресурсов, обращаться к API и поддерживать длительные соединения. Серверы работают с WebSocket, Server-Sent Events, стримингом, MQTT, reverse proxy и другими технологиями. Хорошим примером может послужить WebSocket, когда соединение может оставаться открытым часами, хотя большую часть этого времени через него вообще ничего не передаётся.

Выделять отдельный поток, который будет просто ждать следующего сообщения, слишком затратно, а вот событийная модель позволяет оставить соединение среди тысяч других и вернуться к нему только тогда, когда появятся данные. Поэтому epoll давно используется далеко за пределами Nginx. С ним напрямую или через библиотеки работают такие инструменты как Redis, HAProxy, DNS-серверы, прокси, брокеры сообщений и другое сетевое ПО под Linux. Причём разработчик часто вообще не взаимодействует с epoll напрямую.

Например Node.js использует libuv, которая выбирает подходящий механизм ввода-вывода для конкретной операционной системы. В Python эту работу скрывает asyncio, в Java - Netty, а в Rust - Tokio. Сетевой poller Go Runtime под Linux тоже использует epoll.

В итоге разработчик может писать асинхронный сетевой код, не вызывая epoll_wait() самостоятельно, а работу с системными механизмами Linux берёт на себя та или иная библиотека или runtime.

 

 

 

 

Аналоги epoll и современный io_uring

Конкретно epoll работает только в Linux, а в других операционных системах похожие задачи решают собственные механизмы:

  • kqueue - в BSD и macOS;
  • I/O Completion Ports (IOCP) - в Windows;
  • event ports - в Solaris.

 

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

Кроссплатформенные библиотеки скрывают эти различия. Например, приложение на Node.js работает через libuv, а библиотека уже выбирает подходящий механизм в зависимости от операционной системы.

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

Иногда io_uring называют "новым epoll", хотя работают они по-разному. В частности epoll в первую очередь сообщает о готовности файловых дескрипторов, а io_uring позволяет отправлять ядру сами операции I/O и получать информацию об их завершении.

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

 

 

 

Всегда ли epoll делает программу быстрее

Увы, нет. Иногда можно встретить мнение, что достаточно заменить poll() на epoll, и приложение сразу станет работать быстрее. Но в жизни всё зависит от того, где возникает узкое место. Сам epoll не ускоряет процессор, не уменьшает сетевые задержки и не делает SQL-запросы быстрее, поэтому если сервер после получения запроса две секунды выполняет тяжёлые вычисления, то эти две секунды никуда не исчезнут.

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

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

 

 

 

 

Как узнать, использует ли Nginx epoll

На современных Linux-системах обычно ничего включать вручную не нужно, так как данный механизм работает "из коробки". Nginx сам выбирает наиболее подходящий механизм обработки событий.

Но при желании его можно указать явно с помощью инструкции:

events {
    use epoll;
}

В обычной ситуации в этом нет необходимости. Автоматический выбор почти всегда оказывается правильным.

Чтобы просмотреть версию Nginx, параметры сборки и подключённые модули, выполните команду:

nginx -V

Она покажет включен ли модуль, но не подтвердит, что worker-процесс сейчас работает именно через epoll.

 

Если-же вам нужна точная проверка, то можно посмотреть системные вызовы.

Сначала найдите PID одного из worker-процессов с помощью команды:

ps aux | grep "nginx: worker"

 

Затем подключитесь к нему через strace:

strace -e epoll_create1,epoll_ctl,epoll_wait -p PID

 

Если Nginx использует epoll, то в выводе будут появляться соответствующие системные вызовы. Стоит также учитывать, что на разных версиях ядра, libc и самого Nginx список вызовов может немного отличаться. Подключать strace к рабочему серверу стоит осторожно, так как он создаёт дополнительную нагрузку, поэтому такие проверки лучше выполнять на тестовой машине или в периоды минимальной активности.

 

 

 

 

 

FAQ или Частые вопросы

Что такое epoll простыми словами?

epoll - это встроенный механизм Linux, который позволяет приложению следить за большим количеством файловых дескрипторов, включая сетевые сокеты, и получать уведомления только о тех, где произошло нужное событие. Благодаря этому сервер не тратит время на постоянную проверку всех соединений, а работает только с активными.

 

Чем epoll отличается от select и poll?

При использовании select() и poll() приложение каждый раз передаёт ядру список интересующих дескрипторов. В случае с epoll этот список регистрируется один раз, после чего приложение получает только готовые события через epoll_wait(). Такой подход лучше масштабируется при большом количестве открытых соединений.

 

Использует ли Nginx epoll автоматически?

Да. На Linux веб-сервер Nginx обычно сам выбирает epoll как наиболее подходящий механизм обработки событий, поэтому вручную указывать use epoll чаще всего не требуется.

 

Сколько соединений способен обслуживать один worker?

Это зависит не только от epoll. На это влияют worker_connections, лимиты файловых дескрипторов, объём памяти, настройки Linux и характер нагрузки. А если Nginx работает как reverse proxy, нужно учитывать ещё и соединения с upstream-серверами.

 

Использует ли Node.js epoll?

Под Linux - да, так как Node.js работает через библиотеку libuv, которая использует epoll и другие механизмы операционной системы. Разработчику напрямую взаимодействовать с API epoll обычно не приходится.

 

Чем epoll отличается от io_uring?

epoll сообщает приложению, что файловый дескриптор готов к операции.

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

 

 

 

 

Итоги

Высокая производительность Nginx объясняется не каким-то одним "секретным механизмом", а всей архитектурой сервера.

epoll, как один из составляющих, позволяет отказаться от постоянного перебора тысяч сокетов и получать уведомления только о тех соединениях, где действительно появилась работа. Такой подход особенно эффективен, когда открытых соединений много, а активны одновременно лишь некоторые из них.

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

Стоит помнить, что если Nginx используется на VPS как reverse proxy, API-шлюз, сервер WebSocket или фронтенд для высоконагруженного приложения, то возможности Linux становятся такой же частью общей производительности, как процессор, память и скорость дисковой подсистемы. Именно сочетание грамотной архитектуры и механизмов ядра позволяет даже относительно небольшому серверу обслуживать огромное количество сетевых соединений.

 

3v-Hosting Team

Автор

3v-Hosting Team

Команда 3v-Hosting - это группа преданных своему делу инженеров и операторов, которые занимаются созданием и поддержкой основы наших сервисов. Каждый день мы погружаемся в мир виртуальных и выделенных серверов, занимаясь всем, от развертывания и мониторинга до устранения реальных проблем, возникающих в производственных средах. Большинство наших статей основано на практическом опыте, а не просто на теории. Мы делимся своими наблюдениями о проблемах, с которыми сталкиваемся: перебоях в производительности, ошибках в настройке, тонкостях сетевых решений и архитектурных выборах, влияющих на стабильность и надежность. Наша миссия проста - мы хотим делиться знаниями, которые позволят вам управлять своими проектами с меньшим количеством неожиданностей и гораздо большей предсказуемостью.