Все статьи Асинхронный ввод-вывод средствами 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 и т.д.)
Существует несколько мультиплексирующих системных вызовов:
- Вызов
select
существует во всех POSIX-совместимых системах, включая Linux и MacOSX - Группа вызовов epoll_* существует только на Linux
- Группа вызовов kqueue существует на FreeBSD и других *BSD
Все три варианта реализуют единый принцип: делегировать ядру задачу по отслеживанию прихода данных для операций чтения/записи над множеством файловых дескрипторов. Все варианты могут заблокировать выполнение, пока не произойдёт какого-либо события с одним из дескрипторов из указанного множества. Например, вы можете сообщить ядру ОС, что вас интересуют только события чтения для файлового дескриптора 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-серверов.
- Ищите справку в man-документации для epoll
- Полный пример с использованием epoll есть на github.com/eklitzke/epollet
Мультиплексирование в других операционных системах
- Механизм
select
иread
/write
доступны на всех современных системах, включая Windows, MacOSX, FreeBSD. - В Windows есть механизм I/O Completion Ports
- В MacOSX и FreeBSD есть механизм Kernel Queues