
Récemment, l'attention de l'auteur a été attirée par un article sur LWN sur une nouvelle interface de noyau pour l'interrogation. Il présente le nouveau mécanisme d'interrogation dans l'API Linux AIO (une interface pour la gestion des fichiers asynchrones), qui a été ajouté à la version 4.18 du noyau. L'idée est assez intéressante: l'auteur du patch suggère d'utiliser l'API Linux AIO pour travailler avec le réseau.
Mais attendez un instant! Après tout, Linux AIO a été créé pour fonctionner avec les E / S asynchrones d'un disque à l'autre! Les fichiers sur le disque ne sont pas les mêmes que les connexions réseau. Est-il même possible d'utiliser l'API Linux AIO pour la mise en réseau?
Il s'avère que oui, c'est possible! Cet article explique comment utiliser les points forts de l'API Linux AIO pour créer des serveurs réseau plus rapides et meilleurs.
Mais commençons par expliquer ce qu'est Linux AIO.
Introduction à Linux AIO
Linux AIO fournit des E / S disque à disque asynchrones pour les logiciels utilisateur.
Historiquement, sous Linux, toutes les opérations sur disque étaient bloquées. Si vous appelez
open()
,
read()
,
write()
ou
fsync()
, le flux s'arrête jusqu'à ce que les métadonnées apparaissent dans le cache disque. Ce n'est généralement pas un problème. Si vous n'avez pas beaucoup d'opérations d'E / S et suffisamment de mémoire, les appels système rempliront progressivement le cache et tout fonctionnera assez rapidement.
Les performances des opérations d'E / S diminuent lorsque leur nombre est suffisamment important, par exemple dans les cas avec des bases de données et des proxys. Pour de telles applications, il est inacceptable d'arrêter tout le processus pour attendre un appel système
read()
.
Pour résoudre ce problème, les applications peuvent utiliser trois méthodes:
- Utilisez des pools de threads et des fonctions de blocage d'appels sur des threads séparés. C'est ainsi que POSIX AIO fonctionne dans la glibc (ne le confondez pas avec Linux AIO). Pour plus d'informations, voir la documentation IBM . C'est ainsi que nous avons résolu le problème dans Cloudflare: nous utilisons le pool de threads pour appeler
read()
et open()
.
- Réchauffez le cache disque avec
posix_fadvise(2)
et espérez le meilleur.
- Utilisez Linux AIO en conjonction avec le système de fichiers XFS, en ouvrant des fichiers avec l'indicateur O_DIRECT et en évitant les problèmes non documentés .
Cependant, aucune de ces méthodes n'est idéale. Même Linux AIO, lorsqu'il est utilisé sans réfléchir, peut être bloqué dans l'appel
io_submit()
. Cela a été récemment mentionné dans un autre
article sur LWN :
«L'interface d'E / S asynchrone Linux a beaucoup de critiques et peu de partisans, mais la plupart des gens en attendent au moins de l'asynchronisme. En fait, l'opération AIO peut être bloquée dans le noyau pour un certain nombre de raisons dans des situations où le thread appelant ne peut pas se le permettre. »
Maintenant que nous connaissons les faiblesses de l'API Linux AIO, regardons ses points forts.
Un programme simple utilisant Linux AIO
Pour utiliser Linux AIO, vous devez d'abord
déterminer vous-même les cinq appels système nécessaires - la glibc ne les fournit pas.
- Vous devez d'abord appeler
io_setup()
pour initialiser la structure aio_context
. Le noyau retournera un pointeur opaque sur la structure.
- Après cela, vous pouvez appeler
io_submit()
pour ajouter le vecteur des «blocs de contrôle d'E / S» à la file d'attente de traitement sous la forme d'une structure struct iocb.
- Maintenant, enfin, nous pouvons appeler
io_getevents()
et en attendre une réponse sous la forme d'un vecteur de structures io_event
- les résultats de chacun des blocs iocb.
Il y a huit commandes que vous pouvez utiliser dans iocb. Deux commandes pour la lecture, deux pour l'écriture, deux options fsync et la commande POLL, qui a été ajoutée dans la version 4.18 du noyau (la huitième commande est 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
, qui est passée à la fonction
io_submit
, est assez grande et conçue pour fonctionner avec le disque. Voici sa version simplifiée:
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 structure
io_event
complète
io_event
par
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 exemple. Un programme simple qui lit le fichier / etc / passwd à l'aide de l'API Linux AIO:
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);
Les sources complètes sont bien sûr
disponibles sur GitHub . Voici la sortie strace de ce programme:
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)
Tout s'est bien passé, mais la lecture à partir du disque n'était pas asynchrone: l'appel io_submit a été bloqué et a fait tout le travail, la fonction
io_getevents
exécutée instantanément. Nous pourrions essayer de lire de manière asynchrone, mais cela nécessite l'indicateur O_DIRECT, avec lequel les opérations sur disque contournent le cache.
Illustrons mieux comment
io_submit
verrouille sur des fichiers normaux. Voici un exemple similaire qui montre la sortie de strace suite à la lecture d'un bloc de 1 Go depuis
/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>
Le noyau a passé 738 ms sur un appel
io_submit
et seulement 15 ns sur
io_getevents
. Il se comporte de la même manière avec les connexions réseau - tout le travail est effectué par
io_submit
.
Photo Helix84 CC / BY-SA / 3.0
Linux AIO et réseau
L'implémentation
io_submit
assez conservatrice: si le descripteur de fichier transmis n'a pas été ouvert avec l'indicateur O_DIRECT, la fonction bloque simplement et exécute l'action spécifiée. Dans le cas des connexions réseau, cela signifie que:
- pour bloquer les connexions, IOCV_CMD_PREAD attendra un paquet de réponse;
- pour les connexions non bloquantes, IOCB_CMD_PREAD renverra le code -11 (EAGAIN).
La même sémantique est également utilisée dans l'appel système normal de
read()
, nous pouvons donc dire que io_submit lorsque vous travaillez avec des connexions réseau n'est pas plus intelligent que les bons anciens appels
read() / write()
.
Il est important de noter que les requêtes
iocb
exécutées séquentiellement par le noyau.
Malgré le fait que Linux AIO ne nous aidera pas avec les opérations asynchrones, il peut être utilisé pour combiner les appels système en lots.
Si le serveur Web doit envoyer et recevoir des données à partir de centaines de connexions réseau, l'utilisation d'
io_submit
peut être une excellente idée, car elle évite des centaines d'appels d'envoi et de réception. Cela améliorera les performances - le passage de l'espace utilisateur au noyau et vice versa n'est pas gratuit, en particulier après l'introduction de
mesures de lutte contre Spectre et Meltdown .
| Un tampon
| Tampons multiples
|
Un descripteur de fichier
| lire ()
| readv ()
|
Descripteurs de fichiers multiples
| io_submit + IOCB_CMD_PREAD
| io_submit + IOCB_CMD_PREADV
|
Pour illustrer le regroupement des appels système en paquets à l'aide de
io_submit
écrivons un petit programme qui envoie des données d'une connexion TCP à une autre. Dans sa forme la plus simple (sans Linux AIO), il ressemble à ceci:
while True: d = sd1.read(4096) sd2.write(d)
Nous pouvons exprimer la même fonctionnalité via Linux AIO. Le code dans ce cas sera comme ceci:
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; }
Ce code ajoute deux tâches à
io_submit
: d'abord une demande d'écriture vers
sd2
, puis une demande de lecture depuis sd1. Après lecture, le code corrige la taille du tampon d'écriture et répète la boucle depuis le début. Il y a une astuce: la première fois qu'une écriture se produit avec un tampon de taille 0. Ceci est nécessaire car nous avons la possibilité de combiner écriture + lecture en un
io_submit
appel
io_submit
(mais pas lecture + écriture).
Ce code est-il plus rapide que
read()
/
write()
normal? Pas encore. Les deux versions utilisent deux appels système: lecture + écriture et io_submit + io_getevents. Mais, heureusement, le code peut être amélioré.
Se débarrasser des io_getevents
Au moment de l'exécution
io_setup()
noyau alloue plusieurs pages de mémoire pour le processus. Voici à quoi ressemble ce bloc de mémoire dans / proc // maps:
marek:~$ cat /proc/`pidof -s aio_passwd`/maps ... 7f7db8f60000-7f7db8f63000 rw-s 00000000 00:12 2314562 /[aio] (deleted) ...
Le bloc de mémoire [aio] (12 Ko dans ce cas) a été alloué
io_setup
. Il est utilisé pour le tampon circulaire où les événements sont stockés. Dans la plupart des cas, il n'y a aucune raison d'appeler
io_getevents
- les données de fin d'événement peuvent être obtenues à partir du tampon en anneau sans avoir besoin de passer en mode noyau. Voici la version corrigée du code:
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 version complète du code est disponible
sur GitHub . L'interface de ce tampon en anneau est peu documentée; l'auteur a adapté le code du
projet axboe / fio .
Après cette modification, notre version du code utilisant Linux AIO ne nécessite qu'un seul appel système dans une boucle, ce qui le rend un peu plus rapide que le code d'origine utilisant lecture + écriture.
Photo Train Photos CC / BY-SA / 2.0
Alternative à Epoll
Avec l'ajout de IOCB_CMD_POLL à la version 4.18 du noyau, il est devenu possible d'utiliser
io_submit
en remplacement de select / poll / epoll. Par exemple, ce code attendra des données d'une connexion réseau:
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);
Code complet . Voici sa sortie 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>
Comme vous pouvez le voir, cette fois, l'asynchronie a fonctionné: io_submit exécuté instantanément et
io_getevents
bloqués pendant une seconde, en attente de données. Cela peut être utilisé à la place de l'appel système
epoll_wait()
.
De plus, travailler avec
epoll
nécessite généralement l'utilisation des appels système epoll_ctl. Et les développeurs d'applications essaient d'éviter les appels fréquents à cette fonction - pour comprendre les raisons, il suffit de
lire les indicateurs EPOLLONESHOT et EPOLLET
dans le manuel . En utilisant io_submit pour interroger les connexions, vous pouvez éviter ces difficultés et les appels système supplémentaires. Ajoutez simplement les connexions au vecteur iocb, appelez io_submit une fois et attendez l'exécution. Tout est très simple.
Résumé
Dans cet article, nous avons couvert l'API Linux AIO. Cette API a été initialement conçue pour fonctionner avec le disque, mais elle fonctionne également avec les connexions réseau. Cependant, contrairement aux appels réguliers en lecture () + écriture (), l'utilisation de io_submit vous permet de regrouper les appels système et ainsi d'augmenter les performances.
À partir de la version 4.18 du noyau,
io_submit io_getevents
dans le cas de connexions réseau peuvent être utilisés pour les événements de la forme POLLIN et POLLOUT. C'est une alternative à
epoll()
.
Je peux imaginer un service réseau qui utilise uniquement
io_submit io_getevents
au lieu de l'ensemble standard de lecture, écriture, epoll_ctl et epoll_wait. Dans ce cas, le regroupement des appels système dans
io_submit
peut donner un gros avantage, un tel serveur serait beaucoup plus rapide.
Malheureusement, même après les récentes améliorations apportées à l'API Linux AIO, les discussions sur son utilité se poursuivent. Il est bien connu que
Linus le déteste :
«L'AIO est un terrible exemple de conception à hauteur de genou, où la principale excuse est:« d'autres personnes moins talentueuses ont proposé cela, nous devons donc nous conformer à la compatibilité afin que les développeurs de bases de données (qui sont rarement de bon goût) puissent l'utiliser. » Mais AIO a toujours été très, très tordu. »
Plusieurs tentatives ont été faites pour créer une meilleure interface pour regrouper les appels et l'asynchronie, mais elles n'avaient pas de vision commune. Par exemple, l'
ajout récent
de sendto (MSG_ZEROCOPY) permet un transfert de données véritablement asynchrone, mais ne prévoit pas de regroupement.
io_submit
prévoit le regroupement, mais pas l'asynchronie. Pire encore, il existe actuellement trois façons de fournir des événements asynchrones sur Linux: signaux,
io_getevents
et MSG_ERRQUEUE.
Dans tous les cas, il est formidable qu'il existe de nouvelles façons d'accélérer le travail des services réseau.