35 000 clients, sans broncher...
Par Julien Vehent le jeudi, décembre 4 2008, 16:02 - General - Lien permanent
![]() | Dans la vie, ya des périodes. Ces derniers temps, c'était plus la période où j'avez l'impression de ne rien faire. De fait, quand il m'en prend l'envie, je ressort mes bouquins de C et je bidouille. |
L'énorme avantage de cette technique, c'est que pendant qu'une partie du code ne fait rien, l'autre partie peut bosser, et tout cela dans le même processus. On multiplexe, mon bon monsieur, on multiplexe !
L'objectif ? Hormis satisfaire une curiosité galopante, c'est surtout de démarrer un projet d'injecteur SSL capable de générer plusieurs dizaines de milliers de clients à partir d'un seul processus. Et pour ça, sous Linux, ya un système magique : Epoll...
Le principal problème quand on recherche la performance, c'est de laisser le plus de temps possible au kernel pour ses entrées/sorties (I/O) et de passer le moins de temps possible dans le code.
Dans le cas d'une socket réseau, les opérations sont, par défaut, bloquantes. Ce qui fait que dès que vous allez faire votre connect(), le système va gentillement se tourner les pouces en attendant la fin de l'opération. OK, sauf qu'un connect en TCP, ca veut dire un handshake Syn, Syn/Ack, Ack. Ca se compte en millisecondes sur un réseau local, autrement dit, une éternité pour un processeur.
La méthode classique de programmation réseau consiste à dire : j'ai une connection entre deux entités, je vais lui dédier un processus. Super. Donc si je veux avoir 30 000 connections, il va me falloir 30 000 processus pour mon programme... et si j'en veux plus ? Ha ba les PID sont codés sur 15 bits sous nunux, donc on va pas aller beaucoup plus loin que 32768 (moins les processus existants).
Clairement, ça peut pas marcher.
Une alternative consiste à dire : mes opérations sont non bloquantes. Quand je fais mon connect, je n'attend pas qu'il se termine, je part faire autre chose et j'y reviendrais plus tard. OK, c'est déjà mieux, mais comment je sais que le file descriptor de ma socket est prêt ? Et bien, pour cela, il existe une méthode qui s'appel select() et qui va s'occuper de monitorer tous les files descriptor que l'on va lui donner et remonter ceux qui sont pret à bosser.

Alors, forcément, je vous balance ça comme si c'était génial, tout beau tout neuf, mais non non non, point du tout. C'est juste moi qui viens de découvrir le truc après tout le monde. Il y a près de 10 ans de cela, The C10K Problem posais déjà le sujet. Les questions de scalabilité des noyaux ont également été éprouvées et plusieurs projets ont amenés des améliorations. Aujourd'hui, les poids lourds sont aux nombres de deux : KQueu pour BSD et EPoll pour Linux.
Et paul ?
Oui, je sais, elle est nulle. Donc, EPoll c'est un système intégré dans le noyau qui va rassembler tous les file descriptors de vos sockets, les surveiller, et vous renvoyer ceux qui sont pret à bosser.
Alors, pret à bosser, ça peut vouloir dire plusieurs chose : pret à écrire, à lire, à être fermé, ... En l'occurence, les évènements qui nous intéressent sont à définir pour chaque file descriptor que gère EPoll.
Dans la pratique, ça se passe comme ça :

Avec un peu de pratique (et beaucoup d'erreur parce que je suis pas hyper doué en C), je suis parvenu à coder un client et un serveur basiques.
Le serveur écoute sur un port, crée un socket par client, ajoute le socket dans le file descriptor Epoll. Quand un paquet arrive, il répond juste "ok" et ferme (close()) la connexion.
Le client est un peu plus compliqué. Je lui passe le nombre de sockets que je veux et le serveur de destination. Il créé autant de sockets que demandé, et les ajoute dans le file descriptor Epoll. Puis il attend.
Le test
Bien oui, il faut justifier le titre du billet. Tout d'abord, sur Linux, le nombre maximal de file descriptor ouvrable par un processus est par défaut bridé à 1024. C'est peu. Il faut donc l'agrandir quelque peu avec la commande ulimite, en root évidemment :
Le code ensuite, peut être utile :
le serveur
le client
pardon pour la qualité du code, c'est du quick'n'dirty....
Les lignes de compilations sont en entêtes des code sources.
Concrètement, niveau benchmark, ça donne quoi. Ma machine de Test est un 2.6.24 sous debian lancé dans vmware player. Autrement dit, les temps de réponses sont pas un paramètre pertinent (quoique, les gars de vmware ont plutôt bien bossé). Par contre, le nombre de connections simmultannées m'intéressent grandement.
Après quelques tests (comprendre crash d'OS), je me suis rendu compte que 256Mo de mémoire était un poil juste pour dépasser la barre des 30000 connexions (60000 en fait car le client et le serveur sont sur le même OS). Après un redémarrage avec 400Mo de mémoire, et 1600 secondes de tests, voila ce que ça donne :

(clic pour agrandir)
35000 clients constants, 3000 hits par secondes en moyenne.... c'est loin d'être minable !
Maintenant, la prochaine étape est de faire évoluer le comptage des statistiques, parce que là c'est vraiment crade. Puis, je pourrais rajouter un peu de contrôle de contenu, mais la plus grosse partie consistera en l'intégration de CyaSSL pour le support du SSL/TLS.
D'ailleurs, à ce sujet, il se pourrait bien que mon prochain billet concerne les benchs RSA entre CyaSSL et OpenSSL..... enjoy :)
Dans le cas d'une socket réseau, les opérations sont, par défaut, bloquantes. Ce qui fait que dès que vous allez faire votre connect(), le système va gentillement se tourner les pouces en attendant la fin de l'opération. OK, sauf qu'un connect en TCP, ca veut dire un handshake Syn, Syn/Ack, Ack. Ca se compte en millisecondes sur un réseau local, autrement dit, une éternité pour un processeur.
La méthode classique de programmation réseau consiste à dire : j'ai une connection entre deux entités, je vais lui dédier un processus. Super. Donc si je veux avoir 30 000 connections, il va me falloir 30 000 processus pour mon programme... et si j'en veux plus ? Ha ba les PID sont codés sur 15 bits sous nunux, donc on va pas aller beaucoup plus loin que 32768 (moins les processus existants).
Clairement, ça peut pas marcher.
Une alternative consiste à dire : mes opérations sont non bloquantes. Quand je fais mon connect, je n'attend pas qu'il se termine, je part faire autre chose et j'y reviendrais plus tard. OK, c'est déjà mieux, mais comment je sais que le file descriptor de ma socket est prêt ? Et bien, pour cela, il existe une méthode qui s'appel select() et qui va s'occuper de monitorer tous les files descriptor que l'on va lui donner et remonter ceux qui sont pret à bosser.

Alors, forcément, je vous balance ça comme si c'était génial, tout beau tout neuf, mais non non non, point du tout. C'est juste moi qui viens de découvrir le truc après tout le monde. Il y a près de 10 ans de cela, The C10K Problem posais déjà le sujet. Les questions de scalabilité des noyaux ont également été éprouvées et plusieurs projets ont amenés des améliorations. Aujourd'hui, les poids lourds sont aux nombres de deux : KQueu pour BSD et EPoll pour Linux.
Et paul ?
Oui, je sais, elle est nulle. Donc, EPoll c'est un système intégré dans le noyau qui va rassembler tous les file descriptors de vos sockets, les surveiller, et vous renvoyer ceux qui sont pret à bosser.
Alors, pret à bosser, ça peut vouloir dire plusieurs chose : pret à écrire, à lire, à être fermé, ... En l'occurence, les évènements qui nous intéressent sont à définir pour chaque file descriptor que gère EPoll.
Dans la pratique, ça se passe comme ça :

Avec un peu de pratique (et beaucoup d'erreur parce que je suis pas hyper doué en C), je suis parvenu à coder un client et un serveur basiques.
Le serveur écoute sur un port, crée un socket par client, ajoute le socket dans le file descriptor Epoll. Quand un paquet arrive, il répond juste "ok" et ferme (close()) la connexion.
Le client est un peu plus compliqué. Je lui passe le nombre de sockets que je veux et le serveur de destination. Il créé autant de sockets que demandé, et les ajoute dans le file descriptor Epoll. Puis il attend.
- Quand Epoll retourne un file descriptor pret à écrire, il écris "Hello" dedans, c'est donc envoyé au serveur, puis il met à jour le masque d'évènements pour ne plus être reveillé en écriture sur cette socket.
- Quand un socket a des données à lire, le code récupère les données dans un buffer et ne fait rien d'autre.
- Enfin, quand un socket est reveillé avec l'évènement "la connection a été fermée". Il ferme le socket de son coté et demande la création d'un nouveau socket qui sera ajouté dans le file descriptor Epoll.
Le test
Bien oui, il faut justifier le titre du billet. Tout d'abord, sur Linux, le nombre maximal de file descriptor ouvrable par un processus est par défaut bridé à 1024. C'est peu. Il faut donc l'agrandir quelque peu avec la commande ulimite, en root évidemment :
# ulimit -n
1024
#ulimit -n 40000
Le code ensuite, peut être utile :
le serveur
le client
pardon pour la qualité du code, c'est du quick'n'dirty....
Les lignes de compilations sont en entêtes des code sources.
Concrètement, niveau benchmark, ça donne quoi. Ma machine de Test est un 2.6.24 sous debian lancé dans vmware player. Autrement dit, les temps de réponses sont pas un paramètre pertinent (quoique, les gars de vmware ont plutôt bien bossé). Par contre, le nombre de connections simmultannées m'intéressent grandement.
Après quelques tests (comprendre crash d'OS), je me suis rendu compte que 256Mo de mémoire était un poil juste pour dépasser la barre des 30000 connexions (60000 en fait car le client et le serveur sont sur le même OS). Après un redémarrage avec 400Mo de mémoire, et 1600 secondes de tests, voila ce que ça donne :
(clic pour agrandir)
35000 clients constants, 3000 hits par secondes en moyenne.... c'est loin d'être minable !
Maintenant, la prochaine étape est de faire évoluer le comptage des statistiques, parce que là c'est vraiment crade. Puis, je pourrais rajouter un peu de contrôle de contenu, mais la plus grosse partie consistera en l'intégration de CyaSSL pour le support du SSL/TLS.
D'ailleurs, à ce sujet, il se pourrait bien que mon prochain billet concerne les benchs RSA entre CyaSSL et OpenSSL..... enjoy :)
