Julien Jorge's Personal Website

Tests de bibliothèques signal-slot en C++

Tue Apr 28, 2020

Ce post a été publié sur LinuxFr.org. Vous pouvez le lire là bas, ainsi que les commentaires associés

Le savais-tu, chaque jour de nouvelles bibliothèques C++ pour gérer des signaux et des slots voient le jour. Il y en a tellement qu’on estime aujourd’hui qu’il existe environ 1,14 bibliothèques de ce type pour chaque développeur C++. Jetons-y un coup d’œil.

Le guépard court plus vite qu’une armoire

Le mécanisme dit de signal et de slot est une façon d’implémenter le patron de conception de l’observateur. Dans l’idée, le principe consiste à permettre l’inscription à un événement sous la forme de fonctions de rappel qui seront donc appelées quand ledit événement se produit. Le signal (ou observable) est celui qui émet l’événement et les slots (ou observateurs) sont les fonctions de rappel. Nous appellerons « connexion » l’entité représentant l’enregistrement d’un slot dans un signal.

Il existe un dépôt signal-slot-benchmarks sur GitHub regroupant une trentaine de bibliothèques au sein d’un benchmark global permettant d’avoir un point de comparaison des performances des unes et des autres. C’est un bon point de départ. J’ai découvert ce dépôt lors de l’annonce d’une nouvelle bibliothèque sur le subreddit concernant le C++. Évidemment en tant qu’auteur d’une bibliothèque similaire j’ai voulu m’insérer dans le benchmark.

Et je ne suis pas très bien classé.

Suite à ça j’ai fait un tas de mesures, revu mon code un paquet de fois, et malgré cela j’ai du mal à encore améliorer les performances. Mais que font les autres que je ne fais pas ? Où plutôt, que ne font-ils pas que je fais ?

Il s’avère que dans mon implémentation les slots sont copiés avant le déclenchement afin d’éviter de gérer des histoires de réentrance et de modification de la liste des slots pendant l’activation. En particulier, je souhaite qu’une fonction de rappel inscrite pendant le déclenchement ne soit pas appelé pendant l’itération en cours. Néanmoins une fonction désinscrite pendant le déclenchement ne doit pas être appelée. Cette copie coûte très cher.

D’autres implémentations ne s’embêtent pas avec cela et n’acceptent tout simplement pas que le signal soit modifié pendant qu’il est déclenché, quitte à planter le programme. Difficile dans ces conditions de connecter un slot à usage unique, par exemple, qui se désinscrit lui-même quand il est appelé.

Cette différence d’implémentation m’a amené à regarder les implémentations plus en détail puis, en m’appuyant sur le dépôt de benchmark, à comparer les bibliothèques non plus seulement par rapport à leurs performances mais aussi par rapport à leurs fonctionnalités. Après tout, si on doit comparer les performances de deux choses il faut le faire pour le même service rendu ; autrement on compare des pommes avec des oranges.

En codant un peu de glu pour uniformiser l’interface des différentes bibliothèques incluses dans signal-slot-benchmarks j’ai pu les faire passer dans les tests déjà implémentés dans iscool::signals. Alors évidemment, vu l’origine de ces tests tu te doutes bien qu’il y a un biais en faveur de mon implémentation. Il s’avère aussi que je voulais avoir le même comportement que Boost.Signals2 sur de nombreux aspects, c’est pourquoi ces deux bibliothèques passent tous les tests (quasiment, j’y reviendrai).

Néanmoins, même si d’autres bibliothèques ne passent pas quelques tests pour des questions de divergence dans les choix d’implémentations, certaines restent de très bons outils. Voyons ça plus en détail.

Liste des bibliothèques participant au comparatif

Ci-dessous les bibliothèques testées dans le benchmark, le tag qui les représentera par la suite (souvent un trigramme) et la date de leur dernière mise à jour à l’heure où j’écris ces lignes (avril 2020).

Vous êtes tous différents

Il y a un peu de tout là dedans. Déjà on remarque que certaines bibliothèques ne sont plus maintenues depuis longtemps. Ensuite si on regarde dans les détails il y a quelques subtilités, par exemple :

cps, css, jls et d’autres, requièrent que chaque fonction de rappel attachée à un signal soit une méthode d’une instance dérivant d’une classe donnée. Pour prendre des termes objets, imaginez par exemple qu’il faille dériver d’Observer pour s’enregistrer auprès d’un Observable. Impossible dans ce cas de connecter une fonction libre ou une lambda.

aco, jos, nls, nss, psg, wnk, yas et peut-être d’autres requièrent d’avoir une référence vers le signal d’origine pour couper une connexion. D’autres implémentations permettent de garder une connexion au delà de la durée de vie du signal d’origine (elles sont automatiquement coupées à la destruction du signal) et d’affecter de nouvelles connexions à des instances existantes.

aco, css, jos, mws, nls, psg, pss, sss et peut-être d’autres imposent que les fonctions de rappel ne retournent pas de valeur. D’autres implémentations autorisent les fonctions à retourner une valeur et proposent même différentes façons d’agréger les résultats.

bs2, ics, jls, lfs, lss, mws, nes, nod, nss, psg, vdk et wnk permettent de savoir si un signal a au moins un slot. Les autres ne le permettent pas.

Seuls asg, bs2, dob, evl, ics, jls, ksc, lfs, mws, nod, pssetvdk` proposent un moyen de tester si une connexion est active ou pas.

aco, bs2, cls, ics, jls, ksc, lfs, mws, nod, nss, psg, pss, vdk et yas fournissent une méthode pour déconnecter tous les slots d’un coup. asg, cps, cps, dob, evl, jos, nes, nls, nls, sss et wnk n’ont pas de telle méthode pour le faire mais n’empêchent pas d’affecter un signal vide à une instance existante (s = signal()), a priori pour un résultat similaire. css et lss ne permettent pas de déconnecter tous les slots d’un coup, ni via une méthode ni via l’affectation.

css, lss, mws, nod, nss et vdk n’autorisent pas l’échange (le swap) de deux instance de signaux.

cls, dob, ksc, lfs et vdk permettent l’accès à un signal depuis plusieurs threads. aco, asg, evl, ics, jls, jos, lss, mws, nes, psg, sss, wnk et yas sont uniquement mono-thread. bs2, cps, nls, nod, nss, pss proposent les deux configurations.

Et puis il y a d’autres petites singularités comme avoir un type de connexion différent selon le signal (jos), avoir autant de types de signaux que d’arités de slots (signal0, signal1… dans psg), utiliser un type privé de la classe de signal en retour de la fonction d’enregistrement des slots (nls, difficile de stocker la connexion dans ce cas) et d’autres trucs… disons inattendus.

Le point de vue de l’auteur

Lorsque j’ai implémenté iscool::signals j’avais trois objectifs en tête, issus de l’expérience acquise sur les jeux que je développais :

  • mono-thread : je n’ai quasiment jamais eu besoin de partager un signal entre plusieurs threads. Le mono-thread est le cas général, le multi-thread le cas particulier. Je préfère faire un effort pour gérer la synchronisation dans le peu de cas particuliers plutôt que de la payer pour rien dans le cas général.
  • à la Boost.Signals2 : nous avions utilisé Boost.Signals2 en première implémentation dans nos jeux. Connue, éprouvée et solide, cette bibliothèque était le meilleur choix pour commencer. Lors de la migration il fallait que la nouvelle bibliothèque ait un fonctionnement et une interface compatibles pour que cela se fasse en douceur.
  • temps de compilation : un des problèmes avec Boost était que son utilisation contribuait fortement à l’augmentation des temps de compilation tant pour interpréter les nombreux templates dans les fichiers d’entêtes que pour résoudre les symboles lors de l’édition des liens. La nouvelle implémentation devait en mettre le moins possible dans les entêtes et prévenir la duplication des symboles (bonjour extern template).

Par conséquent de nombreuses bibliothèques parmi celles listés précédemment sont à mon avis clairement inadaptées. Si votre bibliothèque est intrusive, ou qu’elle ne permet pas de créer un slot pour une lambda, ou que son implémentation tient dans un paquet de fichiers d’entêtes, alors je ne pourrai pas l’utiliser.

Mais que fais-tu?

Retour dans les tests. Pour comparer les bibliothèques j’ai repris les tests d’iscool::signals et j’en ai ajouté quelques uns. Les résultats sont listés dans le dépôt de signal-slot-benchmarks.

Les tests sont regroupés en quatre catégories :

  1. activation : que se passe-t-il quand un signal est déclenché ;
  2. paramètre : comment le signal transmet-il ses paramètres aux fonctions de rappel ;
  3. gestion des connexions : faut-il conserver les connexions et est-ce que la déconnexion empêche bien l’appel ;
  4. échange : que se passe-t-il si j’échange deux signaux.

Avant de regarder les résultats, quelques remarques sur leur interprétation. Le cas simple est évidemment celui où le test passe ; dans ce cas, rien à redire. Quand le test ne passe pas il se peut que cela soit simplement une divergence de conception entre iscool::signals et la bibliothèque testée, auquel cas on ne peut pas considérer que la bibliothèque soit défaillante. Par exemple, iscool::signals garantit que les fonctions soient appelées dans l’ordre dans lequel elles ont été inscrites. C’est juste un choix d’implémentation (sur lequel nous comptions dans nos jeux) mais pas une qualité intrinsèque d’un système de signaux et de slots.

Dans le cas où la bibliothèque testée ne fournit pas les méthodes nécessaires pour le test, le résultat est tout simplement ignoré. Après tout, si la fonction n’est pas disponible elle n’est pas erronée pour autant.

Enfin, il y a des bibliothèques qui font carrément planter certains tests. La ça devient réellement problématique d’autant plus que les cas testés me sembles légitimes. Par exemple la déconnexion d’un slot pendant son exécution est une situation que je rencontre fréquemment en pratique et malheureusement certaines bibliothèques ne le supportent pas.

Activation de signaux

Commençons donc par le déclenchement des signaux. Le premier test est le plus évident : est-ce que l’activation d’un signal déclenche l’appel d’une fonction qui lui est connectée ? Toutes les bibliothèques valident ce test.

Deuxième test, l’ordre d’appel correspond-il à celui dans lequel les fonctions ont été connectées ? Seuls cls, cps et nss_ts ne valident pas ce test. Comme expliqué auparavant, il s’agit plus d’un choix de conception plutôt qu’un problème.

Troisième test, si je connecte une fonction pendant l’exécution d’un signal, est-ce qu’elle ne sera pas appelée durant l’activation en cours ? asg, css, jls, lss, mws, nss_tss, sss et vdk ne valident pas ce test. De plus, aco, cls, cps, evl, nes, nss_ts, pss_st, wnk et yas crashent complètement pendant ce test. Là encore la décision d’exécuter ou non la fonction pendant l’activation du signal peut correspondre à un choix de conception. Personnellement je considère qu’une fonction qui n’était pas enregistrée lors de l’émission de l’événement ne devrait pas en être notifiée, mais après tout, ça se discute. Dans tout les cas, il n’y a pas de raison de crasher dans une telle situation.

Quatrième test, si je coupe une connexion pendant l’exécution d’un signal, est-ce que la fonction correspondante ne sera pas appelée dans l’itération en cours ? aco, cps_st, nod, nod_st, nss_st, nss_sts, nss_tss et psg ne passent pas ce test. cls, cps, nss_ts et yas crashent le test. Pour le coup j’ai du mal à imaginer une situation où il est acceptable qu’une fonction soit appelée alors qu’elle a été déconnectée.

Cinquième test, puis-je déclencher un signal pendant qu’il est déclenché ? En d’autres termes, est-ce qu’un signal peut être déclenché récursivement ? Toutes les implémentations passent ce test sauf cls, cps et nss_ts qui crashent.

Enfin, sixième et dernier test, est-ce qu’une fonction enregistrée deux fois dans au même signal est bien appelée deux fois lorsqu’il est déclenché ? La façon dont j’ai implémenté les interfaces vers les bibliothèques ne permet pas d’enregistrer la même fonction deux fois pour celles qui requièrent un héritage pour les slots, par conséquent aco, cps, cps_st, css, jls, nes, nss_st, nss_sts, nss_ts, nss_tss, psg, sss, vdk, wnk et yas ne sont pas testées. Toutes les autres bibliothèques passent le test.

Au final, seuls bs2, bs2_st, dob, ics, ksc, nls, nls_st et pss valident tous les tests de cette catégorie.

Activation avec un paramètre

Le premier test de cette catégorie vérifie que le signal accepte un paramètre et le transmet bien jusqu’au slot. Toutes les implémentations valident ce test.

Le deuxième test s’assure qu’aucune copie du paramètre n’est faite lorsque le slot et le signal prennent le paramètre par adresse. Là encore, toutes les bibliothèques passent le test.

Enfin, le troisième test vérifie que le nombre de copies du paramètre est minimal quand le signal et le slot prennent le paramètre par valeur. En pratique je n’ai jamais trouvé d’implémentation de fonction wrapper qui ne fasse pas au moins une copie quand les paramètres sont par valeur, par conséquent j’ai mis le seuil à une copie maximum pour ce test. Seul lfs valide ce test.

Au final, seul lfs valide tous les tests de cette catégorie.

Gestion des connexions

Là encore on commence par du simplissime, est-ce qu’un signal sans connexion indique bien qu’il n’a pas de connexion ? Évidemment toutes les bibliothèques qui fournissent un moyen de tester si un signal a une connexion ou pas passent ce test, soit bs2, bs2_st, ics, jls, lfs, lss, mws, nes, nod, nod_st, nss_st, nss_sts, nss_ts, nss_tss, psg, vdk, et wnk.

Le deuxième test est son symétrique, est-ce qu’un signal ayant une connexion indique bien qu’il a une connexion ? Les même implémentations valident ce test.

Troisième test, est-ce que la fonction connectée au signal sera appelée au déclenchement de ce dernier si je ne stocke pas l’objet représentant la connexion ? aco, cls, cps, cps_st, css, evl, jls, nss_st, nss_sts, nss_ts, nss_tss et sss ne valident pas ce test. Personnellement je n’ai pas d’avis sur le fait d’imposer ou non de stocker les connexions. D’un côté le fait de les stocker force le programmeur à faire attention à la durée de vie de ses fonctions de rappel, d’un autre côté permettre d’ignorer la connexion réduit le bruit quand il est certain que le signal sera détruit avant les dépendances des fonctions connectées.

Quatrième test, est-ce que la fonction enregistrée ne sera pas appelée si le signal est déclenché après que la connexion soit coupée ? Toutes les bibliothèques valident ce test.

Cinquième et dernier test, est-ce que la fonction ne sera pas appelée si le signal est remis à zéro ? Pour rappel certaines implémentations ne fournissent pas de méthode pour remettre le signal à zéro, et pour certaines d’entre elles il est possible d’affecter à une instance existante un signal fraîchement créé. Pour ce test, css et lss ne permettent aucunement de remettre le signal à zéro. cps, cps_st, dob et mws ne valident pas le test, tandis que sss crashe tout simplement. Les autres bibliothèques valident le test.

Au final, seuls bs2, bs2_st, ics, lfs, nes, nod, nod_st, psg, vdk et wnk valident tous les tests de cette catégorie.

Échange

L’échange de signaux est une fonctionnalité que j’utilise pour avoir des signaux qui déconnectent automatiquement les fonctions de rappel lors du déclenchement. Dans ce cas, lorsque je déclenche le signal je procède en 3 étapes :

  1. création d’un signal temporaire,
  2. échange du signal temporaire avec l’instance active,
  3. déclenchement de l’instance temporaire.

Ainsi à l’issue de la troisième étape toutes les fonctions de rappel sont déconnectées. Éventuellement de nouvelles connexions peuvent être faites pendant le déclenchement, auquel cas elles seront activées à l’itération suivante.

Plusieurs bibliothèques ne permettent aucunement d’échanger deux instances de signaux. css, lss, mws, nod, nod_st, nss_st, nss_sts, nss_ts, nss_tss et vdk sont dans ce cas. Par conséquent elles ne valident pas les tests ci-dessous.

Le premier test effectue un échange de deux signaux vides puis vérifie qu’ils sont vides. Toutes les implémentations passent ce test sauf cls qui crashe.

Le deuxième et le troisième test échangent un signal vide avec un signal ayant respectivement une et deux connexions, puis il les déclenche. cps, cps_st, dob, nls et nls_st ne valident pas ce test. cls et sss plantent.

Le quatrième test échange un signal ayant une connexion avec un autre signal ayant une connexion, puis il les déclenche. cls, cps, cps_st, dob, nls et nls_st ne passent pas ce test. jls et sss crashent.

Le cinquième test échange un signal ayant une connexion avec un signal ayant deux connexions et le sixième test échange un signal ayant deux connexions avec un signal ayant deux connexions. Puis les signaux sont déclenchés. Les résultats sont les mêmes que pour le quatrième test.

Le septième test échange un signal avec un autre pendant le déclenchement du premier. Seuls bs2, bs2_st, evl, ics, ksc, lfs, pss, pss_st et yas valident ce test. cls, cps, jos, psg, sss et wnk crashent dans ce cas.

Enfin le dernier test vérifie que les connexions de signaux échangés sont bien associées à l’instance ayant reçu le signal qui les a créées. cls, cps, cps_st, dob, evl, lfs, nls et nls_st ne passent pas ce test. jls et sss crashent.

Au final, seuls bs2, bs2_st, ics, ksc, pss, pss_st et yas valident tous les tests de cette catégorie.

On fait le bilan

Lorsque j’ai commencé à migrer les tests d’iscool::signals pour les appliquer aux autres bibliothèques je me doutais que le résultat ressemblerait plus à une validation de conformité à Boost.Signals2 plutôt qu’à un ensemble de caractéristiques attendues de toute bibliothèque de ce genre. Néanmoins je suis surpris de la disparités des résultats.

Certaines propriétés me semblent facultatives, comme l’ordre d’appel des fonctions par le signal par exemple, mais d’autres me semblent essentielles, comme l’ajout et la suppression de connexions à un signal pendant le déclenchement de celui-ci. Je ne compte plus le nombre de cas dans nos jeux où la première chose que fait une fonction de rappel est de couper la connexion au signal qui l’a appelée. De même pour l’échange de signaux, qui est une fonctionnalité que nous avons utilisé à plusieurs reprises.

Enfin il y a les crashs. Après tout, que le signal ne supporte pas l’échange ou une autre fonctionnalité, pourquoi pas, mais dans ce cas il faut que l’implémentation ne permette pas de le faire. Et là je crois que c’est une des difficultés du C++, il suffit d’un peu d’inexpérience ou d’une légère inattention et on se retrouve avec des cas qui font planter le programme. Bravo aux auteurs qui ont pris soin d’interdire l’échange quand leur implémentation ne le permettait pas.

Pour en revenir aux performances d’iscool::signals par rapport aux autres, puisque c’était le problème à l’origine de tout cela, il s’avère finalement qu’à fonctionnalités équivalentes c’est l’implémentation la plus efficace. Hourra ! D’ailleurs jusqu’à ce que j’ajoute le test sur le nombre de copies des paramètres par valeur, c’était la seule implémentation qui validait tous les tests, avec celles de Boost.Signals2. Quelle idée d’ajouter ce test alors que j’arrivais au bout de cet article… Allez bon, ça me fera un autre élément à améliorer.

Pour finir, si toi aussi tu as codé un système signal-slot, je t’encourage à l’intégrer au benchmark et aux tests. C’est à la fois enrichissant et très instructif, et ça te permettra de te situer par rapport à l’existant.