GraphLMS

ОС
Начать

ОС · Конкурентность · 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. Память. У каждого потока ОС свой стек — обычно от 1 до 8 МБ. 10 000 потоков — это десятки гигабайт только на стеки, ещё ничего не сделав.
  2. Переключения контекста. Планировщик ОС вынужден постоянно перекидывать 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. Каждая «блокирующая» сетевая операция в горутине превращается в:

  1. регистрацию дескриптора в netpoller (epoll под капотом);
  2. парковку горутины — она снимается с потока ОС, поток освобождается;
  3. позже netpoller через epoll_wait узнаёт о готовности и будит горутину, возвращая её в очередь планировщика Go.

Итог: ты пишешь линейный код «горутина на соединение» (удобно, как потоки), а платишь за это как за event loop (дёшево: горутина стоит ~2–8 КБ против мегабайтов у потока, переключение горутин не трогает ядро). 10 000 соединений в Go — это 10 000 дешёвых горутин, мультиплексируемых одним epoll и горсткой потоков ОС по числу ядер. Проблема C10k решается «бесплатно» на уровне языка.

   Три подхода рядом:
   ──────────────────────────────────────────────────────────
   поток-на-соединение │ event loop вручную │ Go (горутина+netpoller)
   ───────────────────────────────────────────────────────────────
   код:   линейный 🙂   │  колбэки 🙁         │  линейный 🙂
   память: МБ/соед. 🙁  │  КБ всего 🙂        │  ~КБ/горутина 🙂
   масштаб: плохо 🙁    │  отлично 🙂         │  отлично 🙂
   epoll:  нет          │  руками             │  скрыт в рантайме

Что нарисовано: Go берёт удобство линейного кода от потоков и масштабируемость от event loop, пряча epoll внутри рантайма.

Проверь себя· Событийная модель и 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.
Взаимоблокировки: теория и борьбаУстройства ввода-вывода: прерывания и DMA