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

в 13:01, , рубрики: AIO, api, async, C, CloudFlare, epoll, io_submit, linux, Блог компании Badoo, высокая производительность, Разработка под Linux, системное программирование

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

Недавно внимание автора привлекла статья на 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().

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

  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:

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

Теперь, когда мы знаем о слабых сторонах 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 = {.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.

io_submit: альтернатива epoll, о которой вы никогда не слышали - 2
Фото Helix84 CC/BY-SA/3.0

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.

io_submit: альтернатива epoll, о которой вы никогда не слышали - 3
Фото  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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js