Хабрахабр

[Перевод] io_submit: альтернатива epoll, о которой вы никогда не слышали

В ней обсуждается новый механизм опроса в Linux AIO API (интерфейс для асинхронной работы с файлами), который добавили в ядро версии 4. Недавно внимание автора привлекла статья на LWN о новом интерфейсе ядра для опроса (polling). Идея довольно интересная: автор патча предлагает использовать Linux AIO API для работы с сетью. 18.

Ведь Linux AIO был создан для работы с асинхронным вводом-выводом с диска / на диск! Но постойте! Возможно ли вообще использовать Linux AIO API для работы с сетью? Файлы на диске — это не то же самое, что сетевые соединения.

В этой статье объясняется, как использовать сильные стороны Linux AIO API для создания более быстрых и лучших сетевых серверов. Оказывается, да, возможно!

Но давайте начнём с разъяснения, что представляет собой Linux AIO.

Linux AIO предоставляет интерфейс асинхронного ввода-вывода с диска / на диск для пользовательского ПО.

Если вы вызываете open(), read(), write() или fsync(), то поток останавливается до тех пор, пока метаданные не появятся в дисковом кеше. Исторически на Linux все дисковые операции блокировались. Если у вас не много операций ввода-вывода и достаточно памяти, системные вызовы постепенно заполнят кеш, и всё будет работать достаточно быстро. Обычно это не вызывает проблем.

Для подобных приложений неприемлемо останавливать весь процесс ради ожидания одного системного вызова read(). Производительность операций ввода-вывода уменьшается, когда их количество достаточно велико, например в случаях с базами данных и прокси-серверами.

Для решения этой проблемы приложения могут использовать три способа:

  1. Использовать пулы потоков и вызывать блокирующие функции в отдельных потоках. Именно так работает POSIX AIO в glibc (не путайте его с Linux AIO). Подробные сведения можно получить в документации IBM. Именно так мы решили проблему в Cloudflare: для вызова read() и open() мы используем пул потоков.
  2. Прогревать дисковый кеш с помощью posix_fadvise(2) и надеяться на лучшее.
  3. Использовать Linux AIO в сочетании с файловой системой XFS, открывая файлы с флагом O_DIRECT и избегая недокументированных проблем.

Однако ни один из этих способов не идеален. Даже Linux AIO при бездумном использовании может блокироваться в вызове io_submit(). Это недавно упоминалось в другой статье на LWN:

На деле же операция AIO может блокироваться в ядре по целому ряду причин в ситуациях, когда вызывающий поток не может себе этого позволить». «У интерфейса асинхронного ввода-вывода в Linux много критиков и мало сторонников, но большинство людей ожидает от него хотя бы асинхронности.

Теперь, когда мы знаем о слабых сторонах Linux AIO API, давайте рассмотрим его сильные стороны.

Простая программа с использованием Linux AIO

Для того чтобы использовать Linux AIO, вам сначала придётся самостоятельно определить все пять необходимых системных вызовов — glibc их не предоставляет.

  1. Сначала нужно вызвать io_setup() для инициализации структуры aio_context. Ядро вернёт нам непрозрачный (opaque) указатель на структуру.
  2. После этого можно вызвать io_submit(), чтобы добавить в очередь на обработку вектор «контрольных блоков ввода-вывода» в виде структуры struct iocb.
  3. Теперь, наконец, мы можем вызвать io_getevents() и ждать от неё ответа в виде вектора структур struct io_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 = ;
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/passwd\n", 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, с которым дисковые операции идут в обход кеша.

Вот аналогичный пример, который показывает вывод strace в результате чтения блока объёмом 1 Гб из /dev/zero: Давайте лучше проиллюстрируем то, как io_submit блокируется на обычных файлах.

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.


Фото Helix84 CC/BY-SA/3.

Linux AIO и сеть

Реализация io_submit достаточно консервативна: если переданный дескриптор файла не был открыт с флагом O_DIRECT, то функция просто блокируется и выполняет указанное действие. В случае с сетевыми соединениями это означает, что:

  • для блокирующих соединений IOCV_CMD_PREAD будет ждать ответного пакета;
  • для неблокирующих соединений IOCB_CMD_PREAD вернёт код -11 (EAGAIN).

Такая же семантика используется и в обычном системном вызове read(), поэтому можно сказать, что io_submit при работе с сетевыми соединениями не умнее старых добрых вызовов read() / write().

Важно отметить, что запросы iocb выполняются ядром последовательно.

Несмотря на то, что Linux AIO не поможет нам с асинхронными операциями, его можно использовать для объединения системных вызовов в пакеты (batches).

Это улучшит производительность — переход из пользовательского пространства в ядро и обратно не бесплатен, особенно после введения мер по борьбе со Spectre и Meltdown. Если веб-серверу нужно отправить и получить данные из сотен сетевых соединений, то использование io_submit может оказаться отличной идеей, поскольку позволит избежать сотен вызовов send и recv.

Один буфер

Несколько буферов

Один файловый дескриптор

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.

Альтернатива 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().

А разработчики приложений стараются избегать частых вызовов этой функции — чтобы понять причины, достаточно прочитать в мануале о флагах EPOLLONESHOT и EPOLLET. Более того, работа с epoll обычно требует использования системных вызовов epoll_ctl. Просто добавьте соединения в вектор iocb, вызовите io_submit один раз и ожидайте выполнения. Используя io_submit для опроса соединений, можно избежать этих сложностей и дополнительных системных вызовов. Всё очень просто.

Резюме

В этом посте мы рассмотрели Linux AIO API. Этот API изначально задумывался для работы с диском, но он работает также и с сетевыми соединениями. Однако, в отличие от обычных вызовов read() + write(), использование io_submit позволяет группировать системные вызовы и таким образом увеличивать производительность.

18 io_submit и io_getevents в случае с сетевыми соединениями могут быть использованы для событий вида POLLIN и POLLOUT. Начиная с ядра версии 4. Это является альтернативой epoll().

В этом случае группировка системных вызовов в io_submit может дать большое преимущество, такой сервер был бы значительно быстрее. Могу себе представить сетевой сервис, который использует только io_submit и io_getevents вместо стандартного набора read, write, epoll_ctl и epoll_wait.

Хорошо известно, что Линус его ненавидит: К сожалению, даже после недавних улучшений Linux AIO API дискуссия о его полезности продолжается.

Но AIO всегда был очень-очень кривым». «AIO — это ужасный пример дизайна «на коленке», где основное оправдание: «другие, менее одарённые люди придумали это, поэтому мы вынуждены соблюдать совместимость ради того, чтобы разработчики баз данных (которые редко обладают вкусом) могли использовать это».

Было предпринято несколько попыток создать лучший интерфейс для группировки вызовов и асинхронности, однако им не хватило общего видения. Например, недавнее добавление sendto(MSG_ZEROCOPY) позволяет вести действительно асинхронную передачу данных, но не предусматривает группировки. io_submit предусматривает группировку, но не асинхронность. Ещё хуже — в Linux на данный момент есть три способа доставки асинхронных событий: сигналы, io_getevents и MSG_ERRQUEUE.

В любом случае, отлично, что появляются новые способы ускорить работу сетевых сервисов.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть