Событийная модель: как Nginx держит тысячи соединений
Один worker Nginx — как опытный официант, который держит в голове десять столиков сразу, а не стоит столбом у одного, ожидая, пока гость дожуёт.
«Поток на соединение не масштабируется. Цикл событий — масштабируется. В этом вся разница между Nginx и старыми серверами.»
Главный вопрос: как небольшое число процессов обслуживает десятки тысяч клиентов одновременно? Ответ — неблокирующий ввод-вывод и цикл событий (event loop). Разберёмся на пальцах.
Проблема «поток на соединение»
Классический подход: на каждого клиента — отдельный поток. Поток ждёт данных от сети — и пока ждёт, он заблокирован, занимает память (стек потока — это мегабайты) и заставляет ОС переключать контекст. Тысяча клиентов — тысяча спящих потоков. Это называют проблемой C10k: на 10 000 соединений система захлёбывается.
Решение Nginx: цикл событий
Worker Nginx однопоточный. Он не ждёт у каждого сокета. Вместо этого он говорит ядру ОС: «дай знать, когда на любом из этих тысяч сокетов появятся данные», — и засыпает. Когда данные приходят, ядро будит воркер и отдаёт список готовых сокетов. Воркер быстро обрабатывает их и снова засыпает. Этот бесконечный круг — и есть цикл событий:
+-----------------------------------------+ | EVENT LOOP | | | | epoll_wait() -> «готовы сокеты: | | 3, 17, 42» | | | | | v | | обработать готовые (без блокировки) | | | | | +------- вернуться к началу ------+ +-----------------------------------------+
Механизм уведомления на Linux — это epoll (на BSD/macOS — kqueue). Он позволяет следить за тысячами сокетов почти бесплатно: ядро возвращает только те, что готовы, а не заставляет перебирать все.
Смоделируем цикл событий на Python
Чтобы прочувствовать идею «не ждать, а реагировать на готовые события», смоделируем мини-цикл: есть соединения, каждому нужно несколько «тиков» работы; воркер на каждом круге трогает только те, у кого есть готовые данные.
from collections import deque
# Очередь "событий": (id соединения, сколько работы осталось)
ready = deque([("conn-1", 2), ("conn-2", 1), ("conn-3", 3)])
print("Старт цикла событий, один воркер")
tick = 0
while ready:
tick += 1
conn, work_left = ready.popleft() # берём ГОТОВОЕ событие
print(f"тик {tick}: обслуживаю {conn}, осталось работы {work_left}")
work_left -= 1
if work_left > 0:
ready.append((conn, work_left)) # ещё не дописал -> снова в очередь
else:
print(f" -> {conn} завершён, сокет освобождён")
print("Все соединения обработаны одним потоком")
Попробуй сам ▶ Обрати внимание: один «поток» по очереди трогает все соединения понемногу, ни на ком не залипая. Реальный Nginx делает то же, только готовность определяет ядро через epoll.
Как работает под капотом
Воркер настраивает все сокеты в неблокирующий режим: операция чтения/записи возвращается мгновенно, даже если данных нет. Если ОС говорит «пока нечего читать», воркер не зависает, а идёт к следующему сокету. sendfile и асинхронный дисковый ввод-вывод не дают медленному диску застопорить весь цикл. Директива multi_accept on; разрешает воркеру принять сразу несколько новых соединений за один проход.
Частые ошибки
- Думать, что Nginx «многопоточный на запрос». Нет, воркер однопоточный и крутит цикл событий.
- Ставить
worker_connectionsнаугад. Это лимит сокетов на воркер; для прокси помни, что одно клиентское соединение тратит и сокет к бэкенду. - Блокирующие модули. Тяжёлая синхронная операция в воркере застопорит тысячи соединений — Nginx избегает блокировок специально.
Best practices
worker_connections 1024;и выше — в зависимости от лимита файловых дескрипторов ОС.- На Linux событийный модуль
epollвыбирается автоматически — не трогай без нужды. - Подними системный лимит дескрипторов (
worker_rlimit_nofile), если ждёшь много соединений.
Почему это победило исторически
Событийная модель — не прихоть авторов Nginx, а ответ на конкретную боль конца 2000-х. Сайты росли, число одновременных пользователей перевалило за тысячи, и серверы со схемой «поток на соединение» начали захлёбываться: память на стеки потоков, бесконечные переключения контекста, падение производительности именно тогда, когда нагрузка максимальна. Эту проблему назвали C10k — «как обслужить 10 000 соединений одной машиной». Nginx был спроектирован именно вокруг её решения, и поэтому при росте числа простаивающих (медленных, keep-alive) соединений его потребление ресурсов почти не растёт.
Эту же идею ты встретишь повсюду за пределами Nginx. Node.js построен на событийном цикле (libuv). Python-фреймворки на asyncio, Redis, современные базы — все они так или иначе используют неблокирующий ввод-вывод и реактивную обработку событий. Понимание принципа «не жди у каждого сокета, а реагируй на готовые события» делает тебя сильнее не только в настройке Nginx, но и в проектировании высоконагруженных приложений в целом. И именно поэтому в воркере Nginx категорически нельзя выполнять долгие блокирующие операции: один застрявший вызов заморозит тысячи соединений, которые этот воркер обслуживает.
Итоги
Nginx обслуживает тысячи клиентов небольшим числом однопоточных воркеров благодаря неблокирующему вводу-выводу и циклу событий поверх epoll/kqueue. Вместо «потока на клиента» — реакция на готовые события. Это фундамент его производительности. Дальше переходим к практике: научимся читать и писать конфигурацию.