36. select, poll и epoll

Введение: Проблема масштабирования блокирующих I/O

Когда разработчик делает свои первые шаги в сетевом программировании, код выглядит обманчиво прямолинейно. Мы открываем сокет, вызываем блокирующие функции accept(), read(), write(). Пока удаленный клиент думает или передает данные по медленному мобильному интернету, вызывающий поток добросовестно спит.

Такой подход безупречно работает для десятков или сотен клиентов. Но как только нагрузка возрастает до тысяч или сотен тысяч одновременных подключений (знаменитая проблема C10K / C100K), наивная модель «один тред ОС на одного клиента» терпит крах. Запуск отдельного потока ядра на каждое соединение означает гигабайты памяти, впустую сожженные под стеки потоков, и катастрофические накладные расходы планировщика ОС на непрерывные контекстные переключения.

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

Именно этот принцип лежит в основе мультиплексирования ввода-вывода (I/O Multiplexing). Вместо того чтобы спать на каждом сокете по отдельности, один рабочий поток регистрирует в ядре сотни тысяч файловых дескрипторов и задает один-единственный вопрос: «Какие из этих сокетов готовы к чтению или записи прямо сейчас?». Ядро возвращает список активных сокетов, а остальные продолжают спокойно ждать без малейшей траты процессорных ресурсов.

В Linux путь к современному высокопроизводительному мультиплексированию занял почти три десятилетия — от примитивных битовых масок до событийно-ориентированных структур данных ядра. Понимание этой эволюции критично: именно на механизме epoll зиждется феноменальная производительность сетевых серверов на Go.

flowchart TD
    subgraph ClientConnections["Тысячи TCP-клиентов"]
        C1["Клиент 1 (Молчит)"]
        C2["Клиент 2 (Прислал HTTP GET)"]
        C3["Клиент 3 (Молчит)"]
        C4["Клиент 4 (Прислал TCP ACK)"]
    end

    subgraph LinuxKernel["Ядро Linux: Мультиплексор"]
        Multiplexer["I/O Мультиплексор (epoll)"]
    end

    subgraph GoWorker["Go Runtime / Пользовательский тред"]
        Worker["Один системный тред / Netpoller<br/>Обрабатывает только сокеты 2 и 4!"]
    end

    C1 -.-> Multiplexer
    C2 ==>|"Данные готовы!"| Multiplexer
    C3 -.-> Multiplexer
    C4 ==>|"Данные готовы!"| Multiplexer

    Multiplexer ==>|"Возврат готовых дескрипторов: [2, 4]"| GoWorker

    classDef silent fill:#1e293b,stroke:#475569,stroke-width:1px,color:#94a3b8;
    classDef active fill:#022c22,stroke:#10b981,stroke-width:2px,color:#f8fafc;
    classDef kern fill:#0f172a,stroke:#6366f1,stroke-width:2px,color:#f8fafc;
    classDef worker fill:#1e1b4b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;

    class C1,C3 silent;
    class C2,C4 active;
    class LinuxKernel,Multiplexer kern;
    class GoWorker,Worker worker;
  

Эволюция решений: от select к epoll

Исторически операционные системы семейства Unix выработали три последовательных поколения API для мультиплексирования ввода-вывода:

  1. select (BSD 4.2, 1983 год) — управление через компактные битовые маски, жестокий системный лимит на количество сокетов и алгоритмическая сложность $O(n)$ при каждом вызове.
  2. poll (System V Release 3, конец 1980-х годов) — переход от битовых масок к динамическому массиву структур, снятие жесткого лимита на количество сокетов, но сохранение убийственной сложности $O(n)$.
  3. epoll (Linux 2.5.44+, стабилизирован в Linux 2.6, 2002 год, автор — Давиде Либенци) — полноценная событийно-ориентированная подсистема ядра, алгоритмическая сложность ожидания $O(1)$, хранение состояния в памяти ядра и прямая интеграция с драйверами сетевых карт через механизм обратных вызовов.
flowchart LR
    subgraph SelectEra["1983: select()"]
        direction TB
        S1["Битовая маска fd_set"]
        S2["Лимит FD_SETSIZE = 1024"]
        S3["Сложность O(N)"]
    end

    subgraph PollEra["1989: poll()"]
        direction TB
        P1["Массив struct pollfd"]
        P2["Лимит дескрипторов снят"]
        P3["Сложность O(N)"]
    end

    subgraph EpollEra["2002: epoll()"]
        direction TB
        E1["Дерево rb_root + список rdllist"]
        E2["Масштабирование до миллионов FD"]
        E3["Сложность O(1)"]
    end

    SelectEra ==>|"Снятие лимита"| PollEra
    PollEra ==>|"Событийная модель ядра"| EpollEra

    classDef old fill:#450a0a,stroke:#f43f5e,stroke-width:2px,color:#f8fafc;
    classDef mid fill:#78350f,stroke:#f59e0b,stroke-width:2px,color:#f8fafc;
    classDef modern fill:#022c22,stroke:#10b981,stroke-width:2px,color:#f8fafc;

    class SelectEra,S1,S2,S3 old;
    class PollEra,P1,P2,P3 mid;
    class EpollEra,E1,E2,E3 modern;
  

select: Жесткие ограничения и O(n)

Системный вызов select появился на заре сетевого стека Unix:

C
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

select оперирует битовыми масками (fd_set). Каждый бит в маске соответствует номеру файлового дескриптора. Но размер этой битовой маски жестко зашит на этапе компиляции константой ядра FD_SETSIZE (по умолчанию ровно 1024 бита). Если ваш сервер открыл дескриптор с номером 1025, select физически не сможет его обработать и приведет к переполнению буфера.

Под капотом:

  1. Пользовательское приложение заполняет структуры fd_set в User Space с помощью макросов FD_ZERO и FD_SET.
  2. При вызове select эти структуры полностью копируются из памяти пользователя в Kernel Space.
  3. Ядро перебирает все дескрипторы от 0 до nfds (алгоритмическая сложность $O(n)$), последовательно опрашивая подсистемы VFS и сетевые сокеты.
  4. Ядро перезаписывает переданные битовые маски, выставляя единицы только в битах готовых сокетов, и копирует готовую маску обратно в User Space.
  5. Приложение в цикле от 0 до nfds перебирает все биты макросом FD_ISSET, чтобы найти, кто именно готов к работе.

poll: Устранение лимита, но сохранение проблемы

Чтобы избавить программистов от жесткого лимита FD_SETSIZE, стандарт System V представил системный вызов poll:

C
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

Вместо битовой маски poll принимает динамический массив структур struct pollfd, каждая из которых содержит номер дескриптора fd, битовую маску запрашиваемых событий events и битовую маску наступивших событий revents:

C
struct pollfd {
    int   fd;       // Файловый дескриптор
    short events;   // Битовая маска интересующих событий (POLLIN, POLLOUT)
    short revents;  // Битовая маска наступивших событий, заполняемая ядром
};

Лимит FD_SETSIZE исчез: сервер может мониторить миллион дескрипторов, если хватит оперативной памяти. Однако коренная архитектурная проблема осталась нерешенной:

  • Массив pollfd все равно копируется целиком из User Space в Kernel Space при каждом вызове.
  • Ядро все равно линейно перебирает весь массив от первого до последнего элемента ($O(n)$).
  • Приложению после пробуждения все равно приходится в цикле обходить все $N$ элементов, чтобы отыскать те, у которых взведен флаг revents. Если из 50 000 сокетов данные пришли только в один, приложение выполнит 50 000 итераций впустую!

epoll: Архитектура и работа "под капотом"

В 2002 году подсистема epoll произвела революцию в масштабируемости Linux. Архитектурный прорыв заключался в простом разделении ответственности:

  1. Интерес (Interest list) — долговременный список дескрипторов, за которыми мы хотим наблюдать. Он сохраняется в адресном пространстве ядра Linux и не пересоздается при каждом опросе.
  2. Готовность (Ready list) — динамический список дескрипторов, на которых прямо сейчас произошли аппаратные или системные события.
C
#include <sys/epoll.h>
int epoll_create(int size); // или epoll_create1(int flags)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
flowchart TD
    subgraph KernelSpace["Ядро Linux: struct eventpoll"]
        direction TB
        RBTree["Красное-черное дерево (rb_root)<br/>Все отслеживаемые дескрипторы (O(log N))"]
        RDList["Двусвязный список готовых событий (rdllist)<br/>Только те сокеты, где появились байты! (O(1))"]
    end

    App["Приложение (Go Netpoller)"] -->|"epoll_ctl(ADD, fd)"| RBTree
    NIC["Сетевая карта (Пакет пришел!)"] -->|"Аппаратное прерывание"| Callback["ep_poll_callback()"]
    Callback -->|"Мгновенная вставка"| RDList
    RDList ==>|"epoll_wait: копирование ТОЛЬКО готовых событий"| App

    classDef kern fill:#0f172a,stroke:#6366f1,stroke-width:2px,color:#f8fafc;
    classDef app fill:#1e1b4b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    classDef hw fill:#450a0a,stroke:#f43f5e,stroke-width:2px,color:#f8fafc;

    class KernelSpace,RBTree,RDList kern;
    class App app;
    class NIC,Callback hw;
  

Ключевые структуры ядра Linux:

  • struct eventpoll — главный объект контекста, создаваемый вызовом epoll_create (или epoll_create1). Он содержит красное-черное дерево (rb_root) для эффективного поиска интересующих дескрипторов за $O(\log n)$ и связанный список готовых событий (rdllist).
  • struct epitem — элемент красного-черного дерева ядра. Каждая запись связывает конкретный файловый дескриптор с обратным вызовом ядра (ep_poll_callback).
  • struct epoll_filefd — внутренняя обертка для валидации дескриптора и защиты от циклических зависимостей (например, попытки добавить epoll-дескриптор внутрь самого себя).

Пошаговый жизненный цикл события в epoll:

  1. Вызов epoll_ctl(EPOLL_CTL_ADD) вставляет дескриптор в красное-черное дерево ядра rb_root. Это происходит один раз при установлении сетевого соединения.
  2. Ядро регистрирует функцию обратного вызова ep_poll_callback в очереди ожидания сокета (wait_queue).
  3. Когда сетевой адаптер принимает TCP-пакет, срабатывает прерывание драйвера. Драйвер помещает пакет в сокет и немедленно дергает ep_poll_callback.
  4. Обратный вызов ядра атомарно переносит соответствующий struct epitem в двусвязный список rdllist (Ready List) и будит спящий тред.
  5. Вызов epoll_wait не сканирует сокеты! Он мгновенно забирает элементы из rdllist и копирует их в буфер events в пространстве пользователя. Если rdllist пуст — поток засыпает в ядре без расхода тактов процессора.

Triggered modes: Level vs Edge

epoll предоставляет два фундаментально различных режима оповещения о событиях:

Режим Поведение Аналог в сетевом стеке
Level-Triggered (LT) Системный вызов epoll_wait будет возвращать дескриптор до тех пор, пока буфер остается непустым. Если пришло 64 КБ, а вы прочитали только 4 КБ, следующий epoll_wait снова немедленно вернет этот дескриптор. Классический режим по умолчанию. Максимально безопасен, прост в разработке, но может порождать лишние переключения контекста при частичном чтении.
Edge-Triggered (ET) Возвращает дескриптор только при изменении состояния (на фронте волны — когда пришли новые данные). Требует сокетов в неблокирующем режиме (O_NONBLOCK) и полного вычитывания в цикле до получения кода ошибки EAGAIN/EWOULDBLOCK. Режим максимальной производительности (используется в NGINX, HAProxy, Envoy). Минимизирует число пробуждений, но требует предельной аккуратности.
flowchart TD
    subgraph LT["Level-Triggered (По уровню: пока есть байты)"]
        direction TB
        LT1["Данные в сокете: 10 КБ"] --> LT2["epoll_wait: возврат FD"]
        LT2 --> LT3["Приложение прочитало 4 КБ (Осталось 6 КБ)"]
        LT3 --> LT4["Следующий epoll_wait: СНОВА возврат FD!"]
    end

    subgraph ET["Edge-Triggered (По фронту: только при изменении)"]
        direction TB
        ET1["Данные прибыли: 10 КБ (Фронт!)"] --> ET2["epoll_wait: возврат FD"]
        ET2 --> ET3["Приложение прочитало 4 КБ (Осталось 6 КБ)"]
        ET3 --> ET4["Следующий epoll_wait: ТИШИНА! Новых пакетов не было!"]
        ET4 --> ET5["Опасность: Оставшиеся 6 КБ зависнут навсегда!"]
    end

    classDef lt fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    classDef et fill:#450a0a,stroke:#f43f5e,stroke-width:2px,color:#f8fafc;

    class LT,LT1,LT2,LT3,LT4 lt;
    class ET,ET1,ET2,ET3,ET4,ET5 et;
  

Механическая симпатия: Почему это важно для Go

Среда исполнения Go не создает отдельный тред ОС на каждую горутину, а использует легковесную модель планирования M:N. Когда горутина инициирует сетевое чтение (net.Conn.Read или net.Read):

sequenceDiagram
    autonumber
    participant G as Горутина G
    participant M as Системный тред M
    participant NP as Go Netpoller
    participant K as Ядро Linux (epoll)

    Note over NP,K: При создании сокета, один раз: epoll_ctl(EPOLL_CTL_ADD, fd, EPOLLET | ...)
    G->>M: net.Conn.Read(buf)
    Note over M: syscall read() -> EAGAIN (нет данных)
    M->>G: runtime.gopark() (Горутина паркуется на pollDesc: _Gwaiting)
    Note over M: Тред M свободен! Берет другую горутину.

    Note over K: Пакет прибыл от клиента!
    K-->>NP: epoll_wait возвращает готовый fd
    NP->>G: runtime.netpollready() (Горутина переходит в _Grunnable)
    Note over G: Горутина продолжает Read(buf) без ошибок!
  
  1. Go runtime пытается немедленно прочитать данные неблокирующим системным вызовом read().
  2. Если данных нет (EAGAIN), рантайм отвязывает горутину от текущего системного треда M.
  3. Дескриптор сокета к этому моменту уже зарегистрирован в epoll через сетевой поллер netpoll (один раз, при создании net.Conn); горутина привязывается к его pollDesc.
  4. Горутина паркуется с помощью вызова runtime.gopark, а тред M продолжает исполнять другие горутины, не тратя время на сон в ядре.
  5. Фоновый поток сетевого поллера вызывает epoll_wait. Как только ядро сообщает о готовности сокета, рантайм через runtime.netpollready возвращает горутину в очередь готовых к исполнению.

Таким образом, epoll — это краеугольный камень рантайма Go. Без эффективного мультиплексирования сетевых событий на уровне ядра Go-сервер не смог бы обрабатывать миллионы параллельных сетевых соединений на скромном многоядерном процессоре.


Go runtime и netpoller: Ключевые детали

Сетевой поллер Go (netpoller) — это шедевр системного программирования, спроектированный с учетом глубокой механической симпатии:

  • Пул памяти пакетов: рантайм активно использует кэширование буферов (sync.Pool), чтобы снизить давление на сборщик мусора при непрерывном чтении и записи сетевых пакетов.
  • Детекция разрыва соединений: Go регистрирует события с флагом EPOLLRDHUP, позволяя мгновенно узнавать о закрытии TCP-соединения клиентом без необходимости делать пробный холостой системный вызов read.
  • Кооперативная интеграция с планировщиком: вызовы runtime.gopark и runtime.goready переводят горутины между состояниями без переключения контекста системных тредов ядра, экономя тысячи тактов процессора на каждом сетевом пакете.

В высоконагруженных промышленных системах важно придерживаться лучших практик:

  • Избегайте длительных блокирующих расчетов внутри обработчиков net.Conn.
  • Применяйте буферизованный ввод-вывод из пакета bufio, чтобы сгруппировать мелкие записи и минимизировать лишние вызовы syscall.
  • На современных ядрах Linux поддерживается флаг EPOLLEXCLUSIVE, предотвращающий проблему громоподобного стада (thundering herd), когда при поступлении одного входящего соединения ядро пробуждало все ожидающие потоки.

Итог

  1. Системные вызовы select и poll устарели для высоконагруженных систем из-за алгоритмической сложности $O(n)$ и необходимости копирования всех дескрипторов при каждом обращении.
  2. epoll делит дескрипторы на список интереса (хранится в ядре в виде rb_root) и список готовности (rdllist), обеспечивая константную сложность $O(1)$ при ожидании событий.
  3. Режимы Level-Triggered (LT) и Edge-Triggered (ET) определяют семантику уведомлений: LT безопасен и интуитивен, а ET требует неблокирующих сокетов и чтения до получения ошибки EAGAIN.
  4. Сетевой поллер Go (netpoller) прозрачно связывает системный механизм epoll с планировщиком горутин через runtime.gopark и runtime.goready, делая асинхронный сетевой ввод-вывод простым и синхронным по стилю написания.

В следующей лекции мы исследуем, как аналогичную проблему решают операционные системы семейств BSD и Windows, и почему архитектура портов завершения ввода-вывода отличается от модели готовности: 37. kqueue и IOCP. Чем отличаются BSD и Windows.