Embedded HandbookПриложения
Все разделы › XVII. Системное программирование на C
· уровень средний · 12 мин чтения
Открыть в интерактивном справочнике

141. Неблокирующий ввод-вывод: select, poll, epoll, дескрипторы событий

Сервер из раздела 78 обслуживал одно соединение за раз: read ждёт данных, и пока один клиент молчит, остальные стоят. Поток на соединение (раздел 139) решает это до нескольких сотен клиентов, а дальше упирается в память под стеки и в переключения. Третий путь — один поток и ядро, которое говорит, какие дескрипторы готовы: неблокирующие сокеты и мультиплексирование. На нём стоят nginx, Redis и Node.js.

Здесь один эхо-сервер написан трижды — на select, poll и epoll — и прогнан под нагрузкой из 500 и 2000 соединений (_verify/sys141/, stm-sys:1); дальше — таймер, событие и сигнал как дескрипторы, и что стоит за словами «реактор», io_uring и «корутины».

Как это называется в документации

Оригинал Перевод
blocking, non-blocking I/O блокирующий, неблокирующий ввод-вывод
EAGAIN / EWOULDBLOCK «сейчас нечего» — повторить позже
I/O multiplexing мультиплексирование: ждать многих дескрипторов сразу
readiness notification уведомление о готовности
level-triggered, edge-triggered по уровню, по фронту
FD_SETSIZE предел select — 1024 дескриптора
interest list, ready list список наблюдаемых и список готовых (epoll)
timerfd, eventfd, signalfd таймер, событие, сигнал в виде дескриптора
C10K problem задача десяти тысяч соединений
reactor pattern реактор: цикл событий с обработчиками
completion-based I/O ввод-вывод по завершению (io_uring, IOCP)
coroutine сопрограмма

141.1 Неблокирующий режим

По умолчанию read, write, accept и connect ждут. Флаг O_NONBLOCK меняет договор: если сделать нечего, вызов возвращает −1 с errno == EAGAIN сразу. Исключение — connect: он возвращает EINPROGRESS, а итог соединения узнают, когда сокет станет готов к записи, через getsockopt(fd, SOL_SOCKET, SO_ERROR, …):

static int set_nonblocking(int fd)
{
    int fl = fcntl(fd, F_GETFL, 0);
    return fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
неблокирующий read без данных: -1, errno Resource temporarily unavailable

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

epoll: ядро хранит список наблюдаемых сокетов и отдаёт одному потоку только готовые


141.2 Три вызова, один сервер

Сервер делает одно и то же независимо от способа ожидания: слушающий сокет в наборе; готов он — принять всех, кого можно, и добавить в набор; готово соединение — прочитать всё до EAGAIN и вернуть:

static int serve(int fd)
{
    char buf[4096];
    for (;;) {
        ssize_t n = read(fd, buf, sizeof buf);
        if (n < 0) return errno == EAGAIN ? 1 : 0;      /* EAGAIN: данных больше нет — не ошибка */
        if (n == 0) return 0;
        if (write(fd, buf, n) < 0) return 0;
    }
}

Это упрощение: на неблокирующем сокете write может записать часть данных или вернуть EAGAIN, когда буфер отправки полон. Настоящий сервер хранит неотправленный хвост в буфере соединения, подписывается на EPOLLOUT и дописывает, когда сокет снова готов к записи.

select — старейший. Набор дескрипторов — битовая маска fd_set на 1024 бита (128 байт), и это предел: дескриптор с номером 1024 и выше в неё не помещается. Вызов портит набор, поэтому его копируют перед каждым вызовом, а после — перебирают все номера до maxfd:

        fd_set rd = all;                                 /* select портит набор — копия на каждый вызов */
        select(maxfd + 1, &rd, NULL, NULL, NULL);
        for (int fd = 0; fd <= maxfd; fd++) {            /* перебор всех номеров до maxfd */

poll — массив struct pollfd любой длины, без предела 1024, но каждый вызов передаёт в ядро весь массив и получает обратно весь массив с флагами revents; работа растёт с числом соединений, а не с числом готовых.

epoll — ядро хранит список наблюдаемых у себя; epoll_ctl добавляет и удаляет по одному, epoll_wait возвращает только готовые:

        int n = epoll_wait(ep, evs, 64, -1);             /* ядро отдаёт только готовые дескрипторы */

Нагрузка — клиент открывает соединения и по каждому гоняет сообщения туда-обратно:

Сервер 500 соединений × 20 сообщений 2000 соединений × 2
select 10 000 из 10 000, 469 мс 2040 из 4000 — соединения с номером ≥ 1024 отвергнуты
poll 10 000 из 10 000, 436 мс 4000 из 4000, 698 мс
epoll 10 000 из 10 000, 328 мс 4000 из 4000, 154 мс

На 500 соединениях все трое справляются; разница в скорости — от перебора: select и poll на каждое сообщение проходят весь набор. На 2000 соединений select ломается — сервер обязан закрыть всё, что не влезло в fd_set, и клиент получает эхо только по половине. poll справляется, но вчетверо медленнее epoll: 2000 структур в ядро и обратно на каждый из 4000 обменов.


141.3 epoll: по уровню и по фронту

epoll_wait по умолчанию срабатывает по уровню: пока в сокете есть непрочитанные данные, он будет возвращать этот дескриптор снова и снова. Флаг EPOLLET — по фронту: одно уведомление на появление данных, и если прочитать не всё, следующее придёт только с новыми данными. Клиент, который ждёт ответа и молчит, их не пришлёт — хвост так и останется в сокете. Отсюда правило для режима по фронту: сокет неблокирующий, читать до EAGAIN — как в serve выше. Сервер в опыте принимает соединения accept4(…, SOCK_NONBLOCK) и ставит EPOLLIN | EPOLLET:

                    struct epoll_event ce = { .events = EPOLLIN | EPOLLET, .data.fd = c };   /* по фронту */
                    epoll_ctl(ep, EPOLL_CTL_ADD, c, &ce);

Поле data — что угодно: номер дескриптора или указатель на структуру соединения с буферами и состоянием; ядро вернёт его как есть. EPOLLRDHUP сообщает, что клиент закрыл свою сторону; EPOLLONESHOT — одно срабатывание, потом дескриптор надо перевзвести (для пула потоков).

epoll есть только в Linux; в BSD и macOS его роль играет kqueue, в Windows — порты завершения (IOCP). Библиотеки libevent, libuv (ядро Node.js) и libev прячут эту разницу за одним циклом событий и добавляют таймеры, DNS и HTTP.

epoll по уровню и по фронту: после неполного чтения уведомление повторяется или приходит только с новыми данными


141.4 Всё — дескриптор

В одном poll рядом с сокетами удобно ждать и другое, если оно тоже дескриптор. Linux даёт три таких:

    struct pollfd p[3] = { { tfd, POLLIN, 0 }, { efd, POLLIN, 0 }, { sfd, POLLIN, 0 } };
таймер: тик 1
eventfd: 1 от другого потока
таймер: тик 2
signalfd: сигнал 10
таймер: тик 3

Так главный цикл сервера получает таймеры, сигналы SIGTERM и сообщения от рабочих потоков тем же путём, что и данные из сокетов, — без гонок между обработчиком сигнала и циклом (раздел 136).


141.5 Реактор, C10K и io_uring

Цикл «жди готовности → вызови обработчик» называется реактором; так устроены nginx (по одному процессу-реактору на ядро), Redis (один поток) и Node.js. Десять тысяч одновременных соединений — задача, на которой в конце девяностых ломались серверы с потоком или процессом на клиента; epoll и kqueue появились как ответ на неё. Сегодня та же схема держит миллион соединений: расходы — только память на буферы.

epoll сообщает о готовности, а читать и писать программа должна сама. io_uring меняет модель: программа кладёт запросы (прочитать столько-то байт в этот буфер) в кольцо в общей с ядром памяти, ядро выполняет и кладёт результаты в другое кольцо — без системного вызова на каждую операцию. Это ввод-вывод по завершению, как IOCP в Windows, и он покрывает и файлы, для которых epoll бесполезен (обычный файл всегда «готов»). Старый POSIX AIO в glibc — потоки с pread внутри, и выигрыша не даёт.


141.6 Сопрограммы: последовательный код на цикле событий

Обработчики реактора рвут логику соединения на куски между событиями. Сопрограмма прячет это: функция «ждёт данных» и приостанавливается, а цикл событий возобновляет её, когда данные пришли; код читается как последовательный. В C сопрограмм в языке нет, их делают библиотеками: переключение контекста через makecontext/swapcontext (libtask) или короткой вставкой на ассемблере (libco), макросы на switch по номеру строки (приём Даффа — без своего стека), или готовые libmill/libdill в стиле Go. Каждая сопрограмма — свой стек в куче (обычно 8–64 КБ) вместо мегабайт у потока. Переключение на ассемблере обходится без ядра — единицы наносекунд; swapcontext из glibc при каждом переключении ещё и сохраняет маску сигналов системным вызовом и выходит в разы медленнее — в опыте больше сотни наносекунд. Обратная сторона: одна сопрограмма, ушедшая в долгий расчёт, останавливает все.


141.7 Грабли

Симптом Причина
read вернул −1 на неблокирующем сокете EAGAIN — не ошибка, а «пока нечего»
сервер на select теряет клиентов после ~1000 FD_SETSIZE 1024; переходить на poll/epoll
select «перестал видеть» дескрипторы набор портится вызовом — копировать перед каждым
epoll с EPOLLET пропускает данные не дочитали до EAGAIN — второго уведомления не будет
epoll по уровню крутится без дела сокет готов на запись всегда — EPOLLOUT ставить только когда есть что писать
poll тормозит на тысячах соединений весь массив в ядро на каждый вызов — epoll
signalfd не получает сигнал сигнал не заблокирован маской — ушёл в обработчик
один обработчик «завис» — встали все реактор однопоточный: долгую работу — в пул потоков
epoll на обычном файле «всегда готов» файлы не умеют готовность — io_uring или потоки

141.8 Итоги

Полные исходники

echo_epoll.c — эхо-сервер на epoll, select или poll.

/* Раздел 141: эхо-сервер на epoll — один поток обслуживает любое число соединений.
   Ключ -s — select, -p — poll, по умолчанию epoll. Порт — первый аргумент. */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <unistd.h>
#include <poll.h>
#include <sys/select.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>

static int set_nonblocking(int fd)
{
    int fl = fcntl(fd, F_GETFL, 0);
    return fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}

static int listen_on(int port)
{
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    int one = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
    struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(port), .sin_addr.s_addr = htonl(INADDR_ANY) };
    if (bind(fd, (struct sockaddr *)&a, sizeof a) < 0 || listen(fd, 128) < 0) { perror("bind/listen"); exit(1); }
    set_nonblocking(fd);
    return fd;
}

/* обслужить готовый дескриптор: читать всё, что есть, и вернуть; 0 — соединение закрыто */
static int serve(int fd)
{
    char buf[4096];
    for (;;) {
        ssize_t n = read(fd, buf, sizeof buf);
        if (n < 0) return errno == EAGAIN ? 1 : 0;      /* EAGAIN: данных больше нет — не ошибка */
        if (n == 0) return 0;
        if (write(fd, buf, n) < 0) return 0;
    }
}

static void run_epoll(int lfd)
{
    int ep = epoll_create1(0);
    struct epoll_event ev = { .events = EPOLLIN, .data.fd = lfd }, evs[64];
    epoll_ctl(ep, EPOLL_CTL_ADD, lfd, &ev);
    for (;;) {
        int n = epoll_wait(ep, evs, 64, -1);             /* ядро отдаёт только готовые дескрипторы */
        for (int i = 0; i < n; i++) {
            int fd = evs[i].data.fd;
            if (fd == lfd) {
                int c;
                while ((c = accept4(lfd, NULL, NULL, SOCK_NONBLOCK)) >= 0) {
                    struct epoll_event ce = { .events = EPOLLIN | EPOLLET, .data.fd = c };   /* по фронту */
                    epoll_ctl(ep, EPOLL_CTL_ADD, c, &ce);
                }
            } else if (!serve(fd)) {
                epoll_ctl(ep, EPOLL_CTL_DEL, fd, NULL); close(fd);
            }
        }
    }
}

static void run_poll(int lfd)
{
    static struct pollfd fds[8192]; int n = 1;      /* poll: массив любого размера, но весь идёт в ядро */
    fds[0] = (struct pollfd){ lfd, POLLIN, 0 };
    for (;;) {
        poll(fds, n, -1);                                /* каждый вызов — весь массив в ядро и обратно */
        for (int i = 0; i < n; i++) {
            if (!(fds[i].revents & (POLLIN | POLLHUP))) continue;
            if (fds[i].fd == lfd) {
                int c;
                while ((c = accept4(lfd, NULL, NULL, SOCK_NONBLOCK)) >= 0 && n < 8192)
                    fds[n++] = (struct pollfd){ c, POLLIN, 0 };
            } else if (!serve(fds[i].fd)) {
                close(fds[i].fd); fds[i] = fds[--n]; i--;
            }
        }
    }
}

static void run_select(int lfd)
{
    fd_set all; FD_ZERO(&all); FD_SET(lfd, &all); int maxfd = lfd;
    for (;;) {
        fd_set rd = all;                                 /* select портит набор — копия на каждый вызов */
        select(maxfd + 1, &rd, NULL, NULL, NULL);
        for (int fd = 0; fd <= maxfd; fd++) {            /* перебор всех номеров до maxfd */
            if (!FD_ISSET(fd, &rd)) continue;
            if (fd == lfd) {
                int c;
                while ((c = accept4(lfd, NULL, NULL, SOCK_NONBLOCK)) >= 0) {
                    if (c >= FD_SETSIZE) { close(c); continue; }     /* select не умеет больше 1024 */
                    FD_SET(c, &all); if (c > maxfd) maxfd = c;
                }
            } else if (!serve(fd)) { close(fd); FD_CLR(fd, &all); }
        }
    }
}

int main(int argc, char **argv)
{
    signal(SIGPIPE, SIG_IGN);
    int lfd = listen_on(argc > 1 ? atoi(argv[1]) : 7000);
    const char *mode = argc > 2 ? argv[2] : "-e";
    if (!strcmp(mode, "-s")) run_select(lfd);
    else if (!strcmp(mode, "-p")) run_poll(lfd);
    else run_epoll(lfd);
    return 0;
}

Дальше: раздел 142 — веб-сервисы на C: HTTP, CGI, FastCGI, sendfile.

Открыть в интерактивном справочнике