Tout d'abord, vous devez apprendre à penser comme un avocat spécialisé en langues.
La spécification C ++ ne fait référence à aucun compilateur, système d'exploitation ou CPU particulier. Il fait référence à une machine abstraite qui est une généralisation de systèmes réels. Dans le monde du Language Lawyer, le travail du programmeur est d'écrire du code pour la machine abstraite; le travail du compilateur est d'actualiser ce code sur une machine à béton. En codant de manière rigide selon les spécifications, vous pouvez être certain que votre code se compilera et s'exécutera sans modification sur n'importe quel système doté d'un compilateur C ++ conforme, que ce soit aujourd'hui ou dans 50 ans.
La machine abstraite de la spécification C ++ 98 / C ++ 03 est fondamentalement monothread. Il n'est donc pas possible d'écrire du code C ++ multithread "entièrement portable" par rapport à la spécification. La spécification ne dit même rien sur l' atomicité des charges et des magasins de mémoire ou sur l' ordre dans lequel les charges et les magasins peuvent se produire, sans parler des mutex.
Bien sûr, vous pouvez écrire du code multi-thread dans la pratique pour des systèmes concrets particuliers - comme pthreads ou Windows. Mais il n'existe aucun moyen standard d'écrire du code multithread pour C ++ 98 / C ++ 03.
La machine abstraite en C ++ 11 est multithread par conception. Il a également un modèle de mémoire bien défini ; c'est-à-dire qu'il indique ce que le compilateur peut et ne peut pas faire lorsqu'il s'agit d'accéder à la mémoire.
Prenons l'exemple suivant, où une paire de variables globales est accessible simultanément par deux threads:
Global
int x, y;
Thread 1 Thread 2
x = 17; cout << y << " ";
y = 37; cout << x << endl;
Que pourrait produire la sortie de Thread 2?
Sous C ++ 98 / C ++ 03, ce n'est même pas un comportement indéfini; la question elle-même n'a pas de sens car la norme n'envisage rien de ce qu'on appelle un «fil».
Sous C ++ 11, le résultat est un comportement indéfini, car les chargements et les magasins n'ont pas besoin d'être atomiques en général. Ce qui peut ne pas sembler être une grande amélioration ... Et en soi, ce n'est pas le cas.
Mais avec C ++ 11, vous pouvez écrire ceci:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17); cout << y.load() << " ";
y.store(37); cout << x.load() << endl;
Maintenant, les choses deviennent beaucoup plus intéressantes. Tout d'abord, le comportement est ici défini . Le thread 2 peut désormais s'imprimer 0 0
(s'il s'exécute avant le thread 1), 37 17
(s'il s'exécute après le thread 1) ou 0 17
(s'il s'exécute après que le thread 1 a été affecté à x mais avant qu'il soit affecté à y).
Ce qu'il ne peut pas imprimer 37 0
, c'est parce que le mode par défaut pour les chargements / magasins atomiques en C ++ 11 est d'appliquer la cohérence séquentielle . Cela signifie simplement que toutes les charges et tous les magasins doivent être "comme si" ils se sont produits dans l'ordre dans lequel vous les avez écrits dans chaque thread, tandis que les opérations entre les threads peuvent être entrelacées comme le système le souhaite. Ainsi, le comportement par défaut de l'atomique fournit à la fois l' atomicité et l' ordre des charges et des magasins.
Désormais, sur un processeur moderne, assurer la cohérence séquentielle peut être coûteux. En particulier, le compilateur est susceptible d'émettre des barrières de mémoire à part entière entre chaque accès ici. Mais si votre algorithme peut tolérer des chargements et des magasins hors service; c'est-à-dire si elle nécessite une atomicité mais pas de commande; c'est-à-dire, s'il peut tolérer 37 0
comme sortie de ce programme, alors vous pouvez écrire ceci:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17,memory_order_relaxed); cout << y.load(memory_order_relaxed) << " ";
y.store(37,memory_order_relaxed); cout << x.load(memory_order_relaxed) << endl;
Plus le processeur est moderne, plus il est probable qu'il soit plus rapide que l'exemple précédent.
Enfin, si vous avez juste besoin de garder des charges et des magasins particuliers dans l'ordre, vous pouvez écrire:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17,memory_order_release); cout << y.load(memory_order_acquire) << " ";
y.store(37,memory_order_release); cout << x.load(memory_order_acquire) << endl;
Cela nous ramène aux charges et aux magasins commandés - ce 37 0
n'est donc plus une sortie possible - mais cela le fait avec un minimum de frais généraux. (Dans cet exemple trivial, le résultat est le même que la cohérence séquentielle à part entière; dans un programme plus grand, il ne le serait pas.)
Bien sûr, si les seules sorties que vous souhaitez voir sont 0 0
ou 37 17
, vous pouvez simplement enrouler un mutex autour du code d'origine. Mais si vous avez lu jusqu'ici, je parie que vous savez déjà comment cela fonctionne, et cette réponse est déjà plus longue que je ne le pensais :-).
Donc, en bout de ligne. Les mutex sont excellents et C ++ 11 les standardise. Mais parfois, pour des raisons de performances, vous souhaitez des primitives de niveau inférieur (par exemple, le schéma de verrouillage classique à double vérification ). Le nouveau standard fournit des gadgets de haut niveau comme les mutex et les variables de condition, et il fournit également des gadgets de bas niveau comme les types atomiques et les différentes saveurs de la barrière de mémoire. Vous pouvez donc maintenant écrire des routines simultanées sophistiquées et hautes performances entièrement dans le langage spécifié par la norme, et vous pouvez être certain que votre code se compilera et s'exécutera inchangé sur les systèmes d'aujourd'hui et de demain.
Bien que pour être franc, à moins que vous ne soyez un expert et que vous travailliez sur un code de bas niveau sérieux, vous devriez probablement vous en tenir aux mutex et aux variables de condition. Voilà ce que je compte faire.
Pour en savoir plus sur ce sujet, consultez cet article de blog .