Double-checked locking

Un article de Wikipédia, l'encyclopédie libre.
Aller à : navigation, rechercher

En génie logiciel, le verrouillage à double test ou double-checked locking est un ancien patron de conception[1].

Considéré aujourd'hui comme un antipattern du fait des problèmes subtils et difficiles à déceler qu'il pose, il a été utilisé dans le passé pour réduire le surcoût d'acquisition du verrou nécessaire à l'initialisation tardive d'un singleton, en commençant par vérifier l'objet du verrou sans précaution avant, ensuite, de poser effectivement le verrou.

En Java[modifier | modifier le code]

Prenons par exemple la classe Java suivante :

// Version mono-thread
class Singleton1 {
    private Outil outil = null;
    public Outil getOutil() {
        if (outil == null) {
            outil = new Outil();
        }
        return outil;
    }
 
    // autres attributs et méthodes...
}

Le problème de cette classe est qu'elle ne fonctionne pas en environnement multi-thread. Si deux threads appellent pour la première fois getOutil() en même temps, chacun va créer l'objet en même temps que l'autre, ou l'un va obtenir une référence sur un objet partiellement initialisé. Pour éviter cela, il faut utiliser un verrou, ce qui se fait grâce à la synchronisation, comme le montre l'exemple de code ci-dessous.

// Version correcte en environnement multi-thread, mais potentiellement coûteuse
class Singleton2 { 
    private Outil outil = null;
    public synchronized Outil getOutil() {
        if (outil == null) {
            outil = new Outil();
        }
        return outil;
    }
 
    // autres attributs et méthodes...
}

Ici, le premier appel à getOutil() va créer l'objet, et si d'autres threads appellent getOutil() pendant que l'objet est cours d'initialisation, ils seront mis en attente par la synchronisation jusqu'à ce que l'objet soit complètement initialisé et sa référence retournée par le premier appel du premier thread. Tous les appels suivants ne feront que retourner la référence vers l'objet précédemment initialisé.

Cependant, la synchronisation d'une méthode peut augmenter considérablement le temps de son exécution[2]. L'acquisition et la libération d'un verrou à chaque fois que la méthode est appelée pourrait donc sembler un surcoût inutile. De nombreux programmeurs ont essayé d'optimiser ce code comme suit :

  1. Vérifier que la variable n'est pas encore initialisée (sans tenter d'acquérir un verrou). Si elle l'est, retourner sa valeur immédiatement.
  2. Acquérir un verrou.
  3. Re-vérifier que la variable n'est pas encore initialisée : si un autre thread a acquis le verrou juste avant, il pourrait avoir initialisé la variable entre temps. S'il s'avère que la variable est initialisée, retourner sa valeur.
  4. Sinon, initialiser la variable et retourner sa valeur.
// Version multithreaded erronée
// Motif "Double-Checked Locking"
class Singleton3 {
    private Outil outil = null;
    public Outil getOutil() {
        if (outil == null) {
            synchronized (this) {
                if (outil == null) {
                    outil = new Outil();
                }
            }
        }
        return outil;
    }
 
    // autres attributs et méthodes...
}

Intuitivement, cet algorithme semble une solution efficace au problème posé. Cependant, il soulève des problèmes subtils et difficiles à tracer. Ainsi, considérons la séquence d'événements suivante :

  1. Le thread A remarque que la variable partagée n'est pas initialisée. Il acquiert donc le verrou et commence à initialiser la variable.
  2. Du fait de la sémantique du langage de programmation, le code généré par le compilateur a le droit de modifier la variable partagée avant que A ait terminé l'initialisation, de sorte que la variable référence un objet partiellement initialisé (par exemple parce que le thread commence par allouer la mémoire, puis place la référence vers le bloc mémoire alloué dans la variable, avant, enfin, d'appeler le constructeur qui va initialiser le bloc mémoire).
  3. Le thread B remarque que la variable partagée a été initialisée (ou, tout au moins, semble l'être), et retourne sa valeur immédiatement sans tenter d'acquérir un verrou. Si, ensuite, la variable partagée est utilisée avant que A n'ait eu le temps de terminer son initialisation, le programme risque fort de planter.

L'un des dangers du double-checked locking est que, souvent, cela semblera fonctionner. Cela dépend du compilateur, de la manière dont les threads sont ordonnancés par le système d'exploitation, et aussi d'autres mécanismes de gestion de la concurrence d'accès aux données. Reproduire les cas de plantage peut s'avérer d'autant plus difficile qu'ils sont hautement improbables lorsque le code est exécuté au sein d'un débogueur. L'utilisation du double-checked locking doit donc être bannie autant que possible.

Néanmoins, JavaSE 5.0 propose une solution à ce problème avec le mot réservé volatile qui garantit que des threads différents gèrent correctement l'accès concurrent à l'instance unique du singleton[3]:

// Fonctionne grâce à le nouvelle sémantique du mot-clé volatile
// Ne fonctionne pas avec Java 1.4 ou précédent
class Singleton4 {
    private volatile Outil outil = null;
    public Outil getOutil() {
        if (outil == null) {
            synchronized (this) {
                if (outil == null) {
                    outil = new Outil();
                }
            }
        }
        return outil;
    }
 
    // autres attributs et méthodes...
}

Cependant, cette sécurité d'accès a un prix : l'accès à une variable volatile est moins efficace que l'accès à une variable normale.

De nombreuses versions du patron double-checked locking qui n'utilisent pas de synchronisation explicite ou de variable volatile ont été proposées. À part celles qui reposent sur le mot-clé enum ou le motif de support d'initialisation à la demande, toutes se sont révélées incorrectes[4],[5].

Avec Microsoft Visual C++[modifier | modifier le code]

On peut implémenter le double-checked locking avec Visual C++ 2005 si le pointeur vers la ressource est déclaré volatile. Visual C++ 2005 garantit que les variables volatiles se comportent comme des barrières, comme avec JavaSE 5.0, empêchant aussi bien le compilateur que le processeur de réordonnancer les lectures et écritures à ces variables pour les rendre plus efficaces.

Les versions précédentes de Visual C++ n'offraient pas cette garantie. Cependant, marquer le pointeur vers la ressource comme volatile pénalise les performances d'une manière ou d'une autre, en particulier si le pointeur est visible ailleurs dans le code : le compilateur le traitera comme une barrière partout où il est utilisé, même lorsque cela n'est pas nécessaire.

Voir aussi[modifier | modifier le code]

Références[modifier | modifier le code]

  1. Schmidt, D et al. Pattern-Oriented Software Architecture Vol 2, 2000 pp353-363
  2. Boehm, Hans-J. "Threads Cannot Be Implemented As a Library", ACM 2005, p265
  3. La nouvelle sémantique du mot clé volatile est décrite dans [1]
  4. [2]
  5. [3]

Liens externes[modifier | modifier le code]