ОС · Конкурентность · 14 мин
Событийная модель и epoll
Зачем вообще другая модель
В главе про потоки мы разобрали привычный подход к серверу: на каждое соединение — свой поток. Пришёл клиент → создали поток → поток читает, обрабатывает, отвечает, умирает. Просто и понятно: код линейный, читается сверху вниз, как рецепт.
Но представим, что у нас веб-сервер, к которому пришли 10 000 клиентов одновременно (типичный мессенджер, биржа котировок, чат). По модели «поток на соединение» это 10 000 потоков. И тут начинаются проблемы.
Модель "поток на соединение" при 10 000 клиентов
──────────────────────────────────────────────────
клиент 1 ──▶ [ поток 1 ] стек ~1–8 МБ
клиент 2 ──▶ [ поток 2 ] стек ~1–8 МБ
клиент 3 ──▶ [ поток 3 ] стек ~1–8 МБ
... ...
клиент N ──▶ [ поток N ] стек ~1–8 МБ
│
▼
┌──────────────────────────────┐
│ 10 000 × несколько МБ стека │ = десятки ГБ ОЗУ
│ планировщик ОС жонглирует │ = тысячи переключений
│ тысячами потоков │ контекста в секунду
└──────────────────────────────┘Что нарисовано: каждый из N клиентов держит отдельный поток ОС, а у каждого потока — свой стек (память) и своя строчка в таблице планировщика. На схеме видно, куда уходят ресурсы.
Две беды:
- Память. У каждого потока ОС свой стек — обычно от 1 до 8 МБ. 10 000 потоков — это десятки гигабайт только на стеки, ещё ничего не сделав.
- Переключения контекста. Планировщик ОС вынужден постоянно перекидывать CPU между тысячами потоков. Каждое переключение — это сохранение/восстановление регистров, сброс TLB, промахи кэша. Дорого (см. главу про цену системных вызовов).
Историческое название этой проблемы — C10k (challenge 10 000 connections): как одной машине обслужить десять тысяч соединений одновременно. В начале 2000-х это считалось вызовом. Сегодня цель — уже C10M (десять миллионов).
Главное наблюдение: соединения почти всё время ничего не делают
Ключевая мысль: из 10 000 соединений в любой конкретный момент реально что-то
происходит лишь на горстке. Остальные просто висят и ждут — ждут, пока клиент
дошлёт данные, ждут сети, ждут диска. Поток, который сидит в read() и ждёт
байтов, — это полностью занятый ресурс ОС, который ничего не делает.
В любой момент времени из 10 000 соединений:
готовы к работе ▓▓▓ (3–50) ← здесь реально есть данные
ждут данных ░░░░░░░░░░░░░░░░░░░ (остальные ~9950)
└── впустую держат поток ────┘Что нарисовано: лишь малая доля соединений «активна» в данный миг; подавляющее большинство простаивает. Держать под каждое простаивающее соединение целый поток — расточительство.
Отсюда идея событийной модели (event-driven): не плодить потоки, а завести один поток (или несколько — по числу ядер), который крутится в цикле событий (event loop). Цикл спрашивает у ОС: «на каких соединениях сейчас есть работа?» — и обрабатывает только их.
Событийная модель: один поток + цикл событий
──────────────────────────────────────────────
┌──────────────────── EVENT LOOP ───────────────────┐
│ while (true) { │
│ готовые = жди_событий(все_соединения) ◀── ОС │
│ for (соединение : готовые) { │
│ обработай(соединение) // не блокируясь! │
│ } │
│ } │
└───────────────────────────────────────────────────┘
▲ один поток обслуживает тысячи соединенийЧто нарисовано: единственный поток в бесконечном цикле спрашивает ОС о готовых соединениях и быстро прокручивает только их. Нет тысяч стеков, нет тысяч переключений.
Неблокирующий I/O — фундамент
Чтобы один поток не застрял на одном клиенте, нужен неблокирующий I/O.
Обычный (блокирующий) read() работает так: «дай данные, а если их нет — усыпи
меня, пока не появятся». Для event loop это смертельно: поток уснёт на одном
соединении и забросит остальные 9999.
Неблокирующий режим (флаг O_NONBLOCK на дескрипторе) меняет контракт: «дай
данные, а если их прямо сейчас нет — не спи, верни мне ошибку EAGAIN, я займусь
другими». Поток никогда не засыпает на конкретном соединении.
Блокирующий read Неблокирующий read
───────────────── ──────────────────
read(fd) ──▶ нет данных read(fd) ──▶ нет данных
│ │
▼ ▼
поток СПИТ 😴 вернул EAGAIN сразу
(всё встало) поток идёт дальше ▶Что нарисовано: при отсутствии данных блокирующий вызов усыпляет поток, а неблокирующий мгновенно возвращает «пусто» — и поток продолжает работу.
Но возникает вопрос: если мы не спим, то как узнать, когда данные появились? Опрашивать все 10 000 дескрипторов в цикле вхолостую (busy-polling)? Это сожжёт CPU впустую. Нужен способ сказать ОС: «усыпи меня до тех пор, пока хоть на одном из этих дескрипторов не появится работа». Это и есть мультиплексирование ввода-вывода (I/O multiplexing) — один поток «слушает» сразу много источников.
select и poll: первое поколение, O(n)
Самые старые механизмы мультиплексирования — select() и poll(). Идея:
передаём ядру весь список дескрипторов, которые нас интересуют, и говорим
«разбуди, когда на любом из них что-то случится».
select / poll — каждый вызов:
─────────────────────────────
приложение ──▶ ОС: "вот ВСЕ 10 000 fd, проверь каждый"
│
▼
┌───────────────────────────┐
│ ОС проходит по СПИСКУ: │
│ fd0? fd1? fd2? ... fd9999? │ ← O(n)
└───────────────────────────┘
│
приложение ◀── ОС: "готовы fd42 и fd9000"
│
приложение проходит по ВСЕМ 10 000, ищет готовые ← снова O(n)Что нарисовано: на каждый вызов мы отдаём ядру весь список, ядро линейно обходит его, а потом и приложение линейно ищет, кто же готов.
Проблема в слове каждый. На каждой итерации цикла:
- мы копируем весь список дескрипторов в ядро (из user space в kernel space);
- ядро линейно обходит все N дескрипторов, проверяя готовность;
- мы получаем результат и сами линейно обходим все N, выясняя, кто готов.
Это O(n) на одну итерацию, где n — общее число соединений, а не число
активных. Когда из 10 000 готовы всего 3, мы всё равно платим за все 10 000.
У select вдобавок исторический лимит FD_SETSIZE (обычно 1024 дескриптора).
poll снимает лимит, но остаётся таким же O(n).
epoll: второе поколение, платим только за готовых
epoll (Linux) переворачивает идею. Вместо «передавай весь список каждый раз»
он предлагает: один раз зарегистрируй интерес, а потом получай только
готовые события.
Три вызова:
epoll_create()— создать объект epoll (ядро заведёт внутреннюю структуру).epoll_ctl()— один раз сказать «следи за этим дескриптором» (ADD), или «перестань» (DEL). Список интереса живёт в ядре между вызовами.epoll_wait()— заснуть и проснуться со списком только тех дескрипторов, где реально есть работа.
epoll — разделяем "регистрацию" и "ожидание":
─────────────────────────────────────────────
ОДИН РАЗ:
epoll_ctl(ADD, fd) ──▶ ОС запоминает интерес в своей структуре
epoll_ctl(ADD, fd) (красно-чёрное дерево внутри ядра)
... для всех 10 000
В ЦИКЛЕ:
epoll_wait() ──▶ заснуть
◀── проснуться: "готовы fd42, fd9000" ← только 2!
│
обрабатываем ровно 2 события ← O(число готовых)Что нарисовано: интерес регистрируется единожды и хранится в ядре; в цикле мы получаем не весь список, а только реально готовые дескрипторы.
Фокус в том, что ядро не обходит список по запросу. Оно заранее, через механизм
прерываний от устройств, помечает дескрипторы готовыми и складывает их в
отдельный «список готовности». epoll_wait() просто отдаёт этот готовый список.
Стоимость — O(число готовых событий), а не O(всех соединений). Из 10 000
готовы 3 — заплатим за 3.
| Механизм | Где | Регистрация | Сложность | Лимит fd |
|---|---|---|---|---|
select |
везде | весь список каждый раз | O(n) | ~1024 |
poll |
везде | весь список каждый раз | O(n) | нет |
epoll |
Linux | один раз (epoll_ctl) |
O(готовых) | нет |
kqueue |
BSD/macOS | один раз (kevent) |
O(готовых) | нет |
На BSD и macOS аналог epoll называется kqueue — идея та же (регистрируем интерес, получаем только готовые), отличается API. На Windows схожую роль играет IOCP, но модель там немного другая (готовые завершённые операции, а не готовые дескрипторы).
Edge-triggered vs level-triggered
У epoll есть два режима уведомления, и про разницу любят спрашивать.
- Level-triggered (LT, по уровню) — режим по умолчанию. «Есть
непрочитанные данные → буду напоминать на каждом
epoll_wait, пока ты их не вычитаешь». Как будильник, который звонит, пока ты не встанешь. Прощает ошибки: даже если прочитал не всё, в следующий раз снова уведомят. - Edge-triggered (ET, по фронту) — «уведомлю один раз в момент, когда
состояние изменилось (пришли новые данные). Дальше — твоя забота». Как один
стук в дверь: прозевал — больше не повторят. С ET нужно в цикле читать, пока
не получишь
EAGAIN, иначе остаток данных «зависнет» до следующего изменения.
Пришло 100 байт, приложение за раз прочитало 60:
LEVEL-triggered EDGE-triggered
─────────────── ──────────────
wait → "готов" (100) wait → "готов" (изменение!)
read 60, осталось 40 read 60, осталось 40
wait → "готов" (ещё 40) ✅ wait → ...тишина... ❌
read 40 (40 байт зависли, пока
не придёт НОВАЯ порция)Что нарисовано: LT повторно напомнит про недочитанный остаток, ET — нет. ET даёт меньше системных вызовов (важно на огромных нагрузках), но требует аккуратного кода: «дочитывай до EAGAIN». LT проще и безопаснее.
Мост к Go: лучшее из двух миров
Вот тут главное для нас как backend/Go-инженеров. Событийная модель мощная, но писать на ней руками — боль: код выворачивается в каскад колбэков («callback hell»), линейная логика рвётся на куски. А модель «поток на соединение» — наоборот, пишется легко, но не масштабируется.
Go даёт оба преимущества сразу. Ты пишешь простой, линейный, как будто блокирующий код в горутине:
go func(conn net.Conn) {
data := conn.Read(...) // выглядит как блокирующий вызов
process(data)
conn.Write(...)
}(conn)Выглядит как «горутина на соединение» — просто и читаемо. Но под капотом рантайм
Go не создаёт поток ОС на каждую горутину и не делает блокирующий
системный вызов. Когда ты зовёшь conn.Read, а данных нет, происходит вот что:
Что делает рантайм Go при conn.Read без данных:
───────────────────────────────────────────────
горутина G: conn.Read() ──▶ данных нет
│
▼
┌───────────────────────────────────────┐
│ netpoller регистрирует fd в epoll/ │
│ kqueue (epoll_ctl ADD) │
│ горутина G ПАРКуется (снимается с потока│
│ ОС, поток свободен для других горутин) │
└───────────────────────────────────────┘
│
поток ОС M исполняет другие горутины ▶▶▶
│
...позже netpoller (epoll_wait) видит:
fd готов ──▶ G размораживается, ставится
обратно в очередь планировщикаЧто нарисовано: вместо блокировки потока рантайм паркует только саму горутину, освобождая поток ОС под другую работу, а готовность ловит через epoll/kqueue.
Этот внутренний механизм называется netpoller. Он — единственная точка во всём рантайме, которая реально дёргает epoll/kqueue. Каждая «блокирующая» сетевая операция в горутине превращается в:
- регистрацию дескриптора в netpoller (epoll под капотом);
- парковку горутины — она снимается с потока ОС, поток освобождается;
- позже netpoller через
epoll_waitузнаёт о готовности и будит горутину, возвращая её в очередь планировщика Go.
Итог: ты пишешь линейный код «горутина на соединение» (удобно, как потоки), а платишь за это как за event loop (дёшево: горутина стоит ~2–8 КБ против мегабайтов у потока, переключение горутин не трогает ядро). 10 000 соединений в Go — это 10 000 дешёвых горутин, мультиплексируемых одним epoll и горсткой потоков ОС по числу ядер. Проблема C10k решается «бесплатно» на уровне языка.
Три подхода рядом:
──────────────────────────────────────────────────────────
поток-на-соединение │ event loop вручную │ Go (горутина+netpoller)
───────────────────────────────────────────────────────────────
код: линейный 🙂 │ колбэки 🙁 │ линейный 🙂
память: МБ/соед. 🙁 │ КБ всего 🙂 │ ~КБ/горутина 🙂
масштаб: плохо 🙁 │ отлично 🙂 │ отлично 🙂
epoll: нет │ руками │ скрыт в рантаймеЧто нарисовано: Go берёт удобство линейного кода от потоков и масштабируемость от event loop, пряча epoll внутри рантайма.
Почему модель «поток на соединение» плохо масштабируется до 10 000 соединений?
Главное преимущество epoll над select/poll:
Зачем event loop нужен неблокирующий I/O (O_NONBLOCK)?
Что делает рантайм Go, когда conn.Read вызван в горутине, а данных в сокете нет?
В режиме edge-triggered пришло 100 байт, приложение прочитало 60 и не дочитало до EAGAIN. Что произойдёт с оставшимися 40 байтами?
Сколько системных вызовов epoll_ctl/epoll_create на отдельный дескриптор нужно epoll для регистрации интереса (один раз, до цикла ожидания)?
Что спрашивают на собесе
- В чём проблема C10k и почему модель «поток на соединение» не масштабируется. Назови обе причины: память на стеки и стоимость переключений контекста ОС.
- Чем epoll лучше select/poll. Ключевое: select/poll — O(n) (весь список каждый вызов), epoll регистрирует интерес один раз и отдаёт только готовые — O(числа готовых событий). Плюс у select лимит FD_SETSIZE.
- Что такое неблокирующий I/O и зачем он event loop. Без
O_NONBLOCKодин поток заснёт на одном соединении; неблокирующий режим возвращаетEAGAIN. - Edge-triggered vs level-triggered. ET уведомляет один раз на изменение (нужно читать до EAGAIN), LT напоминает, пока есть данные. Уметь сказать, чем ET опасен.
- Как устроен netpoller в Go. Линейный код в горутине, но рантайм паркует горутину и использует epoll/kqueue под капотом — «лучшее из двух миров».
- Где kqueue, где epoll. epoll — Linux, kqueue — BSD/macOS, IOCP — Windows.