31 janv. 2011

[Code source] preview de ekogen 0.5

Non, [ekogen 0.5] n'est pas encore sorti - ça ne saurait tarder ceci-dit. Mais puisque j'ai écrit deux posts sur le sujet sur gamedev.net, il me semblait logique de faire profiter mes lecteurs francophones de mes réflexions sur le sujet. Voici donc une version réécrite des deux posts de GD.net - en français, avec des explications en plus.

[ekogen], future version

La prochaine version de ma librairie va proposer des fonctionnalités réseau, de manière a permettre la création de serveurs et clients (TCP/IP, UDP/IP,...) haute performance, qui pourront à leur tour utiliser les fonctions de sécurité proposées. Ce billet va introduire la motivation derrière la création de cette addition, et va expliquer certains des choix qui ont été faits.

Mais... NIH[1] again ?

Une bonne librairie, non exempte de problèmes

La première chose à remarquer, c'est que je ne suis pas le premier à me pencher sur le sujet : il existe de nombreuses librairies réseau en C++, certaines sous licence libre, d'autres non. On écarte de fait celle qui ne sont pas disponibles sous une licence compatible avec la licence zlib ou plus restrictive qu'elle, puisqu'[ekogen] est distribué sous la licence zlib. Mais ça nous laisse un morceau de choix : Asio, la librairie qui a été incorporée depuis peu à Boost sous le nom Boost.Asio. Voici ses points forts :

  • Ecrite en C++ moderne
  • Complète
  • Stable
  • Efficace

Asio est utilisée depuis de nombreuses années pour proposer des systèmes haute performance fiables, que ce soit dans le milieu financier, dans le milieu des jeux vidéo, ou tout simplement au coeur de serveurs HTTP, NNTP, ... Elle réponds donc à priori à nos besoins - et je ne vous cache pas que j'ai commencé par me dire et bien, finalement, je vais utiliser une partie de Boost. Jusqu'à ce que je tombe sur une question sur sur le forum developpez.com qui me coupe dans mon élan. L'idée était pourtant simple : la personne souhaitait utiliser un pattern très courant pour réaliser un serveur TCP. Dans ce pattern, on utilise la thread principale de l'application pour écouter les communications entrante ; dès qu'une nouvelle communication arrive, elle est traitée par une thread séparée - soit que cette thread est créée pour traiter la connexion, ou bien elle fait déjà partie d'un pool de thread existantes.

Le code ressemble donc à :

si (nouvelle connexion entrante)
  socket s = listener.accept()
  t = new thread(s)
  t.run
fin si

Asio ne permet pas d'écrire un tel code, pour la simple et bonne raison que la librairie ne fournit pas de moyen de copier une socket ; du coup, on ne peut pas transférer la socket à une nouvelle thread sans passer par un shared_ptr<>. Vu qu'une socket est tout de même un objet léger, ce n'est pas que la copie pose problème - c'est une pure question d'architecture. Asio utilise RAII pour contrôler la durée de vie des sockets. Lorsque l'objet socket est détruit, le descripteur de la socket est fermé, de manière inconditionnelle. Du coup, une copie ne résoudrait pas vraiment le problème : le descripteur serait dupliqué, et les deux objets n'ayant pas la même durée de vie celle qui aurait la durée de vie la plus longue ne pourrait de toute façon plus utiliser le descripteur à partir du moment ou l'autre socket aura été détruite. Bref : le fait qu'il n'y ait pas de destructeur par copie est en fait plutôt une bonne chose.

Mais quid d'un move-constructor ? Le concept ne date pas de l'introduction des rvalue-reference dans le futur standard C++1x : std::auto_ptr<> l'utilise déjà - c'est même ce qui fait sa faiblesse vis à vis du reste de la librairie standard, car l'opérateur de copie et le constructeur par copie sont utilisés pour déplacer le contenu de l'objet copié, et non pas le dupliquer. Implémenter un move-constructor et un move-operator est trivial, et utile dans ce cas ; car s'il est admis qu'on ne copie pas de socket, on peut par contre les partager (fait en passant une référence sur la socket à partager) ou en transférer le contrôle à une autre partie du programme (c'est là que la sémantique de déplacement a tout son intérêt par rapport à l'utilisation de shared_ptr<>).

Cette omission est importante, car elle oblige l’utilisation d'une autre librairie (boost.shared_ptr<>) en plus de la librairie Asio. La dépendance n'est pas explicite à ce niveau, ce qui est encore plus gênant.

Une fois coupé dans mon élan. je me suis demandé si Asio avait d'autres problème de l'ordre de la conception, et j'en ai hélas trouvé. Voici deux points qui m'ont gêné.

  • l'interface des classes socket est mal pensée, notamment sur les opérations synchrones / asynchrones. La classe tcp::socket propose toutes les opérations possibles dans son interface. Hors une socket synchrone n'est pas utilisable comme une socket asynchrone sur tous les OS (sur les OS POSIX, pas de problème particulier ; par contre, sur les différentes versions de Windows, une socket asynchrone est créée avec un flag supplémentaire ; on ne peut donc pas utiliser une socket normale avec les fonctions asynchrones). Il y a donc en fait deux interfaces en une - hors, dans 99% des cas, une seule sera utilisée par l'utilisateur. Il y a là une violation du principe de ségrégation des interfaces puisque je dépends d'API dont je n'ai pas l'utilité[2].
  • les noms de certaines interfaces sont mal choisis. Si (globalement) certains noms de classes ou de fonctions sont bien choisi, il est est d'autres qui sont plus étranges. Si quelqu'un dans l'assistance est capable de me dire ce que fait asio::io_service sans regarder la documentation (et sans connaître cette classe à priori), et bien je lui tire mon chapeau. La classe asio::acceptor est elle aussi problématique. Utilisée dans le contexte d'un serveur TCP/IP, elle permet de créer une socket d'écoute attachées à un port local. Du coup, elle propose (entre autres) les services suivants ;
  1. listen()
  2. accept()
  3. is_open()

Ces trois méthodes sont très mal nommées ; d'abord, listen() n'écoute rien ; elle ne fait que ce que fait la fonction du même nom dans l'API BSD socket (elle met une socket dans un état spécifique où elle peut être utilisée pour écouter). Ce qui est acceptable dans le cas d'une API C l'est moins lorsqu'on parle d'une API C++ et ce, quelqu'en soit les raisons[3]. is_open() est encore pire : impossible de savoir exactement ce que fait cette méthode. Est-ce que'elle renvoie true lorsque la socket est attachée au port ? Lorsqu'elle est dans l'état listen ? La classe acceptor a été principalement créé pour sa méthode accept(), qui accepte les connexions entrantes, mais c'est io_service qui fait le polling. Du coup, on a une classe dont les fonctionnalités sont mal définies, et qu'on arrive à utiliser bon gré mal gré parce qu'on sait qu'à un moment donné, lorsqu'on fait un serveur TCP/IP, il faut accepter les connexions entrantes.

Aparté : sur les noms de symboles

On pourrait me répondre qu'un problème de nom n'est que ça : un problème de nom. Et que ça n'entraîne rien de grave qui ne puisse être corrigé par une bonne documentation. C'est faux. Un problème de nommage n'est qu'un symptôme parmi tant d'autres d'une vision parcellaire de l'architecture qu'on est en train de mettre en place. Si on ne peut nommer correctement quelque chose, c'est que cette chose est mal définie - et quelque chose qui est mal défini ne peut pas être complètement et correctement conçu.

Ce que l'on conçoit bien s'énonce clairement
et les mots pour le dire arrivent aisément.[4]

Si c'est vrai de la poésie, c'est vrai aussi des sciences et de toute chose. On ne peut nommer quelque chose dont on n'a pas la vision ; ne sous-estimez donc pas ce point lorsque vous concevez une librairie ou un programme : si vous avez un problème pour nommer une classe ou une fonction, c'est que vous n'avez pas l'esprit assez clair vis-à-vis de cette classe ou cette fonction. Le même auteur, dans le même ouvrage, disait aussi :

Vingt fois sur le métier remettez votre ouvrage
Polissez-le sans cesse et le repolissez ;
Ajoutez quelquefois, et souvent effacez.

On va terminer cet aparté ici - si vous me le demandez gentiment, je vous exposerais mes vues sur le sujet dans un billet ultérieur.

Une solution ?

Lors de l'architecture de la librairie [ekogen], j'ai du répondre à quelques questions, dont certaines un peu inattendues. Je vous livre ici le fruit des mes réflexions plus ou moins brutes.

On l'a vu ci-dessus, je considère qu'il est nécessaire qu'un type socket implémente une sémantique de déplacement - certains patterns ne pouvant être réalisés sans cette sémantique minimale. Rendre une socket copiable alors qu'elle implémente aussi une sémantique de déplacement serait une erreur grave, car il deviendrait difficile de prévoir le comportement du code (la copie étant plus intéressante, mais dans ce cas précis, elle entraîne une nécessaire pénalité pendant l'exécution ; en effet, on peut soit l'implémenter en dupliquant le descripteur de socket via la fonction dup(), ce qui alourdit le travail du système, ou en ajoutant un comptage de référence, ce qui alourdit le programme lui-même). On notera que C++1x permet l'implémentation des deux sémantiques dans la même classe avec un syntaxe permettant d'avoir un contrôle sur ce qui se passe ; C++98 n'offre pas cette possibilité[5].

Quoi qu'il en soit, il peut quand même être utile de copier les sockets, sans pour autant transférer la propriété de la socket en même temps[6]. L'objet résultant est censé avoir une durée de vie plus courte que l'objet socket dont il est issu, et sa destruction n'entraîne pas la fermeture de la socket. Dans mon architecture, j'ai baptisé ces objets des sockets faibles (weak_xxx_socket, où xxx détaille le type de la socket). Les sockets faibles sont crées de manière explicites et sont très aisément copiables.

Un autre point qui me gênait dans la conception des sockets de Asio : le mélange des interfaces synchrones et asynchrones. Pour éviter le mélange, j'ai créé plusieurs types de sockets - et chaque type a son type associé de socket faible.

  • stream_socket - socket synchrone en mode flux (TCP/IP...)
  • async_stream_socket - socket asynchrone en mode flux
  • datagram_socket - socket synchrone en mode datagramme (UDP/IP...)
  • async_datagram_socket - socket asynchrone en mode datagramme

Avec les sockets faibles correspondantes, ça nous fait pas moins de 8 types de sockets différents, sur lesquelles sont appliquées des algorithmes souvent similaires. Pour les différencier, une classe socket_traits<> a été ajoutée. Cette classe permet d'obtenir certaines informations sur le type de la socket considéré, de manière à pouvoir adapter les algorithmes utilisateurs à la compilation (grâce à la spécialisation de classes templates).

Cette classe de traits est importante, car elle permet de limiter l'écriture des algorithmes au seul nécessaire. Selon la valeur booléenne de socket_traits<S>::asynchronous, je peux choisir d'appliquer ou non un algorithme asynchrone - par exemple, l'implémentation de la méthode SocketType::read() est faite sous la forme d'une méthode statique dans une classe template située dans un nom d'espace privé. Selon qu'on est dans un mode synchrone ou asynchrone, l'implémentation diffère.

Une autre question que je me suis posé concerne la présence ou non dans la librairie d'une classe tcp_server. Il est évident que certains opérations sont propres à un serveur TCP ou à un serveur UDP. Le serveur UDP est très simple dans son approche : une fois la socket UDP attachée à un port, on attends patiemment que des donnée arrivent et on les récupèrent avec la fonction recvfrom(). La réalisation d'un serveur TCP performant nécessite la mise en place d'autres mécanismes :

  • la socket attachée au port d'écoute doit être transformée en socket d'écoute (via un appel à listen())
  • il faut ensuite attendre que des données arrivent ; on utilise généralement un système de polling (select(), poll() ; des versions plus performantes existent : l'API epoll sous Linux, kqueue sur les systèmes BSD - y compris Mac OS X -, etc).
  • si des données arrivent sur la socket d'écoute, il faut les accepter (grâce à accept()) pour créer une socket de communication.

Le principe se vérifie pour tous les protocoles de type flux, tout comme l'architecture d'un serveur UDP est adapté à tous les protocoles de type datagramme, y compris RUDP, qui rajoute une couche de fiabilité au dessus d'UDP.

Du coup, on est en droit de se demander si une classe du type stream_server ou datagram_server fait sens. J'avoue que je n'ai pas encore arrêté mon choix sur ce sujet précis - pour une bonne raison ; de telles classes peuvent paraître complexe, mais il n'en est rien. Elles sont construites autour de peu de fonctionnalités qui de toute façon doivent être offertes de manière plus ou moins indépendante. J'ai donc pris le parti de repousser l'inclusion de ces classes, si toutefois je les juge nécessaires.

En parlant du polling, il me vient à l'esprit que ce système - asynchrone par essence - se doit de bien être traité. Et vous allez voir, ce n'est pas simple. Prenons le cas de l'API epoll de Linux. Un appel à epoll_wait() peut être paramétré pour attendre de 1 à N évènements (un évènement étant du type : tel descripteur peut recevoir ou émettre des données). C'est le cas pour epoll, mais aussi pour toutes les autres API du même type (select(), kqueue(), etc). Du coup, en supposant que je construise un objet utilisant ces fonctions, et puisque je ne peux écrire uen fonction qui va me retourner un nombre variable d'objet, j'ai le choix suivant :

  • la fonction qui effectue le polling reçoit en paramètre une séquence de taille variable qui stocke les évènements reçus avant de retourner le contrôle à l'appelant. Le problème : cette séquence va être redimensionnée une fois l'appel à la fonction de polling effectués. Je n'ai donc que peu de contrôle sur les allocations à faire, et l'impact sur les performance sera loin d'être négligeable. C'est très ennuyeux.
  • la fonction qui effectue le polling exécute, pour chaque évènement généré, une routine particulière passée en paramètre. L'une des bonnes idées d'Asio, pour toutes les tâches asynchrones, est de proposer un modèle nommé strand (un bout de thread). Lorsqu'une condition particulière se réalise (la promesse), le strand est activé. Conceptuellement, les strands généralisent les future qui seront implémenté dans le prochain standard C++1x (et qui feront très certainement l'objet d'n article à venir). L'un des problèmes au niveau implémentation est que ce concept nécessite la création d'un thread d'une durée de vie courte qui effectuera l'attente active ou passive avant de lancer l'exécution de l'opération souhaitée par le programmeur. Le futur standard C++ proposera std::thread pour simplifier grandement la création de thread et qui propose la fonctionnalité dont nous avons besoin - j'ai pris le parti de proposer une implémentation limitée de cette classe dans [ekogen] afin de pouvoir implémenter correctement le traitement des fonctions asynchrones.

Conclusion

Voilà. Vous savez presque tout de mes réflexions sur le sujet, et vous devez commencer à avoir un idée un peu plus précise des fonctionnalités qui seront offertes dans la version 0.5 de [ekogen]. Je me suis permi de ne pas parler du support IPv6 (si vous êtes au courant de l'actualité dans ce domaine, alors vous savez qu'au niveau mondial, toutes les classes d'adresse IPv4 ont été distribuées ; IPv4 devrait être saturé au niveau régional d'ici à 2 ans, et les premiers serveurs purement IPv6 ne vont pas tarder à voir le jour). Le développement a pris du retard par rapport à ce que j'avais prévu (normalement, j'aurais du livrer mon code dans la semaine écoulée ; ça attendra quelques jours de plus !)

En attendant le code source, pourquoi ne pas me proposer votre vision des choses, voire des arguments qui démontent l'impression qui est la mienne au sujet de la librairie Asio ? Non pas que ça remette en cause le développement de ma librairie réseau (elle est trop avancée pour que j'efface le code purement et simplement), mais vous pouvez quand même tenter de me réconcilier avec elle !

A bientôt, pour de nouvelles aventures !

Notes

[1] Not Invented Here ; un syndrome bien connu des informaticiens, qui ont tendance à réinventer la roue plutôt que d'utiliser une roue plus ancienne, mais bien plus robuste que la leur

[2] dans mon post sur GameDev.Net, je parle de violation du principe de responsabilité unique ; ce n'est pas tout à fait juste, mais ce n'est pas non plus complètement faux

[3] la raison avancée par les créateurs de la librairie Asio est en plus un peu limite : il s'agirait de coller aux noms de l'API socket BSD, puisqu'elle est reconnue. Ca n'a pas de sens ; si je fais une classe window, je vais appeler le constructeur CreateWindow() parce que l'API Win32 est très utilisée ?

[4] Nicolas Boileau-Despréaux, in L'art Poetique, livre I

[5] en C++98, de nombreuses contraintes s'appliquent à un objet déplaçable ; en particulier, on ne peut pas les mettre dans un conteneur séquence de la librairie standard (std::vector<>...)

[6] les sockets ne sont pas des valeurs, ce sont des entités

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet