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 à
<majordomo CHEZ vger POINT rutgers POINT edu>
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.
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.
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 »).
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.
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…
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 :
Lancez le programme en tant que processus unique.
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)
.
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)
.
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.
Lancement du code en parallèle, en prenant soin d'utiliser
bb_threads_lock(n)
et
bb_threads_unlock(n)
où 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.
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); }
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 :
Lancement du programme en tant que processus unique.
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.
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)
.
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.
Utilisation de pthread_join(thread,&retval)
après chaque thread pour tout nettoyer.
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); }
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.
Lancement du programme en tant que processus unique.
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))
.
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.
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)
.
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.
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.
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