io_submit: une alternative à epoll dont vous n'avez jamais entendu parler



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:

  1. 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() .
  2. Réchauffez le cache disque avec posix_fadvise(2) et espérez le meilleur.
  3. 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.

  1. Vous devez d'abord appeler io_setup() pour initialiser la structure aio_context . Le noyau retournera un pointeur opaque sur la structure.
  2. 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.
  3. 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.

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


All Articles