Réunion des comités de normalisation de C et C++

du 19 au 29 octobre

a marqué ce sujet comme résolu.

A mon avis, la question des threads est essentielle à traiter dans le langage pour une raison très simple : la définition du modèle mémoire. Sans celui-ci, on ne peut pas faire de raisonnement sur le programme, pour de la preuve ou autre.

Praetonus

À mes yeux, la question des fils d'exécution est extérieure au langage. Foncièrement, il s'agit de questions relatives aux intéractions entre deux programmes, elles relèvent donc à mon sens du système d'exploitation et non d'un quelconque langage. C'est au système d'exploitation de préciser comment deux ou plusieurs programmes communiquent et intéragissent entre eux.

PS : Concernant volatile et le multithread :

Note that volatile variables are not suitable for communication between threads; they do not offer atomicity, synchronization, or memory ordering. A read from a volatile variable that is modified by another thread without synchronization or concurrent modification from two unsynchronized threads is undefined behavior due to a data race.

La doc

Praetonus

L'objectif du qualificateur volatile n'est pas d'assurer la synchronisation entre deux fils d'exécution ou l'atomicité d'une opération, ce sont les fonctions de la bibliothèque Pthread qui s'en chargent, mais bien de signaler au compilateur de ne pas optimiser l'accès aux objets qualifiés comme tel, leur valeur pouvant être modifiée via un autre fil.

+0 -0

Je pense que tu prends le problème dans le mauvais sens. Quand on demandait au standard C99 ou C++03 comment se comportait un programme en multithread, il répondait « multithread ? C'est quoi cette bestiole ? ». On ne peut pas construire de raisonnement à partir d'une non-spécification. A contrario, avec l'apparition du modèle mémoire, on peut faire ces raisonnements, que le programme utilise l'interface standard, pthreads ou l'api windows (ces dernières étant d'ailleurs utilisées sous le capot par la lib standard). La question de l'interface est finalement assez secondaire.

+1 -0

Je pense que tu prends le problème dans le mauvais sens. Quand on demandait au standard C99 ou C++03 comment se comportait un programme en multithread, il répondait « multithread ? C'est quoi cette bestiole ? ».

Praetonus

Et c'est ce qui me paraît logique. De la même manière, le standard d'un langage n'a pas à définir ce qu'est un processus, un socket ou un tube. C'est à l'interface qui propose ces services de les définir ainsi que leur comportement.

On ne peut pas construire de raisonnement à partir d'une non-spécification.

Praetonus

Il n'y a pas de « non-spécification », elle est simplement laissée à la discrétion des bibliothèques système. Après, le raisonnement dépend effectivement de chaque bibliothèque, mais cela me semble sensé dans le cas des fils d'exécution étant donné leur proximité avec le système d'exploitation.

+0 -0

La différence entre les threads et les sockets ou les tubes, c'est que ces derniers représentent un canal vers le monde extérieur. Là je suis d'accord, la spécification du langage n'a rien à voir là dedans. En revanche, les threads opèrent sur un seul programme et accèdent au même espace mémoire. Les threads sont donc des composants à part entière du programme, et c'est le rôle du langage de définir le comportement de ces éléments.

Encore une fois, il ne s'agit pas que d'interfaces, mais aussi de comportement du programme au niveau des instructions. Quelque chose comme les memory orders ne peut pas être implémenté par une bibliothèque et demande une spécification par le langage.

+3 -0

L'objectif du qualificateur volatile n'est pas d'assurer la synchronisation entre deux fils d'exécution ou l'atomicité d'une opération, (1) ce sont les fonctions de la bibliothèque Pthread qui s'en chargent, mais bien de signaler au compilateur de (2) ne pas optimiser l'accès aux objets qualifiés comme tel, leur valeur pouvant être modifiée via un autre fil.

Taurre

(1) Et quelle fonction de posix permet de spécifier qu'un load/store est atomique ? Quelle fonction nous donne le CAS ? Qu'est ce qui nous donne les ordres autorisés pour les instructions ?

(2) C'est 100 fois trop restrictif pour avoir un compilateur optimisant. Si on prend un bout de code comme ça :

1
2
3
4
5
6
7
                            int a;
                            int b = 25;
                            std::atomic<int> l(0);

a = 42;                           | while(! l.load(memory_order_acquire));
l.store(1, memory_order_release); | int x = a;
b = 74;                           | int y = b;

On est bien sûr que la lecture de a nous donnera 42, pour b on n'en sait rien. Et c'est précisément le but. Laisser au compilateur le droit de réordonner :

1
2
3
a = 42;
b = 74; 
l.store(1, memory_order_release);

Pour le premier thread s'il veut, mais pas :

1
2
3
l.store(1, memory_order_release);
a = 42;
b = 74; 

Et si l était un volatile, on casserait ce genre d'optimisation possibles et on serait même pas sûrs d'avoir le bon résultat.

Et tout ça c'est précisément le modèle mémoire qui nous donne toutes les informations nécessaires.

La différence entre les threads et les sockets ou les tubes, c'est que ces derniers représentent un canal vers le monde extérieur. Là je suis d'accord, la spécification du langage n'a rien à voir là dedans. En revanche, les threads opèrent sur un seul programme et accèdent au même espace mémoire. Les threads sont donc des composants à part entière du programme, et c'est le rôle du langage de définir le comportement de ces éléments.

Praetonus

Ma comparaison était sans doute effectivement mauvaise. Toutefois, je ne peux considérer les fils d'exécution comme faisant partie du programme, on est déjà au-delà. Techniquement, un fil d'exécution n'est rien d'autre qu'une copie d'un premier programme, mais qui partage d'avantage de données qu'un second processus (d'où la traduction parfois utilisée de « processus léger »). Or, s'agissant d'un phénomène externe au programme, ce n'est à mon sens pas au langage de s'assurer des possibles intéraction entre le programme et une copie de lui-même.

À ce sujet, si ma mémoire est bonne, le noyau Linux a recouru pendant tout un temps à l'appel système clone() pour mettre en œuvre les fils d'exécution.

L'objectif du qualificateur volatile n'est pas d'assurer la synchronisation entre deux fils d'exécution ou l'atomicité d'une opération, (1) ce sont les fonctions de la bibliothèque Pthread qui s'en chargent, mais bien de signaler au compilateur de (2) ne pas optimiser l'accès aux objets qualifiés comme tel, leur valeur pouvant être modifiée via un autre fil. Source:Taurre

(1) Et quelle fonction de posix permet de spécifier qu'un load/store est atomique ?

Ksass`Peuk

Aucune, mais on s'en fout : la seule chose importante est que les accès et modifications à des zones partagées soient confinées entre l'attribution et la libération d'un ou plusieurs verrous. Or, cela n'est assuré que si le compilateur n'optimise pas les portions de code protégées n'importe comment (comprendre : en plaçant du code avant ou après la gestion des verrous ou en ne mettant pas à jour la zone protégée à temps, etc) et donc en recourant au mot-clé volatile.

Évidemment, cela serait moins compliqué si les compilateurs considéraient les appels à des fonctions externes comme des points innamovibles au sein du code.

Et si l était un volatile, on casserait ce genre d'optimisation possibles et on serait même pas sûrs d'avoir le bon résultat.

Ksass`Peuk

Sous réserve d'avoir bien compris ton exemple, le qualificateur volatile assure du respect de la machine abstraite par le compilateur lors de l'accès à un objet qualifié comme tel. Aussi, l'ordre des instructions doit être respecté dans un tel cas.

Maintenant, tu as parfaitement raison : cela nuit aux performances. Toutefois, j'ai tendance à penser que l'absence d'optimisation pour ce genre de portion de code n'est sans doute pas plus mal (mais cela n'engage que moi).

+0 -0

Aucune, mais on s'en fout : la seule chose importante est que (1) les accès et modifications à des zones partagées soient confinées entre l'attribution et la libération d'un ou plusieurs verrous. Or, cela n'est assuré que (2) si le compilateur n'optimise pas les portions de code protégées n'importe comment

Maintenant, tu as parfaitement raison : cela nuit aux performances. Toutefois, (3) j'ai tendance à penser que l'absence d'optimisation pour ce genre de portion de code n'est sans doute pas plus mal (mais cela n'engage que moi).

Taurre

(1) Sauf que les verrous, ça coûte atrocement cher, et qu'on peut faire bien mieux sans. Sans compter qu'avec un modèle mémoire de plus bas niveau, on peut vérifier que nos implémentations de ces verrous sont correctes.

(2) Et s'assurer que les optimisations ne cassent pas ce genre de code nécessite d'avoir un modèle très précis du fonctionnement de la mémoire à bas niveau. Ce qu'offre justement les normes récentes de ce côté.

(3) On parle de C et C++. Les langages où le moindre petit pourcentage de performances compte.

(2) Et s'assurer que les optimisations ne cassent pas ce genre de code nécessite d'avoir un modèle très précis du fonctionnement de la mémoire à bas niveau. Ce qu'offre justement les normes récentes de ce côté.

Ksass`Peuk

Pour ce qui est de la machine abstraite, le modèle existe depuis la norme C89, pareil pour le qualificateur volatile.

+0 -0

(1) Sauf que les verrous, ça coûte atrocement cher, et qu'on peut faire bien mieux sans.

Ksass`Peuk

Sauf qu'en fait la lourdeur des atomiques tient du foutage de gueule complet : c'est même un des truc les plus cheap qui soit, et les toy-benchmarks réalisés à ce jour ne sont pas crédibles. Une opération atomique coute généralement autant que la même opération pas atomique : dites merci le système de cohérence des caches et l'OOO des instructions arithmétiques qui camouflent l'effet des FENCES. Et encore, j'ai pas parlé de la spéculation sur les LOCK. Et si tes verrous sont lus depuis la RAM, tu gagneras même pas quelques pourcents avec ton modèle mémoire superoptimisé.

Sans compter qu'avec un modèle mémoire de plus bas niveau, on peut vérifier que nos implémentations de ces verrous sont correctes.

Avec un modèle mémoire de bas niveau, tu va simplement te brouiller avec les concepteurs de processeurs qui changent de modèle mémoire à chaque génération de CPU : ton processeur aura de toute manière un modèle mémoire différent de celui imposé par ton langage. Après, tu peux préciser un modèle mémoire précis dans la norme du langage, mais faut pas t’étonner si les concepteurs de hardware te disent "La politique de la maison est de chercher les cycles avec les dents dans les applications monothread. Alors t'es gentil, mais si tu veux qu'on respecte ton modèle mémoire, tu codes ton compilateur pour qu'il foute des LOCK ou des FENCES au bon endroit, merci d'avance".

(2) Et s'assurer que les optimisations ne cassent pas ce genre de code nécessite d'avoir un modèle très précis du fonctionnement de la mémoire à bas niveau. Ce qu'offre justement les normes récentes de ce côté.

Ksass`Peuk

De toute manière, ton CPU cassera ton code si ton compilateur ne fout pas une masse délirante de LOCK et de FENCES là où le programmeur et le modèle mémoire l'impose. Autant que ce soit le programmeur qui les mette là où c'est nécessaire, plutôt qu'un compilateur ultraconservateur qui mettra des LOCK et FENCES partout où il pensera qu'il y a possibilité qu'une erreur peut éventuellement survenir.

+0 -0

(1) Sauf qu'en fait la lourdeur des atomiques tient du foutage de gueule complet : c'est même un des truc les plus cheap qui soit […] (2) Et si tes verrous sont lus depuis la RAM, tu gagneras même pas quelques pourcents avec (3) ton modèle mémoire superoptimisé

Mewtow

(1) On parle des mutexes ou des low-level atomics ? Parce que faudra quand même m'expliquer comment un lock/unlock de mutex peut être cheap comparé au couple acquire/release quand c'est adapté.

(2) Si tes verrous sont lus depuis la RAM tu as d'autres trucs à optimiser avant d'aller attaquer les low-level atomics, c'est sûr.

(3) J'aurai dit modèle mémoire précis. Il est pas particulièrement optimisé, il nous dit juste précisément ce qui se passe. En l'occurrence, avant l'arrivée de C++11, difficile de trouver des modèles mathématique de ce qui se passe dans un programme C. Depuis, ça fourmille.

Avec un modèle mémoire de bas niveau, tu va simplement te brouiller avec les concepteurs de processeurs qui changent de modèle mémoire à chaque génération de CPU : ton processeur aura de toute manière un modèle mémoire différent de celui imposé par ton langage. […] tu codes ton compilateur pour qu'il foute des LOCK ou des FENCES au bon endroit, merci d'avance".

Mewtow

Je vois pas en quoi ils se brouillent avec les concepteurs de hardware. Cf le message de mon VDD. Evidemment le compilateur doit placer les locks et fences nécessaires, le but c'est surtout que compilé sur n'importe quel architecture, le compilateur nous place les bons locks et les bons fences pour avoir le comportement correspondant à la sémantique du programme qu'on a écrit (même s'il est faux).

Le but du jeu c'est que quand je colle un release-write à un endroit, si je le compile aujourd'hui sur X86 ou sur ARM, j'ai le même comportement observable (si j'ai bien aucun data-race) et si je le compile l'an prochain sur la nouvelle génération de CPU de la mort, le compilateur me garantit toujours le même comportement.

Typiquement : sans avoir à coller moi même mes putains de barrières qui sont dépendantes de l'architecture.

De toute manière, ton CPU cassera ton code si ton compilateur ne fout pas une masse délirante de LOCK et de FENCES là où le programmeur et le modèle mémoire l'impose. Autant que ce soit le programmeur qui les mette là où c'est nécessaire, plutôt qu'un compilateur ultraconservateur qui mettra des LOCK et FENCES partout où il pensera qu'il y a possibilité qu'une erreur peut éventuellement survenir.

Mewtow

Qui a parlé d'une masse délirante ? A part les compare-exchange qui peuvent être assez méchants selon les architectures, globalement, il y a pas grand chose (mappings). Le compilateur collera pas plus de fence/lock que ce qu'impose le modèle et le modèle n'impose ça que sur les atomics. Tes non-atomics, ils sont compilés au plus simple et bien fait pour toi si tu n'as pas mis la bonne opération atomique au bon endroit.

(1) On parle des mutexes ou des low-level atomics ? Parce que faudra quand même m'expliquer comment un lock/unlock de mutex peut être cheap comparé au couple acquire/release quand c'est adapté.

Je te parle des instructions machines atomiques, qui sont presque aussi rapide que des instructions non-atomique. Dans le pire des cas, une instruction Read-Modify-Write demande simplement de locker une ligne de cache durant quelques cycles (ce que le CPU fait automatiquement en fixant un Flag dans les bit de contrôle de la ligne lue). Et dans les meilleurs des cas, on peut effectuer des spéculations sur l'état de la ligne de cache (lockée ou non ?), transformer l'instruction atomique en transaction, etc.

Qui a parlé d'une masse délirante ? A part les compare-exchange qui peuvent être assez méchants selon les architectures, globalement, il y a pas grand chose (mappings). Le compilateur collera pas plus de fence/lock que ce qu'impose le modèle et le modèle n'impose ça que sur les atomics. Tes non-atomics, ils sont compilés au plus simple et bien fait pour toi si tu n'as pas mis la bonne opération atomique au bon endroit.

Ksass`Peuk

Quelques études sur le sujet indiquent clairement que le modèle mémoire du C++ empêche un grand nombre d'optimisations particulièrement courantes et utilisées par les compilateurs actuels. En clair, le modèle mémoire réduit les performances sur les applications multithreads (comparé à un code écrit à la main), mais aussi monothreads. Une des raisons est l'ajout d'un trop grand nombre de LOCK et FENCES dans le code machine, mais ce n'est pas la seule raison.

A ce sujet, je conseille la lecture de l'étude datée de 2015 nommée "Common Compiler Optimisations are Invalid in the C11 Memory Model and what we can do about it", de Viktor Vafeiadis (MPI-SWS), Thibaut Balabonski (INRIA), Soham Chakraborty (MPI-SWS), Robin Morisset (INRIA) et Francesco Zappa Nardelli (INRIA).

Il faut savoir que le C++ n'est pas le seul langage à avoir précisé un modèle mémoire, et que toutes les tentatives se sont traduites par un échec. Imposer un modèle mémoire va toujours forcer le compilateur à ajouter des FENCES et LOCK quand il ne le faut pas, réduisant les performances. Et c'est encore pire si le modèle mémoire du CPU et celui du langage sont différents. Il existe divers théorèmes mathématiques sur le sujet, qui ne sont que des variantes du problèmes de l’arrêt. C'est pour cela que mettre un modèle mémoire différent de celui du CPU ne peut pas marcher.

Je vois pas en quoi ils se brouillent avec les concepteurs de hardware. Cf le message de mon VDD. Evidemment le compilateur doit placer les locks et fences nécessaires, le but c'est surtout que compilé sur n'importe quel architecture, le compilateur nous place les bons locks et les bons fences pour avoir le comportement correspondant à la sémantique du programme qu'on a écrit (même s'il est faux). Le but du jeu c'est que quand je colle un release-write à un endroit, si je le compile aujourd'hui sur X86 ou sur ARM, j'ai le même comportement observable (si j'ai bien aucun data-race) et si je le compile l'an prochain sur la nouvelle génération de CPU de la mort, le compilateur me garantit toujours le même comportement.

Ça c'est la théorie. Maintenant, ton modèle mémoire ne tiendra pas si la sémantique hardware des FENCES et LOCK change au point que la rétrocompatibilité soit brisée : tu peux très bien te retrouver avec un modèle mémoire hardware tellement laxiste que tu ne peux trouver aucun mapping. Ce qui aurait du être le cas sur les derniers CPU Intel, dont les concepteurs comptaient utiliser des méthodes de speculative elision sur toutes les opérations atomiques qui auraient rendues impossible d'implémenter le modèle mémoire du C++. Heureusement qu'ils ont utilisés des préfixes pour préciser quand il faut utiliser cette optimisation, mais cette décision a apparemment été prise sous la pression des concepteurs de compilateurs…

Je te parle des instructions machines atomiques, qui sont presque aussi rapide que des instructions non-atomique. Dans le pire des cas, une instruction Read-Modify-Write […]

Mewtow

Ah mais celles-ci j'ai aucun soucis avec elles ! C'est des copines. Après, elles ne sont pas utiles tout le temps. Un schéma que je rencontre beaucoup c'est prise d'ownership avec un RMW et restitution avec un ll-atomic.

A ce sujet, je conseille la lecture de l'étude datée de 2015 nommée "Common Compiler Optimisations are Invalid in the C11 Memory Model and what we can do about it", de Viktor Vafeiadis (MPI-SWS), Thibaut Balabonski (INRIA), Soham Chakraborty (MPI-SWS), Robin Morisset (INRIA) et Francesco Zappa Nardelli (INRIA).

Mewtow

Oui je l'ai pas mal potassé en début d'année. J'ai eu l'occasion entre temps d'en discuter avec Francesco et avec Jade Alglave à une école d'été. Le principal problème pour les optimisations n'est pas tant le modèle mémoire que ce que font actuellement les compilateurs. Pour être précis : c'est bien l'introduction du modèle mémoire qui a cassé nos optimisations (et qui a mis en lumière des trucs pétés depuis avant), mais hormis le point noir au sujet du comportement relaxed et non-atomic qui n'est pas encore clair (ils proposent plusieurs solutions mais aucune n''est pleinement satisfaisante), il serait possible d'avoir des compilos optimisant avec (presque) le même modèle.

(1) Il faut savoir que le C++ n'est pas le seul langage à avoir précisé un modèle mémoire, et que toutes les tentatives se sont traduites par un échec. (2) Imposer un modèle mémoire va toujours forcer le compilateur à ajouter des FENCES et LOCK quand il ne le faut pas, réduisant les performances. Et c'est encore pire si le modèle mémoire du CPU et celui du langage sont différents. (3) Il existe divers théorèmes mathématiques sur le sujet, qui ne sont que des variantes du problèmes de l’arrêt. C'est pour cela que mettre un modèle mémoire différent de celui du CPU ne peut pas marcher.

Mewtow

(1) Pour les comparaisons que j'ai pu voir, C++ est vraiment pas si mal.

(2) Oui toujours un peu, mais c'est moins cher que coller des volatiles et des mutexes (et risquer le deadlock) à tous les coins de rues. En plus, ça donne l'autorisation de raisonner sur le programme au niveau du source, pour la preuve, c'est bien pratique.

(3) J'ai jamais croisé de papier qui en parle vraiment. Un lien ?

Ce qui aurait du être le cas sur les derniers CPU Intel, dont les concepteurs comptaient utiliser des méthodes de speculative elision sur toutes les opérations atomiques* qui auraient rendues impossible d'implémenter le modèle mémoire du C++.

Mewtow

Hum là, j'aimerai bien comprendre le concept quand même. Parce que pour le coup, on y gagne rien en mono-thread et tout ce qu'on risque c'est ruiner le multi-thread.

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte