Недавно внимание автора привлекла статья на LWN о новом интерфейсе ядра для опроса (polling). В ней обсуждается новый механизм опроса в Linux AIO API (интерфейс для асинхронной работы с файлами), который добавили в ядро версии 4.18. Идея довольно интересная: автор патча предлагает использовать Linux AIO API для работы с сетью.
Но постойте! Ведь Linux AIO был создан для работы с асинхронным вводом-выводом с диска / на диск! Файлы на диске — это не то же самое, что сетевые соединения. Возможно ли вообще использовать Linux AIO API для работы с сетью?
Оказывается, да, возможно! В этой статье объясняется, как использовать сильные стороны Linux AIO API для создания более быстрых и лучших сетевых серверов.
Но давайте начнём с разъяснения, что представляет собой Linux AIO.
Введение в Linux AIO
Linux AIO предоставляет интерфейс асинхронного ввода-вывода с диска / на диск для пользовательского ПО.
Исторически на Linux все дисковые операции блокировались. Если вы вызываете open()
, read()
, write()
или fsync()
, то поток останавливается до тех пор, пока метаданные не появятся в дисковом кеше. Обычно это не вызывает проблем. Если у вас не много операций ввода-вывода и достаточно памяти, системные вызовы постепенно заполнят кеш, и всё будет работать достаточно быстро.
Производительность операций ввода-вывода уменьшается, когда их количество достаточно велико, например в случаях с базами данных и прокси-серверами. Для подобных приложений неприемлемо останавливать весь процесс ради ожидания одного системного вызова read()
.
Для решения этой проблемы приложения могут использовать три способа:
- Использовать пулы потоков и вызывать блокирующие функции в отдельных потоках. Именно так работает POSIX AIO в glibc (не путайте его с Linux AIO). Подробные сведения можно получить в документации IBM. Именно так мы решили проблему в Cloudflare: для вызова
read()
иopen()
мы используем пул потоков. - Прогревать дисковый кеш с помощью
posix_fadvise(2)
и надеяться на лучшее. - Использовать Linux AIO в сочетании с файловой системой XFS, открывая файлы с флагом O_DIRECT и избегая недокументированных проблем.
Однако ни один из этих способов не идеален. Даже Linux AIO при бездумном использовании может блокироваться в вызове io_submit()
. Это недавно упоминалось в другой статье на LWN:
«У интерфейса асинхронного ввода-вывода в Linux много критиков и мало сторонников, но большинство людей ожидает от него хотя бы асинхронности. На деле же операция AIO может блокироваться в ядре по целому ряду причин в ситуациях, когда вызывающий поток не может себе этого позволить».
Теперь, когда мы знаем о слабых сторонах Linux AIO API, давайте рассмотрим его сильные стороны.
Простая программа с использованием Linux AIO
Для того чтобы использовать Linux AIO, вам сначала придётся самостоятельно определить все пять необходимых системных вызовов — glibc их не предоставляет.
- Сначала нужно вызвать
io_setup()
для инициализации структурыaio_context
. Ядро вернёт нам непрозрачный (opaque) указатель на структуру. - После этого можно вызвать
io_submit()
, чтобы добавить в очередь на обработку вектор «контрольных блоков ввода-вывода» в виде структуры struct iocb. - Теперь, наконец, мы можем вызвать
io_getevents()
и ждать от неё ответа в виде вектора структур structio_event
— результатов работы каждого из блоков iocb.
Есть восемь команд, которые вы можете использовать в iocb. Две команды для чтения, две — для записи, два варианта fsync и команда POLL, которую добавили в версии ядра 4.18 (восьмая команда — NOOP):
IOCB_CMD_PREAD = 0,
IOCB_CMD_PWRITE = 1,
IOCB_CMD_FSYNC = 2,
IOCB_CMD_FDSYNC = 3,
IOCB_CMD_POLL = 5, /* from 4.18 */
IOCB_CMD_NOOP = 6,
IOCB_CMD_PREADV = 7,
IOCB_CMD_PWRITEV = 8,
Структура iocb
, которая передаётся в функцию io_submit
, достаточно крупная и предназначена для работы с диском. Вот её упрощённая версия:
struct iocb {
__u64 data; /* user data */
...
__u16 aio_lio_opcode; /* see IOCB_CMD_ above */
...
__u32 aio_fildes; /* file descriptor */
__u64 aio_buf; /* pointer to buffer */
__u64 aio_nbytes; /* buffer size */
...
}
Полная структура io_event
, которую возвращает io_getevents
:
struct io_event {
__u64 data; /* user data */
__u64 obj; /* pointer to request iocb */
__s64 res; /* result code for this event */
__s64 res2; /* secondary result */
};
Пример. Простая программа, которая читает файл /etc/passwd с помощью Linux AIO API:
fd = open("/etc/passwd", O_RDONLY);
aio_context_t ctx = 0;
r = io_setup(128, &ctx);
char buf[4096];
struct iocb cb = {.aio_fildes = fd,
.aio_lio_opcode = IOCB_CMD_PREAD,
.aio_buf = (uint64_t)buf,
.aio_nbytes = sizeof(buf)};
struct iocb *list_of_iocb[1] = {&cb};
r = io_submit(ctx, 1, list_of_iocb);
struct io_event events[1] = {{0}};
r = io_getevents(ctx, 1, 1, events, NULL);
bytes_read = events[0].res;
printf("read %lld bytes from /etc/passwdn", bytes_read);
Полные исходники, конечно, доступны на GitHub. Вот вывод strace этой программы:
openat(AT_FDCWD, "/etc/passwd", O_RDONLY)
io_setup(128, [0x7f4fd60ea000])
io_submit(0x7f4fd60ea000, 1, [{aio_lio_opcode=IOCB_CMD_PREAD, aio_fildes=3, aio_buf=0x7ffc5ff703d0, aio_nbytes=4096, aio_offset=0}])
io_getevents(0x7f4fd60ea000, 1, 1, [{data=0, obj=0x7ffc5ff70390, res=2494, res2=0}], NULL)
Всё прошло хорошо, но чтение с диска не было асинхронным: вызов io_submit заблокировался и выполнил всю работу, функция io_getevents
выполнилась мгновенно. Мы могли попробовать читать асинхронно, но это требует флага O_DIRECT, с которым дисковые операции идут в обход кеша.
Давайте лучше проиллюстрируем то, как io_submit
блокируется на обычных файлах. Вот аналогичный пример, который показывает вывод strace в результате чтения блока объёмом 1 Гб из /dev/zero
:
io_submit(0x7fe1e800a000, 1, [{aio_lio_opcode=IOCB_CMD_PREAD, aio_fildes=3, aio_buf=0x7fe1a79f4000, aio_nbytes=1073741824, aio_offset=0}])
= 1 <0.738380>
io_getevents(0x7fe1e800a000, 1, 1, [{data=0, obj=0x7fffb9588910, res=1073741824, res2=0}], NULL)
= 1 <0.000015>
Ядро потратило 738 мс на вызов io_submit
и только 15 нс — на io_getevents
. Подобным образом оно ведёт себя и с сетевыми соединениями — вся работа делается io_submit
.
Linux AIO и сеть
Реализация io_submit
достаточно консервативна: если переданный дескриптор файла не был открыт с флагом O_DIRECT, то функция просто блокируется и выполняет указанное действие. В случае с сетевыми соединениями это означает, что:
- для блокирующих соединений IOCV_CMD_PREAD будет ждать ответного пакета;
- для неблокирующих соединений IOCB_CMD_PREAD вернёт код -11 (EAGAIN).
Такая же семантика используется и в обычном системном вызове read()
, поэтому можно сказать, что io_submit при работе с сетевыми соединениями не умнее старых добрых вызовов read() / write()
.
Важно отметить, что запросы iocb
выполняются ядром последовательно.
Несмотря на то, что Linux AIO не поможет нам с асинхронными операциями, его можно использовать для объединения системных вызовов в пакеты (batches).
Если веб-серверу нужно отправить и получить данные из сотен сетевых соединений, то использование io_submit
может оказаться отличной идеей, поскольку позволит избежать сотен вызовов send и recv. Это улучшит производительность — переход из пользовательского пространства в ядро и обратно не бесплатен, особенно после введения мер по борьбе со Spectre и Meltdown.
Один буфер | Несколько буферов | |
Один файловый дескриптор | read() | readv() |
Несколько файловых дескрипторов | io_submit + IOCB_CMD_PREAD | io_submit + IOCB_CMD_PREADV |
Для иллюстрации группировки системных вызовов в пакеты с помощью io_submit
давайте напишем небольшую программу, которая пересылает данные из одного TCP-соединения в другое. В простейшей форме (без Linux AIO) она выглядит примерно так:
while True:
d = sd1.read(4096)
sd2.write(d)
Тот же функционал мы можем выразить через Linux AIO. Код в этом случае будет таким:
struct iocb cb[2] = {{.aio_fildes = sd2,
.aio_lio_opcode = IOCB_CMD_PWRITE,
.aio_buf = (uint64_t)&buf[0],
.aio_nbytes = 0},
{.aio_fildes = sd1,
.aio_lio_opcode = IOCB_CMD_PREAD,
.aio_buf = (uint64_t)&buf[0],
.aio_nbytes = BUF_SZ}};
struct iocb *list_of_iocb[2] = {&cb[0], &cb[1]};
while(1) {
r = io_submit(ctx, 2, list_of_iocb);
struct io_event events[2] = {};
r = io_getevents(ctx, 2, 2, events, NULL);
cb[0].aio_nbytes = events[1].res;
}
Этот код добавляет два задания в io_submit
: сначала запрос на запись в sd2
, а потом запрос на чтение из sd1. После выполнения чтения код исправляет размер буфера записи и повторяет цикл сначала. Есть одна хитрость: первый раз запись происходит с буфером размера 0. Это необходимо потому, что у нас есть возможность объединить write + read в одном вызове io_submit
(но не read + write).
Быстрее ли этот код, чем обычные read()
/ write()
? Пока нет. Обе версии используют два системных вызова: read + write и io_submit + io_getevents. Но, к счастью, код можно улучшить.
Избавляемся от io_getevents
Во время выполнения io_setup()
ядро выделяет несколько страниц памяти для процесса. Вот как этот блок памяти выглядит в /proc//maps:
marek:~$ cat /proc/`pidof -s aio_passwd`/maps
...
7f7db8f60000-7f7db8f63000 rw-s 00000000 00:12 2314562 /[aio] (deleted)
...
Блок памяти [aio] (12 Кб в данном случае) был выделен io_setup
. Он используется для кольцевого буфера, где хранятся события. В большинстве случаев нет причин для вызова io_getevents
— данные о завершении событий можно получить из кольцевого буфера без необходимости перехода в режим ядра. Вот исправленная версия кода:
int io_getevents(aio_context_t ctx, long min_nr, long max_nr,
struct io_event *events, struct timespec *timeout)
{
int i = 0;
struct aio_ring *ring = (struct aio_ring*)ctx;
if (ring == NULL || ring->magic != AIO_RING_MAGIC) {
goto do_syscall;
}
while (i < max_nr) {
unsigned head = ring->head;
if (head == ring->tail) {
/* There are no more completions */
break;
} else {
/* There is another completion to reap */
events[i] = ring->events[head];
read_barrier();
ring->head = (head + 1) % ring->nr;
i++;
}
}
if (i == 0 && timeout != NULL && timeout->tv_sec == 0 && timeout->tv_nsec == 0) {
/* Requested non blocking operation. */
return 0;
}
if (i && i >= min_nr) {
return i;
}
do_syscall:
return syscall(__NR_io_getevents, ctx, min_nr-i, max_nr-i, &events[i], timeout);
}
Полная версия кода доступна на GitHub. Интерфейс этого кольцевого буфера плохо документирован, автор адаптировал код из проекта axboe/fio.
После этого изменения наша версия кода с использованием Linux AIO требует только одного системного вызова в цикле, что делает её чуть быстрее, чем оригинальный код с использованием read + write.
Фото Train Photos CC/BY-SA/2.0
Альтернатива epoll
С добавлением IOCB_CMD_POLL в ядро версии 4.18 стало возможным использование io_submit
в качестве замены select/poll/epoll. Например, этот код будет ожидать данных от сетевого соединения:
struct iocb cb = {.aio_fildes = sd,
.aio_lio_opcode = IOCB_CMD_POLL,
.aio_buf = POLLIN};
struct iocb *list_of_iocb[1] = {&cb};
r = io_submit(ctx, 1, list_of_iocb);
r = io_getevents(ctx, 1, 1, events, NULL);
Полный код. Вот его вывод strace:
io_submit(0x7fe44bddd000, 1, [{aio_lio_opcode=IOCB_CMD_POLL, aio_fildes=3}])
= 1 <0.000015>
io_getevents(0x7fe44bddd000, 1, 1, [{data=0, obj=0x7ffef65c11a8, res=1, res2=0}], NULL)
= 1 <1.000377>
Как видите, в этот раз асинхронность сработала: io_submit выполнилась мгновенно, а io_getevents
заблокировалась на одну секунду в ожидании данных. Это можно использовать вместо системного вызова epoll_wait()
.
Более того, работа с epoll
обычно требует использования системных вызовов epoll_ctl. А разработчики приложений стараются избегать частых вызовов этой функции — чтобы понять причины, достаточно прочитать в мануале о флагах EPOLLONESHOT и EPOLLET. Используя io_submit для опроса соединений, можно избежать этих сложностей и дополнительных системных вызовов. Просто добавьте соединения в вектор iocb, вызовите io_submit один раз и ожидайте выполнения. Всё очень просто.
Резюме
В этом посте мы рассмотрели Linux AIO API. Этот API изначально задумывался для работы с диском, но он работает также и с сетевыми соединениями. Однако, в отличие от обычных вызовов read() + write(), использование io_submit позволяет группировать системные вызовы и таким образом увеличивать производительность.
Начиная с ядра версии 4.18 io_submit и io_getevents
в случае с сетевыми соединениями могут быть использованы для событий вида POLLIN и POLLOUT. Это является альтернативой epoll()
.
Могу себе представить сетевой сервис, который использует только io_submit и io_getevents
вместо стандартного набора read, write, epoll_ctl и epoll_wait. В этом случае группировка системных вызовов в io_submit
может дать большое преимущество, такой сервер был бы значительно быстрее.
К сожалению, даже после недавних улучшений Linux AIO API дискуссия о его полезности продолжается. Хорошо известно, что Линус его ненавидит:
«AIO — это ужасный пример дизайна «на коленке», где основное оправдание: «другие, менее одарённые люди придумали это, поэтому мы вынуждены соблюдать совместимость ради того, чтобы разработчики баз данных (которые редко обладают вкусом) могли использовать это». Но AIO всегда был очень-очень кривым».
Было предпринято несколько попыток создать лучший интерфейс для группировки вызовов и асинхронности, однако им не хватило общего видения. Например, недавнее добавление sendto(MSG_ZEROCOPY) позволяет вести действительно асинхронную передачу данных, но не предусматривает группировки. io_submit
предусматривает группировку, но не асинхронность. Ещё хуже — в Linux на данный момент есть три способа доставки асинхронных событий: сигналы, io_getevents
и MSG_ERRQUEUE.
В любом случае, отлично, что появляются новые способы ускорить работу сетевых сервисов.
Автор: Antony Dovgal