TP 9 - Synchronisation des threads

Présentation théorique

Pour la synchronisation des fils d'exécution, on dispose de:   

Mutex

Les mutex (verrous d'exclusion mutuelle) sont des objets de synchronisation utilisés pour garantir un accès exclusif dans une section de code où les données partagées sont utilisées entre deux threads ou plusieurs. Un mutex a deux états possibles: occupé et libre . Un mutex peut être occupé par un seul thread à la fois. Lorsqu'un mutex est occupé par un thread, il ne peut être occupé par aucun autre thread. Dans ce cas, une demande d'emploi d'un autre thread bloquera généralement le thread jusqu'à ce que le mutex devienne libre.

Création d'un mutex

En Python, le mutex est représenté par la classe threading.Lock. Lors de la création du mutex, il est à l'état déverrouillé . Une fois occupé par un thread, tout autre appel à occuper le mutex échouera.

Mutex récursif

En utilisant la classe threading.RLock, nous pouvons créer un mutex récursif. Ce mutex peut être occupé plusieurs fois par le même thread. Pour être libéré pour un autre thread, le thread qui a occupé le mutex doit appeler la fonction de libération un certain nombre de fois egal avec le nombre d'appels de la fonction d'occupation du mutex.

Occupation / libération d'un mutex

Les fonctions d'occupation / de libération d'un mutex (acquire, release):

mutex.acquire (blocking=True, timeout=-1)
mutex.release()

Si le mutex est libre au moment de l'appel, il sera occupé par le fil appelant et la fonction retournera immédiatement. Si le mutex est occupé par un autre thread , le comportement de la fonction dépend du paramètre blockling :

  • True - le thread se verrouille jusqu'à ce que le mutex soit libéré; si le paramètre timeout a une valeur positive, le thread ne se verrouillera que pendant timeout secondes (dans ce cas, la fonction renverra True si le mutex a été occupé et False sinon)
  • Faux - la fonction renvoie immédiatement la valeur Vrai si la fonction acquérir a fonctionné et la valeur Faux si l'appel a échoué (le mutex est occupé par un autre thread).

Une commande FIFO pour l'emploi d'un mutex n'est pas garantie. N'importe lequel des fils en attente de déverrouillage d'un mutex peut l'attraper.

Pour libérer un mutex, vous devez appeler la fonction release .

Si la fonction release est appelée sur un mutex free , la fonction générera une exception.

Exemple d'utilisation de mutex

Un exemple d'utilisation d'un mutex pour sérialiser l'accès à la variable globale global_counter :

import threading
 
NUM_THREADS = 5
 
# global mutex
mutex = threading.Lock()
 
global_counter = 0
def thread_routine(): 
    global global_counter
 
    thread_id = threading.get_ident()
    # acquire global mutex
    mutex.acquire()
 
    # print and modify global_counter
    print("Thread {} says global_counter={}".format (thread_id, global_counter))
    global_counter = global_counter + 1
 
    # release mutex - now other threads can modify global_counter
    mutex.release()
 
threads = []
# all threads execute thread_routine
for i in range (NUM_THREADS):
    t = threading.Thread (target=thread_routine)
    threads.append (t)
    t.start()
 
for i in range (NUM_THREADS):
    threads[i].join()
$ python3 example.py 
Thread 123145557700608 says global_counter=0
Thread 123145562955776 says global_counter=1
Thread 123145557700608 says global_counter=2
Thread 123145568210944 says global_counter=3
Thread 123145562955776 says global_counter=4

Sémaphores

Les sémaphores sont des objets de synchronisation qui représentent une généralisation des mutex dans la mesure où ils enregistrent le nombre d'opérations de libération (incrément) effectuées sur eux. Fondamentalement, un sémaphore est un entier qui augmente / diminue atomiquement. La valeur d'un sémaphore ne peut pas devenir négative. Si le sémaphore a une valeur de 0, l'opération de décrémentation sera bloquée jusqu'à ce que la valeur du sémaphore devienne strictement positive. Les mutex peuvent donc être considérés comme des sémaphores binaires.

En Python, les semaphores sont représentés par la classe Semaphore:

import threading
semaphore = threading.Semaphore (value=1)

Opérations

   * Occupation du sémaphore (acquire) - diminue le nombre du sémaphore;    * libération du sémaphore (release - augmente le compteur du sémaphore.

La fonction acquire est similaire à la fonction acquire spécifique au mutex. La différence est que dans ce cas, à l'appel acquire il est vérifié si le compteur du sémaphore est supérieur à 0.

Sémaphore limité

En plus de la classe Semaphore , le module de threading exporte la classe BoundedSemaphore qui garantit que la fonction release n'est pas appelé plus que la fonction acquire. Si l'appel de la fonction release entraîne une augmentation du compteur supérieure à la valeur initiale, l'appel de la fonction entraînera une erreur.

Exemple d'utilisation du sémaphore

import threading
 
NUM_THREADS = 5
 
# global semaphore
my_sem = threading.Semaphore(value=2)
 
add_counter = 0
subtract_counter = 0
 
def add_routine(): 
    global add_counter
 
    thread_id = threading.get_ident()
    # acquire global mutex
    my_sem.acquire()
 
    # print and modify global_counter
    print("Thread {} says add_counter={}".format (thread_id, add_counter))
    add_counter = add_counter + 1
 
    # release mutex - now other threads can modify global_counter
    my_sem.release()
 
def subtract_routine(): 
    global subtract_counter
 
    thread_id = threading.get_ident()
    # acquire global mutex
    my_sem.acquire()
 
    # print and modify global_counter
    print("Thread {} says subtract_counter={}".format (thread_id, subtract_counter))
    subtract_counter = subtract_counter - 1
 
    # release mutex - now other threads can modify global_counter
    my_sem.release()
 
threads = []
# all threads execute thread_routine
for i in range (NUM_THREADS):
    t1 = threading.Thread (target=add_routine)
    t2 = threading.Thread (target=subtract_routine)
    threads.append (t1)
    t1.start()
    threads.append (t2)
    t2.start()
 
for i in range (NUM_THREADS):
    threads[i].join()
$ python3 example.py 
Thread 123145375227904 says add_counter=0
Thread 123145380483072 says subtract_counter=0
Thread 123145375227904 says add_counter=1
Thread 123145375227904 says subtract_counter=-1
Thread 123145375227904 says add_counter=2
Thread 123145380483072 says subtract_counter=-2
Thread 123145375227904 says subtract_counter=-2
Thread 123145385738240 says add_counter=3
Thread 123145380483072 says add_counter=4
Thread 123145375227904 says subtract_counter=-4

Nous pouvons voir que les deux threads (add et subtract) fonctionnent en parallèle.

Variables de condition

Les variables de condition fournissent un système de notification pour les threads d'exécution, permettant à un thread de se bloquer en attendant le signal d'un autre thread. L'utilisation correcte des variables de condition nécessite un protocole coopératif entre les threads d'exécution.

Les mutex et les feux de signalisation permettent de verrouiller d'autres threads d'exécution. Les variables de condition sont utilisées pour bloquer le fil de courant jusqu'à ce qu'une condition soit remplie.

Les variables de condition sont des objets de synchronisation qui permettent à un thread de suspendre son exécution jusqu'à ce qu'une condition (prédicat logique) devienne vraie . Lorsqu'un thread d'exécution détermine que le prédicat est devenu vrai, il signale la condition de variable, déverrouillant ainsi un ou tous les threads d'exécution verrouillés sur cette condition de variable (selon l'intention).

En Python, une variable de condition est la classe Condition.

Une condition variable doit toujours être utilisée avec un mutex pour éviter la course qui se produit lorsqu'un thread se prépare à attendre la condition variable après l'évaluation du prédicat logique, et un autre thread signale la condition variable avant même le premier thread il se verrouille, perdant ainsi le signal.

Lors de la création de la condition, nous pouvons passer au constructeur comme paramètre un mutex ou nous pouvons laisser le constructeur en créer un.

import threading
 
mutex = threading.Lock()
mutex_cond = threading.Condition (mutex)
default_mutex_cond = threading.Condition()

Par conséquent, les opérations de signalisation, de test de la condition logique et de blocage de la variable de condition doivent être effectuées en ayant occupé le mutex associé à la variable de condition. La condition logique est testée sous la protection du mutex, et si elle n'est pas remplie, le thread appelant se verrouille sur la condition variable, libérant atomiquement le mutex. Au moment du déverrouillage, un thread tentera d'occuper le mutex associé à la condition variable. De plus, le test du prédicat logique doit être effectué dans une boucle , car si plusieurs threads sont libérés à la fois, un seul pourra occuper le mutex associé à la condition. Le reste attendra qu'il soit libéré, mais il est possible que le thread qui occupait le mutex change la valeur du prédicat logique pendant la propriété du mutex. Pour cette raison, les autres threads doivent à nouveau tester le prédicat car, sinon, il commencerait son exécution en supposant le vrai prédicat, alors qu'il est en fait faux.

On va utiliser les fonctions acquire et release sur l'objet condition.

Verrouillage sur une condition variable

Pour suspendre l'exécution et attendre une condition variable, un thread appellera l'une des fonctions wait ou wait_for:

wait(timeout=None)
wait_for(predicate, timeout=None)

La différence entre les deux fonctions est que wait attend qu'une notification soit émise, tandis que wait_for attend que le prédicat ait la valeur True .

Le thread appelant doit avoir déjà occupé le mutex associé, au moment de l'appel. La fonction 'attente' va libérer le mutex et verrouiller , en attendant que la condition variable soit signalée par un autre thread. Les deux opérations sont effectuées atomiquement . Lorsque la variable de condition est signalée, il essayera d'occuper le mutex associé, et après l'avoir occupe, l'appel de fonction retournera. Notez que le thread appelant peut être suspendu, après déblocage, en attendant l'utilisation du mutex associé, tandis que le prédicat logique, vrai au moment du déblocage du thread, peut être modifié par d'autres threads. Par conséquent, l'appel d'attente doit être effectué dans une boucle dans laquelle la valeur de vérité du prédicat logique associé à la condition de variable est testée, pour garantir une sérialisation correcte des threads d'exécution. Un autre argument pour tester en boucle le prédicat logique est qu'un appel «d'attente» peut être interrompu par un signal asynchrone (voir laboratoire de signaux), avant que le prédicat logique ne devienne vrai. Si les threads d'exécution en attente de la condition de variable ne testaient pas à nouveau le prédicat logique, ils poursuivraient leur exécution en supposant que cela était vrai.

Déverrouillage d'un seul thread verrouillé dans une variable de condition

Pour déverrouiller un seul fil d'exectution verrouillé sur une variable de condition, la variable de condition sera marquée à l'aide de notify:

notify(n=1)

Si aucun thread d'exécution n'attend la condition variable, l'appel de fonction n'a aucun effet et le signal sera perdu . Si plusieurs threads attendent la condition variable, un seul d'entre eux sera déverrouillé. Le thread qui sera déverrouillé est choisi par le planificateur de threads. On ne peut pas supposer que les fils en attente seront déverrouillés dans l'ordre dans lequel ils ont commencé à attendre. Le thread appelant doit avoir le mutex associé à la condition de variable au moment de l'appel de cette fonction.

Exemple:

import threading
import time
 
mutex = threading.Lock()
cond = threading.Condition(mutex)
 
count = 0
 
def decrement_count():
    global count
    id = threading.get_ident()
    cond.acquire()
    print ('{} a blocat mutex-ul.'.format (id))
    while count == 0:
        cond.wait()
    count = count - 1
    print ('count = {}' .format(count))
    cond.release()
    print ('{} a eliberat mutex-ul.'.format(id))
 
 
def increment_count():
    global count
    id = threading.get_ident()
    if cond.acquire(blocking=False):
        print ('Mutex-ul a fost eliberat')
        print ('{} a blocat mutex-ul.'.format(id))
        count = count + 1
        cond.notify()
        print ('count = {}' .format(count))
        cond.release()
        print ('{} a eliberat mutex-ul.'.format(id))
 
t1 = threading.Thread (target=decrement_count)
t1.start()
 
time.sleep (2)
 
t2 = threading.Thread (target=increment_count)
t2.start()

Nous pouvons voir qu'à l'appel wait , le mutex est libéré et peut être occupé par un autre thread.

Déverrouillage de tous les threads échoués dans une condition variable

Pour déverrouiller tous les threads verrouillés sur une variable de condition, signalez la variable de condition à l'aide de notify_all:

notify_all()

Si aucun thread d'exécution n'attend la condition variable, l'appel de fonction n'a aucun effet et le signal sera perdu . Si la variable d'exécution attend des threads d'exécution, tous ces éléments seront déverrouillés, mais ils seront en concurrence pour l'occupation mutex associée à la condition de variable. Le thread appelant doit avoir le mutex associé à la condition de variable au moment de l'appel de cette fonction.

Exemple d'utilisation des variables de condition

Dans le programme suivant, une barrière est utilisée pour synchroniser les threads d'exécution du programme. La barrière est implémentée à l'aide d'une variable de condition.

import threading
 
NUM_THREADS = 5
nr_still_to_come = NUM_THREADS
 
condition = threading.Condition()
 
def thread_routine():
    global nr_still_to_come
    id = threading.get_ident()
 
    # înainte de a lucra cu datele interne ale barierei trebuie să preluam mutex-ul
    condition.acquire()
 
    print("Thread {}: before the barrier".format (id))
 
    # suntem ultimul fir de execuție care a sosit la barieră?
    if nr_still_to_come == 1:
        is_last_to_arrive = True
    else:
        is_last_to_arrive = False
    # decrementăm numarul de fire de execuție așteptate la barieră
    nr_still_to_come = nr_still_to_come - 1
 
    # cât timp mai sunt fire de execuție care nu au ajuns la barieră, așteptăm.
    while nr_still_to_come != 0:
        # mutex-ul se eliberează automat înainte de a incepe așteptarea
        condition.wait() 
 
    # ultimul fir de execuție ajuns la barieră va semnaliza celelalte fire 
    if is_last_to_arrive:
        print("    let the flood in")
        condition.notify_all()
 
    print("Thread {}: after the barrier". format (id))
 
    # la ieșirea din funcția de așteptare se preia automat mutex-ul, care trebuie eliberat
    condition.release()
 
threads = []
 
for i in range (NUM_THREADS):
    t = threading.Thread (target=thread_routine)
    threads.append (t)
    t.start()
 
for i in range (NUM_THREADS):
    threads[i].join()
$ python3 example.py 
Thread 123145396199424: before the barrier
Thread 123145401454592: before the barrier
Thread 123145406709760: before the barrier
Thread 123145411964928: before the barrier
Thread 123145417220096: before the barrier
    let the flood in
Thread 123145417220096: after the barrier
Thread 123145411964928: after the barrier
Thread 123145396199424: after the barrier
Thread 123145401454592: after the barrier
Thread 123145406709760: after the barrier

Dès l'exécution du programme, on observe:

  • l'ordre dans lequel les threads d'exécution sont planifiés n'est pas nécessairement l'ordre de leur création
  • l'ordre dans lequel les threads d'exécution en attente d'une variable de condition sont réveillés n'est pas l'ordre dans lequel ils ont attendus.

Barrière

La norme POSIX définit également un ensemble de fonctions et de structures de données avec des barrières.

En Python, la barrière est implémentée par la classe Barrier.

Création d'une barrière

Lors de la création d'une barrière, le nombre de threads qui attendront à la barrière sera spécifié:

barrier = threading.Barrier (parties, action=None, timeout=None)

L'action est une fonction qui sera appelée uniquement par le premier thread exécuté après la barrière.

En attendant une barrière

L'attente de la barrière se fait en appelant wait:

barrier.wait()

Si la barrière a été créée avec parties = NUM_THREADS , les premiers threads NUM_THREADS-1 qui appellent attente sont bloqués. Lorsque le dernier (du NUM_THREAD ) arrive, il débloquera tous les threads NUM_THREAD-1 . La fonction 'wait' renvoie des valeurs de 0 à NUM_THREADS-1, une valeur différente pour chaque thread.

Exemple d'utilisation de la barrière

Avec des barrières, le programme ci-dessus peut être simplifié:

import threading
 
NUM_THREADS = 5
 
def print_flood():
    print("   let the flood in") 
 
def thread_routine():
    id = threading.get_ident()
 
    print("Thread {}: before the barrier".format(id))
 
    # toate firele de execuție așteaptă la barieră.
    rc = barrier.wait()           
    print("Thread {}: after the barrier".format(id))
 
# bariera este inițializată o singură dată și folosită de toate firele de execuție
# cel de-al doilea parametru este o functie care va fi apelata doar de primul thread care a primit release
barrier = threading.Barrier (NUM_THREADS, print_flood) 
 
threads = []
for i in range (NUM_THREADS):
    t = threading.Thread (target=thread_routine)
    threads.append (t)
    t.start()
for i in range (NUM_THREADS):
    threads[i].join()
$ python3 example.py 
Thread 123145501237248: before the barrier
Thread 123145506492416: before the barrier
Thread 123145511747584: before the barrier
Thread 123145517002752: before the barrier
Thread 123145522257920: before the barrier
   let the flood in
Thread 123145522257920: after the barrier
Thread 123145501237248: after the barrier
Thread 123145506492416: after the barrier
Thread 123145511747584: after the barrier
Thread 123145517002752: after the barrier

Exercices pratiques

Pour résoudre le laboratoire, utilisez le référentiel github. Pour télécharger le référentiel, exécutez la commande git clone https://github.com/UPB-FILS/sde.git dans le terminal.

Exercice 1 - Print once

Dans le fichier main.py dans le répertoire 1-once, nous avons créé un programme qui utilise 10 threads pour afficher le texte 10 fois. Modifiez la fonction print_text pour qu'après avoir exécuté le programme, le texte ne s'affiche qu'une seule fois sur l'écran.

Exercice 2 - Magic number

Dans le fichier main.py du répertoire 2-magic, on a décrit une fonction qui recherche le premier palindrome dans la plage min-max, où min et max sont deux valeurs que la fonction reçoit comme paramètres, en utilisant deux threads.

Modifiez le code afin que la variable globale magic_number contienne la première valeur trouvée (si un thread a trouvé une valeur, la seconde ne stockera plus la valeur trouvée).

Exercice 3 - Moyenne aritmetique

Dans le fichier main.py du répertoire 3-avg, nous utilisons deux threads pour calculer la moyenne arithmétique des nombres générés aléatoirement. Utilisez la variable de condition pour garantir le bon fonctionnement du programme.

Exercice 4 - Sleep

Modifiez le programme de l'exercice précédent comme suit: Créez un programme qui lance 10 threads qui dorment pendant un nombre aléatoire de secondes, transmis par le programme principal. Une fois tous les threads en veille, affichez un message dans la console. Utiliser une structure barrière pour assurer le bon fonctionnement du programme.

sde/laboratoare/09_python.txt · Last modified: 2020/04/15 10:19 by ioana_maria.culic
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0