Все статьи / Асинхронный ввод-вывод средствами POSIX

API для ввода-вывода в современных ОС позволяют обрабатывать сотни и тысячи одновременно открытых сетевых запросов либо файлов. Для этой цели не нужно создавать множество потоков - достаточно запустить специальный цикл событий на одном потоке, начав мультиплексирование ввода-вывода


Материал основан на статье Blocking I/O, Nonblocking I/O, And Epoll (англ.)

В этой статье мы покажем, что именно происходит, когда вы используете неблокирующий ввод-вывод. Мы рассмотрим:

  • Что означают понятия “неблокирующий”, “асинхронный”, “событийный” для ввода-вывода
  • Смысл добавления флага O_NONBLOCK для файловых дескрипторов через fcntl
  • Почему неблокирующий ввод-вывод часто сочетается с мультиплексированием через select, epoll и kqueue
  • Как неблокирующий режим ввода-вывода взаимодействует со средствам опроса дескрипторов, такими как epoll

Термины: неблокирующий, асинхронный, событийный

Абзац является переводом ответа на вопрос What’s the difference between: Asynchronous, Non-Blocking, Event-Base architectures?

  • “Асинхронный” буквально означает “не синхронный”. Например, отправка email асинхронная, потому что отправитель не ожидает получить ответ сразу же. В программировании “асинхронным” называют код, в котором компоненты посылают друг другу сообщения, не ожидая немедленного ответа.
  • “Неблокирующий” - термин, чаще всего касающийся ввода-вывода. Он означает, что при вызове “неблокирующего” системного API управление сразу же будет возвращено программе, и она продолжит использовать свой квант процессорного времени. Обычные, простые в использовании системные вызовы блокирующие: они усыпляют вызывающий поток до тех пор, пока данные для ответа не будут готовы.
  • “Событийный” означает, что компонент программы обрабатывает очередь событий с помощью цикла, а тем временем кто-либо добавляет события в очередь, формируя входные данные компонента, и забирает у него выходные данные.

Термины часто пересекаются. Например, протокол HTTP сам по себе синхронный (пакеты отправляются синхронно), но при наличии неблокирующего ввода-вывода программа, работающая с HTTP, может оставаться асинхронной, то есть успевать совершить какую-либо полезную работу между отправкой HTTP-запроса и получением ответа на него.

Блокирующий режим

По умолчанию все файловые дескрипторы в Unix-системах создаются в “блокирующем” режиме. Это означает, что системные вызовы для ввода-вывода, такие как read, write или connect, могут заблокировать выполнение программы вплоть до готовности результата операции. Легче всего понять это на примере чтения данных из потока stdin в консольной программе. Как только вы вызываете read для stdin, выполнение программы блокируется, пока данные не будут введены пользователем с клавиатуры и затем прочитаны системой. То же самое происходит при вызове функций стандартной библиотеки, таких как fread, getchar, std::getline, поскольку все они в конечном счёте используют системный вызов read. Если говорить конкретнее, ядро погружает процесс в спящее состояние, пока данные не станут доступны в псевдо-файле stdin. То же самое происходит и для любых других файловых дескрипторов. Например, если вы пытаетесь читать из TCP-сокета, вызов read заблокирует выполнение, пока другая сторона TCP-соединения не пришлёт ответные данные.

Блокировки - это проблема для всех программ, требующих конкурентного выполнения, поскольку заблокированные потоки процесса засыпают и не получают процессорное время. Существует два различных, но взаимодополняющих способа устранить блокировки:

  • неблокирующий режим ввода-вывода
  • мультиплексирование с помощью системного API, такого как select либо epoll

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

Неблокирующий режим (O_NONBLOCK)

Файловый дескриптор помещают в “неблокирующий” режим, добавляя флаг O_NONBLOCK к существующему набору флагов дескриптора с помощью fcntl:

/* Добавляем флаг O_NONBLOCK к дескриптору fd */
const int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

С момента установки флага дескриптор становится неблокирующим. Любые системные вызовы для ввода-вывода, такие как read и write, в случае неготовности данных в момент вызова ранее могли бы вызвать блокировку, а теперь будут возвращать -1 и глобальную переменную errno устанавливать в EWOULDBLOCK, Это интересное изменение поведения, но само по себе мало полезное: оно лишь является базовым примитивом для построения эффективной системы ввода-вывода для множества файловых дескрипторов.

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

// Интервал сна: 1 микросекунда
const timespec sleep_interval{.tv_sec = 0, .tv_nsec = 1000};
ssize_t nbytes = 0;
for (;;) {
    // Проверяем, есть ли данные в fd1
    if ((nbytes = read(fd1, buf, sizeof(buf))) < 0) {
        if (errno != EWOULDBLOCK) {
            // TODO: обработать ошибку ввода-вывода
            perror("read");
            exit(EXIT_FAILURE);
        }
    } else {
        // Как-либо обрабатываем данные.
        handleData(buf, nbytes);
    }

    // Проверяем, есть ли данные в fd2
    if ((nbytes = read(fd2, buf, sizeof(buf))) < 0) {
        if (errno != EWOULDBLOCK) {
            // TODO: обработать ошибку ввода-вывода
            perror("read");
            exit(EXIT_FAILURE);
        }
    } else {
        // Как-либо обрабатываем данные.
        handleData(buf, nbytes);
    }

    // Засыпаем на короткое время
    nanosleep(sleep_interval, NULL);
}

Такой подход работает, но имеет свои минусы:

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

Для решения этих проблем операционная система предоставляет мультиплексирование ввода-вывода.

Мультиплексирование ввода-вывода (select, epoll, kqueue и т.д.)

Существует несколько мультиплексирующих системных вызовов:

Все три варианта реализуют единый принцип: делегировать ядру задачу по отслеживанию прихода данных для операций чтения/записи над множеством файловых дескрипторов. Все варианты могут заблокировать выполнение, пока не произойдёт какого-либо события с одним из дескрипторов из указанного множества. Например, вы можете сообщить ядру ОС, что вас интересуют только события чтения для файлового дескриптора X, события чтения-записи для дескриптора Y, и только события записи - для Z.

Все мультиплексирующие системные вызовы, как правило, работают независимо от режима файлового дескриптора (блокирующего или неблокирующего). Программист может даже все файловые дескрипторы оставить блокирующими, и после select либо epoll возвращённые ими дескрипторы не будут блокировать выполнение при вызове read или write, потому что данные в них уже готовы. Есть важное исключение для epoll, о котором скажем далее.

Как O_NONBLOCK сочетается с мультиплексером select

Подробнее о select и fd_set читайте в man-документации select

Допустим, мы пишем простую программу-daemon, обслуживающее клиентские приложения через сокеты. Мы воспользуемся мультиплексером select и блокирующими файловыми дескрипторами. Для простоты предположим, что мы уже открыли файлы и добавили их в переменную read_fds, имеющую тип fd_set (то есть “набор файлов”). Ключевым элементом цикла событий, обрабатывающего файл, будет вызов select и дальнейшие вызовы read для каждого из дескрипторов в наборе.

Тип данных fd_set представляет просто массив файловых дескрипторов, где с каждым дескриптором связан ещё и флаг (0 или 1). Примерно так могло бы выглядеть объявление:

const size_t FD_SETSIZE = 1024; // Размер определён платформой.

struct fd_set {
  int fd[FD_SETSIZE]; // файловые дескрипторы
  short flags[FD_SETSIZE]; // флаги, можно считать за 0 или 1
  unsigned count; // число добавленных дескрипторов
};

Функция select принимает несколько объектов fd_set. В простейшем случае мы передаём один fd_set с набором файлов для чтения, а select модифицирует их, проставляя флаг для тех дескрипторов, из которых можно читать данные. Также функция возвращает число готовых для обработки файлов. Далее с помощью макроса FD_ISSET(index, &set) можно проверить, установлен ли флаг, т.е. можно ли читать данные без блокировки.

for (;;) {
    // Шаг 1: ожидание данных через вызов select
    if (select(FD_SETSIZE, &read_fds, NULL, NULL, NULL) < 0) {
        // TODO: обработать ошибку вызова select
        perror("select");
        exit(EXIT_FAILURE);
    }
    for (int i = 0; i < FD_SETSIZE; i++) {
        if (FD_ISSET(i, &read_fds)) {
            // Шаг 2: чтение данных из каждого файла
            char buf[1024];
            const ssize_t nbytes = read(i, buf, sizeof(buf));
            if (nbytes >= 0) {
                // Как-либо обрабатываем данные.
                handleData(nbytes, buf);
            } else {
                // TODO: обработать ошибку ввода-вывода
                perror("read");
                exit(EXIT_FAILURE);
            }
        }
    }
}

Такой подход работает, но давайте предположим, что размер буфера buf очень маленький, а объём пакетов данных, читаемых из дескрипторов файлов, очень велик. Например, в примере выше размер buf всего 1024 байта, допустим что через сокеты приходят пакеты по 64КБ. Для обработки одного пакета потребуется 64 раза вызвать select, а затем 64 раза вызвать read. В итоге мы получим 128 системных вызовов, но каждый вызов приводит к одному переключению контекста между kernel и userspace, в итоге обработка пакета обходится дорого.

Можем ли мы уменьшить число вызовов select? В идеале, для обработки одного пакета мы хотели бы вызвать select только один раз. Чтобы сделать это, потребуется перевести все файловые дескрипторы в неблокирующий режим. Ключевая идея - вызывать read в цикле до тех пор, пока вы не получите код ошибки EWOULDBLOCK, обозначающий отсутствие новых данных в момент вызова. Идея реализована в примере:

for (;;) {
    // Шаг 1: ожидание данных через вызов select
    if (select(FD_SETSIZE, &read_fds, NULL, NULL, NULL) < 0) {
        // TODO: обработать ошибку вызова select
        perror("select");
        exit(EXIT_FAILURE);
    }
    for (int i = 0; i < FD_SETSIZE; i++) {
        if (FD_ISSET(i, &read_fds)) {
            /* НОВИНКА: крутим цикл, пока не получим EWOULDBLOCK */
            for (;;) {
                // Шаг 2: чтение данных из каждого файла
                char buf[1024];
                const ssize_t nbytes = read(i, buf, sizeof(buf));
                if (nbytes >= 0) {
                    // Как-либо обрабатываем данные.
                    handle_read(nbytes, buf);
                } else {
                    if (errno != EWOULDBLOCK) {
                        // TODO: обработать код ошибки EINTR
                        perror("read");
                        exit(EXIT_FAILURE);
                    }
                    break;
                }
            }
        }
    }
}

В этом примере при наличии буфера в 1024 байта и входящего пакета в 64КБ мы получим 66 системных вызовов: select будет вызван один раз, а read будет вызываться 64 раза без каких-либо ошибок, а 65-й раз вернёт ошибку EWOULDBLOCK.

Мультиплексер epoll в режиме edge-triggered

Группа вызовов epoll является наиболее развитым мультиплексером в ядре Linux и способна работать в двух режимах:

  • level-triggered - похожий на select упрощённый режим, в котором файловый дескриптор возвращается, если остались непрочитанные данные
    • если приложение прочитало только часть доступных данных, вызов epoll вернёт ему недопрочитанные дескрипторы
  • edge-triggered - файловый дескриптор с событием возвращается только если с момента последнего возврата epoll произошли новые события (например, пришли новые данные)
    • если приложение прочитало только часть доступных данных, в данном режиме оно всё равно будет заблокировано до прихода каких-либо новых данных

Чтобы глубже понять происходящее, рассмотрим схему работы epoll с точки зрения ядра. Допустим, приложение с помощью epoll начало мониторинг поступления данных для чтения из какого-либо файла. Для этого приложение вызывает epoll_wait и засыпает на этом вызове. Ядро хранит связь между ожидающими данных потоками и файловым дескриптором, который один или несколько потоков (или процессов) отслеживают. В случае поступления порции данных ядро обходит список ожидающих потоков и разблокирует их, что для потока выглядит как возврат из функции epoll_wait.

  • В случае level-triggered режима вызов epoll_wait пройдёт по списку файловых дескрипторов и проверит, не соблюдается ли в данный момент условие, которое интересует приложение, что может привести к возврату из epoll_wait без какой-либо блокировки.
  • В случае edge-triggered режима ядро пропускает такую проверку и усыпляет поток, пока не обнаружит событие прихода данных на одном из файловых дескрипторов, за которыми следит поток. Такой режим превращает epoll в мультиплексер с алгоритмической сложностью O(1): после прихода новых данных из файла ядро сразу же знает, какой процесс надо пробудить.

Для использования edge-triggered режима нужно поместить файловые дескрипторы в неблокирующий режим, и на каждой итерации вызывать read либо write до тех пор, пока они не установят код ошибки EWOULDBLOCK. Вызов epoll_wait будет более эффективным благодаря мгновенному засыпанию потока, и это крайне важно для программ с огромным числом конкурирующих файловых дескрипторов, например, для HTTP-серверов.

Мультиплексирование в других операционных системах

  • Механизм select и read/write доступны на всех современных системах, включая Windows, MacOSX, FreeBSD.
  • В Windows есть механизм I/O Completion Ports
  • В MacOSX и FreeBSD есть механизм Kernel Queues