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

Accès relaxé à la mémoire, Fork/Join


Double-Checked Locking, Fork/Join

Exercice 1 - 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

  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 ?

Exercice 2 - BigDoubleArray

On cherche à écrire une classe BigDoubleArray qui permet de manipuler des grands tableau de valeurs flottantes 64bits. La classe proposera deux opérations, setAll et reduce qui permette respectivement d'initialiser le tableau et de calculer une valeur agrégée à partir des valeurs du tableau (comme la somme, le produit, etc).
Chaque opération est déclinée suivant 3 algorithmes
  • un algorithme séquenciel, qui assigne/calcul les valeurs en ordre croissant sur un seul coeur,
  • un algorithme parallèle qui utilise l'API des Stream pour effectuer le calcul de façon parallèle et
  • un algorithme fork/join qui utilise une technique divide and conquer nommée Fork/Join.

La technique Fork/Join consiste à utiliser l'algorithme suivant
  solve(problem):
    if problem is small enough:
        solve problem directly (sequential algorithm)
    else:
        for part in subdivide(problem)
            fork subtask to solve(part)
        join all subtasks spawned in previous loop
        return combined results
   

Java depuis la version 7 fourni les classes ForkJoinPool et ForkJoinTask qui correspondent respectivement à un pool de thread ayant des tâches sachant se subdiviser et une tâche en elle-même.

On partira du code suivant
public class BigDoubleArray {
  private final double[] values;
  
  public BigDoubleArray(int capacity) {
    values = new double[capacity];
  }
  
  public double get(int index) {
    return values[index];
  }
  public void set(int index, double value) {
    values[index] = value;
  }
  
  public void sequentialSetAll(XXX generator) {
    sequentialSetAll(0, values.length, generator);
  }
  
  public void sequentialSetAll(int start, int end, XXX generator) {
    for(var index = start; index < end;  index++) {
      values[index] = generator.xxx(index);
    }
  }
  
  public static void main(String[] args) {
    var bigArray = new BigDoubleArray(10_000_000);
    bigArray.sequentialSetAll(index -> index);
    //bigArray.parallelSetAll(index -> index);
    //bigArray.forkJoinSetAll(1_000_000, index -> index);
    
    System.out.println(bigArray.get(6_666_666));  // 6666666.0
  }
}
   

  1. Modifier le code des méthodes sequentialSetAll pour que la classe compile.
  2. Ajouter deux méthodes parallelSetAll (sur le modèle de sequentialSetAll) qui effectue l'initialisation du taleau en parallèle en utilisant un Stream parallèle.
  3. Il existe une méthode parallelSetAll dans la classe java.util.Arrays, a votre avis, cette méthode est-elle plus lente ou plus rapide que votre implantation ?
  4. Ajouter deux méthodes forkJoinSetAll (sur le modèle de sequentialSetAll mais avec un paramètre threshold supplémentaire qui indique le nombre d'élement maximum pour lequel le traitement doit être fait en séquentiel) qui effectue l'initialisation du taleau en utilisant le ForkJoinPool common et en ecrivant la tâche dans une classe interne héritant de RecursiveAction.
    Note: pour décomposer le problème, on coupera la partie de tableau que l'on veut traiter en 2.
  5. En écrivant une méthode time qui prend un paramètre un appel de méthode comme bigArray.sequentialSetAll(index -> index) afficher le temps de calcul pour les 3 algorithmes sequentail, parallel et forkJoin.
    Que peut-on en conclure ?
    Modifier aussi les paramètre pour utiliser un tableau de 100_000_000 élements ainsi qu'un threshold de 10_000_000.
  6. Completer les méthodes ci-dessous
      public double sequentialReduce(double initialValue, XXX merger) {
        return sequentialReduce(0, values.length, initialValue, merger);
      }
      
      public double sequentialReduce(int start, int end, double initialValue, XXX merger) {
        var accumulator = initialValue;
        for(var index = start; index < end;  index++) {
          accumulator = merger.xxx(accumulator, values[index]);
        }
        return accumulator;
      }
    
  7. Tester en modifiant le main pour calculer la somme des valeurs du tableaux en utilisant sequentialReduce.
  8. Ajouter les méthodes forkJoinReduce utilisant le common ForkJoinPool ainsi qu'une classe interne héritant de la classe RecursiveTask.