Les threads et la programmation concurrente
Frédéric Boussinot
EMP-CMA/INRIA - Projet MIMOSA
2004 route des Lucioles - BP 93
F-06902 Sophia Antipolis, Cedex
Frederic.Boussinot@sophia.inria.fr

Avant d'acheter un ordinateur, on se renseigne généralement sur ses caractéristiques techniques : quel est le processeur utilisé, combien de mémoire possède-t-il, quelle est la taille du disque dur, etc. Parfois, on entre même dans certains détails, comme la marque de la carte graphique, essentielle pour les jeux vidéo. Bien sûr, d'autre critères que les critères techniques peuvent également être pris en compte, mais l'acheteur d'un ordinateur connaît en gros les principaux composants et leurs caractéristiques. Il sait, pour résumer, que le processeur doit être le plus rapide possible, que plus la mémoire est importante et plus gros est le disque, mieux cela vaut (par ailleurs, il commence aussi à savoir que la machine la plus puissante au jour J sera très probablement dépassée dans les six mois qui viennent...).

Là où les choses deviennent moins claires, c'est lorsque l'on considère le système d'exploitation (OS) (operating system en anglais). Peu de gens savent quelles sont ses caractéristiques principales et de quoi il est constitué. Il s'agit pourtant du composant logiciel essentiel d'un ordinateur, celui sans lequel aucun autre ne pourrait être utilisé. Il existe en gros trois familles : les systèmes Windows de Microsoft (Windows 98, NT et XP principalement), les systèmes MacOS d'Apple et le dernier venu, Linux, qui est une variante open-source du système Unix. Cette découpe en trois familles cache en fait une situation beaucoup plus complexe dans laquelle, par exemple, on constate que le dernier né d'Apple, le système MacOS-X, est en fait une variante d'Unix, ce qui le rend donc très proche de Linux (à noter que Unix est un système qui date de 1974 ; dans le monde des systèmes d'exploitation, les innovations mettent du temps à percer...).

Une des tâches essentielles d'un OS est de coordonner l'exécution des divers composants logiciels exécutés par la machine. Le terme composant logiciel est pris ici au sens large : il s'agit aussi bien de l'explorateur du Web que du logiciel de traitement de texte ou du pilote d'imprimante. Dans les OS modernes, que l'on qualifie de multi-tâches, les composants logiciels s'exécutent "en même temps" : il n'est pas nécessaire d'attendre que celui qui est en train de s'exécuter ait terminé pour commencer à en exécuter d'autres. Comme dans la plupart des cas il n'y a qu'un seul processeur, celui-ci doit être donné à tour de rôle aux divers composants qui s'exécutent : c'est la tâche de l'OS de distribuer le processeur et on dit que les composants logiciels sont concurrents (ils sont en concurrence pour l'accès au processeur). Notons que l'OS lui-même est un des composants logiciels exécutés par le processeur.

La partie de l'OS qui effectue la distribution du processeur est appelée ordonnanceur et, en Unix, les composants logiciels dont s'occupe l'ordonnanceur sont les processus. Un des aspects essentiels d'un processus concerne la protection des données : chaque processus utilise ses propres données en mémoire et il n'y a aucun risque qu'un processus accède accidentellement aux données propres à un autre processus. Une conséquence concrète est que la machine n'est plus jamais (ou du moins, très rarement) complètement "plantée" : lorsqu'un des composants logiciels commet une erreur (un bug), le processus qui l'encapsule ne risque pas de la transmettre aux autres processus (par exemple en détériorant leurs données propres) et ceux-ci peuvent donc continuer à s'exécuter sans problème. En particulier, l'OS ne risque pas d'être lui-même corrompu, ce qui est une des raisons pour laquelle les développeurs d'applications (ceux qui programment et qui, donc, "font des bugs") préfèrent généralement utiliser Unix.

Les processus sont cependant "trop lourds" pour pouvoir être considérés comme l'unité élémentaire de concurrence : en effet, le passage de l'exécution d'un processus A à un processus B nécessite de sauvegarder l'état de A, puis de rétablir celui de B ; or ce changement de contexte peut nécessiter des transferts d'information entre la mémoire et le disque, ce qui est une opération qui prend du temps. Ainsi, l'utilisation des processus doit être nécessairement limitée pour préserver le temps de réponse global du système.

Les processus sont en fait composés d'entités concurrentes plus élémentaires, appelées threads. Le mot anglais thread est parfois traduit en francais par fil d'exécution ; pour faire court, on gardera dans la suite le terme anglais.

1 Les threads

Le thread est l'unité élémentaire de concurrence traitée par les OS. Contrairement aux processus, les threads partagent la mémoire (en fait, ils partagent la mémoire du processus qui les contient), ce qui rend les changements de contexte peu coûteux en temps. Les threads sont d'ailleurs pour cela parfois appelés "processus légers". Un thread est composé d'une suite d'instructions (représentées sur le dessin qui suit par des traits horizontaux) et d'un compteur-programme qui indique quelle est la prochaine instruction à exécuter (représentée par la flèche). Exécuter un thread signifie exécuter les instructions qui le composent, en partant de celle désignée par le compteur programme. La tâche de l'ordonnanceur est alors de faire progresser les threads à tour de rôle, comme illustré sur la partie droite du dessin :

Rappelons que la mémoire est partagée entre tous les threads d'un même processus ce qui rend les changements de contexte minimaux : un changement de contexte consiste uniquement à sauvegarder en mémoire le compteur-programme et la pile du thread dont l'exécution est abandonnée, puis à rétablir ceux du thread suivant. Deux questions se posent: quelle est la stratégie de l'ordonnanceur pour passer à l'exécution d'un autre thread ? et comment choisit-il celui-ci ?

Les difficultés des threads

Deux options sont possibles pour l'ordonnanceur : soit il laisse toujours le thread qui s'exécute libre de garder le processeur tant qu'il le désire, soit il peut, dans certaines situations, le forcer à s'arrêter. Dans le premier cas, on parle de stratégie coopérative, tandis que la stratégie correspondant au second cas est dite préemptive. Dans une stratégie coopérative, les threads doivent obligatoirement coopérer avec les autres et éviter de monopoliser le processeur indéfiniment. En effet, dans le cas contraire, le système risque de se bloquer complètement puisque l'OS lui-même ne peut plus s'exécuter. C'est ce qui arrive parfois avec les OS anciens comme Windows 3.1 ou les premières versions de MacOS. Dans ces situations, l'unique solution est de "rebooter" la machine. L'évolution des OS a naturellement conduit à des stratégies préemptives dans lesquelles un thread non-coopératif ne risque pas de bloquer le système tout entier et tous les OS modernes utilisent maintenant, à la suite d'Unix, des stratégies préemptives.

Protection des données

Un ordonnanceur préemptif peut très bien retirer le processeur à un thread (on dit alors que le thread est préempté) en plein milieu du traitement d'une donnée qui risque ainsi d'être laissée dans un état incohérent. L'outil utilisé pour protéger les données est appelé verrou; intuitivement, un thread commence par fermer un verrou avant d'accèder à une donnée protégée, ce qui interdit aux autres threads de pouvoir y accéder même s'ils recoivent le contrôle de l'ordonnanceur. Le thread qui avait fermé le verrou ne le rouvre que lorsqu'il a fini de transformer la donnée, ce qui permet alors aux autres threads d'accéder à leur tour à la donnée protégée.

Malheureusement, des situations peuvent apparaître dans lesquelles certains threads ne peuvent plus progresser, quoique qu'il advienne ; ces situations sont appelées deadlocks (traduits en français par étreintes fatales ; on comprend que, ici aussi, on préférera garder le terme anglais...). Le dessin suivant montre une situation de deadlock : sur la partie gauche du dessin, on voit la situation de départ : deux threads sont en concurrence pour deux verrous. La partie droite montre la situation de deadlock : chacun des threads a fermé un verrou et essaie de fermer l'autre ; les deux threads ne peuvent plus progresser par eux-mêmes et les moyens connus de sortir de cette situation sont très coûteux.

Les deadlocks sont véritablement une des "plaies" principales de la programmation concurrente, en particulier parce que, dans le cas général, il est impossible de déterminer à l'avance si un programme peut ou non tomber dans un deadlock (il s'agit d'un problème indécidable, comme l'est celui de déterminer si l'exécution d'un programme termine ou boucle indéfiniment).

Priorités

Des priorités peuvent être associées aux threads, pour guider l'ordonnanceur dans le choix des threads à exécuter : l'ordonnanceur choisit toujours, en effet, un thread de priorité maximale parmi ceux qui peuvent être exécutés. Ainsi, en associant des priorités différentes aux threads, on peut espérer contrôler l'ordre dans lequel ils sont exécutés. A noter cependant que, lorsque plusieurs threads de même priorité peuvent être exécutés, l'ordonnanceur est libre de choisir n'importe lequel parmi ceux-ci (son comportement est dit non-déterministe).

En pratique, les priorités sont difficiles à utiliser et leur utilisation peut conduire à des situations connues sous le nom "d'inversions de priorités" dans lesquelles un thread de basse priorité interdit à un thread de haute priorité de s'exécuter. C'est une de ces situations qui a failli faire échouer la mission Mars Pathfinder, en 1997. Le petit robot posé sur Mars se mettait en effet à rebooter sans arrêt lors des transmissions radio vers la Terre. Cela était du à un phénomène d'inversion de priorité entre les 3 threads que le robot avait à exécuter : le thread de haute priorité qui réglait le fonctionnement du robot ne pouvait pas s'exécuter parce que le thread de basse priorité d'acquisition des données possédait un verrou dont il avait besoin ; mais le thread de moyenne priorité de communication avec la Terre prenait alors le processeur car il n'avait pas besoin du verrou et était plus prioritaire. Ainsi, on obtenait une situation où le thread de priorité basse interdisait au thread de priorité haute de s'exécuter et où le thread de priorité moyenne pouvait en profiter pour prendre le processeur. Comme la communication avec la Terre dure longtemps, un timer détectait un problème grave, puisque la tâche prioritaire n'était pas exécutée dans les temps, et rebootait purement et simplement le système...

Dans le domaine des OS, il est maintenant clair que les stratégies préemptives ont gagné et que les stratégies coopératives font partie du passé. Les possibilités de deadlocks et d'inversions de priorité rendent la programmation par threads délicate et la réservent à des spécialistes. Cependant, les threads sont également utilisés dans d'autres contextes que les OS, par exemple en programmation Java, ce qui pose de nouveaux problèmes que l'on va considérer maintenant.


2 Les threads en Java

L'utilisation du langage Java se développe actuellement, en particulier dans l'enseignement de l'informatique. Java a, parmi ses nombreuses qualités, celle de permettre la programmation d'activités concurrentes. Le fait de pouvoir découper une activité complexe en plusieurs sous-activités concurrentes est souvent un moyen de simplifier la programmation. La concurrence est maintenant largement reconnue comme étant un outil fondamental pour la programmation des systèmes complexes.

Java propose les threads comme outil pour la concurrence. On se trouve donc, lorsque l'on programme en Java, directement confronté aux problèmes des threads évoqués précédemment. La situation est rendue encore plus complexe par le fait qu'il n'y a en Java aucun moyen d'éviter l'utilisation des threads (qui apparaissent dès que l'on utilise la partie graphique du langage, ou bien le réseau) ni de spécifier une stratégie particulière d'ordonnancement.

On va donner une idée de la difficulté de manipulation des threads Java à partir d'un petit exemple utilisant uniquement deux threads. Considérons un objet qui se déplace sur un cercle et projetons le sur l'axe des x et sur l'axe des y. On obtient un déplacement en cosinus sur les x et en sinus sur les y. Maintenant, considérons la démarche inverse : on a deux programmes, l'un qui provoque le déplacement de l'objet suivant l'axe des x et l'autre suivant l'axe des y ; on s'attend à retrouver un cercle lorsque ces deux programmes sont exécutés ensemble. Voici ce que l'on cherche à obtenir (l'objet est une petite pastille dont la couleur est changée à chaque étape, pour mieux voir son déplacement) :

Supposons que chacun des deux programmes soit exécuté par un thread ; les deux threads doivent alors obligatoirement être synchronisés, pour s'exécuter en alternance. Sinon, voici ce que l'on risque d'obtenir:

Le passage d'une ligne à une colonne, et vice-versa, est provoqué par l'ordonnanceur qui est ici, comme on peut le constater, préemptif (sinon, il n'y aurait qu'une seule ligne ou une seule colonne, puisque le premier thread à être exécuté garderait le processeur indéfiniment). Le résultat obtenu dépend donc de l'ordonnanceur, et dans le cas présent, on n'obtient pas vraiment le cercle souhaité...

A noter, qu'il ne suffit pas que le thread en train de s'exécuter "rende la main" à l'ordonnanceur après chaque déplacement élémentaire, car celui-ci peut parfaitement ne pas choisir l'autre thread, mais continuer à exécuter le même. Voici un résultat possible :

En fait, la solution générale est relativement compliquée et nécessite l'utilisation d'un mécanisme (que l'on ne présente pas ici) d'attente/notification sur un verrou, pour forcer le fonctionnement en alternance des deux threads. Ce petit exemple, bien que très simple, montre que la programmation par threads est complexe, difficile à maîtriser et peut donner des résultats inattendus.


3 FairThreads et approche réactive

Un ordonnancement coopératif, bien que peu adapté aux OS, a cependant de nombreux avantages. Tout d'abord, il est plus simple qu'un ordonnancement préemptif car il n'y a plus de risque que le contrôle soit retiré à un thread en plein milieu d'une opération critique. Il n'y a donc plus besoin de verrou puisque l'accès d'un thread aux données en mémoire ne peux plus être intempestivement interrompu par l'ordonnanceur ce qui simplifie le travail du programmeur et évite pas mal de bugs... à condition bien sûr que les threads soient bien coopératifs ! Par ailleurs, un ordonnancement coopératif est généralement plus efficace car il évite certains changement de contextes inutiles qui surviennent avec un ordonnancement préemptif.

FairThreads

Diverses propositions de threads coopératifs ont été faites récemment. L'une d'elles, appelée FairThreads, est développée dans le projet MIMOSA, commun à l'École des Mines de Paris et à l'INRIA et soutenu par France Télécom R&D. Elle consiste en des threads équitables (fair) exécutés par des ordonnanceurs spécialisés leur donnant des possibilités égales d'accès au processeur. Les threads équitables peuvent être contrôlés finement, permettant ainsi à l'utilisateur de coder ses propres stratégies d'exécution. De plus, les threads équitables peuvent communiquer par des événements diffusés, ce qui simplifie la programmation. Enfin, les programmes utilisant ces threads sont déterministes : leur résultat est indépendant de l'ordonnanceur de la machine qui les exécute, ce qui n'est généralement pas le cas avec des threads standards. Le déterminisme facilite grandement le portage et le debugging des programmes.

Reprenons l'exemple des deux threads sinus et cosinus précédents. En utilisant des threads équitables, on obtient un cercle sans avoir à introduire de synchronisation particulière : la synchronisation entre les deux threads résulte en effet automatiquement du caractère équitable de l'exécution. De même, l'ajout d'un troisième thread ayant également un comportement en cosinus donne immédiatement la courbe de Lissajous attendue :

L'ordonnanceur équitable synchronise en effet automatiquement les trois threads, et ceci sans avoir à changer quoi que ce soit aux deux threads initiaux.

Les FairThreads ont été portés en Java, en C et en Scheme (dans le système Bigloo). Un serveur Web utilisant les FairThreads a en particulier été codé de manière extrêmement simple et modulaire en Scheme.

L'implémentation en C des FairThreads utilise la librairie pthread. Dans cette version, il est possible de mélanger les fair threads coopératifs avec des threads natifs, soumis au contrôle de l'OS préemptif. Cette version des fair threads permet de profiter des architectures multiprocesseurs (SMP).

Nous sommes également, dans le projet MIMOSA, en train d'étudier comment utiliser les FairThreads, ou plus généralement la programmation réactive, pour la programmation concurrente de systèmes embarqués avec de faibles ressources (les Personal Digital Assistants, par exemple).

Programmation synchrone

Enfin, il existe également une autre approche, appelée réactive-synchrone, qui permet une programmation simple et efficace de la concurrence, en résolvant certains problèmes des threads. Plusieurs équipes françaises, à l'INRIA, au CNRS et à l'École des Mines de Paris ont développé et utilisent des langages construits sur cette approche, dont les plus connus sont les langages synchrones Esterel, Lustre et Signal. La programmation synchrone est principalement utilisée pour les systèmes embarqués (comme ceux des avions) et pour les systèmes sécurisés (conduite de centrale nucléaire, par exemple, dans le cas de Lustre).


Références



This Html page has been produced by Skribe.
Last update Wed Oct 18 15:16:37 2006.