:: Enseignements :: ESIPE :: E4INFO :: 2019-2020 :: Collections Concurrentes ::
[LOGO]

Memory Model, Publication et Lock


Memory Model, Opération atomique, CompareAndSet, Réimplantation de locks

Exercice 1 - SpinLock pas réentrant

On cherche à écrire un simple lock non-réentrant que l'on appelera SplinLock en utilisant les volatiles et la méthode compareAndSet de la classe VarHandle.

  1. Rappeler ce que "réentrant" veux dire.
  2. Expliquer, pour le code ci-dessous, quel est le comportement que l'on attend si la classe est thread-safe.
    Puis expliquer pourquoi le code du Runnable n'est pas thread-safe (même si on déclare counter volatile).

    Vérifier vos affirmations en exécutant le main.
  3. Pour coder le lock, l'idée est d'utiliser un champ boolean pour représenter le jeton que doit prendre un thread pour rentrer dans le lock. Acquérir le lock revient à passer le champs booléen de faux à vrai. Redonner le lock revient à assigner le champ à faux.
    Que doit-on faire si on arrive pas à acquérir le lock ? Quel est le problème ? Pourquoi utiliser Thread.onSpinWait permet de résoudre le problème ? Sachant que les CPUs modernes ont un pipeline, à votre avis, à quoi sert Thread.onSpinWait ?
  4. Implanter les méthodes lock et unlock et vérifier en exécutant le main que le code du Runnable est désormais thread-safe.
  5. Ajouter une méthode tryLock dans SpinLock qui a le même comportement que la méthode tryLock de l'interface java.util.concurrent.locks.Lock.
    Après avoir mis le code du main en commentaire, ajouter un test vérifiant le fonctionnement de tryLock.

Exercice 2 - SpinLock Réentrant

On souhaite maintenant écrire un lock réentrant, on propose pour cela l'algorithme suivant
public class ReentrantSpinLock {
  private volatile int lock;
  private volatile Thread ownerThread;
  
  public void lock() {
    // idée de l'algo
    // on récupère la thread courante
    // si lock est == à 0, on utilise un CAS pour le mettre à 1 et
    //   on sauvegarde la thread qui possède le lock dans ownerThread.
    // sinon on regarde si la thread courante n'est pas ownerThread,
    //   si oui alors on incrémente lock.
    //
    // et il faut une boucle pour retenter le CAS après avoir appelé onSpinWait()
  }
  
  public void unlock() {
    // idée de l'algo
    // si la thread courante est != ownerThread, on pète une exception
    // si lock == 1, on remet ownerThread à null
    // on décrémente lock
  }
  
  public static void main(String[] args) throws InterruptedException {
    var runnable = new Runnable() {
      private int counter;
      private final ReentrantSpinLock spinLock = new ReentrantSpinLock();
      
      @Override
      public void run() {
        for(var i = 0; i < 1_000_000; i++) {
          spinLock.lock();
          try {
            spinLock.lock();
            try {
              counter++;
            } finally {
              spinLock.unlock();
            }
          } finally {
            spinLock.unlock();
          }
        }
      }
    };
    var t1 = new Thread(runnable);
    var t2 = new Thread(runnable);
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println("counter " + runnable.counter);
  }
}
   

  1. Ecrire la classe ReentrantSpinLock et vérifier que le main fonctionne.
  2. En fait, on peut rendre le code un peu plus efficace en ne déclarant pas ownerThread volatile et en profitant du fait que l'effet d'une lectere/ecriture volatile sur lock a aussi des effets sur la lecture/ecriture des champs au alentour.
    Modifier votre code et vérifier que le main fonctionne toujours.
    Note: la lecture d'un champ par une même thread ne nécessite pas de lecture/ecriture volatile.

Exercice 3 - Double-Checked Locking

Le Double-Checked Locking est un design pattern de concurrence hérité du C++ qui consiste à essayer d'initialiser un singleton de façon paresseuse (lazy) et thread-safe.
Ce design pattern ne sert à rien en Java (on va voir pourquoi) mais c'est un exercice comme un autre.

  1. Pourquoi le code suivant ne marche pas ?
    public class Utils {
      private static Path HOME;
    
      public static Path getHome() {
        if (HOME == null) {
          return HOME = Path.of(System.getenv("HOME"));
        }
        return HOME;
      }
    }
        
  2. Peux-t'on dire que le code ci-dessus n'est pas thread-safe ?
  3. Pourquoi le code suivant ne marche pas non plus ?
    Indice: pensez au problème de publication !
    Comment peut-on corriger le problème ?
    public class Utils {
      private static Path HOME;
    
      public static Path getHome() {
        var home = HOME;
        if (home == null) {
          synchronized(Utils.class) {
            home = HOME;
            if (home == null) {
              return HOME = Path.of(System.getenv("HOME"));
            }
          }
        }
        return home;
      }
    }
        
  4. Il existe une autre façon de corriger le problème en utilisant les méthodes getAcquire et setRelease de la classe VarHandle qui fournisse un accès relaxé à la mémoire.
    Quelles sont les garanties fournient par getAcquire et setRelease et en quoi ces garanties sont moins forte qu'un accès volatile ?
    En quoi ces garanties sont suffisantes dans notre cas ?
    Modifier le code ci-dessous (en commentant l'ancien) en introduisant les méthodes getAcquire et setRelease:
    public class Utils {
      private static Path HOME;
    
      public static Path getHome() {
        var home = HOME; // TODO  ??
        if (home == null) {
          synchronized(Utils.class) {
            home = HOME; // TODO ??
            if (home == null) {
              return HOME = Path.of(System.getenv("HOME"));  // TODO ??
            }
          }
        }
        return home;
      }
    }
        
  5. En fait, il y a une façon beaucoup plus simple de résoudre le problème sans utiliser le pattern Double-checked locking et en utilisant le pattern initialization on demand holder.
    Modifier votre code (en commentant l'ancien) pour utiliser ce pattern.
    Pourquoi ce pattern est plus efficace ?