Concurrence

Compléments

Interblocages (deadlocks), problèmes de publication et ReentrantLock


Interblocages

Ping Pong

  public class PingPong {
    private final Object lock1 = new Object();
    private final Object lock2 = new Object();

    public void ping() {
      synchronized (lock1) {
        synchronized (lock2) { System.out.println("ping"); }
      }
    }

    public void pong() {
      synchronized (lock2) {
        synchronized (lock1) { System.out.println("pong"); }
      }
    }

    public static void main(String[] args) throws InterruptedException {
      var pingpong = new PingPong();
      Thread.ofPlatform().start(() -> {
        for (;;) { pingpong.ping(); }
      });
      for (;;) { pingpong.pong(); }
    }
  }

Quel affichage obtient-on ? Parfois des ping et des pong ... parfois que des ping ou que des pong ... ou rien ! Mais presque sûrement, ça bloque!

Quel est le problème?

  • Le thread qui fait ping() prend le verrou du lock1,
  • et est dé-schédulé juste après (avant de prendre le verrou du lock2).

  • Le thread qui fait pong() prend le verrou du lock2,
  • et est dé-schédulé juste après (avant de prendre le verrou du lock1).

  • Le thread "ping" est re-schédulé et essaie de prendre le lock2... mais ne peut pas tant que le thread "pong" ne le lâche pas...
  • ... et le thread "pong" ne le lâchera que s'il arrive à avoir le lock1. On est donc bloqués!

Interblocage (1/2)

Si plusieurs threads (au moins 2) prennent plusieurs verrous (au moins 2), dans un ordre différent : il risque d'y avoir un problème d'interblocage (deadlock) .

3 solutions :

  • N'utiliser qu'un seul verrou.
  • La programmation lock-free, qui utilise des champs volatile et des opérations atomiques spéciales (elle sera vue en fin de semestre).
  • Toujours prendre les verrous dans le même ordre (exo des philosophes):
    Cela suppose de connaître tous les locks du programme :
    • on fixe un ordre arbitraire sur les locks
    • et tous les threads prennent les locks en respectant cet ordre
  • Dans ce cas, le programme ne peut pas avoir d'interblocage. Pourquoi ?

Interblocage (2/2)

Pour simplifier

  • deux threads t1 et t2
  • les locks sont pris dans l'ordre lock 1, puis lock 2, ... et enfin lock n.

On suppose par l'absurde qu'il y a un interblocage et on va chercher une contradiction.

Le thread t1 attend le lock i qui est pris par t2.

Le thread t2 attend le lock j qui est pris par t1.

Comme lock i est déja pris par t2, on a i < j.

Comme lock j est déja pris par t1, on a j < i ... contradiction!

Les dangers d'avoir plusieurs locks

    public class DeadLock {
      private final Object lock1 = new Object();
      private final Object lock2 = new Object();
      private boolean finished;

      public void process() throws InterruptedException {
        synchronized (lock1) {
          synchronized (lock2) {
            while (!finished) {
              lock2.wait();
            }
          }
        }
        System.out.println("Finished !");
      }

      public void stop(){
        synchronized (lock1) {
          synchronized (lock2) {
            finished = true;
            lock2.notify();
          }
        }
      }

      public static void main(String[] args) throws InterruptedException {
        var deadLock = new DeadLock();
        Thread.ofPlatform().start(()-> {
          deadLock.stop();
        });
        deadLock.process();
      }
    }
  

Attention, lock2.wait() rend le lock2 mais pas le lock1 !
Évitez d'utiliser pas plusieurs locks dans la même classe !

Constructeur et concurrence

Exemple

  
    public class A {
      private final int value;
      private static A a;

      public A(int value) {
        this.value = value;
      }

      public static void main(String[] args) {
        Thread.ofPlatform().start(() -> {
          if (a != null) {
            System.out.println(a.value); 
          }
          // affiche 7 ou rien
        });
        a = new A(7);
      }
    }
  

Publication : un champ déclaré final est visible avec sa valeur d'initialisation par tous les threads après la fin du constructeur !

Problème de publication (1/3)

  
    public class A {
      private final int value;
      private static A a;

      public A(int value) {
        this.value = value;
      }

      public static void main(String[] args) {
        Thread.ofPlatform().start(() -> {
          if (a != null) {
            System.out.println(a.value); 
          }
          // affiche 7 ou 0 !!! ou rien
        });
        a = new A(7);
      }
    }
  

Si on oublie le final, on peut voir la valeur par défaut.

Ici, c'est value = 0.

Problème de publication (2/3)

  
    public class A {
      private final int value;
      private static A a;

      A(int value) {
        this.value = value;
        A.a = this; // quelle drôle d'idée...
      }

      public static void main(String[] args) {
        Thread.ofPlatform().start(() -> {
          if (a != null) {
            System.out.println(a.value); 
          }
          // affiche 7 ou 0 !!! ou rien
        });
        new A(7);
      }
    }
  

Si on publie this avant la fin du constructeur, il y a un risque aussi. Ça peut arriver, pour faire un singleton, mais là encore, ça n'est pas l'objet de ce cours...

Pour faire simple : ne le faites pas !

Problème de publication (3/3)

Enfin, on pourrait avoir envie de démarrer un thread dans un constructeur.

Dans ce cas, le thread aura très probablement accès à this (capturé dans l'environement du Runnable) avant qu'il soit publié...

Pour faire simple : ne le faites pas !

Si on veut vraiment démarrer un thread avec la classe, on peut le faire dans une méthode start qui sera appelée après la construction de l'objet.

Et les champs qui ne peuvent pas être final ?

On utilise un bloc synchronized dans le constructeur.

Il y a une autre façon de faire, en utilisant les champs volatile, mais on verra ça plus tard.

Le cas des champs statiques

Les initialisations statiques (directes ou dans un bloc statique) sont visibles par touts les threads qui utilisent la classe.

Remarque : la bloc statique est executé avant l'accès à une variable statique, à une méthode statique ou à un constructeur.

  
    class A {
      private static Object o = new Object(); 
      private static String[] tab = new String[100];

      static {
      Arrays.setAll(tab, __ -> "foo");
      }

      public static void main(String[] args) {
        Thread.ofPlatform().start(() -> {
          System.out.println(A.o + " " + Arrays.toString(A.tab)); // pas de null
        });
        System.out.println(A.o + " " + Arrays.toString(A.tab)); // pas de null
      }
    }  
  

Donc pas de problème de publication avec les intialisations statiques.

Problèmes de publication : résumé

Dans un constructeur,

  • on ne démarre pas de thread
  • on ne publie pas this

Dans un constructeur, pas besoin de synchronisation :

  • si le champ est final
  • si le champ est initialisé à sa valeur par défaut

sinon il faut utiliser la synchronisation !

ReentrantLock

L'API java.util.concurrent.locks

Il existe un autre mécanisme permettant de définir des sections critiques introduit en Java 5 :

  • Une interface Lock
  • Une classe qui l'implémente : ReentrantLock

Quel intérêt ?

  • C'est un "vrai" objet avec des méthodes et un peu plus de flexibilité (utilisation avancée) ;
  • Plusieurs signaux différents sur un même lock (Condition).

ReentrantLock

C'est également un mécanisme de verrou (appelé aussi mutex). Un objet ReentrantLock a deux méthodes principales :

  • lock() qui permet de prendre le verrou ;
  • unlock() qui permet de rendre le verrou.

Un appel à lock() doit toujours être suivi d'un appel à unlock(). Le code entre les deux est une section critique.

Section critique et exception

En Java, beaucoup de lignes de codes sont susceptibles de lever une exception...

  public class StupidLockDemo {
    private final ReentrantLock lock = new ReentrantLock();

    public void increment() {
      lock.lock();
      // do anything 
      lock.unlock();
    }
  }

... par conséquent, le code ci-dessus est faux, car l'appel à unlock() pourrait ne pas être effectué.

Solution

Il faut obligatoirement utiliser un bloc try/finally pour verrouiller avec un ReentrantLock

  public class StupidLockDemo {
    private final ReentrantLock lock = new ReentrantLock();

    public void increment() {
      lock.lock(); // en dehors du try :)
      try{
        // do anything
      } finally {
        lock.unlock();
      }
    }
  }

Garanties de la section critique


Les garanties offertes par la section critique des ReentrantLock sont les mêmes qu'avec un bloc synchronized :

  • Atomicité (exclusion mutuelle) de la section grâce au verrou.
  • Barrière en mémoire.

À condition de les utiliser correctement bien sûr :)

Des méthodes en plus avec ReentrantLock...

Un ReentrantLock possède, par exemple, les méthodes d'instance :

  • tryLock() qui permet d’acquérir un verrou (comme lock()) mais qui renvoie false au lieu de bloquer si un autre thread possède déjà le verrou ; c'est une opération atomique.

  • lockInterruptibly() throws InterruptedException qui permet de prendre un verrou (comme lock()) mais qui peut être interrompue (voir cours suivant) si le thread est bloqué (parce qu'un autre thread possède déjà le verrou).

Signaux (et Condition)

Attention, on ne peut pas utiliser lock.wait() et lock.notify() car cela suppose qu'on est dans un bloc synchronized(lock) !

On utilise un objet Condition fourni par le ReentrantLock :

    private final ReentrantLock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
  

On peut ensuite utiliser :

  • condition.await() (à la place de wait)
  • condition.signal() (à la place de notify)
  • condition.signalAll() (à la place de notifyAll)

Ces méthodes fonctionnent exactement comme leurs équivalents ...
avec le mêmes problèmes, notamment, le risque de spurious wake-up.

Exemple

    public class Holder {
      private int value;
      private final ReentrantLock lock = new ReentrantLock();
      private final Condition condition = lock.newCondition();

      private void init() {
        lock.lock();
        try {
          value = 12;
          condition.signal();
        } finally {
          lock.unlock();
        }
      }

      private void display() throws InterruptedException {
        lock.lock();
        try {
          while (value == 0) {
            condition.await();
          }
          System.out.println(value);
        } finally {
          lock.unlock();
        }
      }
    }
  

Condition multiples

... et petit bonus : l'API des Conditions permet de créer plusieurs conditions pour un même ReentrantLock. C'est pratique pour utiliser des conditions différentes liées à un même champs.

Lorsque l'on souhaite pouvoir réveiller/endormir un thread pour deux raisons différentes, il suffit d'associer une Condition à chaque raison.

File d'attente thread-safe (come-back)

  ...
  private final ReentrantLock lock = new ReentrantLock();
  private final Condition canAdd = lock.newCondition();
  private final Condition canRemove = lock.newCondition();
  ...

  public void add(V value) throws InterruptedException {
    Objects.requireNonNull(value);
    lock.lock();
    try {
      while (queue.size() >= capacity) {
        canAdd.await();
      }
      queue.add(value);
      canRemove.signal();
    } finally {
      lock.unlock();
    }
  }

  public V take() throws InterruptedException {
    lock.lock();
    try {
      while (queue.isEmpty()) {
        canRemove.await();
      }
      canAdd.signal();
      return queue.remove();
    } finally {
      lock.unlock();
    }
  }

En résumé ...

Tout ce que l'on faisait avec un block synchronized peut être fait avec un ReentrantLock.

Il ne faut jamais oublier le try ... finally !

    lock.lock(); // en dehors du try :)
    try {
      // do anything
    } finally{
      lock.unlock();
    }
  

On peut utiliser des Condition différentes pour accorder les paires d'appels aux méthodes await/signal.

Attention : Ne jamais faire wait et notify sur une Condition !