IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Rapide introduction à la programmation concurrente en Java

Date de publication : 20/02/2012. Date de mise à jour : 26/06/2012.

Par Thibaut Cuvelier Site personnel Blog

 

Jusqu'à présent, tous les programmes que vous écriviez étaient probablement séquentiels : à chaque instant, le contrôle est situé à un seul endroit et il progresse d'instruction en instruction.
Cependant, dans le cas où on doit garder une interface graphique réactive avec des calculs très lourds, il n'est pas imaginable de remplir le code de calcul de vérification d'événements envoyés à la fenêtre (ce qui, de plus, mélangera les deux modules censés indépendants) : on préférera exécuter ces deux parties en simultané.

    
Viadeo Twitter Facebook Share on Google+        



II. Problèmes causés
III. Sémantique d'entrelacement
IV. Mécanismes du langage
IV-A. Ordonnancement
IV-B. Création d'un contexte avec l'héritage
IV-C. Création d'un contexte avec l'héritage d'interfaces
IV-D. Verrous
IV-E. Synchronisation


II. Problèmes causés

Cependant, la programmation concurrente soulève de nouveaux problèmes : il faut partager le processeur quand il n'a qu'une seule unité de calcul (en gardant un certain découplage entre le code et le matériel sur lequel il s'exécute) et caractériser l'exécution des programmes concurrents. Notamment, cette sémantique du parallélisme gère les cas d'accès simultané aux variables, de manière indépendante des détails de l'environnement d'exécution.

On cherche donc à éviter que plusieurs tâches effectuent certaines opérations qui, si elles sont effectuées en simultané, pourraient corrompre l'état de l'objet (ou faire qu'il ignore l'une ou l'autre, comme dans l'insertion dans une pile). On utilisera donc des mécanismes d'attente entre les tâches ; cependant, si chaque tâche doit attendre que les ressources soient libérées par les autres et que ces autres attendent que la tâche courante libère des ressources, on arrive à un interblocage (deadlock ou étreinte fatale) : plus rien ne bouge, tout le monde s'attend. Chaque tâche peut également prévoir des mécanismes en interne pour gérer ce genre de cas, en exécutant répétitivement certaines actions en attendant qu'une condition portant sur ce blocage soit satisfaite (livelock).


III. Sémantique d'entrelacement

La machine virtuelle Java, ainsi que les systèmes d'exploitation multitâches, atteignent le but d'indépendance de la caractérisation de l'exécution des programmes concurrents par rapport à l'environnement d'exécution et les vitesses d'exécution relatives des différentes tâches par la sémantique de l'entrelacement.

Les instructions d'un programme se décomposent en un certain nombre d'opérations atomiques, dont l'exécution a un effet instantané et indivisible. L'exécution concurrente de plusieurs tâches produit toujours le même effet que l'exécution successive d'une séquence d'opérations atomiques où les opérations relatives à une tâche apparaissent selon leur ordre d'exécution ; cependant, un système concurrent n'est pas forcément déterministe, l'ordre effectif des instructions qui seront exécutées (les séquences d'ordonnancement) peut changer d'une exécution à l'autre : on garantit que la première instruction de chaque tâche sera toujours exécutée avant la seconde, bien que toute la première tâche puisse être exécutée avant la seconde.


IV. Mécanismes du langage

Une tâche correspond à un contexte d'exécution (ou fil d'exécution léger ou thread), un flux de contrôle unique au sein d'un programme. Un tel contexte regroupe un point de contrôle, identifiant la prochaine opération atomique à effectuer, et une pile d'exécution (stack), utilisée pour mémoriser les méthodes invoquées non terminées, les variables locales et les valeurs temporaires manipulées par le contexte.

Les objets sont retenus dans une mémoire centrale accessible par tous les contextes d'exécution (le tas, heap). La copie d'une valeur depuis la mémoire centrale vers la pile d'un contexte ou dans l'autre sens est une opération atomique (sauf pour les types sur soixante-quatre bits, comme long ou double). La modification d'une variable de classe ou d'instance par un contexte s'effectue sur la pile, la modification sera répercutée en mémoire centrale ultérieurement (à l'exception des variables définies comme volatile, pour lesquelles toute modification devient effective en mémoire centrale avant toute autre opération impliquant cette variable).

Un contexte d'exécution n'est pas toujours exécutable, il peut se trouver dans un total de quatre états :

  • initial : le contexte vient d'être créé, mais il n'a pas commencé à exécuter des opérations ;
  • exécutable : le contexte peut exécuter des opérations (état habituel), mais n'en exécute pas forcément (on peut avoir plus de contextes que de processeurs) ;
  • non exécutable (bloqué) : le contexte a suspendu son exécution et attend qu'une condition soit satisfaite pour la reprendre ;
  • final : le contexte a terminé son exécution.

IV-A. Ordonnancement

Sur un système monoprocesseur, on considère plusieurs contextes exécutables. Le partage du processeur s'effectuera par l'ordonnanceur de la machine virtuelle, qui, de manière cyclique, sélectionnera un contexte exécutable et lui attribuera le processeur pendant un certain temps ou jusqu'à ce qu'il cesse d'être exécutable. Cet ordonnancement peut être non déterministe.

L'ordonnanceur est équitable : un contexte ne peut pas rester indéfiniment exécutable sans obtenir le processeur. On peut toutefois l'influencer en indiquant une priorité à un contexte : plus elle est élevée, plus l'ordonnanceur le sélectionnera souvent. Ceci permet d'assurer de meilleures performances, pour garder autant que possible une partie temps réel (comme l'affichage d'une vidéo) par rapport à d'autres ; ce mécanisme ne peut pas apporter de garantie sur le bon fonctionnement du programme, l'ordonnancement ne devant pas être déterministe.


IV-B. Création d'un contexte avec l'héritage

Pour créer un contexte d'exécution, on peut instancier une classe définie comme sous-classe de Thread.

Cela implique plusieurs contraintes : ses constructeurs doivent appeler le constructeur parent avec comme paramètre une chaîne de caractères (le nom du contexte, utile en cas de débogage) et elle doit disposer d'une méthode publique run() définissant les instructions qu'il doit effectuer.

L'instanciation de la classe mettra le contexte en état initial ; dès la réception du message start(), il devient exécutable. Quand sa méthode run() est finie, il passe dans l'état final.


IV-C. Création d'un contexte avec l'héritage d'interfaces

Pour éviter d'utiliser l'héritage de classes, on peut implémenter l'interface Runnable. Ainsi, on sera forcé d'implémenter la méthode run().

Afin d'initialiser un contexte, on devra instancier la classe Thread en lui passant deux arguments : la classe implémentant Runnable contenant les instructions à exécuter et le nom du contexte.


IV-D. Verrous

Pour éviter que plusieurs contextes qui tentent de modifier l'état d'un objet avec une séquence non atomique d'opérations n'en corrompent l'état, on utilise des verrous. Tous les objets disposent d'un verrou, mais il n'est pas toujours utilisé.

Un verrou peut être acquis ou relâché. Quand il est acquis, personne d'autre ne pourra utiliser l'objet (afin d'éviter l'interblocage, si le contexte courant possède le verrou et demande à nouveau à l'acquérir, l'opération réussit). L'opération d'acquisition de verrou est atomique : plusieurs contextes ne peuvent pas acquérir un verrou simultanément.

Avec l'instruction synchronized, on peut acquérir un verrou ; tant qu'il est acquis par un autre contexte, la méthode courante sera mise en pause. À la fin du bloc, le verrou est relâché. Si la section critique ainsi protégée est trop longue, on court le risque de bloquer un objet pendant très longtemps, sans qu'un autre contexte puisse y accéder : cette situation doit donc être évitée.
public void method() {
    synchronized(this) {
        // Section critique
    }
}

public synchronized void method() {
    // Section critique, l'objet courant voit son propre verrou acquis. 
}

IV-E. Synchronisation

Si plusieurs contextes s'échangent des données, il arrive qu'un d'entre eux suspende son exécution jusqu'à ce qu'un autre ait effectué certaines opérations (comme la génération de données à traiter).

On peut implémenter ce mécanisme par des boucles vérifiant le contenu d'une variable partagée (busy waiting), mais cette solution monopolise le processeur pendant un certain temps uniquement pour vérifier le contenu d'une variable, probablement en ralentissant le code dont on attend la fin de l'exécution. La classe Object fournit trois méthodes pour pallier ce défaut, qui ne peuvent être invoquées que si le contexte courant a acquis sans déjà relâcher le verrou associé à l'objet :

  • wait() : place le contexte courant dans un état non exécutable et relâche le verrou associé à l'objet ;
  • notify() : sélectionne arbitrairement un contexte en attente sur l'objet courant à la suite d'un message wait() et le rend à nouveau exécutable ;
  • notifyAll() : rend exécutables tous les contextes en attente sur l'objet courant.


            

Valid XHTML 1.0 TransitionalValid CSS!

Copyright © 2012 Thibaut Cuvelier. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.