Quelques raccourcis :
Ce qui suit détaille un exemple complet et opérationnel de programme implémentant un (en fait, plusieurs, mais de manière générique) duo concurrent comprenant un producteur et un consommateur, transigeant à travers une file d'attente sans verrous. Nous verrons trois implémentations :
Dans les trois cas, l'exposé d'origine est de Herb Sutter lui-même. Le code proposé ici n'est qu'une adaptation maison de son propre discours. Je vous invite à lire les articles originaux pour enrichir l'éclairage que vous pourrez en tirer.
Quelques outils généraux apparaîtront ci-dessous. Les voici.
Pour des raisons algorithmiques, nous aurons recours à une fonction éliminant les blancs aux extrémités d'une chaîne de caractères. Plusieurs langages offrent une telle fonction de manière standard, mais pas C++ (à ma connaissance), donc en voici une « maison ». Nous utiliserons pour ce faire la fonction visible à droite, mais vous pouvez en implémenter une par vous-mêmes si vous le souhaitez (c'est amusant à coder). |
|
Sous Microsoft Windows, l'implémentation de get_cache_line_size() que j'ai utilisé est présentée à droite. Il est probable que cette approche soit perfectible (pour moi, c'était du code un peu jetable), mais si ça peut vous rendre service... |
|
Nous monterons tout d'abord le programme utilisant les files sans verrous, dans le but de clarifier la démarche qui sera suivie par la suite et de faciliter la lecture du reste du code.
Tout d'abord, notons que pour les fins de l'exemple, nous implémenterons les trois cas annoncés en introduction dans un même fichier d'en-tête, lock_free_queue.h, dans des espaces nommés distincts (version_0, version_1 et version_2 respectivement). Le programme passera d'une implémentation à l'autre par une simple directive using. Le nom utilisé pour isoler les diverses versions de file sans verrou sera file_test<T>::type dans l'un ou l'autre de ces espaces nommés (les noms des véritables classes varieront selon les implémentations). Si votre compilateur n'est pas tout à fait à jour, vous pouvez remplacer dans le code chaque occurrence de
...par...
|
|
Les threads producteur et consommateur manipuleront chacun deux entités, soit une file synchronisée sans verrous et un booléen permettant de signaler la fin des opérations. Remarquez au passage :
Les threads producteur et consommateur seront génériques (oui, c'est légal), pour que le code client puisse les générer sur la base des types à transiger. Puisque nous avons accès aux threads standards de C++ 11, le code proposé ici sera relativement simple. En effet :
Dans cet exemple, il sera nécessaire que le type T expose un constructeur par défaut et qu'il soit possible d'en sérialiser une instance sur un flux en sortie. |
|
L'un des générateurs de valeurs utilisés dans le thread producteur sera un foncteur, plus précisément une instance du type decoupeur proposé à droite, qui générera mot à mot et de manière cyclique le contenu d'un message significatif. Notez que son attribut texte ne terminera jamais par un blanc (il s'agit d'un invariant qu'assure le constructeur de cette classe), ce qui permet d'alléger le code de son opérateur (). Je n'ai pas utilisé une expression λ ici (ça aurait été lourd, il me semble). Le lien entre cur et texte est une sorte d'invariant silencieux. C'est la raison pour laquelle j'ai représenté cur par un indice plutôt que par un itérateur : si cur était un itérateur, dans le cas où un decoupeur serait copié, sa copie serait un itérateur sur la string originale, pas sur la copie... Nous aurions alors un sérieux problème sur les bras, qui pourrait rester silencieux selon le contexte d'utilisation. Utiliser un indice, puis recalculer les itérateurs correspondants au besoin, est ici un choix plus sain. |
|
Enfin, le code de test est proposé à droite. On y remarquera une fonction tester() générique sur la base du type à transiger et du type de générateur utilisé par le thread producteur, de même qu'un programme principal relativement simple. La fonction tester() crée une file sans verrous correctement typée et un signal de fin atomique, lance deux threads qui opéreront sur ces objets (et, dans le cas du producteur, sur l'objet gen), puis lit une touche et assure la bonne terminaison des threads. Remarquez la ligne en commentaires, qui permet d'afficher à la console la taille d'une Cache Line pour la cache L1 sur un ordinateur donné avec la fonction get_cache_line_size(). Cette information servira dans l'une des implémentations de file sans verrou (plus bas), mais ne peut pas (à ma connaissance) être obtenue de manière statique, donc à la compilation. Pour l'obtenir, j'ai exécuté mon programme une 1re fois, affiché cette valeur, puis utilisé cete information pour apposer une valeur à une constante dans l'implémentation pour laquelle elle s'avérait opportune. Dans le programme principal, la génération d'entiers passe par une λ. Notez le mot-clé mutable appliqué à la λ, d'ailleurs :
|
|
Abordons maintenant les ébauches de files sans verrous, en commençant par une implémentation qui est presque correcte, mais comprend une condition de course sournoise. Nous ferons ensuite une digression par une brève discussion de l'atomicité, et nous reviendrons vers deux implémentations correctes de l'idée de file sans verrous.
La première implémentation examinée ici est due à Petru Marginean (article original : http://www.drdobbs.com/high-performance-computing/208801974). Elle semble fonctionner mais en fait, elle comprend des conditions de course (pour une critique de Sutter en ce sens : http://www.drdobbs.com/cpp/210600279). Ce qui est sournois avec de telles conditions de course est qu'elles tendent à ne causer de problèmes que de manière occasionnelle, et sont donc très difficiles à dépister et à régler.
Cette première implémentation supporte un seul producteur et un seul consommateur. J'ai conservé le nom de classe utilisé dans l'article original mais je me suis permis quelques ajustements pour les fins de la présentation.
La proposition de Marginean repose sur une encapsulation d'une liste doublement chaînée standard, std::list, tirée directement de STL. Elle tient à jour des marqueurs sur la tête et sur la queue de cette liste; ces marqueurs sont importants du fait qu'ils isolent le substrat sous-jacent (std::list) de la logique de la liste sans verrous elle-même. Modifier tete signifiera publier le retrait d'un élément de la liste (sa consommation), alors que modifier queue signifiera y publier l'insertion d'un élément. Puisque tete ne sera pas nécessairement égal à liste.begin(), les éléments dans l'intervalle (liste.begin()..tete( devront être nettoyés au moment opportun, dans l'optique d'éviter de polluer la mémoire toute entière. Les méthodes clés de chacune des trois files sans verrous que nous explorerons seront :
|
|
Ici, le constructeur par défaut insère initialement un élément bidon dans la file, pour marquer une distance entre le début (queue) et la fin (tete) de la file. Un T par défaut est utilisé à cette fin ici, ce qui impose une contrainte sur le type T utilisé dans cete implémentation. Cette démarcation ente le début et la fin est une clé de l'approche mise de l'avant ici : si cet invariant est maintenu, alors le consommateur (unique) et le producteur (aussi unique) ne toucheront jamais au même noeud. L'insertion d'un élément se fait en fin de liste (liste.push_back()), mais n'est publié (modification à queue) qu'une fois l'insertion réussie. Au sens du consommateur, tant que la modification à queue n'a pas pris effet, l'élément n'est pas encore réellement inséré dans la file. Marginean préconise de saisir cette opportunité pour faire du nettoyage des éléments inutilisés : invoquer erase() sur l'intervalle (begin(liste)..tete( en utilisant bien sûr une copie de ces deux itérateurs permet d'y arriver. L'extraction d'un élément tient d'abord compte du fait qu'il y a une démarcation d'un noeud entre la tete et la queue : si queue est juste après tete, alors la file est considérée vide. Par la suite, tete_ est déplacé, l'élément est saisi (T::operator=(const T&) doit être défini) et un signal de réussite est retourné au code client. |
|
Pour les fins des tests présentés ici, même si les diverses files sans verrous que nous utiliserons porteront des noms distincts, nous regrouperons ces noms sous une seule et même dénomination. Ainsi, dans chaque espace nommé, le type file_test<T> correspondra au type de file sans verrou du même espace. |
|
Quels sont les problèmes de l'implémentation de Marginean? Ici encore, Sutter nous éclaire.
Le principal problème et que les attributs tete_ et queue_ sont sujets à des conditions de course. Il faudrait que les opérations sur ces attributs soient à la fois atomiques et ordonnancées, au sens du mot clé volatile dans son acception Java ou C#, mais ils ne sont ni l'un, ni l'autre. Une entité sur laquelle les opérations sont atomiques doit typiquement être au plus aussi grande qu'un mot mémoire. En effet, l'atomicité (surtout sur une plateforme munie de plusieurs processeurs ou de plusieurs coeurs) exige un support du substrat matériel (du code machine) pour permettre des lectures et des écritures atomiques par un processeur.
À propos de l'atomicité, un article du Code Project (oui, je sais, c'est pas toujours très rigoureux, mais certains sont pas si mal) sur les approches sans verrous, disponible à l'adresse http://www.codeproject.com/KB/threads/LockFree.aspx, décrit une opération atomique comme étant « an operation that is guaranteed to finish and not be interrupted half-way by another thread ». L'article poursuit :
« On almost all modern processors, reads and writes of naturally aligned native types are atomic. This means that as long as the memory bus is at least as wide as the type being read or written the CPU reads and writes will happen in a single bus transaction. This makes it impossible for other threads to see them half-completed. For x86 and x64 platforms, types larger than 8 bytes are not guaranteed to be atomic. »
Sur un entier, l'affectation d'un littéral (int i = 3;) sera typiquement atomique mais une opération d'autoincrémentation (i++) pourra prendre jusqu'à trois opérations machines, et ne le sera donc pas.
Pour ce qui est des risques de réordonnancement, examinons quelques lignes de la méthode produce() (à droite). Les instructions A et B peuvent sembler indépendantes l'une de l'autre (car liste.end() n'est pas le dernier noeud valide de liste mais bien ce qui suit ce noeud); elles risquent donc d'être réordonnancées par un processeur, ce qui peut poser un risque du fait que l'écriture sur queue est une publication d'état. |
|
Dans le même ordre d'idées, examinons quelques lignes de la méthode consume() (à droite). Cet extrait pose deux risques :
|
|
À propos du réordonnancement, nous devons être prudents face aux réorganisations du code susceptibles d'être réalisées soit par le compilateur, soit par le processeur.
Les réorganisations réalisées par un compilateur peuvent être en partie prévenues par le recours à la qualification volatile. Le standard ISO de 1998 (qui n'est pas le plus détaillé en ce qui a trait à la multiprogrammation, c'est le moins qu'on puisse dire) définit le comportement d'un programme C++ en termes du comportement attendu d'une machine abstraite, indiquant entre autres :
« The observable behavior of the abstract machine is its sequence of reads and writes to volatile data and calls to library functions » (§1.9, 6) et « Accessing an object designated by a volatile lvalue, modifying an object, calling a library function, or calling a function that does any of these operations are all side effects, which are changes in the state of the execution environment. Evaluation of an expression might produce side effects. At certain specified points in the execution sequence called sequence points, all side effects of previous evaluations shall be complete and no side effects of subsequent evaluations shall have taken place » (§1.9, 7).
Autre problème : le compilateur peut appliquer des transformations brisant notre logique. Par exemple, les deux cas proposés à droite sont possibles :
Clairement, bien que cette première implémentation fonctionne dans bien des cas, elle est à risque, et nous ne pouvons pas nous arrêter ici. |
|
|
Les réordonnancements réalisés par les processeurs varient selon les technologies. Un article de 2007, somme toute détaillé sur le sujet et expliquant comment Linux s'en sort, peut être consulté sur http://www.rdrop.com/users/paulmck/scalability/paper/ordering.2007.09.19a.pdf. Heureusement, C++ 11 encadre les optimisations possibles sur les variables atomiques, dans l'optique de garder le code généré séquentiellement cohérent; les variables atomiques nous permettent donc de garder une certaine capacité de raisonner à partir du code source, malgré tout.
Cette deuxième implémentation est due à Herb Sutter (article original : http://www.drdobbs.com/cpp/211601363). Elle repose sur des variables atomiques servant de verrous booléens pour les extrémités de la file. Cette version supporte un plusieurs consommateurs et plusieurs producteurs.
Le recours à diverses variables atomiques dans la structure même de la file sans verrous nous demande de nous assurer (sur Microsoft Windows, du moins) que chacune d'elles se trouve alignée sur la frontière du début d'une Cache Line. Avec un compilateur C++ 11 le supportant, nous aurions aussi pu utiliser alignas() pour en arriver au même résultat mais de manière plus élégante. La taille de la Cache Line sur l'ordinateur sur lequel ce code a été écrit est de 64 bytes, valeur obtenue par un appel à la fonction get_cache_line_size(). Je ne connais pas d'approche de programmation statique pour obtenir ces informations au moment d'écrire ceci. Cette approche construit manuellement une file, pour éviter le problème de la non-atomicité des itérateurs de std::list. Remarquez que tous les noeuds sont de la taille de la Cache Line (dû au padding qui se trouve inséré dans chacun d'eux), et remarquez que chaque attribut de la file est aussi aligné sur cette frontière. Ici, deux simili verrous contrôlent les accès aux extrémités (consumerLock, un booléen atomique partagé entre les consommateurs, et producerLock, un booléen atomique partagé entre les consommateurs). Les noeuds ne sont pas atomiques, mais les booléens qui contrôlent l'accès aux extrémités de la file, eux, le sont. |
|
Le constructeur par défaut, à l'image de celui dans l'approche 0 plus haut, insère un noeud séparateur qulque peu bidon (ici, un noeud contenant un pointeur nul), et initialise les deux « verrous » à false. Le destructeur, quant à lui, détruit à la fois les noeuds et ce vers quoi ils pointent. À titre de rappel, appliquer l'opérateur delete sur un pointeur nul est sans effet, donc même le noeud séparateur (qui contient un pointeur nul) peut être détruit sans problème de cette manière. |
|
L'insertion d'un nouvel élément dans la file attend d'abord l'obtention de l'exclusivité d'accès sur producerLock (un Spin Lock, semblable à ce qu'on trouve dans une section critique de Windows) puis insère le nouveau noeud et publie l'insertion une fois celle-ci complétée. Notez qu'en cas de levée d'exceptions sur new Node, ce code n'est pas sécuritaire (le new T{val} qui y serait exécuté en premier lieu mènerait à une fuite de ressources). L'extraction d'un élément de la file attend d'abord l'obtention de l'exclusivité d'accès sur consumerLock (un autre Spin Lock), puis fait l'essentiel de ses opérations sur des variable temporaires (zeFirst et zeNext). La publication de nouveaux états (first = zeNext) n'est faite qu'une fois les opérations sur la file complétées. La méthode exchange() d'une variable atomique décrit une opération « lire-modifier-écrire » qui retourne l'ancienne valeur de la variable ainsi modifiée. Ainsi, si une variable lock est de type atomic<bool>, alors l'expression suivante :
signifie « tant que j'essaie d'écrire true dans lock et qu'il s'avère que lock était déjà true auparavant » (car si lock était true avant la modification atomique, alors le verrou que cette variable représente n'était pas disponible à ce moment). |
|
Cette troisième implémentation est aussi due à Herb Sutter (article original : http://www.drdobbs.com/high-performance-computing/210604448). Tout comme la première implémentation, elle évite les verrous booléens sur les extrémités, mais les remplace ici par des pointeurs atomiques.
Cette version est beaucoup plus simple et beaucoup plus courte que la précédente. En fait, elle s'apparente surtout à la version 0, à ceci près qu'elle n'a pas recours à std::list comme substrat mais bien à une implémentation maison, à base de pointeurs (à plus forte partie, de pointeurs atomiques). |
|
Le constructeur par défaut insère un noeud bidon dès le début, comme dans les deux autres approches. Ici, puisque les noeuds contiennent des valeurs (des T) plutôt que des indirections (des T*), le noeud séparateur contiendra un T par défaut (un T{}), ce qui impose une contrainte sur le type T. Le destructeur détruit tous les noeuds, incluant bien sûr le noeud bidon. |
|
L'approche globale respecte celle mise de l'avant pour l'approche 0. Une phase de nettoyage des éléments inutilisés est réalisée suite à la production d'une nouvelle valeur (ce qui ressemble à l'approche suivie par la plupart des moteurs de collecte d'ordures, qui réclament la mémoire inutilisée seulement lors d'une nouvelle allocation dynamique de mémoire). La clé est le recours à des noeuds atomiques (en fait, à des pointeurs atomiques sur des noeuds) pour le séparateur et pour le point de consommation, adjointe à un moment choisi avec soin pour la publication des changements apportés à la structure (ici, au moment de modification de divider, qui suit la consommation d'un élément). Notez qu'un pointeur atomique n'offre pas l'opérateur ->, ce qui explique par exemple le recours à l'écriture (*last).next plutôt qu'à last->next. Les appels à load() sur les atomiques lors de la comparaison sont nécessaires. Un load() sur une atomique est une opération en lecture retournant une copie de la valeur de l'atomique au moment de l'appel. En effet, on ne peut réaliser une comparaison avec == ou != qui soit atomique puisque cette opération est binaire (elle implique deux opérandes) alors que l'atomicité ne s'opère que sur une donnée à la fois. Évidemment, il est possible que les valeurs de l'une ou de l'autre des atomiques ait changé entre le moment du plus récent load() et celui du test avec != alors il importe que l'algorithme n'en souffre pas. |
|
Voilà.
Quelques liens pour enrichir le propos :