2. Linux sur SMP

Ce document donne un bref aperçu de la manière dont on utilise le SMP sous Linux pour le traitement en parallèle. L'information la plus à jour concernant le SMP sous Linux est fort probablement disponible via la liste de diffusion du SMP Linux Project (N.D.T. : en anglais). Envoyez un courrier électronique à avec le texte subscribe linux-smp pour rejoindre la liste.

Le SMP sous Linux fonctionne-t-il vraiment ? En juin 1996, j'ai fait l'achat d'un bi-Pentium 100MHz flambant neuf. Le système complet et assemblé, comprenant les deux processeurs, la carte-mère Asus, 256 kilo-octets de mémoire cache, 32 méga-octets de RAM, le disque dur d'1.6 giga-octet, le lecteur de CD-ROM 6X, une carte Stealth 64 et un moniteur 15'' Acer m'a coûté 1800 dollars. Cela ne fait que quelques centaines de dollars de plus qu'un système monoprocesseur. Pour faire fonctionner le SMP sous Linux, il a suffi d'installer le Linux monoprocesseur d'origine, de recompiler le noyau en décommentant la ligne SMP=1 dans le Makefile (bien que je trouve le fait de mettre SMP à 1 un peu ironique ! ;-) ), et informer lilo de l'existence du nouveau noyau. Ce système présente une stabilité et des performances suffisamment bonnes pour qu'il me serve depuis de station de travail principale. Pour résumer, le SMP sous Linux, ça fonctionne !

La question qui se présente alors est : existe-t-il suffisamment d'API de haut niveau permettant d'écrire et d'exécuter des programmes en parallèle et utilisant la mémoire partagée sous Linux SMP ? Courant 1996, il n'y en avait pas beaucoup. Les choses ont changé. Par exemple, il existe désormais une bibliothèque POSIX de gestion des threads[2] très complète.

Bien que les performances soient moins élevées que celles des mécanismes de mémoire partagée natifs, un système Linux sur SMP peut aussi utiliser la plupart des logiciels de traitement en parallèle initialement développés pour des clusters de stations de travail en utilisant la communication par socket. Les sockets (voir section 3.3) fonctionnent à l'intérieur d'une machine en SMP, et même dans un cluster de machines SMP reliées en réseau. Cependant, les sockets engendrent beaucoup de pertes en temps inutiles pour du SMP. Cela complique le problème car Linux SMP n'autorise en général qu'un seul processeur à la fois à se trouver dans le noyau et le contrôleur d'interruption est réglé de façon à ce que seul le processeur de boot[3] puisse traiter les interruptions. En dépit de cela, l'électronique de communication typique des systèmes SMP est tellement meilleure que la plupart des clusters en réseau que les logiciels pour cluster fonctionneront souvent mieux sur du SMP que sur le cluster pour lequel ils ont été conçus.

Le reste de cette section traite de l'électronique contrôlant le SMP, passe en revue les mécanismes Linux de base partageant de la mémoire à travers les différents processus d'un programme en parallèle, fait quelques remarques concernant l'atomicité, la volatilité, les verrous et les lignes de cache, et donne enfin des références vers d'autres ressources de traitement en parallèle à mémoire partagée.

2.1. L'électronique SMP

Bien que les systèmes SMP soit répandus depuis de nombreuses années, jusque très récemment, chaque machine tendait à implémenter les fonctions de base d'une manière suffisamment différente des autres pour que leur gestion par le système d'exploitation ne soit pas portable. Les choses ont changé avec la Intel's MultiProcessor Specification (Spécification MultiProcesseurs d'Intel) souvent désignée par MPS. La spécification MPS 1.4 est actuellement disponible sous forme de document PDF sur http://www.intel.com/design/intarch/MANUALS/242016.htm, mais gardez à l'esprit qu'Intel réorganise souvent son site web. Un large panel de constructeurs fabrique des systèmes conformes à MPS pouvant recevoir jusqu'à quatre processeurs, mais en théorie, MPS admet bien plus de processeurs[4].

Les seuls systèmes non MPS et non IA32 reconnus par Linux SMP sont les machines SPARC multiprocesseurs de Sun4m. Linux SMP prend aussi en charge la plupart des machines Intel conformes à MPS 1.1 ou 1.4, comptant jusqu'à 16 processeurs 486DX, Pentium, Pentium MMX, Pentium Pro ou Pentium II. Parmi les processeurs IA32 non pris en charge (N.D.T. : par le SMP), on trouve les Intel 386 et 486SX/SLC (l'absence de coprocesseur mathématique interfère sur les mécanismes du SMP) et les processeurs AMD et Cyrix (qui nécessitent des circuits de gestion du SMP différents et qui ne semblent pas être disponibles à l'heure où ce document est écrit).

Il est important de bien comprendre que les performances de différents systèmes conformes à MPS peuvent fortement varier. Comme l'on peut s'y attendre, une des causes de différence de performance est la vitesse du processeur : Une horloge plus rapide tend à rendre les systèmes plus rapides, et un processeur Pentium Pro est plus rapide qu'un Pentium. En revanche, MPS ne spécifie pas vraiment comment le matériel doit mettre en œuvre la mémoire partagée, mais seulement comment cette implémentation doit fonctionner d'un point de vue logiciel. Cela signifie que les performances dépendent aussi de la façon dont l'implémentation de la mémoire partagée interagit avec les caractéristiques de Linux SMP et de vos applications en particulier.

La principale différence entre les systèmes conformes à MPS réside dans la manière dont ils implémentent l'accès à la mémoire physiquement partagée.

2.1.1. Chaque processeur possède-t-il sa propre mémoire cache de niveau 2 (L2) ?

Certains systèmes MPS à base de Pentium, et tous les systèmes MPS Pentium Pro et Pentium II ont des mémoires cache L2 indépendantes (le cache L2 est embarqué dans le module des Pentium Pro et Pentium II). Les mémoires caches L2 dissociées sont généralement réputées augmenter les performances de l'ordinateur, mais les choses ne sont pas si évidentes sous Linux. La principale complication provient du fait que l'ordonnanceur de Linux SMP n'essaie pas de maintenir chaque processus sur le même processeur, concept connu sous le nom d'affinité processeur. Cela pourrait bientôt changer. Un débat a récemment eu lieu sur ce sujet dans la communauté des développeurs Linux SMP, sous le titre « processor bindings » (« associations de processeurs »). Sans affinité processeur, des caches L2 séparés peuvent introduire des délais non négligeables lorsqu'un processus se voit allouer une tranche de temps d'exécution sur un processeur qui n'est pas le même que celui sur lequel il s'exécutait juste avant.

Plusieurs systèmes relativement bon marché sont organisés de manière à ce que deux processeurs Pentium puissent partager la même mémoire cache L2. La mauvaise nouvelle, c'est que cela crée des conflits à l'utilisation de ce cache, qui dégradent sérieusement les performances lorsque plusieurs programmes séquentiels indépendants s'exécutent simultanément. La bonne nouvelle, c'est que bon nombre de programmes parallèles pourraient tirer profit de la mémoire cache partagée, car si les deux processeurs veulent accéder à la même ligne de mémoire partagée, seul un processeur doit aller la rapatrier dans le cache, et l'on évite des conflits de bus. Le manque d'affinité processeur peut aussi s'avérer moins désastreux avec un cache L2 partagé. Ainsi, pour les programmes parallèles, il n'est pas vraiment certain que partager la mémoire cache L2 soit si préjudiciable que l'on pourrait le penser.

À l'usage, notre bi-Pentium à mémoire cache partagée de 256Ko présente une vaste échelle de performances, dépendantes du niveau d'activité noyau requis. Au pire, le gain en vitesse n'atteint qu'un facteur de 1,2. En revanche, nous avons aussi constaté une accélération de 2,1 fois la vitesse d'origine, ce qui suggère que les calculs intensifs à base de SPMD tirent vraiment profit de l'effet d'« acquisition partagée » (« shared fetch »).

2.1.2. Configuration du bus ?

La première chose à dire est que la plupart des systèmes modernes relient le processeur à un ou plusieurs bus PCI qui à leur tour sont « pontés » vers un ou plusieurs bus ISA ou EISA. Ces ponts engendrent des temps de latence, et l'ISA comme l'EISA offrent généralement des bandes passantes plus réduites que le PCI (ISA étant le plus lent). C'est pourquoi les disques, cartes vidéos et autres périphériques de haute performance devraient en principe être connectés sur un bus PCI.

Bien qu'un système MPS puisse apporter un gain en vitesse honorable à plusieurs programmes parallèles de calcul intensif même avec un seul bus PCI, les opérations d'entrées/sorties, elles, ne sont pas meilleures que sur un système monoprocesseur. Elles sont peut-être même un peu moins bonnes à cause des conflits de bus entre les processeurs. Ainsi, si votre objectif est d'accélérer les entrées/sorties, prenez soin de choisir un système MPS comportant plusieurs bus PCI indépendants et plusieurs contrôleurs d'entrées/sorties (par exemple : plusieurs chaînes SCSI). Il vous faudra être prudent, et sûr que Linux reconnaît tout votre matériel. Gardez aussi à l'esprit le fait que Linux n'autorise qu'un seul processeur à la fois à entrer en mode noyau, aussi devrez-vous choisir des contrôleurs qui réduisent au minimum le temps noyau nécessaire à leurs opérations. Pour atteindre des performances vraiment très élevées, il se pourrait même qu'il vous faille envisager d'effectuer vous-même les opérations d'entrée/sortie de bas niveau directement depuis les processus utilisateurs, sans appel système… ce n'est pas forcément aussi difficile que cela en a l'air, et cela permet d'éviter de compromettre la sécurité du système (voir la section 3.3 pour une description des techniques de base).

Il est important de remarquer que la relation entre vitesse du bus et vitesse du processeur est devenue très floue ces dernières années. Bien que la plupart des systèmes utilisent maintenant la même fréquence de bus PCI, il n'est pas rare de trouver un processeur rapide apparié avec un bus lent. L'exemple classique est celui du Pentium 133 qui utilise en général un bus plus rapide que celui du Pentium 150, produisant des résultats étranges sur les logiciels bancs de tests (« benchmarks »). Ces effets sont amplifiés sur les systèmes SMP. Il est encore plus important d'utiliser un bus rapide.

2.1.3. Interfoliage de la mémoire et technologie DRAM

L'interfoliage de la mémoire n'a en fait absolument rien à voir avec le MPS, mais vous verrez souvent cette mention accompagner les systèmes MPS car ces derniers sont typiquement gourmands en bande passante mémoire. Concrètement, l'interfoliage en deux ou en quatre voies organise la RAM de façon à ce que l'accès à un bloc de mémoire se fasse au travers de plusieurs bancs de RAM plutôt qu'un seul. Ceci accélère grandement les accès à la mémoire, particulièrement en ce qui concerne le chargement et l'enregistrement du contenu des lignes de cache.

Il faut toutefois souligner que ce fait n'est pas aussi évident qu'il y parait, car la DRAM EDO et les différentes technologies mémoire tendent à optimiser ce genre d'opérations. Un excellent aperçu des différentes technologies DRAM est disponible sur http://www.pcguide.com/ref/ram/tech.htm.

Ainsi, par exemple, mieux vaut-il avoir de la mémoire DRAM EDO interfoliée à 2 voies, ou de la mémoire SDRAM non interfoliée ? C'est une très bonne question et la réponse n'est pas simple, car la mémoire interfoliée comme les technologies DRAM exotiques ont tendance à être coûteuses. Le même investissement en mémoire plus ordinaire vous apporte en général une mémoire centrale bien plus vaste. Même la plus lente des mémoire DRAM reste autrement plus rapide que la mémoire virtuelle par fichier d'échange…

2.2. Introduction à la programmation en mémoire partagée

Okay, donc vous avez décidé que le traitement en parallèle sur SMP, c'est génial… Par quoi allez-vous commencer ? Eh bien, la première étape consiste à en apprendre un peu plus sur le fonctionnement réel de la communication par mémoire partagée.

A première vue, il suffit qu'un processeur range une valeur en mémoire et qu'un autre la lise. Malheureusement, ce n'est pas aussi simple. Par exemple, les relations entre processus et processeurs sont très floues. En revanche, si nous n'avons pas plus de processus actifs que de processeurs, les termes sont à peu près interchangeables. Le reste de cette section résume brièvement les cas de figure typiques qui peuvent poser de sérieux problèmes, si vous ne les connaissiez pas déjà : Les deux différents modèles utilisées pour déterminer ce qui est partagé, les problèmes d'atomicité, le concept de volatilité, les instructions de verrouillage matériel, les effets de la ligne de cache, et les problèmes posés par l'ordonnanceur de Linux.

2.2.1. Partage Intégral contre Partage Partiel

Il existe deux modèles fondamentaux couramment utilisés en programmation en mémoire partagée : le partage intégral et le partage partiel. Ces modèles permettent tous deux aux processeurs de communiquer en chargeant et rangeant des données depuis et dans la mémoire. La différence réside dans le fait que le partage intégral place toutes les structures en mémoire partagée, quand le partage partiel, lui, distingue les structures qui sont potentiellement partageables et celles qui sont privées, propres à un seul processeur (et oblige l'utilisateur à classer explicitement ses structures dans l'une de ces catégories).

Alors quel modèle de partage mémoire faut-il utiliser ? C'est surtout une affaire de chapelle. Beaucoup de gens aiment le partage intégral car ils n'ont pas spécialement besoin d'identifier les structures qui doivent être partagées au moment de leur déclaration. On place simplement des verrous sur les objets auxquels l'accès peut créer des conflits, pour s'assurer qu'un seul processeur (ou processus) y accède à un moment donné. Mais là encore, ce n'est pas aussi simple… aussi beaucoup d'autres gens préfèrent, eux, le modèle relativement sûr du partage partiel.

2.2.1.1. Partage intégral

Le bon coté du partage intégral est que l'on peut aisément reprendre un programme séquentiel existant et le convertir progressivement en programme parallèle en partage intégral. Vous n'avez pas à déterminer au préalable les données qui doivent être accessibles aux autres processeurs.

Posé simplement, le principal problème avec le partage intégral vient du fait qu'une action effectuée par un processeur peut affecter les autres processeurs. Ce problème ressurgit de deux manières :

  • Plusieurs bibliothèques utilisent des structures de données qui ne sont tout simplement pas partageables. Par exemple, la convention UNIX stipule que la plupart des fonctions peuvent renvoyer un code d'erreur dans une variable appelée errno. Si deux processus en partage intégral font des appels divers, ils vont interférer l'un sur l'autre car ils partagent la même variable errno. Bien qu'il existe désormais une bibliothèque qui règle le problème de cette variable, ce problème continue d'exister dans la plupart des bibliothèques comme par exemple X-Window qui, à moins de prendre des précautions très spéciales, ne fonctionnera pas si différents appels sont passés depuis différents processus en partage intégral.

  • En temps normal, un programme qui utilise un pointeur ou un index défaillant provoque au pire l'arrêt du processus qui contient le code corrompu. Il peut même générer un fichier core vous renseignant sur les conditions dans lesquelles se sont déroulés les événements. En programmation parallèle à partage intégral, il est fort probable que les accès illégaux provoquent la fin d'un processus qui n'est pas le fautif, rendant la localisation et la correction de l'erreur quasiment impossibles.

Aucun de ces deux problèmes n'est courant dans le cas du partage partiel, car seules sont partagées les structures explicitement marquées comme telles. De plus, il est trivial que le partage intégral ne peut fonctionner que si les processeurs exécutent exactement la même image en mémoire. On ne peut pas utiliser le partage intégral entre des images de code différentes (c'est-à-dire que vous pourrez travailler en SPMD, mais pas d'une manière générale en MIMD).

Les supports de programmation en partage intégral existent le plus couramment sous la forme de bibliothèques de threads. Les threads sont essentiellement des processus « allégés » dont l'exécution peut ne pas être planifiée comme celle des processus UNIX normaux et qui, c'est le plus important, partagent tous la même page mémoire. L'adaptation des Pthreads POSIX a fait l'objet de nombreux efforts. La grande question est : ces adaptations parallélisent-elles les threads d'un programme en environnement Linux SMP (idéalement, en attribuant un processeur à chaque thread) ?. L'API POSIX ne l'impose pas, et certaines versions comme PCthreads semblent ne pas implémenter une exécution en parallèle des threads : tous les threads d'un programme sont conservés à l'intérieur d'un seul processus Linux.

La première bibliothèque de threads à avoir pris en charge le parallélisme sous Linux SMP fut la désormais quelque peu obsolète bibliothèque bb_thread, une toute petite bibliothèque qui utilisait l'appel Linux clone() pour donner naissance à de nouveaux processus Linux, planifiés indépendamment les uns des autres, tous partageant un même espace d'adressage. Les machines Linux SMP peuvent lancer plusieurs de ces « threads » car chaque « thread » est un processus Linux à part entière. L'inconvénient, c'est que l'on ne peut obtenir l'ordonnancement « poids-plume » apportée par les bibliothèques de threads d'autres systèmes d'exploitation. La bibliothèque utilisait un peu de code assembleur intégré dans un code source en langage C pour mettre en place un bloc de mémoire pour la pile de chaque thread et fournir des fonctions d'accès atomiques à un tableau de verrous (les mutex). Sa documentation se résumait à un fichier LISEZMOI et à un court programme d'exemple.

Plus récemment, une version de threads POSIX utilisant clone() a été développée. Cette bibliothèque, LinuxThreads, est clairement la bibliothèque en partage intégral favorite pour l'utilisation sous Linux SMP. Les threads POSIX sont bien documentés, et les documents LinuxThreads README et LinuxThreads FAQ sont vraiment très bien réalisés. A présent, le principal problème est que les threads POSIX ont encore beaucoup de détails à régler, et que LinuxThread est toujours un projet en cours d'évolution. D'autre part, les threads POSIX ont évolué pendant dans leur phase de standardisation, aussi devrez-vous être prudent pour ne pas développer en suivant une version obsolète du standard.

2.2.1.2. Partage Partiel

Le Partage Partiel consiste réellement à « ne partager que ce qui doit être partagé ». Cette approche est valable pour le MIMD en général (et pas simplement le SPMD) à condition de prendre soin d'allouer les objets partagés aux mêmes endroits dans le plan mémoire de chaque processeur. Plus important encore, le partage partiel facilite l'estimation et l'ajustage des performances, le débogage des sources, et cætera. Les seuls problèmes sont :

  • Déterminer à l'avance ce qui doit être partagé peut s'avérer difficile.

  • L'allocation d'objets dans la mémoire partagée peut en fait se révéler malaisé, spécialement en ce qui concerne tout ce qui aurait du être déclaré dans la pile. Par exemple, il peut être nécessaire d'allouer explicitement des objets partagés dans des segments de mémoire séparés, nécessitant des routines d'allocation mémoire séparées, et impliquant l'ajout de pointeurs et d'indirections supplémentaires à chaque référence.

Actuellement, il existe deux mécanismes similaires permettant aux groupes de processus sous Linux de posséder des espaces mémoire indépendants, mais de tous partager un unique et relativement étroit segment de mémoire. En supposant que vous n'ayez pas bêtement exclu l'option « System V IPC » lorsque que vous avez configuré votre système Linux (N.D.T. : ici à la recompilation du noyau), Linux gère un mécanisme très portable devenu célèbre sous le nom de « mémoire partagée System V ». L'autre alternative est une fonction de projection en mémoire dont l'implémentation varie grandement selon le système UNIX utilisé : L'appel système mmap. Vous pouvez — et devriez — apprendre le fonctionnement de ces primitives au travers des pages du manuel (man pages)… mais vous trouverez quand même un rapide aperçu de chacune d'elles dans les sections 2.5 et 2.6 pour vous aider à démarrer.

2.2.2. Atomicité et ordonnancement

Que vous utilisiez l'un ou l'autre des modèles cités ci-dessus, le résultat est à peu près le même : vous obtenez un pointeur sur un bloc de mémoire en lecture/écriture accessible par tous les processus de votre programme en parallèle. Cela signifie-t-il que je peux laisser mes programmes accéder aux objets partagés comme s'ils se trouvaient en mémoire locale ordinaire ? Pas tout à fait…

L'atomicité désigne une opération sur un objet effectuée en une séquence indivisible et ininterruptible. Malheureusement, les accès à la mémoire partagée n'impliquent pas que les toutes les opérations sur les données de cette mémoire se fassent de manière atomique. A moins de prendre des précautions spéciales, seules les opérations de lecture ou d'écriture s'accomplissant en une seule transaction sur le bus (c'est-à-dire alignées sur une adresse multiple de 8, 16 ou 32 bits, à l'exclusion des opérations 64 bits ou mal alignées) sont atomiques. Pire encore, les compilateurs « intelligents » comme GCC font souvent des optimisations qui peuvent éliminer les opérations mémoire nécessaires pour s'assurer que les autres processeurs puissent voir ce que le processeur concerné a fait. Heureusement, ces problèmes ont tous deux une solution… en acceptant seulement de ne pas se soucier de la relation entre l'efficacité des accès mémoire et la taille de la ligne de cache.

En revanche, avant de traiter de ces différents cas de figure, il est utile de préciser que tout ceci part du principe que les références à la mémoire pour chaque processeur se produisent dans l'ordre où elles ont été programmées. Le Pentium fonctionne de cette manière, mais les futurs processeurs d'Intel pourraient ne pas le faire. Aussi, quand vous développerez sur les processeurs à venir, gardez à l'esprit qu'il pourrait être nécessaire d'encadrer les accès à la mémoire avec des instructions provoquant l'achèvement de toutes les accès à la mémoire en suspens, provoquant ainsi leur mise en ordre. L'instruction CPUID semble provoquer cet effet.

2.2.3. Volatilité

Pour éviter que l'optimiseur du GCC ne conserve les valeurs de la mémoire partagée dans les registres de processeur, tous les objets en mémoire partagée doivent être déclarés avec l'attribut volatile. Tous les accès en lecture ou écriture ne nécessitant l'accès qu'à un seul mot se feront alors de manière atomique. Par exemple, en supposant que p est un pointeur sur un entier, et que ce pointeur comme l'entier qu'il pointe se trouvent en mémoire partagée, la déclaration en C ANSI ressemblera à :

volatile int * volatile p;

Dans ce code, le premier volatile concerne l'int que p pointe éventuellement, quand le second volatile s'applique au pointeur lui-même. Oui, c'est ennuyeux, mais c'est le prix à payer pour que GCC puisse faire des optimisations vraiment puissantes. En théorie, l'option -traditional devrait suffire à produire du code correct au prix de quelques optimisations, car le standard C K&R (N.D.T. : Kernigan & Ritchie) pré norme ANSI établit que toutes les variables sont volatiles si elles ne sont pas explicitement déclarées comme register. Ceci étant dit, si vos compilations GCC ressemblent à cc -O6 …, vous n'aurez réellement besoin de déclarer les choses comme étant volatiles qu'aux endroits où c'est nécessaire.

Un rumeur a circulé à propos du fait que les verrous écrits en assembleur signalés comme modifiant tous les registres du processeur provoquaient de la part du compilateur GCC l'enregistrement adéquat de toutes les variables en suspens, évitant ainsi le code compilé « inutile » associé aux objets déclarés volatile. Cette astuce semble fonctionner pour les variables globales statiques avec la version 2.7.0 de GCC… En revanche, ce comportement n'est pas une recommandation du standard C ANSI. Pire encore, d'autres processus n'effectuant que des accès en lecture pourraient conserver éternellement les valeurs dans des registres, et ainsi ne jamais s'apercevoir que la vraie valeur stockée en mémoire partagée a en fait changé. En résumé, développez comme vous l'entendez, mais seules les variables déclarées volatile offrent un fonctionnement normal garanti.

Notez qu'il est possible de provoquer un accès volatile à une variable ordinaire en utilisant un transtypage (« casting ») imposant l'attribut volatile. Par exemple, un int i; ordinaire peut être référencé en tant que volatile par *((volatile int *) &i); . Ainsi, vous pouvez forcer la volatilité et les coûts supplémentaires qu'elle engendre seulement aux endroits où elle est critique.

2.2.4. Verrous (Locks)

Si vous pensiez que ++i; aurait toujours incrémenté une variable i sans problème, vous allez avoir une mauvaise surprise : même codées en une seule instruction, le chargement et l'enregistrement du résultat sont deux transactions mémoire séparées, et d'autres processeurs peuvent accéder à i entre ces deux transactions. Par exemple, deux processus effectuant chacun l'instruction ++i; pourraient n'incrémenter la variable i que d'une unité et non deux. Selon le « Manuel de l'Architecture et de la Programmation » du Pentium d'Intel, le préfixe LOCK peut être employé pour s'assurer que chacune des instructions suivantes soit atomique par rapport à l'adresse mémoire à laquelle elles accèdent :

BTS, BTR, BTC                     mem, reg/imm
XCHG                              reg, mem
XCHG                              mem, reg
ADD, OR, ADC, SBB, AND, SUB, XOR  mem, reg/imm
NOT, NEG, INC, DEC                mem
CMPXCHG, XADD

En revanche, il n'est pas conseillé d'utiliser toutes ces opérations. Par exemple, XADD n'existait même pas sur 386, aussi l'employer en programmation peut poser des problèmes de portabilité.

L'instruction XCHG engendre toujours un verrou, même sans le préfixe LOCK, et est ainsi et indiscutablement l'opération atomique favorite pour construire d'autres opérations atomiques de plus haut niveau comme les sémaphores et les files d'attente partagées. Bien sûr, on ne peut pas demander à GCC de générer cette instruction en écrivant simplement du code C. Il vous faudra à la place écrire un peu de code assembleur en ligne[5]. En prenant un objet volatile obj et un registre du processeur reg, tous deux de type word (longs de 16 bits), le code assembleur GCC sera :

__asm__ __volatile__ ("xchgl %1,%0"
                      :"=r" (reg), "=m" (obj)
                      :"r" (reg), "m" (obj));

Quelques exemples de programmes assembleur en ligne utilisant des opérations bit-à-bit pour réaliser des verrous sont disponibles dans le code source de la bibliothèque bb_threads.

Il est toutefois important de se souvenir que faire des transactions mémoire atomiques a un coût. Une opération de verrouillage engendre des délais supplémentaires assez importants et peut retarder l'activité mémoire d'autres processeurs, quand des références ordinaires auraient utilisé le cache local. Les meilleures performances s'obtiennent en utilisant les opérations atomiques aussi peu souvent que possible. De plus, ces instructions atomiques IA32 ne sont évidement pas portables vers d'autres systèmes.

Il existe plusieurs alternatives permettant aux instructions ordinaires d'être utilisées pour mettre en œuvre différents types de synchronisation, y compris l'exclusion mutuelle, qui garantit qu'au plus un seul processeur met à jour un objet partagé donné à un moment précis. La plupart des manuels des différents systèmes d'exploitation traitent d'au moins une de ces techniques. On trouve un très bon exposé sur le sujet dans la quatrième édition des Operating System Concepts (Principes des Systèmes d'Exploitation), par Abraham Silberschatz et Peter B. Galvin, ISBN 0-201-50480-4.

2.2.5. Taille de la ligne de cache

Encore une chose fondamentale concernant l'atomicité et qui peut avoir des conséquence dramatiques sur les performances d'un SMP : la taille de la ligne de cache. Même si le standard MPS impose que les références soient cohérentes quelque soit le cache utilisé, il n'en reste pas moins que lorsque qu'un processeur écrit sur une ligne particulière de la mémoire, chaque copie en cache de l'ancienne ligne doit être invalidée ou mise à jour. Ceci implique que si au moins deux processeurs écrivent chacun sur des portions différentes de la ligne de cache, cela peut provoquer un trafic important sur le bus et le cache, pour au final transférer la ligne depuis le cache vers le cache. Ce problème est connu sous le nom de faux partage (« false sharing »). La solution consiste uniquement à organiser les données de telle manière que ce que les objets auxquels on accède en parallèle proviennent globalement de différentes lignes de cache pour chaque processus.

Vous pourriez penser que le faux partage n'est pas un problème quand on utilise un cache de niveau 2 partagé, mais souvenez-vous qu'il existe toujours des caches de niveau 1 séparés. L'organisation du cache et le nombre de niveaux séparés peut varier, mais la ligne de cache de premier niveau d'un Pentium est longue de 32 octets, et le cache externe typique tourne autour de 256 octets. Supposons que les adresses (physiques ou logiques) de deux objets soient a et b, et que la taille de la ligne de cache soit c, que nous admettrons être une puissance de 2. Pour être très précis, si ((int) a) & ˜(c-1) est égal à ((int) b) & ˜(c-1), alors les deux références se trouvent dans la même ligne de cache. Une règle plus simple consiste à dire que si deux objets référencés en parallèle sont éloignés d'au moins c octets, ils devraient se trouver dans des lignes de cache différentes.

2.2.6. Les problèmes de l'ordonnanceur de Linux

Bien que tout l'intérêt d'utiliser de la mémoire partagée pour les traitements en parallèle consiste à éviter les délais dûs au système d'exploitation, ces délais peuvent parfois provenir d'autres choses que les communications en elles-mêmes. Nous avons déjà remarqué que le nombre de processus que l'on devrait créer doit être inférieur ou égal au nombre de processeurs de la machine. Mais comment décide-t-on exactement du nombre de processus à créer ?

Pour obtenir les meilleures performances, le nombre de processus de votre programme en parallèle doit être égal au nombre de processus qui peuvent être exécutés simultanément, chacun sur son processeur. Par exemple, si un système SMP à quatre processeurs héberge un processus très actif pour un autre usage (par exemple un serveur web), alors votre programme en parallèle ne devra utiliser que trois processus. Vous pouvez vous faire une idée générale du nombre de processus actifs exécutés sur votre système en consultant la « charge système » (« load average ») mis en évidence par la commande uptime.

Vous pouvez en outre « pousser » la priorité de vos processus de votre programme parallèle en utilisant, par exemple, la commande renice ou l'appel système nice(). Vous devez être privilégié[6] pour augmenter la priorité d'un processus. L'idée consiste simplement à éjecter les autres programmes des autres processeurs pour que votre programme puisse être exécuté sur tous les processeurs simultanément. Ceci peut être effectué de manière un peu plus explicite en utilisant la version prototype de Linux SMP disponible sur http://www.fsmlabs.com/products/openrtlinux/ et qui propose un ordonnanceur en temps réel (N.D.T. : il existe désormais un guide consacré à RTLinux, accessible en ligne : RTLinux HOWTO).

Si vous n'êtes pas le seul utilisateur employant votre système SMP comme une machine en parallèle, il se peut que vous entriez en conflit avec les autres programmes en parallèle essayant de s'exécuter simultanément. La solution standard est l'ordonnancement de groupe (« gang scheduling »), c'est-à-dire la manipulation de la priorité d'ordonnancement de façon à ce que seuls les processus d'un seul programme en parallèle s'exécutent à un moment donné. Il est bon de rappeler, en revanche, que multiplier les parallélismes tend à réduire les retours et que l'activité de l'ordonnanceur introduit des délais supplémentaires. Ainsi, par exemple, il sera sûrement préférable, pour une machine à quatre processeurs, d'exécuter deux programmes contenant chacun deux processus, plutôt que d'ordonnancer en groupe deux programmes de quatre processus chacun.

Il y a encore une chose dont il faut tenir compte. Supposons que vous développiez un programme sur une machine très sollicitée le jour, mais disponible à cent pour cent pendant la nuit pour le traitement en parallèle. Il vous faudra écrire et tester votre code dans les conditions réelles, donc avec tous ses processus lancés, même en sachant que des tests de jour risquent d'être lents. Ils seront en fait très lents si certains de vos processus sont en état d'attente active (« busy waiting »)[7], guettant le changement de certaines valeurs en mémoire partagée, changement censé être provoqué par d'autres processus qui ne sont pas exécutés (sur d'autres processeurs) au même moment. Ce même problème apparaît lorsque l'on développe et que l'on teste un programme sur un système monoprocesseur.

La solution consiste à intégrer des appels système à votre code là où il peut se mettre en boucle en attendant une action d'un autre processeur, pour que Linux puisse donner une chance de s'exécuter à un autre processus. J'utilise pour cela une macro en langage C, appelons-la IDLE_ME (N.D.T. : MetsMoiEnAttente) : pour faire un simple test, compilez votre programme par « cc -DIDLE_ME=usleep(1);… ». Pour produire un exécutable définitif, utilisez « cc -DIDLE_ME={}… ». L'appel usleep(1) réclame une pause d'une microseconde, qui a pour effet de permettre à l'ordonnanceur de Linux de choisir un nouveau processus à exécuter sur ce processeur. Si le nombre de processus dépasse le nombre de processeurs disponibles, il n'est pas rare de voir des programmes s'exécuter dix fois plus rapidement avec usleep(1) que sans.

2.3. bb_threads

La bibliothèque bb_threads ("Bare Bones" threads) est une bibliothèque remarquablement simple qui fait la démonstration de l'utilisation de l'appel système Linux clone(). Le fichier tar.gz n'occupe que 7 ko ! Bien que cette bibliothèque ait été rendue pour l'essentiel obsolète par la bibliothèque LinuxThreads, traitée dans la section 2.4, bb_threads reste utilisable, et est suffisamment simple et peu encombrante pour former une bonne introduction à la gestion des threads sous Linux. Il est beaucoup moins effrayant de se lancer dans la lecture de ce code source que dans celui de LinuxThreads. En résumé, la bibliothèque bb_threads forme un bon point de départ, mais n'est pas vraiment adaptée à la réalisation de grands projets.

La structure de base des programmes utilisant la bibliothèque bb_threads est la suivante :

  1. Lancez le programme en tant que processus unique.

  2. Il vous faudra estimer l'espace maximum dans la pile qui sera nécessaire à chaque thread. Prévoir large est relativement sage (c'est à çà que sert la mémoire virtuelle ;-), mais souvenez-vous que toutes les piles proviennent d'un seul espace d'adressage virtuel, aussi voir trop grand n'est pas une idée formidable. La démo suggère 64Ko. Cette taille est fixée à b octets par bb_threads_stacksize(b).

  3. L'étape suivante consiste à initialiser tous les verrous dont vous aurez besoin. Le mécanisme de verrouillage intégré à cette bibliothèque numérote les verrous de 0 à MAX_MUTEXES, et initialise un verrou i par bb_threads_mutexcreate(i).

  4. La création d'un nouveau thread s'effectue en appelant une routine de la bibliothèque recevant en arguments la fonction que le nouveau thread doit exécuter, et les arguments qui doivent lui être transmis. Pour démarrer un nouveau thread exécutant la fonction f de type void et attendant un argument arg, l'appel ressemblera à bb_threads_newthread (f, &arg), où f devra être déclaré comme suit :

    void f (void *arg, size_t dummy)
    

    Si vous avez besoin de passer plus d'un argument à votre fonction, utilisez un pointeur sur une structure contenant les valeurs à transmettre.

  5. Lancement du code en parallèle, en prenant soin d'utiliser bb_threads_lock(n) et bb_threads_unlock(n)n est un entier indiquant le verrou à utiliser. Notez que les opérations de verrouillage et déverrouillage sont des opérations de blocage[8] très primaires et utilisant des instructions atomiques de verrouillage du bus, lesquelles peuvent causer des interférences d'accès à la mémoire, et qui n'essaient en aucun cas d'agir « proprement ». Le programme de démonstration fourni avec bb_threads n'utilisait pas correctement les verrous pour empêcher printf() d'être exécuté depuis les fonctions fnn et main, et à cause de cela, la démo ne fonctionne pas toujours. Je ne dis pas cela pour démolir la démo, mais plutôt pour bien mettre en évidence le fait que ce travail comporte beaucoup de pièges. Ceci dit, utiliser LinuxThreads ne se révèle que légèrement plus facile.

  6. Lorsqu'un thread exécute return, il détruit le processus… mais la pile locale n'est pas automatiquement désallouée. Pour être plus précis, Linux ne gère pas la désallocation, et l'espace mémoire n'est pas automatiquement rendu à la liste d'espace libre de malloc(). Aussi, le processus parent doit-il récupérer l'espace mémoire de chaque processus fils mort par bb_threads_cleanup(wait(NULL)).

Le programme suivant, écrit en langage C, utilise l'algorithme traité dans la section 1.3 pour calculer la valeur de Pi en utilisant deux threads bb_threads.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include "bb_threads.h"

volatile double pi = 0.0;
volatile int intervalles;
volatile int pids[2];      /* Numéros de processus Unix des threads */

void
do_pi(void *data, size_t len)
{
  register double largeur, sommelocale;
  register int i;
  register int iproc = (getpid() != pids[0]);

  /* Fixe la largeur des intervalles */
  largeur = 1.0 / intervalles;

  /* Effectue les calculs locaux */
  sommelocale = 0;
  for (i=iproc; i<intervalles; i+=2) {
    register double x = (i + 0.5) * largeur;
    sommelocale += 4.0 / (1.0 + x * x);
  }
  sommelocale *= largeur;

  /* Obtention des permissions, mise à jour de Pi, et déverrouillage */
  bb_threads_lock(0);
  pi += sommelocale;
  bb_threads_unlock(0);
}

int
main(int argc, char **argv)
{
  /* Récupère le nombre d'intervalles */
  intervalles = atoi(argv[1]);

  /* Fixe la taille de la pile, et crée le verrou */
  bb_threads_stacksize(65536);
  bb_threads_mutexcreate(0);

  /* crée deux threads ... */
  pids[0] = bb_threads_newthread(do_pi, NULL);
  pids[1] = bb_threads_newthread(do_pi, NULL);

  /* nettoie derrière les deux threads */
  /* (forme ainsi une barrière de synchro) */

  bb_threads_cleanup(wait(NULL));
  bb_threads_cleanup(wait(NULL));

  /* Affiche le résultat */
  printf("Estimation de la valeur de Pi: %f\n", pi);

  /* Sortie avec code de SUCCES */
  exit(0);
}

2.4. LinuxThreads

LinuxThreads (http://pauillac.inria.fr/~xleroy/linuxthreads/) est une implémentation assez complète et bien construite en accord avec le standard de threads POSIX 1003.1c. Contrairement aux autres adaptations d'implémentations de threads POSIX, LinuxThreads utilise également l'appel clone() du noyau Linux, déjà employé par bb_threads. La compatibilité POSIX implique qu'il est relativement aisé de faire l'adaptation de certaines applications provenant d'autres systèmes, et différents tutoriels et leur support sont disponibles. Bref, c'est incontestablement la bibliothèque à utiliser pour développer des applications multi-threads à grande échelle sous Linux.

La structure de base d'un programme utilisant LinuxThreads suit ce modèle :

  1. Lancement du programme en tant que processus unique.

  2. Initialisation de tous les verrous dont vous aurez besoin. Contrairement aux verrous de bb_threads qui sont identifiés par des numéros, les verrous POSIX sont déclarés comme des variables de type pthread_mutex_t lock. Utilisez pthread_mutex_init(&lock,val) pour initialiser chacun des verrous que vous utiliserez.

  3. Comme avec bb_threads, la création d'un nouveau thread se fait par l'appel d'une fonction de la bibliothèque admettant des arguments spécifiant à leur tour la fonction que le nouveau thread doit exécuter et les arguments que celle-ci reçoit. Cependant, POSIX impose à l'utilisateur la déclaration d'une variable de type pthread_t pour identifier chaque thread. Pour créer un thread pthread_t thread exécutant la fonction f(), on appelle pthread_create(&thread,NULL,f,&arg).

  4. Lancement de la partie parallèle du programme, en prenant soin d'utiliser pthread_mutex_lock(&lock) et pthread_mutex_unlock(&lock) comme il se doit.

  5. Utilisation de pthread_join(thread,&retval) après chaque thread pour tout nettoyer.

  6. Utilisation de -D_REENTRANT à la compilation de votre programme en C.

Voici l'exemple du calcul de Pi en parallèle, s'appuyant sur LinuxThreads. L'algorithme de la section 1.3 est utilisé et, comme pour l'exemple de bb_threads, deux threads s'exécutent en parallèle.

#include <stdio.h>
#include <stdlib.h>
#include "pthread.h"

volatile double pi = 0.0;    /* Approximation de pi (partagée) */
pthread_mutex_t pi_lock;     /* Verrou de la variable ci-dessous */
volatile double intervalles; /* Combien d'intervalles ? */

void *
process(void *arg)
{
  register double largeur, sommelocale;
  register int i;
  register int iproc = (*((char *) arg) - '0');

  /* Fixe la largeur */
  largeur = 1.0 / intervalles;

  /* Fais les calculs locaux */
  sommelocale = 0;
  for (i=iproc; i<intervalles; i+=2) {
    register double x = (i + 0.5) * largeur;
    sommelocale += 4.0 / (1.0 + x * x);
  }
  sommelocale *= largeur;

  /* Verrouille la variable pi en vue d'une mise à jour,
     effectue la mise à jour, puis déverrouille Pi. */

  pthread_mutex_lock(&pi_lock);
  pi += sommelocale;
  pthread_mutex_unlock(&pi_lock);

  return(NULL);
}

int
main(int argc, char **argv)
{
  pthread_t thread0, thread1;
  void * retval;

  /* Récupère le nombre d'intervalles */
  intervalles = atoi(argv[1]);

  /* Initialise un verrou sur pi */
  pthread_mutex_init(&pi_lock, NULL);

  /* Crée les deux threads */
  if (pthread_create(&thread0, NULL, process, "0") ||
      pthread_create(&thread1, NULL, process, "1")) {
    fprintf(stderr, "%s: Création des threads impossible\n", argv[0]);
    exit(1);
  }

  /* « Joint » (détruit) les deux threads */
  if (pthread_join(thread0, &retval) ||
      pthread_join(thread1, &retval)) {
    fprintf(stderr, "%s: Erreur à la fusion des threads\n", argv[0]);
    exit(1);
  }

  /* Affiche le résultat */
  printf("Estimation de la valeur de Pi: %f\n", pi);

  /* Sortie */
  exit(0);
}

2.5. La mémoire partagée de System V

La gestion des IPC (Inter-Process Communication) System V s'effectue au travers d'un certain nombre d'appels système fournissant les mécanismes des files de message, des sémaphores et de la mémoire partagée. Bien sûr, ces mécanismes ont été initialement conçus pour permettre à plusieurs processus de communiquer au sein d'un système monoprocesseur. Cela signifie néanmoins que ces mécanismes devraient aussi fonctionner dans un système Linux SMP, quelque soit le nombre de processeurs.

Avant d'aller plus loin dans l'utilisation de ces appels, il est important de comprendre que même s'il existe des appels IPC System V pour des choses comme les sémaphores et la transmission de messages, vous ne les utiliserez probablement pas. Pourquoi ? Parce ces fonctions sont généralement lentes et sérialisées sous Linux SMP. Inutile de s'étendre.

La marche à suivre standard pour créer un groupe de processus partageant l'accès à un segment de mémoire partagée est la suivante.

  1. Lancement du programme en tant que processus unique.

  2. En temps normal, chaque instance de votre programme en parallèle devra avoir son propre segment de mémoire partagée, aussi vous faudra-t-il appeler shmget() pour créer un nouveau segment de la taille souhaitée. Mais d'autre part, cet appel peut être utilisé pour récupérer l'identifiant d'un segment de mémoire partagée déjà existant. Dans les deux cas, la valeur de retour est soit l'identifiant du segment de mémoire partagée, soit -1 en cas d'erreur. Par exemple, pour créer un segment de mémoire partagée long de b octets, on passe un appel ressemblant à shmid = shmget(IPC_PRIVATE, b, (IPC_CREAT | 0666)).

  3. L'étape suivante consiste à attacher ce segment de mémoire partagée au processus, c'est-à-dire l'ajouter à son plan mémoire. Même si l'appel shmat() permet au programmeur de spécifier l'adresse virtuelle à laquelle le segment doit apparaître, cette adresse doit être alignée sur une page (plus précisément être un multiple de la taille d'une page renvoyée par getpagesize(), correspondant à 4096 octets), et recouvrera (prendra le pas sur) tout segment de mémoire s'y trouvant déjà. Ainsi est-il plus sage de laisser le système choisir une adresse. Dans les deux cas, la valeur de retour est un pointeur sur l'adresse virtuelle de base du segment fraîchement installé dans le plan mémoire. L'instruction correspondante est la suivante : shmptr = shmat(shmid, 0, 0). Remarquez que vous pouvez allouer toutes vos variables statiques dans ce segment de mémoire partagée en déclarant simplement vos variables partagées comme étant les membres d'une structure de type struct, et en déclarant shmptr comme étant un pointeur vers ce type de données. Avec cette technique, une variable partagée x serait accessible par shmptr->x.

  4. Comme ce segment de mémoire partagée doit être détruit quand le dernier processus à y accéder prend fin ou s'en détache, il nous faut appeler shmctl() pour configurer cette action par défaut. Le code correspondant ressemble à shmctl(shmid, IPC_RMID, 0).

  5. Utiliser l'appel Linux fork()[9] pour créer le nombre désiré de processus. Chacun d'eux héritera du segment de mémoire partagée.

  6. Lorsqu'un processus a fini d'utiliser un segment de mémoire partagée, il doit s'en détacher. On accomplit cela par un shmdt(shmptr).

Même avec si peu d'appels système, une fois le segment de mémoire partagée établi, tout changement effectué par un processeur sur une valeur se trouvant dans cet espace sera automatiquement visible par les autres processus. Plus important, chaque opération de communication sera exonérée du coût d'un appel système.

Ci-après, un exemple de programme en langage C utilisant les segments de mémoire partagée System V. Il calcule Pi, en utilisant les algorithmes de la section 1.3.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ipc.h>
#include <sys/shm.h>

volatile struct shared { double pi; int lock; } * partage;

inline extern int xchg(register int reg,
volatile int * volatile obj)
{
  /* Instruction atomique d'échange */
__asm__ __volatile__ ("xchgl %1,%0"
                      :"=r" (reg), "=m" (*obj)
                      :"r" (reg), "m" (*obj));
  return(reg);
}

main(int argc, char **argv)
{
  register double largeur, sommelocale;
  register int intervalles, i;
  register int shmid;
  register int iproc = 0;;

  /* Alloue de la mémoire partagée */
  shmid = shmget(IPC_PRIVATE,
                 sizeof(struct shared),
                 (IPC_CREAT | 0600));
  partage = ((volatile struct shared *) shmat(shmid, 0, 0));
  shmctl(shmid, IPC_RMID, 0);

  /* Fais les inits ... */
  partage->pi = 0.0;
  partage->lock = 0;

  /* Crée un fils */
  if (!fork()) ++iproc;

  /* Récupère le nombre d'intervalles */
  intervalles = atoi(argv[1]);
  largeur = 1.0 / intervalles;

  /* Fais les calculs locaux */
  sommelocale = 0;
  for (i=iproc; i<intervalles; i+=2) {
    register double x = (i + 0.5) * largeur;
    sommelocale += 4.0 / (1.0 + x * x);
  }
  sommelocale *= largeur;

  /* Verrou d'attente atomique, ajout, et déverrouillage ... */
  while (xchg((iproc + 1), &(shared->lock))) ;
  shared->pi += sommelocale;
  shared->lock = 0;

  /* Fin du processus fils (barrière de synchro) */
  if (iproc == 0) {
    wait(NULL);
    printf("Estimation de pi: %f\n", partage->pi);
  }

  /* Sortie en bonne et due forme */
  return(0);
}

Dans cet exemple, j'ai utilisé l'instruction atomique d'échange pour mettre le verrouillage en œuvre. Pour de meilleures performances, préférez-lui une technique de synchronisation évitant les intructions verrouillant le bus.

Pendant les phases de débogage, il est utile de se souvenir que la commande ipcs renvoie la liste des facilités des IPC System V en cours d'utilisation.

2.6. Projection mémoire (Memory Map Call)

L'utilisation des appels système pour accéder aux fichiers (les entrées/sorties) peut revenir cher. En fait, c'est la raison pour laquelle il existe une bibliothèque de gestion des entrées/sorties gérant un tampon dans l'espace utilisateur (getchar(), fwrite(), et cætera). Mais les tampons utilisateur ne remplissent pas leur fonction si plusieurs processus accèdent au même fichier ouvert en écriture. La solution Unix BSD à ce problème fut l'ajout d'un appel système permettant à une portion d'un fichier d'être projetée en mémoire utilisateur, en utilisant principalement les mécanismes de la mémoire virtuelle pour provoquer les mises à jour. Le même mécanisme a été utilisé pendant plusieurs années dans les systèmes de Sequent comme base de leur gestion du traitement parallèle en mémoire partagée. En dépit de commentaires très négatifs dans la page de manuel (assez ancienne), Linux semble correctement effectuer au moins quelques unes des fonctions de base, et sait prendre en charge l'usage dérivé de cet appel pour projeter un segment anonyme de mémoire pouvant être partagé par plusieurs processus.

L'implémentation Linux de l'appel mmap() est en elle-même une solution intégrée de remplacement des étapes 2, 3 et 4 du schéma classique de mémoire partagée System V, mis en évidence dans la section 2.5. Pour créer un segment de mémoire partagée anonyme :

shmptr =
    mmap(0,                        /* Le système choisit l'adresse */
         b,                        /* Taille du segment de mémoire partagée */
         (PROT_READ | PROT_WRITE), /* droits d'accès, peuvent être rwx */
         (MAP_ANON | MAP_SHARED),  /* anonyme, partagé */
         0,                        /* descripteur de fichier (inutilisé) */
         0);                       /* offset fichier (inutilisé) */

L'équivalent de l'appel de mémoire partagée System V shmdt() est munmap() :

munmap(shmptr, b);

À mon avis, on ne gagne pas grand chose à utiliser mmap() plutôt que les mécanismes de gestion de la mémoire partagée de System V.



[2] N.D.T. : décomposition d'un programme en plusieurs processus distincts, mais travaillant simultanément et de concert.

[3] N.D.T. : qui a démarré la machine avant de passer en mode SMP.

[4] N.D.T. : le lien vers l'ancienne version 1.1, lui, n'existe plus. La documentation la plus récente se trouve à ce jour sur http://www.intel.com/design/Pentium4/documentation.htm.

[5] N.D.T. : inséré au sein du code source, ici en C.

[6] N.D.T. : soit, sous Unix, être sous le compte root.

[7] N.D.T. : temporisations introduites au sein d'un programme en utilisant par exemple des boucles et en consommant ainsi tout le temps machine alloué au processus plutôt qu'en rendant la main au système.

[8] N.D.T. : « spin locks » : mise en état d'attente jusqu'à ce qu'une condition soit remplie.

[9] N.D.T. : Il s'agit en fait d'un appel Unix standard.

Hosting by: Hurra Communications GmbH
Generated: 2007-01-26 18:01:15