logo le blog invivoo blanc

Singletons – Design Patterns – Partie 2

22 décembre 2021 | Python | 0 comments

1. Contexte

On a mis en œuvre le patron de conception Singletons en Python 3 pour les connexions à la même base des ordres du marché financier dans la première partie de cet article sans tenir en compte les multiples accès simultanés, lorsque de nombreux programmes traitent les massives données à la base en parallèle afin d’accélérer la vitesse d’exécution avant d’obtenir les résultats désirés. Du coup, la manipulation des concurrences des accès aux données devient un sujet très important pendant notre conception du singleton.

Du fait, à cause de la thread safety, on a introduit le GIL (Global Interpreter Lock) qui est un verrou exclusif (mutex) auquel l’interpréteur CPython (écrit en C) fait appel constamment pour protéger tous les objets qu’il manipule contre les concurrences des accès multiples. Donc dans ce cas, on n’a qu’un seul thread sûr au programme subordonné au temps de calcul (CPU Bound). Néanmoins dans les programmes multithreads liés aux Entrée/Sortie (I/O Bound), le verrou est partagé entre les threads pendant qu’ils attendent les Entrée/Sortie. Sinon, Jython, IronPython et PyPy qui sont écrits respectivement Java, C# et Python n’intègrent pas tel verrou pour gérer les concurrences critiques. Par conséquent, les accès concurrentiels aux appels vers le singleton ont besoin d’une solution efficace lors du développement du programme.

En effet, Python 3 propose deux mécanismes de parallélisme : le parallélisme basé sur threads (multithreading) et le parallélisme par processus (multiprocessing), ce qui nous rend deux contextes problématiques où on accède à l’instance unique dans le singleton. A la suite de cet article on va mettre en lumière deux clés correspondantes en Python 3 pour s’adapter à ces deux situations.

2. Conception

Le principe de résoudre les issues des concurrences critiques notamment consiste au verrouillage à double test qui s’applique au moment où on décide si l’on a besoin d’instancier l’objet unique dans le singleton. Grâce au verrou entre deux conditions de test, la ressource de l’instanciation unique est bien protégée sans risque concurrentiel.

En détail si l’instance unique existe déjà, les appels d’accès concurrentiels s’arrêtent à la 1e condition en retournant directement cette instance du singleton.

Sinon, tandis que la 1e condition est bien satisfaite lors des multiples accès simultanés, ces appels vers les singletons sont tous acceptés. En revanche, la mise en place du verrou ne fait entrer que le 1e appel dans la section critique où l’objet unique est mise en œuvre, si et seulement si la 2e condition est vérifiée tout à fait correcte.

A la fin du traitement du 1e appel à la section crique verrouillée, on libère le verrou tout d’un coup, avant que les autres appels en attente qui ont bien passé la 1e condition de test ne puissent rendre en compte l’existence de l’objet unique et le récupérer directement.

3. Développement

Dans Singleton – Design Patterns – Partie 1 on a mis en valeur les avantages et les inconvénients des trois implémentations des singletons en Python 3 : la classe de base, le décorateur et la métaclasse. Comme la méthode de métaclasse réalise le singleton d’une manière plus concise et efficace que les autres, on va appliquer le principe du verrouillage à double test dans son développement au grès de deux mécanismes de parallélisme : le parallélisme basé sur threads et le parallélisme par processus. Sinon, les deux autres méthodes pourraient également être protégées contre les situations de compétition de façon similaire.

3.1. Le parallélisme basé sur threads

Pour réaliser le verrouillage à double test dans ce cas, on profite du verrou Lock mis en place dans la librairie de threading chez Python 3. Dès que la 1e condition de test sur l’existence de l’instance de Connexion est satisfaite, on appelle tout de suite le verrou sous le gestionnaire de contexte with. Du coup, seulement le 1e coureur peut entrer dans la section crique, avant qu’il ne décide et crée une seule instance si cette dernière n’existe pas encore dans le singleton. Une fois que ces manipulations sont finies, le gestionnaire de contexte libère le verrou et tous les appels concurrentiels qui accèdent au singleton mis en protection retournent la seule instance partagée.

#multithreading.py

import threading
import time

class Singleton(type):
    _instance = None
    _lock = threading.Lock()
    def __call__(cls, *args, **kwargs):
        if not isinstance(cls._instance, cls):
            with cls._lock:
                if not isinstance(cls._instance, cls):
                    cls._instance = super().__call__(*args, **kwargs)
        return cls._instance


class Connexion(metaclass=Singleton):
    number = 0
    def __init__(self, *args,**kwargs): 
        self.number += 1
        print(f"connexion init called to get Singleton Connexion {self.number}...")
        
    def __str__(self):
        return f"Singleton Connexion {self.number}"


if __name__ == '__main__': 
    def connect():
        time.sleep(2)
        connexion = Connexion()
        print(f'Thread ID {threading.current_thread().ident} got {connexion}...')

    start_time = time.time()
    threads = []
    for i in range(10):
        thread = threading.Thread(target=connect)
        threads.append(thread)
        thread.start()
    # join all remaining running threads
    for thread in threads:
        thread.join()
print(f"the program takes {time.time()-start_time} seconds...")

Afin de judicieusement tester l’existence d’une seule instance de Connexion au cours des accès concurrentiels, on définit dans la classe Connexion une variable number qui s’incrémente par 1 lors de chaque initialisation d’un objet de Connexion. En plus, on affiche cette variable pendant l’impression de cet objet de Connexion. Du coup, on attend qu’une seule initialisation aura lieu avec un affichage unique par le singleton, tandis que les accès aux singletons par les threads seront tous à la Singleton Connexion 1 identique.

Pendant l’exécution du programme dix connexions supposées attendre 2 secondes chacune accède à la base en concurrence avant l’affichage de leur objet partagé dans la mémoire, alors que dix threads s’occupe d’établir ces connexions par les appels simultanés.

Comme ce que l’on a anticipé aux impressions du teste qui sont relevées en-dessous, il n’y a qu’un affichage dans la méthode __init__ de la classe Connexion, ce qui implique une seule instanciation pour l’objet Singleton Connexion 1 au parallélisme du programme, lorsque tous les 10 threads distingués par leurs identifiants partagent cet objet unique dont la création dure 2 secondes en total.

connexion init called to get Singleton Connexion 1...
Thread ID 140381434906368 got Singleton Connexion 1...
Thread ID 140380930565888 got Singleton Connexion 1...
Thread ID 140381443299072 got Singleton Connexion 1...
Thread ID 140381418120960 got Singleton Connexion 1...
Thread ID 140381460084480 got Singleton Connexion 1...
Thread ID 140381426513664 got Singleton Connexion 1...
Thread ID 140380922173184 got Singleton Connexion 1...
Thread ID 140381468477184 got Singleton Connexion 1...
Thread ID 140381451691776 got Singleton Connexion 1...
Thread ID 140380938958592 got Singleton Connexion 1...
the program takes 2.014531373977661 seconds... 

De cette façon, on a bien protégé la section critique où se valide l’unicité de l’instance des singletons contre les accès concurrentiels par threads multiples dans un programme.

3.2. Le parallélisme par processus

Dans ce cas, on va profiter de la même idée pour protéger la section crique à la création d’une instance unique que l’on a mise au point dans le cas précédent. Néanmoins, comme on étudie les processus multiples qui n’ont pas la mémoire partagée dans un programme, il faut que les communications interprocessus aient lieu de la manière synchronisée pour accéder à l’objet uniquement instancié dans le singleton et au verrou exclusif en commun défini globalement dans le processus principal main.

Dans ce processus main, on créé un dictionnaire pris en charge par un objet gestionnaire renvoyé par Manager() qui contrôle un processus serveur et autorise les autres processus à les manipuler à l’aide de mandataires. Ce dictionnaire va détenir l’objet unique du singleton comme une valeur qui correspond à la clé _instance. En outre, on exploite le verrou Lock mis en place dans la librairie multiprocessing en Python 3. Ce verrou est partagé par tous les processus dans le programme, lorsque le singleton le gère par le gestionnaire de contexte with de la même manière que le verrou à double test mis au point dans le cas précédent.

#multiprocessing.py

import multiprocessing
import os
import time

class Singleton(type):
    def __call__(cls, *args, **kwargs):
        if not isinstance(shared_state.get('_instance'), cls):
            with shared_state_lock:
                if not isinstance(shared_state.get('_instance'), cls):
                    shared_state['_instance'] = super().__call__(*args, **kwargs)
        return shared_state['_instance']


class Connexion(metaclass=Singleton):
    number = 0
    def __init__(self, *args,**kwargs): 
        self.number += 1
        print(f"connexion init called to get Singleton Connexion {self.number}...")
        
    def __str__(self):
        return f"Singleton Connexion {self.number}"


if __name__ == '__main__':
    def connect():
        time.sleep(2)
        connexion = Connexion()
        print(f'Process ID {os.getpid()} got {connexion}...')

    shared_state = multiprocessing.Manager().dict()
    shared_state_lock = multiprocessing.Lock()

    start_time = time.time()
    processes = []
    for i in range(10):
        process = multiprocessing.Process(target=connect)
        processes.append(process)
        process.start()
    # join all remaining running processes
    for process in processes:
        process.join()

    print(f"the program takes {time.time()-start_time} seconds...")

On teste le singleton protégé par le verrou à double test en lançant dix processus concurrentiels qui tentent d’accéder à son instance unique simultanément dans 2 secondes chacun. Une seule instanciation a lieu lors du 1e appel d’un processus en imprimant l’affichage dans la méthode __init__. Ensuite tous les processus distingués par leurs identifiants retournent le même objet de Connexion instancié par le singleton à leurs propres mémoires. La durée pour obtenir tel objet unique en commun est 2 secondes en total par les 10 processus parallélisés. 

connexion init called to get Singleton Connexion 1...
Process ID 1642 got Singleton Connexion 1...
Process ID 1640 got Singleton Connexion 1...
Process ID 1641 got Singleton Connexion 1...
Process ID 1639 got Singleton Connexion 1...
Process ID 1646 got Singleton Connexion 1...
Process ID 1651 got Singleton Connexion 1...
Process ID 1650 got Singleton Connexion 1...
Process ID 1645 got Singleton Connexion 1...
Process ID 1649 got Singleton Connexion 1...
Process ID 1653 got Singleton Connexion 1...
the program takes 2.0910840034484863 seconds...

De cette façon, on a bien protégé la section critique où se valide l’unicité de l’instance du singleton contre les accès concurrentiels par les processus multiples dans un programme.

Grâce au principe du verrouillage à double test, on peut résoudre les issues produites par les concurrences critiques, qui risquent de violer l’unicité de l’instance dans la définition des singletons. On a étudié les deux situations de concurrence lors de la création de l’instance, avant que l’on ne mette en place les protections pour singletons en Python 3. Enfin on a réussi à régler le problème de connexion par les multiples accès simultanés vers la base de données partagée.

4. Conclusion

Dans cet article, on essaie de répondre aux questions sur les violations pendant la conception du singleton à cause des accès concurrentiels vers son instance unique. Deux types de parallélismes sont étudiés : le parallélisme basé sur threads (multithreading) et le parallélisme par processus (multiprocessing), tandis que le verrouillage à double test est introduit pour protéger leurs sections critiques où se trouve la phase de l’instanciation de l’objet. Les implémentations sont mises en œuvre en Python 3 et les tests sous les contextes du parallélisme confirment leurs justesse et efficacité. On pourrait d’ores et déjà bénéficier sûrement du patron de conception créateur singleton en l’adaptant à tous les cas où on ne demande qu’un seul objet partagé par tous les appels du programme.