
Recientemente, la atención del autor fue atraída por un artículo en LWN sobre una nueva interfaz de kernel para sondeo. Discute el nuevo mecanismo de sondeo en la API AIO de Linux (una interfaz para el manejo de archivos asíncrono), que se agregó a la versión 4.18 del kernel. La idea es bastante interesante: el autor del parche sugiere usar la API AIO de Linux para trabajar con la red.
Pero espera un momento! Después de todo, ¡Linux AIO fue creado para trabajar con E / S asíncronas de disco a disco! Los archivos en el disco no son lo mismo que las conexiones de red. ¿Es posible usar la API AIO de Linux para redes?
Resulta que sí, ¡es posible! Este artículo explica cómo usar las fortalezas de la API AIO de Linux para crear servidores de red más rápidos y mejores.
Pero comencemos explicando qué es Linux AIO.
Introducción a Linux AIO
Linux AIO proporciona E / S asíncronas de disco a disco para el software del usuario.
Históricamente, en Linux, todas las operaciones de disco estaban bloqueadas. Si llama a
open()
,
read()
,
write()
o
fsync()
, la secuencia se detiene hasta que los metadatos aparecen en la memoria caché del disco. Esto generalmente no es un problema. Si no tiene muchas operaciones de E / S y suficiente memoria, las llamadas al sistema llenarán gradualmente el caché y todo funcionará lo suficientemente rápido.
El rendimiento de las operaciones de E / S disminuye cuando su número es lo suficientemente grande, por ejemplo, en casos con bases de datos y servidores proxy. Para tales aplicaciones, es inaceptable detener todo el proceso por el simple hecho de esperar una llamada al sistema
read()
.
Para resolver este problema, las aplicaciones pueden usar tres métodos:
- Use grupos de subprocesos y funciones de bloqueo de llamadas en subprocesos separados. Así es como funciona POSIX AIO en glibc (no lo confunda con Linux AIO). Para obtener más información, consulte la documentación de IBM . Así es como resolvimos el problema en Cloudflare: utilizamos el grupo de subprocesos para llamar a
read()
y open()
.
- Calienta el caché del disco con
posix_fadvise(2)
y espera lo mejor.
- Use Linux AIO junto con el sistema de archivos XFS, abriendo archivos con el indicador O_DIRECT y evitando problemas indocumentados .
Sin embargo, ninguno de estos métodos es ideal. Incluso Linux AIO, cuando se usa sin pensar, puede bloquearse en la llamada
io_submit()
. Esto se mencionó recientemente en otro
artículo sobre LWN :
“La interfaz de E / S asíncrona de Linux tiene muchas críticas y pocos partidarios, pero la mayoría de las personas esperan al menos asincronismo. De hecho, la operación AIO puede bloquearse en el núcleo por varias razones en situaciones en las que el hilo de llamada no puede permitírselo ”.
Ahora que conocemos las debilidades de la API AIO de Linux, veamos sus puntos fuertes.
Un programa simple que usa Linux AIO
Para utilizar Linux AIO, primero debe
determinar las cinco llamadas necesarias del sistema usted mismo: glibc no las proporciona.
- Primero debe llamar a
io_setup()
para inicializar la estructura aio_context
. El núcleo devolverá un puntero opaco a la estructura.
- Después de eso, puede llamar a
io_submit()
para agregar el vector de "bloques de control de E / S" a la cola de procesamiento en forma de una estructura de estructura iocb.
- Ahora, finalmente, podemos llamar a
io_getevents()
y esperar una respuesta de este en forma de un vector de estructuras struct io_event
, los resultados de cada uno de los bloques iocb.
Hay ocho comandos que puede usar en iocb. Dos comandos para leer, dos para escribir, dos opciones de fsync y el comando POLL, que se agregó en la versión 4.18 del kernel (el octavo comando es 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 se pasa a la función
io_submit
, es bastante grande y está diseñada para funcionar con el disco. Aquí está su versión 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 */ ... }
La estructura completa
io_event
que devuelve
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 */ };
Un ejemplo Un programa simple que lee el archivo / etc / passwd usando la API AIO de 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);
Las fuentes completas están, por supuesto,
disponibles en GitHub . Aquí está la salida de este 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)
Todo salió bien, pero la lectura desde el disco no fue asíncrona: la llamada io_submit se bloqueó e hizo todo el trabajo, la función
io_getevents
ejecutó al instante. Podríamos intentar leer de forma asincrónica, pero esto requiere el indicador O_DIRECT, con el cual las operaciones de disco omiten el caché.
Vamos a ilustrar mejor cómo
io_submit
bloquea en los archivos normales. Aquí hay un ejemplo similar que muestra la salida de strace como resultado de leer un bloque de 1 GB desde
/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>
El núcleo pasó 738 ms en una llamada
io_submit
y solo 15 ns en
io_getevents
. Se comporta de manera similar con las conexiones de red: todo el trabajo lo realiza
io_submit
.
Foto Helix84 CC / BY-SA / 3.0
Linux AIO y red
La implementación de
io_submit
bastante conservadora: si el descriptor de archivo pasado no se abrió con el indicador O_DIRECT, entonces la función simplemente bloquea y realiza la acción especificada. En el caso de las conexiones de red, esto significa que:
- para bloquear conexiones, IOCV_CMD_PREAD esperará un paquete de respuesta;
- para conexiones sin bloqueo, IOCB_CMD_PREAD devolverá el código -11 (EAGAIN).
La misma semántica también se usa en la llamada regular del sistema
read()
, por lo que podemos decir que io_submit cuando se trabaja con conexiones de red no es más inteligente que las buenas llamadas
read() / write()
.
Es importante tener en cuenta que
iocb
solicitudes
iocb
ejecutadas secuencialmente por el núcleo.
A pesar de que Linux AIO no nos ayudará con operaciones asincrónicas, se puede usar para combinar llamadas del sistema en lotes.
Si el servidor web necesita enviar y recibir datos de cientos de conexiones de red, usar
io_submit
podría ser una gran idea, ya que evita cientos de llamadas de envío y recepción. Esto mejorará el rendimiento: cambiar del espacio de usuario al kernel y viceversa no es gratis, especialmente después de la introducción de
medidas para combatir a Spectre y Meltdown .
| Un búfer
| Múltiples tampones
|
Un descriptor de archivo
| leer ()
| readv ()
|
Descriptores de múltiples archivos
| io_submit + IOCB_CMD_PREAD
| io_submit + IOCB_CMD_PREADV
|
Para ilustrar la agrupación de llamadas del sistema en paquetes usando
io_submit
escriba un pequeño programa que envíe datos de una conexión TCP a otra. En su forma más simple (sin Linux AIO), se parece a esto:
while True: d = sd1.read(4096) sd2.write(d)
Podemos expresar la misma funcionalidad a través de Linux AIO. El código en este caso será así:
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; }
Este código agrega dos trabajos a
io_submit
: primero una solicitud de escritura a
sd2
, y luego una solicitud de lectura de sd1. Después de leer, el código corrige el tamaño del búfer de escritura y repite el ciclo desde el principio. Hay un truco: la primera vez que se produce una escritura con un búfer de tamaño 0. Esto es necesario porque tenemos la capacidad de combinar escritura + lectura en una llamada
io_submit
(pero no lectura + escritura).
¿Es este código más rápido que el normal
read()
/
write()
? Aún no Ambas versiones usan dos llamadas al sistema: lectura + escritura y io_submit + io_getevents. Pero, afortunadamente, el código se puede mejorar.
Deshacerse de io_getevents
En tiempo de ejecución
io_setup()
kernel asigna varias páginas de memoria para el proceso. Así es como se ve este bloque de memoria en / proc // maps:
marek:~$ cat /proc/`pidof -s aio_passwd`/maps ... 7f7db8f60000-7f7db8f63000 rw-s 00000000 00:12 2314562 /[aio] (deleted) ...
Al bloque de memoria [aio] (12 Kb en este caso) se le asignó
io_setup
. Se utiliza para el búfer circular donde se almacenan los eventos. En la mayoría de los casos, no hay razón para llamar a
io_getevents
: los datos de finalización de eventos se pueden obtener del búfer de anillo sin la necesidad de cambiar al modo kernel. Aquí está la versión corregida del 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); }
La versión completa del código está disponible
en GitHub . La interfaz de este buffer de anillo está pobremente documentada; el autor adaptó el código del
proyecto axboe / fio .
Después de este cambio, nuestra versión del código que usa Linux AIO requiere solo una llamada al sistema en un bucle, lo que lo hace un poco más rápido que el código original que usa lectura + escritura.
Foto Tren Fotos CC / BY-SA / 2.0
Alternativa Epoll
Con la adición de IOCB_CMD_POLL a la versión 4.18 del kernel, se hizo posible usar
io_submit
como un reemplazo para select / poll / epoll. Por ejemplo, este código esperará datos de una conexión de red:
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 Aquí está su salida de 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 puede ver, esta vez la asincronía funcionó: io_submit se ejecutó instantáneamente y
io_getevents
bloqueó durante un segundo, esperando datos. Esto se puede usar en lugar de la llamada al sistema
epoll_wait()
.
Además, trabajar con
epoll
generalmente requiere el uso de las llamadas del sistema epoll_ctl. Y los desarrolladores de aplicaciones intentan evitar llamadas frecuentes a esta función: para comprender los motivos, solo
lea los indicadores EPOLLONESHOT y EPOLLET
en el manual . Usando io_submit para consultar conexiones, puede evitar estas dificultades y llamadas adicionales al sistema. Simplemente agregue las conexiones al vector iocb, llame a io_submit una vez y espere la ejecución. Todo es muy sencillo.
Resumen
En esta publicación, cubrimos la API AIO de Linux. Esta API se diseñó originalmente para funcionar con el disco, pero también funciona con conexiones de red. Sin embargo, a diferencia de las llamadas normales read () + write (), el uso de io_submit le permite agrupar las llamadas del sistema y así aumentar el rendimiento.
A partir de la versión 4.18 del kernel,
io_submit io_getevents
en el caso de las conexiones de red se pueden usar para eventos de la forma POLLIN y POLLOUT. Esta es una alternativa a
epoll()
.
Me imagino un servicio de red que usa solo
io_submit io_getevents
lugar del conjunto estándar de lectura, escritura, epoll_ctl y epoll_wait. En este caso, agrupar llamadas del sistema en
io_submit
puede ofrecer una gran ventaja, tal servidor sería mucho más rápido.
Desafortunadamente, incluso después de las recientes mejoras en la API AIO de Linux, continúan las discusiones sobre su utilidad. Es bien sabido que
Linus lo odia :
"AIO es un terrible ejemplo de diseño a la altura de las rodillas, donde la excusa principal es:" a otras personas menos talentosas se les ocurrió esto, por lo que debemos cumplir con la compatibilidad para que los desarrolladores de bases de datos (que rara vez tengan buen gusto) puedan usarla ". Pero AIO siempre ha sido muy, muy torcido ".
Se han hecho varios intentos para crear una mejor interfaz para agrupar llamadas y asincronía, pero carecían de una visión común. Por ejemplo, la reciente
incorporación de sendto (MSG_ZEROCOPY) permite una transferencia de datos verdaderamente asincrónica, pero no permite la agrupación.
io_submit
proporciona agrupación, pero no asincronía. Peor aún: actualmente hay tres formas de entregar eventos asincrónicos en Linux: señales,
io_getevents
y MSG_ERRQUEUE.
En cualquier caso, es genial que haya nuevas formas de acelerar el trabajo de los servicios de red.