04 fév. 2013

Programmation concurrente : introduction

threads

Sur ce blog, on a déjà parlé des fonctions lambda, des classes d'énumérateurs, des variadic templates et de pas mal d'autres choses concernant la dernière norme C++11. Il nous reste pourtant encore pas mal de sujets importants à aborder - entre les expressions constantes, les rvalue reference, la notion de sémantique de mouvement... les sujets ne manquent pas. Et parmi ces sujets d'importance, il nous en reste un qui, de part son large domaine d'application, va nécessiter plusieurs billets : il s'agit de la programmation concurrente.

Avant d'expliquer les threads et leur gestion dans le langage et dans la librairie standard C++11, j'ai souhaiter faire un petit détour afin d'expliquer ce qu'est réellement un thread et comment ceux-ci sont implémentés dans les systèmes d'exploitation modernes. Cet article revient donc sur l'historique des threads et explique certains des concepts sous-jacents.

De l'histoire des thread[1]

L'invention des thread se perd dans la nuit des temps informatique. C'est au début des années 1960 qu'on commence à penser à la notion de ce qui deviendra plus tard un thread. A cette époque, il faut trouver un moyen pour simplifier et améliorer les systèmes de partage de temps utilisés sur les mainframes. Dans ce temps que les moins de 50 ans ne peuvent pas connaître, un ordinateur était une ressource précieuse, chère, achetée en très petit nombre par des universités ou des sociétés qui, pour rentabiliser leur matériel, louait du temps CPU à ceux qui en avait besoin[2]. Il fallait donc s'assurer d'une part que chaque client obtenait le meilleur de la machine pendant le temps ou elle lui était loué, et d'autre part que plusieurs clients pouvaient profiter de cette offre au même moment.

Les années 60 ont de fait été prolixes en ce qui concerne de la programmation concurrente : démonstration de CTSS en 1961, début de la mise en place du projet Multics et invention de la notion de processus en 1964, invention des sémaphores et des mutex par E.W. Dkikstra en 1965, support de plusieurs processeurs dans Multics en 1968, etc. En fait, toutes les technologies que nous utilisons maintenant ont été inventées pendant cette période faste.

Paradoxalement, les processus légers ont été inventés avant les processus lourds, mais ils ont été correctement identifiés après. Il a fallut pour cela l'invention de la notion de mémoire virtuelle comme moyen de sécuriser l'accès à la mémoire. Ainsi, ce n'est que dans les années 70 que la notion de processus lourd est vraiment mise en place - dans Multics, encore une fois - et qu'un processus prends la forme qu'on lui connait aujourd'hui : chaque processus a son espace mémoire qui n'appartiens qu'à lui. Avant cela, les processus s'exécutaient en partageant la mémoire mise à leur disposition par le système d'exploitation.

Au moment ou Bell Labs se désengage du développement de Multics, il se lance dans une autre aventure - Unix. Unix reprends de nombreux concepts de Multics (processus, lien dynamique des librairies, shell, everything as a file... : bon nombre des concepts que vous prêtez à Unix sont en fait des inventions de Multics). Dès le début, Unix implémente des processus lourds et met en place certains mécanismes de communication entre les process (IPC pour inter process communication) comme les pipes et les signaux. Plus tard, vers la fin des années 70, les développeurs d'Unix améliorent les mécanismes disponibles et offrent la possibilité de créer des processus léger ayant ces caractéristiques similaires aux processus lourds mais se partageant la mémoire et le contexte d'exécution. Les thread, tels que nous les connaissons aujourd'hui, sont nés.

A noter que vous pouvez, si vous le souhaitez, télécharger le code source de Multics (attention : PL/I est un langage peu connu, mais les programmeurs C devraient y retrouver quelques petites choses intéressantes ici et là).

Une note avant de continuer

Dans la suite de ce texte, les termes fil ou thread fait référence aux processus dits légers. Quand aux termes process ou processus, il fait référence uniquement aux processus lourds.

Les différences entre les threads et les processus

Vous avez déjà entendu dire "les threads sont plus léger que les processus" ou quelque chose de similaire (si vous avez entendu l'inverse, vous trouverez ici des arguments pour démontrer que c'est nécessairement faux). Pour bien voir d'ou viens la différence, il faut regarder un peu plus dans le détail comment ces petites bêtes sont implémentés dans les systèmes d'exploitation (en tout cas, dans les systèmes dits préemptifs). Bien évidemment, je ne vais pas me lancer dans l'étude du code source d'un kernel - je vais juste m'attacher à vous énoncer les trois idées importantes de la plupart (toutes ?) des implémentations.

En premier lieu, on a besoin d'une interruption matérielle régulière dont la venue n'est pas liée au code en cours d'exécution (modulo le fait que les interruption matérielles soient inhibées, bien évidemment). Cette interruption agit comme un signal qui dit au système "là, si tu le souhaite, tu peux transférer le contrôle du CPU à quelqu'un d'autre". Tous les systèmes informatiques ont de toute façon besoin d'une horloge pour fonctionner correctement (une horloge est un signal électronique oscillant entre 0 et 1 avec une période définie et stable dans le temps). Généralement, on se sert d'un timer programmable, qui va émettre des interruptions à une fréquence fixe donnée.

En second lieu, on a besoin d'une structure particulière (la forme réelle importe peu) qui nous permette de sauvegarder l'intégralité de l'état dans lequel le processus (léger ou lourd) est au moment où on reçoit l'interruption :

  • état de registres
  • signaux (au sens Unix) délivrés mais non encore traités
  • table des fichiers ouverts
  • ...

Tout ce à quoi le processus fait référence, on doit le sauvegarder. Parmi ces données, il en est une plus importante que les autres puisqu'elle concerne l'exécution du processus/thread en cours : c'est le registre qui donne l'adresse de la prochaine instruction machine à exécuter. Sur un processeur x86 32 bits, c'est le registre eip (sauvegardé sur la pile par le processeur lorsque celui-ci reçoit l'interruption). Sur MIPS, c'est le registre pc, sauvegardé lui aussi par le processeur dans le registre spécial ra lorsqu'on reçoit l'interruption. Bien évidemment, les mécanismes de sauvegarde et de restauration de ce registre dépendent fortement de l'architecture machine.

Avec ces deux pré-requis, on peut déjà implémenter un mécanisme de gestion de plusieurs processus asynchrones - ce n'est pas trivial, ça nécessitera peut-être un peu d'assembleur, mais la tâche n'est pas impossible non plus. Dans l'esprit, il faut juste mettre en place le timer et un scheduler. Le scheduler est appelé sur l'interruption générée par le timer. Par exemple, en pseudo-code :

// dans l'initialisation du système d'exploitation
setup_timer_isr(5 * milliseconds, isr_timer_func);
...
// this should be written in ASM is_timer_func: inhiber les interruptions sauver les registres sur la pile sauver tous les registres dans current_task()->regs y compris ceux qui sont déjà sur la pile y compris le "pointeur instruction" appeler shedule() // voir ci-dessous restorer tous les registres depuis current_task()->regs y compris ceux qui ont été sauvé sur la pile y compris le futur "pointeur instruction" autoriser les interruption end_is_timer_func: fin de la routine
// the call to the scheduler void shedule() { task* current_task = get_current_task(); task* next_task = scheduler_get_next_task(); if (current_task != next_task) { save_exec_context(current_task); set_current_task(next_task); } }

Le pseudo-code a beau être simple, il recèle tout de même un certain nombre de pièges. Cependant, avec une documentation correcte de votre architecture machine, vous devriez y arriver sans trop de problème (la partie vraiment complexe se situant dans la gestion de tâches elles-mêmes - liste chaînée ? arbre ? - et dans la fonction scheduler_get_next_task(), qui implémente l'algorithme du scheduler et joue donc avec les priorités).

En troisième lieu, on a besoin d'une gestion de la mémoire virtuelle. Derrière ce terme assez barbare se cache quelque chose de relativement simple.

Avec l'avènement des systèmes proposant une gestion des processus lourds est venu le besoin de gérer différemment la mémoire. Plutôt que de gérer la mémoire comme un bloc compact et monolithique (cette gestion permettant à chaque processus d'accéder à la totalité de la mémoire, y compris celle utilisée par le kernel), on essaie de la présenter sous une forme différente - par exemple pour interdire un processus A d'accéder à la mémoire allouée par un processus B. Si on veut proposer une gestion efficace et correcte de la mémoire on peut opposer deux solutions :

  1. on effectue toute la gestion de la mémoire au niveau logiciel. Tous les processus ont accès à toute la mémoire, mais on introduit une API pour effectuer ces accès - et cette API est gérée par le kernel. Mais dans ce cas, la barrière qui empêche un processus d'accéder à la mémoire allouée à un autre processus est de fait une barrière perméable : il est impossible de traiter convenablement tous les cas qui peuvent se présenter ; et notamment, il reste toujours une possibilité de passer outre les mécanismes mis en place par le kernel en décidant de ne pas utiliser cette API. Cette solution n'est donc pas vraiment une solution correcte, quand bien même on passerait plusieurs décennies à améliorer le système.
  2. on demande au matériel (au microprocesseur) de faire cette gestion pour nous.

Si la première solution bat vite de l'aile, encore faut-il que la seconde solution soit mise en face d'une proposition matérielle équivalente : en gros, il faut que les fondeurs de microprocesseurs prennent le problème à bras le corps.

Vous vous en doutez, nous sommes en 2013, et les fondeurs en question n'ont pas attendu que je parle de ce sujet ici pour proposer leur solution matérielle : la MMU, pour memory management unit. Une MMU divise la mémoire physique en pages (généralement de 4 Ko sur les systèmes 32 bits). Le lien entre mémoire physique et numéro de page est le plus souvent le résultat d'un calcul (numéro de page = adresse physique / taille de la page). Lorsqu'on demande à une OS de nous allouer de la mémoire, le système crée une adresse virtuelle, regarde le nombre de pages disponibles pour le processus et informe le processeur qu'il va nous autoriser à user de ces pages physiques à partir de l'adresse virtuelle qu'il nous a donné. Dans le principe, cette adresse virtuelle est complètement arbitraire : c'est le fait qu'elle soit liée à un numéro de page physique qui nous permet d'accéder à la mémoire proprement dite. En évitant l'adresse 0x00000000 (correspondant au pointeur NULL sur une architecture 32 bits), je peux tout à fait envisager de proposer les adresses allant de 0x00010000 - dès lors que le processeur est au courant.

Pour prévenir le processeur, on initialise son TLB (translation lookaside buffer). Schématiquement, le TLB est une table qui associe une adresse virtuelle à un numéro de page physique. Si, par exemple, j'associe l'adresse 0x10000000 à la page 0x5604, alors lorsque je vais accéder à l'adresse 0x100001a0 il va se passer ceci :

  • le processeur reçoit une demande de lecture ou d'écriture à l'adresse virtuelle 0x100001a0
  • la MMU du processeur regarde le TLB : à cette adresse correspond la page 0x5604
  • à cette page correspond la zone de mémoire physique de 4 Ko commençant à l'adresse (0x5604 * 4096) = 0x05604000
  • le processeur lit ou écrit la donnée à l'adresse physique 0x056041a0

La taille de la TLB n'est pas infinie. En simplifiant à l'extrème, plus est est grande, moins la MMU sera performante. Du coup, certaines entrées sont quelques fois supprimées pour faire de la place à de nouvelles entrées. Si la mémoire physique associée est toujours détenue par un processus et que celui-ci souhaite y accéder, alors la MMU, ne trouvant pas la page associée à une adresse virtuelle, va générer une exception (TLB miss), redonnant ainsi la main à l'OS qui va mettre à jour la TLB. Si le processus n'est pas censé accéder à cette adresse virtuelle, l'OS n'est pas capable de mettre à jour la TLB et réagit autrement : il envoie un signal violation d'accès au programme coupable, ce qui provoque une faute de segmentation.

Voilà pour l'origine de votre segfault.

Pour des raisons d'optimisation, on ne lit jamais la TLB d'un processeur - de fait, l'OS est obligé de garder un mirroir de cette TLB pour plusieurs raisons :

  • mettre à jour la TLB en cas de TLB miss
  • remplacer la TLB d'un processus en cas de changement de contexte (c'est à dire lorsque le système donne la main à un autre processus).

Voilà pour les trois composantes essentielles d'un système préemptif de gestion multiprocessus : interruption, sauvegarde de contexte, gestion de la MMU.

Alors, qu'est-ce qui fait alors qu'on peut différencier un thread d'un processus ? La réponse est simple : étant donné que deux threads cohabitent dans le même espace mémoire, ils partagent la même TLB. Du coup, passer de l'un à l'autre ne nécessite pas de vider la TLB et la re-remplir. Cette opération évitée rends le changement de contexte entre deux threads du même processus nettement plus léger, d'où le nom de processus léger.

Un problème se profile

Bien évidemment, qui dit mémoire partagée pour tous les threads d'un même processus dit aussi que toutes les ressources disponibles via des accès mémoire sont eux aussi partagés. Et là, on sent poindre un soucis, parce que pour un système informatique quelconque, tout est accessible par la mémoire. Plus exactement, le fonctionnement de tous les périphériques externes est contrôlé par le ou les processeurs en passant par des accès mémoire. Ça peut être l'accès à une carte réseau PCI, aux GPIO (general purpose input/output[3]) proposée par un composant externe ou par le processeur lui même, l'accès à la carte graphique, l'écriture dans un fichier (qui passe nécessairement par un buffer), etc.

Si tout est mémoire et que deux threads peuvent accéder "en même temps" à la même zone mémoire, alors en théorie deux thread peuvent écrire "au même moment" dans cette zone mémoire. Du coup, il est impossible de savoir exactement ce qui est écrit.

Car bien évidemment, accéder à la mémoire est, sauf cas particulier, une opération qui n'est pas atomique. Comprendre par là que pour accéder à une zone mémoire, il faut que le processeur exécute au moins deux opérations :

  • charger un registre avec l'adresse de la zone mémoire à laquelle on veut accéder
  • effectuer l'opération proprement dite

De fait, une opération d'écriture ou de lecture n'est pas atomique, et ce même dans le cas où on effectue une opération de lecture/écriture d'un contenu dont la taille est inférieure à celle des registres du microprocesseur.

La solution à ce problème est connue depuis le milieu des années 60 : il faut implémenter un mécanisme de synchronisation qui, lui, sera basé sur un système atomique.

Hum. Reprenons : je viens de dire que les accès à la mémoire n'étaient pas atomiques, et je dis que pour régler le problème, il faut implémenter un système de synchronisation qui lui, comme par magie, le serait ? Il manque un lien logique là. Ce à quoi je vous réponds par un sarcastique "mon cher, relisez ma phrase. N'y voyez vous pas trois mots importants qui me permettent d'enchaîner ainsi ? N'ai-je pas préciser : sauf cas particulier ?"

Le cas particulier en question existe sous deux formes : premièrement, à l'origine, et étant donné que les processeurs étaient - il faut bien l'avouer - un peu moins complexe qu'aujourd'hui, la solution au problème de l'écriture/lecture atomique s'est présentée sous une forme mathématique - un algorithme. Prenons le cas suivant :

  • deux processus veulent accéder à la même information en écriture
  • cette information a la valeur Vinit
  • le processus A veut y mettre la valeur Va
  • le processus B veut y mettre la valeur Vb

Dans ce cas, si A accède à l'information en premier, celle-ci vaut Vinit - il peut y stocker Va. Sauf qu'entre temps,on a donné le contrôle au processus B, qui y met Vb. On rend la main au processus A - et celui-ci s’aperçoit que l'information ne vaut plus Vinit - il sait donc qu'il a été interrompu, et n'écrit pas Va.

L'algorithme utilisé s'appelle compare and swap, ou CAS. Il est encore utilisé aujourd'hui sur les architecture qui ne propose pas cette opération au niveau hardware et ne nécessite que quelques instructions assembleur bien choisie (exemple sur l'architecture ARMv5 en utilisant 4 intructions assembleur).

La deuxième forme est bien évidemment plus récente. Les processeurs récents proposent en grande majorité au moins une instruction qui effectue un compare and swap au plus bas niveau - dans le silicium. Le même algorithme est utilisé, mais le fondeur du processeur vous assure que l'opération est atomique - que ce soit parce qu'elle est implémentée via un micro-code dont l’ordonnancement est sur et connu ou parce que le processeur passe dans un mode opératoire ou l'opération elle même ne peut pas être interrompue.

Grâce à l'opération CAS, on peut implémenter toute sorte de primitive de synchronisation - mutex, spin lock, sémaphores... La solution à notre problème d'accès mémoire est presque complète.

Un système de synchronisation gourmand

La plus simple des primitives de synchronisation est le spinlock, qui peut s'écrire ainsi (en imaginant que tout ce qui se passe dans les pseudo-fonctions C suivantes est atomique).

void spin_lock_aquire(spinlock* s)
{
        while (1) {
                if (!*s) {
                        *s = 1;
                        break;
                }
        }
}
void spin_lock_release(spinlock *s) { *s = 0; }

On voit d'ailleurs apparaître dans cet algorithme une opération CAS[4]. Le problème de ce spinlock, c'est que le thread qui attend de pouvoir acquérir une ressource attend de manière active - c'est à dire qu'il ne donne pas la main aux autres threads ou processus tant que le système ne lui impose pas. Dans le pire des cas, si ce thread a une priorité élevée, il pourrait bien ne jamais rendre la main au thread qui attends pour libérer le spinlock.

On appelle ce problème une inversion de priorité : le thread prioritaire attends une action d'un thread moins prioritaire pour pouvoir donner la main à celui-ci. Pendant ce temps, il prends tout le temps CPU, donc ne donne jamais la main à l'autre thread, donc n'arrive jamais à avoir le contrôle du spinlock.

Il existe des algorithmes qui permettent de mitiger ce problème, mais aucun n'a réussi à battre dans mes expériences une méthode empirique et de très mauvais goût : booster de manière forte la priorité de thread qui tiens le spinlock. C'est très mauvais parce que vous ne pouvez pas être sûr de ne pas perturber un processus temps réel fort, mais si l'opération qui est protégée par le spinlock est courte, ça peut valoir le coup.

Ceci dit, ça n'empêche pas le processus qui attends de prendre du temps CPU de manière complètement inutile. Si on veut alléger ce système, il faut trouver une bien meilleure solution. Et cette solution passe par le système d'exploitation.

L'implémentation des mutex[5]

Si les spinlock peuvent être implémentés complètement dans l'espace utilisateur, ce n'est pas le cas des mutex[6]. Coté utilisateur, les mutex ne font rien. Tout le travail est fait dans le kernel - et ça marche beaucoup mieux.

Pourquoi ? Avant de répondre à la question, il faut comprendre comment les mutex sont implémentés sur les systèmes d'exploitation modernes.

Bien évidemment, au coeur de système se trouve - encore et toujours - une instruction CAS. Cependant, au lieu d'attendre en boucle que le mutex se libère tel qu'on le fait pour un spinlock, on se contente de faire une simple série d'opération opération :

  • au niveau utilisateur,
    • on effectue un CAS
    • si le CAS réussit, on est riche : c'est à nous qu'appartient maintenant le mutex, et le programme suit son cours
    • si le CAS échoue, on effectue un appel système pour endormir le processus[7]
  • au niveau kernel
    • le thread est placé dans un état dormant ; on note que le thread est en attente sur ce mutex
    • l'OS donne la main à un autre thread.

Lorsque les interruption timer sont reçue et que l’ordonnanceur de l'OS doit choisir le thread/processus à qui il doit redonner la main, les thread/processus qui sont en état dormant sont tout simplement ignorés. Du coup, notre thread endormi prends 0% de temps CPU. Comment sera-t-il réveillé ? Et bien tout simplement par un moyen similaire :

  • au niveau utilisateur
    • on met le mutex dans l'état libre
    • on effectue un appel système pour réveiller les threads en attente[8]
  • au niveau kernel
    • on regarde la liste des processus/thread qui attendent un changement d'état de ce mutex
    • l'OS change l'état de ces processus à "en cours d'exécution" et donne la main à l'un d'entre eux

Bien évidemment, ce n'est qu'une vision schématique de ce qui se passe réellement.

L'avantage évident d'une telle implémentation par rapport à des spinlock coté utilisateur, c'est que le thread en attente est vraiment placé en attente (état dormant, il ne nécessite aucun temps CPU) jusqu'au moment où il peut se saisir du mutex. S'il n'y a pas de contention, alors l'acquisition d'un mutex ne nécessite pas d'entrer dans l'espace kernel. Dans le cas contraire, le fait d'entrer dans le kernel n'est plus un problème - de toute façon, on va devoir attendre que le mutex se libère, donc autant ne pas utiliser de ressources pendant ce temps. Le désavantage se situe du coté de la libération du mutex - car si l'entrée dans le kernel est optionnel dans le cas de l'acquisition, elle ne l'est plus dans le cas de la libération car le thread qui libère le mutex ne peut pas savoir quels sont les autres threads/processus qui attendent cette libération.

Conclusion

Cet article s'est contenté de vous faire un rappel sur des notions qui paraissent simple à priori mais qui sont au final relativement complexes à bien comprendre. Si vous avez des questions particulières sur le contenu de cet article, je vous encourage à me les poser via les commentaires ci-dessous - je tenterais d'y répondre au mieux.

Nous allons continuer de parler de programmation concurrente dans certains des articles à venir. Comme annoncé en introduction, nous allons tenter de couvrir la partie du nouveau standard C++11 qui permet d'implémenter ces concepts dans un programme.

En attendant, je vous dit à bientôt !

Notes

[1] un grosse partie des informations présentées ci-dessous proviennent du document "Les processus Légers", de Patrick DEIBER, que je vous encourage à lire

[2] étonnamment, la notion de cloud remet cette vision sur le devant de la scène.

[3] dans la pratique, ce sont des pattes d'un composant dont on peut contrôler l'état en le mettant à 1 (voltage haut) ou 0 (voltage bas). Ca permet d'implémenter toute sorte de bus, par exemple un bus i2c, smbus ou un bus parallèle, et ce sans avoir de support natif pour ce type de bus

[4] pour implémenter un spinlock de manière réelle, on va utiliser des intrinsics fournies par le compilateur, ou on va l'implémenter directement en assembleur. Dernière solution : la librairie open source atomic_ops de HP, qui fournit des opérations atomiques émulées pour les systèmes n'en proposant pas.

[5] le même principe vaut pour les sémaphores ; un mutex est un cas particulier de sémaphore.

[6] avec un bémol : oui, un CAS peut être utilisé pour implémenter les mutex coté utilisateur. Oui, leur implémentation est dans ce cas tellement similaire à celle du spinlock que, et bien, en fait, c'est un spin lock.

[7] sous linux, on utilise l'appel système futex(2)

[8] idem - sous linux, c'est encore futex(2) qui est utilisé dans ce cas

Commentaires

1. Le mardi, mai 14 2013, 10:34 par Troctsch

Un billet très intéressant!
Merci pour le partage. une question me taraude tout de même en ce qui concerne le concept de 'processus lourd'.

Est-il possible que les fondeur de processeur (de type CISC) pensent à intégrer sur leur puces des mécanismes permettant d'alléger le coûts de changements de contexte (En incorporant une sorte de cache dédié à cette tâche)?

Cela permettrait entre autre d'éviter d'avoir à utiliser la pile (RAM) pour sauvegarder le contexte dont les temps d'accès en lecture/écriture sont beaucoup plus longs que dans des registres ou des caches...

2. Le mardi, mai 14 2013, 15:16 par Emmanuel Deloget

C'est plus compliqué qu'on ne le croit. Il y a deux point : un changement de fonctionnement des processeurs, et un problème plus fondamental.

Tout d'abord, un tel mécanisme doit être prévu par les OS - dans le domaine CISC (donc, grosso modo, les x86 ; les autres architectures CISC ont quasiment disparu : les Motorola ont été remplacé par des PowerPC qui sont des processeurs RISC), il faut rester compatible avec ce qui existe déjà. Hors, les OS prévoient le stockage sur la pile de l'état du processus. Impossible de changer ça sans changer de manière drastique Linux, Windows, les BSD... Trop de travail pour ce qu'on obtiendrait. Il faut savoir en outre que les processeurs x86 proposent depuis pas mal de temps des instructions qui permettent d'optimiser la sauvegarde du contexte (pusha...) ; mais ce contexte est toujours stocké sur la pile (je te rassure : c'est le cas aussi pour les processeurs RISC). Et j'ajouterais que de toute façon, on travaille dans le cache de niveau 1 - à la sauvegarde et à la restauration du contexte. La mise à jour de la RAM est désynchronisées.

D'autre part, quelle taille doit on prévoir pour ce cache (et est-ce qu'on est sûr que tous les processus lancés par un user pourront y être stockés) ? Si il y a un moment où le cache devient limité, où doit on stocker les données qu'on va vider du cache ?

Le problème du switch de context étant particulièrement important, c'est un point d'amélioration constant des processeurs. Les processeurs 8086 au 80286 avait des instructions push prohobitives. Le 386 commence à corriger le problème : il apporte la possibilité d'émuler des processeurs 16 bits et la protection de la mémoire par une MMU, ouvrant donc la voie aux OS multitâche sur x86. Le 486 améliore encore l'instruction (cf. ce document). Mais ce qui change vraiment la donne, c'est l'apparition de la mémoire accessible à des fréquences plus élevées - à l'époque du Pentium III, quand on commence à basculer de manière massive sur des FSB > 66 Mhz (grâce à AMD, d'ailleurs). Le PIV, avec l'hyperthreading, a encore amélioré les performances par ce que les changements de contexte sont moins nombreux.

Le fondeur principale de processeurs CISC (Intel, donc) travaille activement sur le sujet. Son but étant de faire en sorte que les logiciels écrits hier continuent à fonctionner aujourd'hui, on ne verra que des changements incrémentaux, sans véritable révolution dans le domaine (encore que...). Tant que ça sera le cas, il sera obligé de monter dans les tours pour être efficace en multithreading (et multiprocess) contrairement à un processeur RISC - qui, par design, est de base efficace quelle que soit sa fréquence (après, bien évidement, un Core i7 effectue ses context switch plus rapidement qu'un ARMv5 à 333Mhz ; il faut rester un minimum mesuré :)).

Ajouter un commentaire

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

Fil des commentaires de ce billet