25 fév. 2013

Programmation concurrente : le modèle de mémoire C++11

thread.jpg

Dans l'article précédent, on a pris contact avec ce qu'est la programmation concurrente, et comment elle était (grosso-modo) mise en oeuvre dans les systèmes d'exploitation récent. On a aussi parlé de primitives de synchronisation et de tout un tas de petites choses qui sont autant de concepts importants, nécessaires à une bonne compréhension du problème. Et dans ce cadre, la relation avec le standard C++11 a été mis de coté de manière explicite.

Cet article revient sur le C++11, mais d'une façon détournée : on va parler du modèle de mémoire mis en place par le standard.

Un modèle de mémoire ? Qu'est-ce que c'est ?

Ce qu'il y a de pratique avec le vocabulaire[1] de l'informatique, c'est qu'il y a toujours un nouveau concept à définir. Celui-ci n'est certes pas nouveau, mais il a l'avantage d'être si compliqué qu'il mérite tout de même qu'on revienne sur sa définition.

En 1968, Richard Atkinson et Richard Shiffrin décrivent la mémoire humaine comme la somme de trois mémoires distinctes[2] :

  • la mémoire sensorielle (acquisition des stimuli extérieurs)
  • la mémoire à court terme (permettant la discussion et la réaction à des stimuli extérieurs)
  • la mémoire à long terme (permettant le stockage des souvenirs)

La mémoire à long terme est alimentée par la mémoire à court terme grâce à l'apprentissage. Cette dernière dispose de caractéristiques particulières : elle permet l'apprentissage (la répétition des même concepts permet de garder en mémoire ce concept pendant une durée supérieure à ce que la mémoire à court terme prévoit) et elle permet un prétraitement de l'information en la découpant en morceaux.

On peut détailler de manière similaire la mémoire à long terme et la mémoire sensorielle. D'autres descriptions joignent à ces trois composantes la mémoire dite de travail (qui permet une réflexion sur les informations stockées).

L'approche informatique n'est pas différente : afin de bien comprendre ce qui se passe lorsqu'un programme s'exécute, il est nécessaire de comprendre comment la mémoire est organisée et les conséquences des actions liées à son utilisation - et c'est, vous vous en doutez maintenant, ce que fait un modèle de mémoire.

Un modèle de mémoire (informatique) est la description de l'organisation de la mémoire et du fonctionnement de celle-ci.

Bien évidemment, le terme "modèle" devrait vous titiller un peu. Car qui dit "modèle" dit que cette description n'est pas nécessairement la réalité. Dans les faits, certaines descriptions sont moins précises que d'autres. Par exemple, les modèles de mémoire utilisé par Java ou C# n'ont pas besoin de détailler le fonctionnement du microprocesseur, car leur but est de décrire une interface haut niveau - comment la mémoire est gérée au niveau de la machine virtuelle.

En C++98, le modèle de mémoire est simple - si simple en fait qu'il n'est que très peu décrit. Il y a une bonne raison à cela : comme C, C++98 n'a pas vraiment besoin d'un modèle de mémoire car il utilise celui qui est proposé par la plateforme. Les seules choses qu'il impose sont les suivantes :

  • l'unité fondamentale de stockage est le byte (sa taille n'est pas précisée ; cf. la section 1.7 du standard C++98).
  • la mémoire allouée par un appel à l'opérateur new() doit être contiguë du point de vue de l'utilisateur - que cette mémoire soit physique, virtuelle ou autre.
  • les données volatiles sont stables à chaque point de séquence, c'est à dire que toute évaluation antérieure est terminée, et aucune évaluation ultérieure n'est entamée (section 1.9, §11 du standard C++98). Un point de séquence (sequence point) est un point (moment) particulier de l'exécution (section 1.9, §7 du même standard).

Ces règles (associées à quelques autres concernant l'exécution du programme dans la machine abstraite) suffisent à décrire l'organisation et le fonctionnement de la mémoire en C++98.

Qu'en est-il en C++11 ?

Bien évidemment, la description simple ci-dessus n'est plus valide en C++11. Le modèle de mémoire doit évoluer car un nouvel artéfact a fait son apparition : la programmation concurrente - et notamment la notion de thread. Les conséquence de cet ajout sont importantes.

La section 1.7 du standard C++11 décrit les bases du modèle de mémoire utilisé par cette évolution du langage. Elle décrit l'unité fondamentale de stockage (le byte ; son sens ne change pas par rapport à la version précédente du standard) et définit un espace mémoire (memory location) de cette manière :

Un espace mémoire est soit un objet de type scalaire, soit une séquence maximale de champs de bits adjacents ayant tous une taille non nulle.

L'intérêt de cette définition, c'est que deux espaces mémoires différents peuvent être mis à jour par deux threads différents sans qu'on ait à craindre un quelconque problème. Le premier point qu'il faut prendre en considération ici est le fait que des champs de bits adjacents de taille non nulles sont regroupés dans un même espace mémoire - de fait, leur mise à jour via deux thread différents n'est pas sur.

Prenons par exemple cette définition[3] :

struct s {
    int a : 8;
    int b : 8;
    int :0;
    int c:8;
    char d;
    struct { int e:4 } e;
    int f:4;
};

Cette structure défini 5 espace mémoire : le premier est composé des membres a et b. Le second est composé de c (séparé de a et b par un champ de bit de taille nulle). Le troisième est d. Le quatrième est e, le cinquième est f. Ces deux champs de bits sont placés dans des espace mémoire différents car malgré le fait qu'ils soient consécutifs et de taille non nulle, l'un est placé dans une structure tandis que l'autre ne l'est pas.

Cette notion d'espace mémoire est importante, car de l'existence même de cette définition découle un ensemble d'autres définitions :

  • la notion de conflit d'accès (autrement nommé race condition) existe parce que deux thread tente d'accéder aux même espace mémoire - et qu'au moins un de ces deux thread tente d'y accéder en écriture.
  • les opérations atomiques sont définies pour permettre la synchronisation des accès.
  • les opérations de synchronisation sur les espace mémoire atomiques sont nécessairement définies pour que l'ordre des modification (modification order, section 1.9, §6 du standard C++11) puisse être lui-même défini. Sans la définition de cet ordre de modification, on ne peut pas dire au compilateur quel est l'ordre des opérations qu'il doit implémenter, et il est impossible de lui faire écrire un code qui fait ce que le programme décrit.
  • etc...

Si on reprends la définition de la structure ci-dessus, on voit qu'il existe un conflit d'accès si un thread essaie d'écrire le champs de bits a alors qu'un autre essaie de lire b. Ce conflit est lié au fait que a et b sont contenus dans le même espace mémoire.

Pour plus d'information sur les conséquences de la définition d'un espace mémoire, je vous engage à lire les sections 1.9 et 1.10 du standard C++11.

C'est très abscons tout ça...

Je le concède. Le modèle de mémoire du C++11 est complexe, et sa description dans le standard reste assez abstraite. Mais il existe une autre manière de le traiter - manière qui résulte d'une simple constatation.

Java et C# sont des langages dit de haut niveau. Conséquemment, leur modèle de mémoire est lui-même haut niveau. C++ est un langage intermédiaire, avec une multitude d'usages allant de la programmation graphique à la programmation système. De fait, son modèle de mémoire doit prendre en compte l'intégralité de ces besoins et se rapproche par conséquent du modèle de mémoire d'un système d'exploitation. J'irais même plus loin : le modèle de mémoire du C++11 est celui de la machine - on ne peut guère faire plus proche du métal en fait.

Du coup, plutôt que de partir de la description présente dans le standard, on va essayer de partir du matériel et de remonter vers le langage.

Du point de vue d'un programme, on a coutume de voir la mémoire comme étant un bloc situé on ne sait où (sur des barrettes, à priori) lié au processeur par on ne sait quel mécanisme magique. L'accès à la mémoire est vu comme quelque chose de monolithique : lorsqu'on souhaite écrire dans la mémoire, on spécifie une adresse et une donnée et hop, la donnée est stockée quelque part, dans une cellule d'une des barrettes mémoire présente sur la machine. Bien évidemment, tout n'est pas aussi simple. On a vu dans un précédent billet qu'il fallait déjà faire la différence entre la mémoire physique (les cellules sur les barrettes) et la mémoire virtuelle (une abstraction commode proposée par le processeur). Les choses réelles sont encore plus compliquées que cela.

Les processeurs récents ont à peu près tous la même architecture pour ce qui est de leur rapport à la mémoire[4]. Cette architecture est composée de plusieurs niveaux liés entre eux :

  • les registres du microprocesseur ;
  • un ou plusieurs niveaux de mémoire cache CPU (très régulièrement 2, souvent 3 sur les systèmes modernes);
  • la mémoire physique elle-même ;

Chacun de ces niveaux de mémoire propose un fonctionnement particulier. Que se passe-t-il lorsqu'on veut lire une donnée qui est stockées en mémoire ? En simplifiant grossièrement (et sans prendre en compte les cas d'erreur, qui peuvent être nombreux), on obtient à peu près ceci :

  • On effectue la translation d'adresse afin de savoir quelle est l'adresse physique qui est concernée par l'accès mémoire.
  • On vérifie dans le premier niveau de cache si la donnée est présente ; si elle l'est, on charge cette donnée dans un registre du microprocesseur. Sinon, on va voir dans le second niveau de cache en générant une exception de type cache miss. Au retour de cette exception, la donnée est censée être présente dans le cache - on la charge donc dans le registre.
  • On vérifie si la donnée est présente dans le second niveau de cache. Si elle l'est, on copie la ligne de cache concernée dans le cache de niveau inférieur ; sinon, on vérifie le ou les niveaux de cache inférieure en générant un cache miss. Au retour, on met à jour le cache de niveau 1.
  • Les vérifications sur les niveaux de cache ultérieurs se passent tous sur le même mode. Seul le dernier niveau de cache est différent.
  • Au dernier niveau de cache, dans le cas où l'on s’aperçoit que la donnée n'est pas présente, on génère à nouveau une exception cache miss. Cette exception va permettre de charger une ligne de cache en copiant quelques octets de la mémoire physique cible dans le cache. A la fin de cette copie, on remonte l'information dans le niveau de cache précédent.

Bien évidemment, même en considérant que tous les cache miss ont le même coût, on voit vite qu'on aura beaucoup plus rapidement la donnée si elle est déjà présente dans le cache de premier niveau. La première conclusion qu'on peut tirer de cette liste d'opération est qu'à chaque instant, une donnée peut exister en de nombreux exemplaires : un exemplaire est stocké en mémoire physique, et N+1 autres copies peuvent exister au même instant - dans les N niveaux de cache et dans un registre du processeur.

Lorsqu'on veut écrire une donnée, un ensemble d'opérations similaires est effectué. Dans un premier temps, il faut s'assurer que le cache de premier niveau contient bien la ligne de cache qui sera impacté par l'accès en écriture. La suite des opérations est similaire à celle qui se passe lorsqu'on souhaite lire une valeur. Une fois que la zone mémoire impactée est dans le cache de premier niveau, on peut y écrire la valeur souhaitée. A ce moment, la ligne de cache est considéré comme étant salie (dirty) - elle n'est plus synchrone avec les lignes de caches de plus bas niveau, ni même avec le contenu de la mémoire physique. Ceci dit, du point de vue du microprocesseur, l'écriture en mémoire est terminée, et il continue son chemin en exécutant l'instruction suivante. Les données ne sont réellement écrites en mémoire que plus tard - généralement, lorsque la ligne de cache salie est supprimée du cache.

Lorsque le programme s'exécute sur un seul coeur d'un microprocesseur, il a toujours accès à la dernière valeur mise à jour dans la mémoire. Si elle n'est pas encore dans la mémoire, elle est de toute façon présente dans une ligne de cache salie d'un des niveau de cache. Il ne reste au programme qu'à gérer la problématique de la synchronisation des accès aux données (on en reparlera plus tard).

Que se passe-t-il lorsque deux coeurs différents (possédant chacun leurs propres caches jusqu'au niveau C) tentent d'accéder au même espace mémoire, l'un en écriture, puis l'autre en lecture ?

  • Le premier coeur va remonter la ligne de cache impactée dans son cache de premier niveau, et va mettre à jour ce cache. La ligne impactée est salie, mais la donnée n'est pas encore présente en mémoire puisque le cache n'est pas flushé.
  • Puis le second coeur va remonter la zone de mémoire dans une ligne de cache de sont cache de premier niveau. Puisque le premier coeur n'a pas encore écrit la donnée en mémoire, celle-ci n'est pas présente - le second coeur exécute donc son code sur la mauvaise donnée.

Oups. Il semblerait que ce cas d'utilisation ne fonctionne pas - et en pratique, c'est vrai. Il est tout à fait possible d'écrire du code qui propose ce comportement[5]. L'utilisation d'un qualifiant volatile ne change rien à la donne - ce qualifiant dit expressément que la valeur n'est pas stockée dans un registre, pas que les opérations qui la concernent passent au travers du cache[6]. En fait, par défaut (donc dans le modèle de mémoire proposé par C++98), le code s'exécute de cette manière.

Que faut-il pour corriger ce problème ? Pas grand chose, en fait. Les différents fondeurs des processeurs récents (je parle de processeurs à coeurs multiples) ont parfaitement connaissance de ce problème, et ils ont pris le temps de proposer leur solution avant même que leurs processeurs ne soient sur le marché. Leur réponse tient en un mot : barrière.

Barrières mémoire

Une barrière mémoire (memory fence ou memory barrier) est un effet de bord d'une instruction du microprocesseur (quand bien même cet effet de bord est son seul effet[7], il n'en reste pas moins un effet de bord, du fait même qu'il soit disponible en tant que tel sur d'autres instructions). Cet effet de bord est triple :

  • une barrière dite d'acquisition assure que la donnée à laquelle on souhaite avoir accès est effectivement présente sous sa forme la plus récente avant l'exécution du traitement de l'instruction. Au niveau fonctionnel, c'est comme si une barrière d'aquisition considérait que la donnée accédée n'était jamais dans le cache. Bien évidemment, une telle barrière n'a d'intérêt que sur les opérations de lecture.
  • une barrière dite de libération assure que la donnée traitée est rendue disponible pour les autres coeurs. Au niveau fonctionnel, tout se passe comme si on forçait l'éviction des lignes de cache concernées par l'opération effectuée, ce qui force l'écriture en mémoire. Bien évidemment, une telle éviction n'a d'intérêt que si l'opération considérée est une opération de lecture.
  • une barrière complète est à la fois une barrière d'acquisition et une barrière de libération.

Les barrières ont un autre intérêt, plus subtil. Avant de continuer, il est nécessaire de revenir sur la compilation d'un programme et sur son exécution du point de vue du microprocesseur.

Supposons le code suivant :

int m = 10;
for (int i = 0; i < 10; ++i) {
  for (int j = 0; j < 10; ++j) {
    int k = i * m + j; 
    do_something_with(k);
  }
}

Ce code, très simple, est rapidement optimisé par le compilateur, car la valeur de k ne dépends pas de j, et k est un multiple entier de i :

// code réellement compilé
int p = 0;
int m = 10;
for (int i = 0; i < 10; ++i) {
  for (int j = 0; j < 10; ++j) {
    int k = p + j;
    do_something_with(k);
  }
  p += m; 
}

L'optimisation qu'effectue le processeur est une modification de l'ordonnancement des instructions. Le code écrit par le compilateur a le même comportement que le code écrit par le programmeur (à chaque appel de so_something_with(), le paramètre p de la seconde version vaut le paramètre k de la première version. Dans le même temps, on passe de (100 multiplications + 100 additions) à (10 additions + 100 additions).

Il faut savoir que le compilateur se donne toujours le droit de modifier l’ordonnancement des instructions de votre code - pour votre bien[8]. Selon le niveau d'optimisation choisi, il peut aller jusqu'à la suppression complète de variables intermédiaires, à l'inversion pure et simple de ligne de code, etc. Le but du compilateur est toujours de vous fournir un exécutable qui fonctionne tel que vous l'avez décrit - mais il n'a pas à respecter vos moindre désirs s'il s’aperçoit qu'il peut réécrire une code au comportement identique de manière plus optimisée[9].

Supposons maintenant que nous travaillons avec deux variables m et n. m est écrit par un thread, et vaut n / 2 tandis que n est manipulé par un autre thread et vaut m * 3. les opérations sur m et n sont censées être atomiques.

 // init 
 m = 10; 
 n = 20;
// thread 1 while (1) { m = n / 2; if ((m & 3) == 0) // m est divisible par 4 do_cocorico(); }
// thread 2 while (1) { n = m * 3; if (n & 1) do_whatever_you_want(); }

Si le compilateur prend le temps d'analyser chacun de ces codes de manière indépendante, voilà ce qu'il en déduit :

  • pour le thread 1, en considérant que m et n ne sont jamais modifié à l'extérieur, alors la condition (m & 3) == 0 n'est jamais vérifiée ; m est en faut toujours égal à 10. Le calcul m = n / 2 n'est pas dépendant du temps ni d'un index de boucle, donc il peut être effectué à l'extérieur de la boucle. De fait, le test est effectué sur une valeur calculée à l'extérieur de la boucle - il est donc soit toujours vrai, soit toujours faux. Le compilateur changerait donc le code en
 // thread 1
 m = n / 2;
 c = (m & 3) == 10 ? true : false;
 while (1) {
   if (c)
       do_cocorico();
 }
  • pour le thread 2, une analyse similaire est effectuée : n de dépends que de valeurs qui sont censées être constante vis à vis du thread. Du coup, le compilateur se bornerait à écrire :
 // thread 2
 n = m / 3;
 c = n & 1;
 while (1) {
   if (c)
     do_whatever_you_want(); 
 }

On voit clairement que cette approche n'est pas correcte : m et n sont interdépendant, et varient dès lors que l'autre boucle s'exécute. Il est difficile voir impossible de prévoir les valeur que vont prendre les variables. Comment fait-on pour que le compilateur cesse de réorganiser le code, quitte à supprimer certaines optimisations ? Est-ce que les barrières mémoire sont suffisante dans ce cas ?

On a vu précédemment le rôle des barrière mémoire - dans la mise à disposition ou dans la récupération de données stockées dans la mémoire. Ce que je n'ai pas encore dit, c'est que les compilateurs C++11 sont censé comprendre ce rôle aussi. Et puisque le fonctionnement des barrière impose un ordre de code particulier, alors le compilateur doit s'interdire certaines facéties dès lors qu'il doit traiter du code qui contient des barrières mémoire. En fait, il applique (grosso-modo) la simplification suivante :

quel que soit le code contenu entre une barrière d’acquisition et une barrière de libération, aucune des parties de ce code ne peut être déplacé a l'extérieur de ces barrières.

En supposant dans notre exemple que

  • l'accès en lecture à m ou n nécessite une barrière en acquisition
  • l'écriture de n ou m nécessite une barrière en libération

Alors le compilateur n'a plus le choix - il doit faire exactement ce qu'on lui demande :

 // thread 1
 while (1) {
   __acquire_fence(); // acquisition de n
   m = n / 2;
   __release_fence(); // libération de m
   if ((m & 3) == 0) // m est divisible par 4
       do_cocorico();
 }
// thread 2 while (1) { __acquire_fence(); // acquisition de m n = m * 3; __release_fence(); // libération de n if (n & 1) do_whatever_you_want(); }

Dans la pratique, les barrières de mémoire sont principalement utilisées en conjonctions des opérations atomiques déjà décrites dans un billet précédent afin d'implémenter des primitives de synchronisation. Le modèle de mémoire du C++11 assure que le code écrit dans un bloc délimité par deux primitives de synchronisation (par exemple mutex.lock() et mutex.unlock()) ne peut pas être déplacé en dehors de ce bloc. On s'assure ainsi que le code qui s’exécute est bel et bien celui que le programmeur a écrit. Cela ne signifie pas que le code à l'intérieur du bloc ne sera pas réordonné, mais ce nouvel ordre respectera tout de même le souhait du programmeur.

L'utilisation de barrière, en conjonction avec l'utilisation d'instructions atomiques, permet d'assurer un ordre spécifique sur les opération sur la mémoire. Cet ordre spécifique est l'ordre de modification imposé par le standard C++11.

L'introduction du concept de barrière dans le langage C++11 permet à la norme de spécifier un ordonnancement particulier de certaines instructions, et donc de spécifier

Conclusion

Espace mémoire et barrières mémoire sont les deux points importants du modèle mémoire du C++. Ces deux points exprimés, le reste n'est plus qu'une affaire de conséquence. De fait, le contrat du langage a évolué : en C++98, le contrat était relativement simple :

Si le code écrit est valide du point de vue de la norme, alors le compilateur et le processeurs sont utilisés en conjonction pour donner l'illusion que le programme exécuté est celui qui a été écrit.

En aucun cas la norme C++98 ne présume de la machine abstraite qui exécute le programme - elle ne fait que préciser que cette machine abstraite doit exécuter le code C++98 écrit d'une manière qui est non différentiable de celle imposée par le standard.

Pour obtenir le même résultat, C++11 impose de nouvelles restrictions au programmeur.

Si le code écrit est valide du point de vue de la norme, et si le programmeur s'assure qu'il n'y a pas de conflit dans les accès mémoire, alors le compilateur et le processeurs sont utilisés en conjonction pour donner l'illusion que le programme exécuté est celui qui a été écrit.

Cette nouvelle restriction est importante, mais dans leur sagesse, les créateurs de cette nouvelle mouture de la norme C++ ont choisi de rester proche du système et du matériel, et donc de ce qui se fait déjà dans le cadre de la programmation concurrente. Du coup, celui qui a déjà pris l'habitude de programmer correctement dans un environnement concurrent ne sera pas dépaysé[10] : il s'agit principalement de bien prendre en compte les notions d'espace mémoire et de synchronisation. Une fois ces deux notions comprises, le reste viendra facilement.

Si vous souhaitez aller plus loin sur ce sujet, je vous conseille de regarder la présentation de Herb Sutter, atomic<> weapons. Cette présentation va encore plus loin, en décrivant les opérations atomique relaxée (à ne pas utiliser ; mais il est bon de savoir à quoi elle correspondent) ou en présentant les conséquences de ce modèle de mémoire sur les hardwares existants.

A bientôt !

Notes

[1] ok, le jargon...

[2] Bien entendu, cette description (dite modèle de mémoire Atkinson-Shiffrin) n'est pas la plus ancien. Je le prends ici comme référence pour notre discussion.

[3] pour information, c'est à peu de chose prêt le même exemple qui se retrouve dans la section 1.7 du standard C++11.

[4] et pour cause : ils se basent tous plus ou moins sur le même modèle de mémoire - DDR2, DDR3...

[5] en considérant les registres du microprocesseur comme étant un niveau de cache supplémentaire (niveau 0), alors on peut mettre en évidence ce problème très simplement, même sur un processeur mono-coeur : il suffit d'avoir un des deux threads qui fait une attente active sur le changement d'état d'un booléen, avec par exemple une boucle while(my_boolean){ }. La valeur considérée est chargée dans un registre, et ce registre est testé en boucle. Dans le même temps, un autre thread change la valeur de my_boolean, mais ce changement n'est pas pris en compte par le premier thread car la valeur n'est pas relue à partir de la mémoire.

[6] ce type d'opération est possible sur certaines architectures matérielles, mais nécessite de mettre le CPU dans un état particulier en lui disant qu'il n'a pas de mémoire cache. Un processeur MIPS démarre par exemple dans cet état

[7] par exemple, l'instruction SYNC des processeurs MIPS4k

[8] c'est ce qui rend l'écriture de benchmark si compliquée. Et inutile de vous dire "soit, alors je vais écrire ce bout de code en assembleur" - le compilateur n'en a cure. Il va aussi modifier l'ordre des instructions assembleur s'il juge pertinent les modifications apportées.

[9] de fait, il est très difficile d'écrire du code assembleur qui sera plus rapide que le code émis par le compilateur C ou C++ : le compilateur connaît mieux l'architecture matérielle que vous.

[10] modulo certaines petites feintes qui deviennent claires à la lecture de la norme, telle la notion d'espace mémoire décrite plus haut et son lien avec les champs de bits.

Commentaires

1. Le jeudi, février 28 2013, 15:39 par Mat

Article très sympas!!

Deux petites erreurs néanmoins dans le "code réellement compilé" dans la section "barrières mémoire".
Vous avez mis un "j" à la place d'un "i", et il me semble que la première valeur de "p" devrait etre "-m" pour commencer comme la première boucle. Bref, des erreurs d'inattention.

Bon changement de serveur!

2. Le vendredi, mars 1 2013, 09:58 par Emmanuel Deloget

Bonjour Mat,

Article très sympas!!

Merci :)

Deux petites erreurs néanmoins dans le "code réellement compilé" dans la section "barrières mémoire".

Effectivement ! J'ai corrigé ces deux erreurs (la seconde, en repoussant l'opération p += m en fin de boucle). Merci pour cette relecture soignée :)

3. Le dimanche, janvier 12 2014, 01:21 par darco

heuuuu, je suis pas un bon programmeur en C, mais je me pose tout de même la question : est-ce normal que l'on ai :
@@int a : 8;

   int b : 8:@@

je crois qu'après le "int b : 8", on met un ";" non?

4. Le dimanche, janvier 12 2014, 15:59 par Emmanuel Deloget

Je confirme : il s'agit d'une erreur qui est passée à travers les yeux fatigué de ce pauvre auteur (moi).

Merci :)

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet