Écrivons un serveur événementiel

21.03.2013

Vous avez sans doute déjà entendu parler de Node.js et de sa promesse de scalabilité. Si ce n’est pas le cas, retenez que Node est un serveur web événementiel assez populaire. Mais quelle est la grande innovation de Node ? Cela fait un longtemps que nous utilisons des serveurs HTTP (Apache a presque 20 ans). Les logiques sont-elles si différentes ? Qu’avons-nous raté pendant tout ce temps ?

Découvrons ensemble cette construction !

Qu’est-ce qu’un serveur HTTP ?

Écrire un prototype de seveur web est relativement simple. Il suffit de suivre les étapes suivantes :

  • Écouter sur un port TCP
  • Lorsqu’un client essaye d’ouvrir une connexion, il faut l’accepter
  • Il faut ensuite parser le texte envoyé par le client (la requête HTTP)
  • Traiter la dite requête
  • Envoyer notre réponse (la réponse HTTP)

Construisons ensemble un serveur de démonstration : il répondra “Hello, world” à n’importe quelle requête.

// ATTENTION: ce code ne fait aucune vérification d'erreur. Son seul but étant
// d'être le plus concis possible tout en expliquant les concepts sous-jacents.
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netdb.h>
#include <sys/select.h>
//
void processRequest(int socket) {
  int requestBufferSize = 8096;
  char * request = malloc(requestBufferSize);
  recv(socket, request, requestBufferSize, 0);
  free(request); // Actually we don't care about the client's request
  char * response = "HTTP/1.1 200 OK/nContent-Type: application/json\n\n{\"hello\": \"world\"}\n";
  send(socket, response, strlen(response), 0); // Let's send our static response
  close(socket);
}
//
int main(int argc, char * argv[]) {
  struct addrinfo hints;
  memset(&hints, 0, sizeof(hints));
  hints.ai_family = PF_INET;       // We want an IP socket
  hints.ai_protocol = IPPROTO_TCP; // That'll speak TCP
  hints.ai_socktype = SOCK_STREAM; // TCP is stream-based
  hints.ai_flags = AI_PASSIVE;     // We'll want to be a server
  struct addrinfo * res;
  getaddrinfo("localhost", "8080", &hints, &res); // Let's setup our server on "localhost:8080"
  int serverSocket = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
  int reuse = 1;
  setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
  int status = bind(serverSocket, res->ai_addr, res->ai_addrlen); // Let's bind to the actual socket
  listen(serverSocket, 10000); // And listen. We setup a 10'000 clients buffer
  while (1) {
    struct sockaddr clientAddress;
    socklen_t clientAddressLength;
    // The call to "accept" will *wait* until a client actually knocks on the door of our server
    int currentClientSocket = accept(serverSocket, &clientAddress, &clientAddressLength);
    processRequest(currentClientSocket); // Let's process that client
  }
}

Comme vous pouvez le voir, c’est extrêmement simple. Certes nous ne faisons aucune vérification d’erreur, mais cela dit le message est passé : la logique centrale d’un serveur web n’est pas si compliquée.

Maintenant, ce serveur ne fait absolument aucun traitment. Simulons un peu de travail : nous allons écrire un serveur HTTP avec du délai. Nous allons répondre à nos clients 5 secondes après avoir reçu leur requête. Nous ne ferons absolument rien pendant ces 5 secondes, mais ça sera un bon exemple.

Le modèle synchrone

Donc, nous voulons répondre 5 secondes après avoir reçu la requête. L’approche classique consiste à utiliser la fonction sleep.

void processRequest(int socket) {
  int requestBufferSize = 8096;
  char * request = malloc(requestBufferSize);
  recv(socket, request, requestBufferSize, 0);
  free(request);
  // And now, some magic...
  sleep(5);
  // Ta-daa! Easy, wasn't it?
  char * response = "HTTP/1.1 200 OK/nContent-Type: application/json\n\n{\"hello\": \"world\"}\n";
  send(socket, response, strlen(response), 0);
  close(socket);
}

Le cas mono-process

Très simple, n’est-ce pas ? Bien sûr. Mais maintenant, que se passe-t-il si un autre client fait une requête à notre serveur pendant que celui-ci traite la requête initiale ? Et bien ce nouveau client n’aura pas de réponse: nous sommes en train de dormir !

Les sous-process et les threads à la rescousse

Si vous demandez à un développeur comment fixer cela, sa réponse sera très probablement “il suffit de détacher un thread”. Et cela fait tout à fait sens. Notre serveur devient :

pthread_t thread;
while (1) {
  struct sockaddr clientAddress;
  socklen_t clientAddressLength;
  long int currentClientSocket = accept(serverSocket, &clientAddress, &clientAddressLength);
  pthread_create(&thread, NULL, threadRoutine, (void *)currentClientSocket);
  pthread_detach(thread);
}

Et cela fonctionne : pour chaque requête entrante, le serveur détachera un nouveau thread, de sorte à pouvoir traiter les nouvelles requêtes entrantes.

C’est comme cela que fonctionne une grande majorité de serveurs web. Avec quelques améliorations :

  • Pour éviter d’épuiser les ressources du système hôte, les serveurs limitent en général le nombre maximum de threads qu’ils vont détacher, et utilisent un mécanisme de “threadpool”.
  • Il est aussi possible de lancer des process multiples et de leur dispatcher le travail
  • Et enfin, on peut aussi faire un savant mélange de ces deux options, et avoir plusieurs process ayant chacun plusieurs threads.

Le modèle asynchrone

Problème résolu, donc ? Pas tout à fait ! Il se trouve qu’utiliser des threads et des process a un coût. Il n’est pas très important initialement, mais si vous utilisez beaucoup de thread votre serveur passera un temps non négligeable à changer de contexte. Et cela finira par devenir un goulot d’étranglement. Revenons à notre exemple basé sur “sleep”: a priori nous devrions pouvoir gérer des millions d’utilisateurs simultanés sur une machine modeste pusiqu’après tout nous ne faisons qu’attendre. Mais bien entendu la version threadée de notre serveur ne pourrait pas soutenir une telle charge.

Une boucle événementielle

Ceux d’entre vous qui ont déjà développé un GUI auront déjà interragi avec une boucle événementielle. C’est un modèle de programmation qui est au coeur d’iOS et d’Android par exemple. Il se déroule de la façon suivante:

  • Votre application met en place un ensemble de “widgets” à l’écran (boutons, zones de texte)
  • Et elle entre dans une boucle infinie. À l’intérieur de cette boucle, elle attend un tout petit peu. Un peu comme cela :
event * queue[100];
int queue_length;
while(TRUE) {
  usleep(1000); // Sleeps for 0.001 second
  if (queue_length > 0) { // Check if there is something to do. If there is, do it!
    process_event(queue[0]);
    queue_length--;
  }
}

La fonction process_event regarde le paramètre event, et effectue le tâche correspondante. Par exemple, un objet event pourrait décrire un clic sur un bouton “calcul”, et la tâche correspondante serait de calculer un hash.

Sur les smartphones, lorsque l’utilisateur touche son écran, l’OS crée un objet event et l’ajoute à la file d’attente pour qu’il soit traité par votre application.
Il est intéressant de remarquer que l’application utilisera probablement très peu le CPU : en effet, tous les millièmes de secondes elle fait une petite vérification rapide, s’apreçoit qu’il n’y a aucun évènement à traiter, et retourne dormir.

Un serveur web événementiel

Il se trouve que cette métaphore s’adapte particulièrement bien aux serveurs web ! Ré-écrivons notre serveur en utilisant cette approche :

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netdb.h>
#include <sys/select.h>
//
void processRequest(int socket) {
  int requestBufferSize = 8096;
  char * request = malloc(requestBufferSize);
  recv(socket, request, requestBufferSize, 0);
  free(request); // Actually we don't care about the client's request
  char * response = "HTTP/1.1 200 OK/nContent-Type: application/json\n\n{\"hello\": \"world\"}\n";
  send(socket, response, strlen(response), 0); // Let's send our static response
  close(socket);
}
//
int main(int argc, char * argv[]) {
  // Let's initialize our server. Just like before.
  struct addrinfo hints;
  memset(&hints, 0, sizeof(hints));
  hints.ai_family = PF_INET;
  hints.ai_protocol = IPPROTO_TCP;
  hints.ai_socktype = SOCK_STREAM;
  hints.ai_flags = AI_PASSIVE;
  struct addrinfo * res;
  getaddrinfo("localhost", "12345", &hints, &res);
  int serverSocket = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
  int reuse = 1;
  setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
  int status = bind(serverSocket, res->ai_addr, res->ai_addrlen);
  listen(serverSocket, 10000);
  // So far, no change. Here comes the evented action
  struct timeval timeout;
  timeout.tv_sec = 0;
  timeout.tv_usec = 1000000;
  fd_set readFdSet;
  // Let's define a "responseEvent" structure.
  // This will store an event describing an HTTP response to be sent.
  typedef struct {
    int socket;
    time_t fireTime;
  } responseEvent;
  // And let's create our event queue
  int eventPoolSize = 1024; // We won't queue more than 1024 events
  responseEvent * eventPool = malloc(sizeof(responseEvent) * eventPoolSize);
  memset(eventPool, 0, sizeof(responseEvent) * eventPoolSize);
  // We're all set. Let's enter our infinite event loop!
  while (1) {
    FD_ZERO(&readFdSet);
    FD_SET(serverSocket, &readFdSet);
    // The following is equivalent to the "accept" call, except it times out
    // The "select" call will wait for a new client for 1ms
    // If none comes in, it'll skip the embedded code
    if (select(serverSocket+1, &readFdSet, NULL, NULL, &timeout) > 0) {
      // A new client knocked on our door!
      // We will *not* answer the client straight away! That's precisely the trick.
      // Instead, we're going to build a "responseEvent" structure, and process it later on
      //   when the time will be right.
      struct sockaddr clientAddress;
      socklen_t clientAddressLength;
      int currentClientSocket = accept(serverSocket, &clientAddress, &clientAddressLength);
      int eventIndex = 0;
      // Let's create a new event. Actually, we're not deleting processed events, we're simply
      //   setting their "socket" field to zero. So let's just find the first event whose socket
      //   is equal to zero, and re-use it!
      for (eventIndex = 0; eventIndex < eventPoolSize; eventIndex++) {
        if (eventPool[eventIndex].socket == 0) {
          break;
        }
      }
      // Mmh, we didn't find any un-used event...
      if (eventIndex >= eventPoolSize-1) {
        // This may happen if we can't process events as fast as they are created
        // unfortunately going the evented way won't solve *all* your problems!
        printf("Overflowing queue size !\n");
        return;
      }
      // So, here's our event. We have to populate it with all the data we'll need
      //  for sending an answer to the client when we'll process it.
      responseEvent * newEvent = &eventPool[eventIndex];
      struct timeval currentTime;
      gettimeofday(&currentTime, NULL);
      newEvent->socket = currentClientSocket; // We'll need that socket to know *where* to reply
      newEvent->fireTime = (currentTime.tv_sec + 5); // And we'll need to know *when* to reply
    }
    // Since "select" times out after 1ms, we'll run this at least once every 1ms
    for (int i=0; i < eventPoolSize; i++) {
      // We're iterating on all events. We could have sorted them and only process the relevant ones
      // But we didn't for clarity
      struct timeval currentTime;
      gettimeofday(&currentTime, NULL);
      responseEvent * event = &(eventPool[i]);
      if (event->socket != 0) { // That event is live
        if (event->fireTime <= currentTime.tv_sec) {
          // And it is now the time to reply to that client !
          processRequest(event->socket);
          event->socket = 0; // "Delete" the event
        }
      }
    }
  }
}

Ce code aura de bien meilleures performances que la version threadée. En effet, pour chaque client nous nous contentons de créer une simple structure d’événement.

Il y a un gros inconvénient cela dit : comme vous pouvez le voir, nous avons significativement modifié notre code. Nous n’appellons d’ailleurs même plus la fonction sleep : nous stockons une date de réponse (fireTime) pour chaque événement, et nous la comparons régulièrement à la date actuelle. Cela fonctionne très bien dans notre cas, mais que se passerait-il dans un cas plus général ?

Prenons un cas plus général : un serveur qui sert des assets statiques. Assez courrant, n’est-ce pas ? Il se trouve que ce cas est très proche de notre exemple : la plupart du temps, le CPU est en attente. Certes il n’est pas en pause comme dans notre exemple, mais il attend que le disque dur termine sa recherche. Toujours est-il que le CPU ne fait pratiquement rien la plupart du temps. Ce serait donc un très bon candidat pour un serveur événementiel. Mais comment ?

Fermeture

Il se trouve que l’appel read est synchrone. Il bloque l’exécution tant que les données n’ont pas été lues du disque. Exactement comme la fonction sleep. Si nous voulons écrire un serveur événementiel, nous allons devoir utiliser une API d’entrées-sorties événementielle.

C’est possible en C en utilisant des callbacks, mais c’est relativement pénible. Utiliser un langage ayant une notion de fermeture rend cela beaucoup plus aisé. Prenons un exemple :

// We're using blocks, which is a proprietary extension made by Apple
// to add closure to the C language
int fileDescriptor = open("path/to/my/file.txt", "r");
read_from_file_then(fileDescriptor, ^(char * data){
  // That is a *block*. It represents a piece of code.
  // That called is called with "data" being equal to the content of the file.
  // It is called whenever the "read" code has finished
});
// Here is the trick: execution of "read_from_file_then" would return *immediately*
// And the call to block would happen "later on", whenever appropriate.

Avec une API asynchrone, il devient beaucoup plus facile d’utiliser des événements puisque l’on peut utiliser un bloc pour décrire le traitement de la réponse.

Et pour conclure, quel modèle devrais-je choisir ?

Les serveurs événementiels peuvent être très performants, mais ils ne sont pas la réponses à tous les maux. En substance, si votre application n’est pas limitée par le temps CPU, un serveur événementiel sera un très bon candidat. Sinon il ne fera pas une très grande différence.

Voilà quelques exemples où il ferait sens d’utiliser un serveur événementiel :

  • Lorsque vous écrivez une application de type “proxy” : un site qui récupère des données d’une source extérieure et qui les reformate simplement.
  • Lorsque vous construisez un site qui utilise une base de données, dans la mesure où le temps pris pour générer vos vues est significativment plus faible que celui pris pour requêter la base de données.

Et voilà les cas où il n’est pas utile de prendre un serveur web événementiel :

  • Quand votre langage n’a pas de notion de fermeture
  • Lorsque votre application est limitée par le CPU : par exemple un service de traitement d’images.

Si vous avez des commentaires ou des questions, nous serons très heureux d’y répondre : envoyez-nous un message sur Twitter !