Le programme ci-dessous met en relief un exemple d'échange de données synchronisé entre deux threads à l'aide d'une zone de transit sans synchronisation. Nous représenterons ici cette zone de transit par un double tampon (ou -uplet, spécialisation d'un tampon -uplet) tel que pendant qu'un thread remplira l'un des tampons, l'autre tampon sera vidé par l'autre thread.
Ce schème quelque peu périlleux peut fonctionner si nous prenons soin d'offrir quelques garanties de cohérence séquentielle, sous la forme de variables atomiques, et si notre thread vidant les tampons (ici, le thread scripteur()) est toujours plus rapide que le thread remplissant les tampons (ici, le thread lecteur()). Des ralentissements occasionnels peuvent être compensés par l'ajout de tampons ou par l'accroissement de la taille des tampons (ces deux techniques sont équivalentes pour nos fins), du moins si le pire cas de ralentissement possible est connu a priori.
La liste des en-têtes auxquels nous aurons recours ici est indicative du fait que nous utiliserons les outils de C++ 11. En effet, tous nos en-têtes sont standards, et nous serons en mesure d'accomplir notre tâche en totalité sans avoir recours à des outils propres à une plateforme ou l'autre. D'ailleurs, cette tâche sera, de manière concurrente :
Procéder sans synchronisation accélère l'exécution, mais introduit des pièges et rend l'écriture beaucoup plus subtile. Dans la mesure de possible, préférez un équivalent synchronisé au code présenté ici. |
|
La zone de transit que nous utiliserons sera générique sur la base :
Dans notre cas, puisque nous consommerons du texte, le type sera char. La classe ntuple_buffer<T,N,M> sera le cas général d'un tampon -uplet dont les éléments sont de type T et dont chaque tampon a une capacité de M éléments, mais en pratique notre code se limitera à des double_buffer<T,M>, équivalents directs du type ntuple_buffer<T,2,M>. Notez que j'ai choisi de fixer la capaciuté de chaque tampon à bufsize (4096 pour le moment), mais ce choix est purement arbitraire. |
|
Le thread qui remplira la zone de transit sera représenté par la fonction lecteur(), alors que le thread qui videra cette zone sera représenté par la fonction scripteur(). Remarquez les paramètres qualifiés volatile, en particulier le booléen fini qui, de plus, est qualifié atomic. Nous y reviendrons. |
|
La fonction append_newline() est dispendieuse avec C++ 03 mais l'est beaucoup moins avec C++ 11, dû à la sémantique de mouvement, qui s'applique ici à la variable anonyme résultant de la concaténation de s avec "\n". |
|
La définition d'un tampon -uplet reposera sur les états suivants :
J'aurais pu utiliser des tableaux bruts, mais une instance de la classe array offre les mêmes seuils de performance, occupe le même espace en mémoire, et offre des services supplémentaires. L'implémentation proposée ici repose sur les outils de C++ 11, et expose une interface volatile vouée aux situations multiprogrammées de même qu'une interface non-volatile axée sur la vitesse. Dans ce cas, en l'absence de tout mécanisme de synchronisation, les deux interfaces joueront le même rôle (on pourrait dire que la marque volatile est décorative, sans plus). La chose à ne pas faire est d'offrir un service permettant d'ajouter un seul T à la fois : le coût de la synchronisation serait alors trop gros en proportion du coût du traitement qu'il protège., Il est toujours possible d'ajouter un objet à la fois (p. ex. : zt.ajouter(&obj, &obj+1);) mais il ne faut pas encourager des comportements inefficaces en les rendant faciles d'accès. La clé de cette classe est le fait que write_buf est atomique. Pour cette raison, le compilateur et le processeur ne sont pas autorisés à déplacer les moments où les accès en lecture et en écriture à cette donnée sont faits dans le programme. Le caractère inamovible des accès à cette donnée s'avère essentiel, du fait que modifier sa valeur implique signaler à un autre thread du droit d'accès. |
|
Reste maintenant à examiner le code de test, et à voir comment les threads qui se partageront la zone de transit sont construits. Nous examinerons d'abord le code de test en tant que tel, qui se déclinera en trois temps :
La fonction de test minuter(f,ntests) accepte une opération nullaire f de type F et l'appelle ntests fois, cumulant la somme des temps écoulés et retournant cette somme. C'est une approche simpliste et éminemment perfectible, mais elle suffira pour nos fins. |
|
La fonction nb_lignes() consomme tous les bytes d'un fichier nommé fich à l'aide d'un istreambuf_iterator et compte le nombre d'occurrences de bytes représentant un saut de ligne. Il est présumé ici que fich soit un fichier texte, bien que ce fichier soit ouvert en mode binaire pour que tous les bytes y soient traités équitablement. |
|
Le programme principal lance les tests en procédant comme suit :
À noter :
Les threads de ce programme sont très simples :
|
|
Un résultat possible de l'exécution de ce programme de test serait :
Temps ecoule: 327 ms. pour 10 tests sur un fichier de 100001 lignes
moyenne: 32.7 ms. par test
En espérant que le tout vous soit utile...