io_submit: uma alternativa ao epoll de que você nunca ouviu falar



Recentemente, a atenção do autor foi atraída para um artigo no LWN sobre uma nova interface do kernel para pesquisas. Ele discute o novo mecanismo de pesquisa na API AIO do Linux (uma interface para manipulação de arquivos assíncrona), que foi adicionada à versão 4.18 do kernel. A ideia é bastante interessante: o autor do patch sugere o uso da API Linux AIO para trabalhar com a rede.

Mas espere um momento! Afinal, o Linux AIO foi criado para trabalhar com E / S assíncrona de disco para disco! Os arquivos no disco não são iguais às conexões de rede. É possível usar a API do Linux AIO para redes?

Acontece que sim, é possível! Este artigo explica como usar os pontos fortes da API AIO do Linux para criar servidores de rede mais rápidos e melhores.

Mas vamos começar explicando o que é o Linux AIO.

Introdução ao Linux AIO


O Linux AIO fornece E / S de disco para disco assíncronas para o software do usuário.

Historicamente, no Linux, todas as operações do disco foram bloqueadas. Se você chamar open() , read() , write() ou fsync() , o fluxo será interrompido até que os metadados apareçam no cache do disco. Isso geralmente não é um problema. Se você não tiver muitas operações de E / S e memória suficiente, as chamadas do sistema preencherão gradualmente o cache e tudo funcionará rápido o suficiente.

O desempenho das operações de E / S diminui quando seu número é grande o suficiente, por exemplo, nos casos com bancos de dados e proxies. Para tais aplicativos, é inaceitável interromper todo o processo, aguardando uma chamada de sistema read() .

Para resolver esse problema, os aplicativos podem usar três métodos:

  1. Use conjuntos de encadeamentos e funções de bloqueio de chamada em encadeamentos separados. É assim que o POSIX AIO funciona na glibc (não confunda com o Linux AIO). Para mais informações, consulte a documentação da IBM . Foi assim que resolvemos o problema no Cloudflare: usamos o pool de threads para chamar read() e open() .
  2. Aqueça o cache do disco com posix_fadvise(2) e espere o melhor.
  3. Use o Linux AIO em conjunto com o sistema de arquivos XFS, abrindo arquivos com o sinalizador O_DIRECT e evitando problemas não documentados .

No entanto, nenhum desses métodos é ideal. Mesmo o Linux AIO, quando usado sem pensar, pode ser bloqueado na chamada io_submit() . Isso foi mencionado recentemente em outro artigo no LWN :
“A interface de E / S assíncrona do Linux tem muitos críticos e poucos apoiadores, mas a maioria das pessoas espera pelo menos assincronismo. De fato, a operação da AIO pode ser bloqueada no kernel por vários motivos em situações em que o encadeamento de chamada não pode pagar. ”
Agora que conhecemos os pontos fracos da API Linux AIO, vejamos seus pontos fortes.

Um programa simples usando o Linux AIO


Para usar o Linux AIO, primeiro você deve determinar todas as cinco chamadas de sistema necessárias - a glibc não as fornece.

  1. Primeiro você precisa chamar io_setup() para inicializar a estrutura aio_context . O kernel retornará um ponteiro opaco para a estrutura.
  2. Depois disso, você pode chamar io_submit() para adicionar o vetor de "blocos de controle de E / S" à fila de processamento na forma de uma estrutura struct iocb.
  3. Agora, finalmente, podemos chamar io_getevents() e aguardar uma resposta dele na forma de um vetor de estruturas struct io_event - os resultados de cada um dos blocos iocb.

Existem oito comandos que você pode usar no iocb. Dois comandos para leitura, dois para gravação, duas opções fsync e o comando POLL, que foi adicionado na versão 4.18 do kernel (o oitavo comando é 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 , que é passada para a função io_submit , é bastante grande e projetada para funcionar com o disco. Aqui está sua versão simplificada:

 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 */ ... } 

A estrutura io_event completa que io_getevents retorna:

 struct io_event { __u64  data; /* user data */ __u64  obj; /* pointer to request iocb */ __s64  res; /* result code for this event */ __s64  res2; /* secondary result */ }; 

Um exemplo Um programa simples que lê o arquivo / etc / passwd usando a API AIO do Linux:

 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/passwd\n", bytes_read); 

Naturalmente, fontes completas estão disponíveis no GitHub . Aqui está a saída strace deste programa:

 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) 

Tudo correu bem, mas a leitura do disco não era assíncrona: a chamada io_submit foi bloqueada e fez todo o trabalho, a função io_getevents executada instantaneamente. Poderíamos tentar ler de forma assíncrona, mas isso requer o sinalizador O_DIRECT, com o qual as operações de disco ignoram o cache.

Vamos ilustrar melhor como o io_submit bloqueado em arquivos regulares. Aqui está um exemplo semelhante que mostra a saída do strace como resultado da leitura de um bloco de 1 GB em /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> 

O kernel gastou 738 ms em uma chamada io_submit e apenas 15 ns em io_getevents . Ele se comporta de maneira semelhante às conexões de rede - todo o trabalho é realizado pelo io_submit .


Foto Helix84 CC / BY-SA / 3.0

AIO e rede Linux


A implementação io_submit bastante conservadora: se o descritor de arquivo passado não foi aberto com o sinalizador O_DIRECT, a função simplesmente bloqueia e executa a ação especificada. No caso de conexões de rede, isso significa que:

  • para bloquear conexões, IOCV_CMD_PREAD aguardará um pacote de resposta;
  • para conexões sem bloqueio, IOCB_CMD_PREAD retornará o código -11 (EAGAIN).

A mesma semântica também é usada na chamada regular do sistema read() , portanto, podemos dizer que io_submit ao trabalhar com conexões de rede não é mais inteligente do que as boas e antigas chamadas read() / write() .

É importante observar que iocb solicitações iocb executadas pelo kernel sequencialmente.

Apesar do Linux AIO não nos ajudar com operações assíncronas, ele pode ser usado para combinar chamadas do sistema em lotes.

Se o servidor da web precisar enviar e receber dados de centenas de conexões de rede, usar o io_submit pode ser uma ótima idéia, pois evita centenas de chamadas de envio e recv. Isso melhorará o desempenho - alternar do espaço do usuário para o kernel e vice-versa não é gratuito, especialmente após a introdução de medidas para combater o Spectre e o Meltdown .

Um buffer
Vários buffers
Descritor de um arquivo
read ()
readv ()
Vários descritores de arquivo
io_submit + IOCB_CMD_PREAD
io_submit + IOCB_CMD_PREADV

Para ilustrar o agrupamento de chamadas do sistema em pacotes usando io_submit vamos escrever um pequeno programa que envia dados de uma conexão TCP para outra. Na sua forma mais simples (sem Linux AIO), é algo como isto:

 while True: d = sd1.read(4096) sd2.write(d) 

Podemos expressar a mesma funcionalidade através do Linux AIO. O código nesse caso será assim:

 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; } 

Esse código adiciona dois trabalhos ao io_submit : primeiro um pedido de gravação no sd2 e, em seguida, um pedido de leitura do sd1. Após a leitura, o código corrige o tamanho do buffer de gravação e repete o loop desde o início. Há um truque: a primeira vez que uma gravação ocorre com um buffer de tamanho 0. Isso é necessário porque temos a capacidade de combinar gravação + leitura em uma chamada io_submit (mas não leitura + gravação).

Esse código é mais rápido que o regular read() / write() ? Ainda não. Ambas as versões usam duas chamadas de sistema: leitura + gravação e io_submit + io_getevents. Mas, felizmente, o código pode ser melhorado.

Livrar-se de io_getevents


No tempo de execução io_setup() kernel aloca várias páginas de memória para o processo. É assim que este bloco de memória se parece nos mapas / proc //:

 marek:~$ cat /proc/`pidof -s aio_passwd`/maps ... 7f7db8f60000-7f7db8f63000 rw-s 00000000 00:12 2314562     /[aio] (deleted) ... 

O bloco de memória [aio] (12 Kb neste caso) foi alocado io_setup . É usado para o buffer circular onde os eventos são armazenados. Na maioria dos casos, não há motivo para chamar io_getevents - os dados de conclusão do evento podem ser obtidos no buffer de anel sem a necessidade de alternar para o modo kernel. Aqui está a versão corrigida do código:

 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); } 

A versão completa do código está disponível no GitHub . A interface desse buffer de anel está mal documentada; o autor adaptou o código do projeto axboe / fio .

Após essa alteração, nossa versão do código usando o Linux AIO requer apenas uma chamada do sistema em um loop, o que o torna um pouco mais rápido que o código original usando leitura + gravação.


Foto fotos de trem CC / BY-SA / 2.0

Alternativa Epoll


Com a adição de IOCB_CMD_POLL à versão do kernel 4.18, tornou-se possível usar io_submit como um substituto para select / poll / epoll. Por exemplo, este código espera dados de uma conexão de rede:

 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); 

Código completo . Aqui está sua saída 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> 

Como você pode ver, desta vez a assincronia funcionou: io_submit executado instantaneamente e io_getevents bloqueados por um segundo, aguardando dados. Isso pode ser usado em vez da chamada do sistema epoll_wait() .

Além disso, trabalhar com epoll geralmente requer o uso das chamadas do sistema epoll_ctl. E os desenvolvedores de aplicativos tentam evitar chamadas frequentes para essa função - para entender os motivos, basta ler os sinalizadores EPOLLONESHOT e EPOLLET no manual . Usando io_submit para consultar conexões, você pode evitar essas dificuldades e chamadas adicionais ao sistema. Apenas adicione as conexões ao vetor iocb, chame io_submit uma vez e aguarde a execução. Tudo é muito simples.

Sumário


Nesta postagem, abordamos a API AIO do Linux. Essa API foi projetada originalmente para funcionar com o disco, mas também funciona com conexões de rede. No entanto, diferentemente das chamadas regulares de leitura () + gravação (), o uso do io_submit permite agrupar chamadas do sistema e, assim, aumentar o desempenho.

A partir da versão 4.18 do kernel, io_submit io_getevents no caso de conexões de rede podem ser usados ​​para eventos do formato POLLIN e POLLOUT. Esta é uma alternativa ao epoll() .

Eu posso imaginar um serviço de rede que use apenas io_submit io_getevents vez do conjunto padrão de leitura, gravação, epoll_ctl e epoll_wait. Nesse caso, as chamadas de sistema de agrupamento no io_submit podem oferecer uma grande vantagem, pois esse servidor seria muito mais rápido.

Infelizmente, mesmo após melhorias recentes na API do Linux AIO, as discussões sobre sua utilidade continuam. É sabido que Linus o odeia :

“AIO é um exemplo terrível de design na altura dos joelhos, onde a principal desculpa é:“ outras pessoas menos talentosas inventaram isso, então precisamos cumprir a compatibilidade para que os desenvolvedores do banco de dados (que raramente são de bom gosto) possam usá-lo. ” Mas a AIO sempre foi muito, muito torta. ”

Várias tentativas foram feitas para criar uma interface melhor para agrupar chamadas e assincronia, mas elas careciam de uma visão comum. Por exemplo, a recente adição de sendto (MSG_ZEROCOPY) permite transferência de dados verdadeiramente assíncrona, mas não fornece agrupamento. io_submit fornece agrupamento, mas não assincronia. Pior ainda - atualmente existem três maneiras de fornecer eventos assíncronos no Linux: sinais, io_getevents e MSG_ERRQUEUE.

De qualquer forma, é ótimo que haja novas maneiras de acelerar o trabalho dos serviços de rede.

Source: https://habr.com/ru/post/pt439972/


All Articles