Les threads

Nous avons vu comment les processus sont générés grâce à l'appel système fork, et comment une hiérarchie de processus se crée au fur et à mesure de l'utilisation de l'ordinateur, tous ayant pour racine le processus initial (systemd ou init sur les distributions linux). Il existe un système plus léger que le fork de processus : les threads. Nous allons voir ça avec le langage python.

Attention

La gestion des forks ou des threads n'est pas possible avec l'interpréteur python dans le navigateur. Au moment de la rédaction de ces pages, le web assembleur ne permet pas de gérer des threads. Tout les codes seront donc à éxécutez sur une station de travail.

Programmation séquentielle

Commençons par un programme simple : nous allons afficher 5 fois la phrase «Bonjour !» et 5 fois la phrase «Ça va ?». Le code sera le suivant :

#!/usr/bin/python3
def f1():
    for _ in range(5):
         print("Bonjour !")

def f2():
    for _ in range(5):
        print("Ça va ?")

if __name__ == "__main__" :
    f1()
    f2()
À faire

Recopiez le code ci dessus dans un fichier que vous nommerez sequentiel.py et exécutez le.

Lorsque vous exécutez le programme, vous avez la fonction f1 qui s'éxécute complétement, puis ensuite la fonction f2 qui s'éxécute. Le programme affiche donc d'abord 5 fois «Bonjour !» puis ensuite 5 fois «Ça va ?». C'est le fonctionnement habituel des programmes. Nous allons voir qu'il peut en être autrement.

Nos premiers threads

Un thread est un processus qui va partager avec notre programme l'espace des données et va s'éxécuter de façon simultané avec d'autres thread. On parle de processus léger. Ils peuvent être très utile, mais peuvent aussi causer de multiples problèmes. Mettons les choses en pratique. Pour cela, nous allons utiliser la bibliothèque threading avec le code ci dessous :

#!/usr/bin/python3
from threading import Thread
from time import sleep

def f1():
    for _ in range(5):
        print("Bonjour !")
        sleep(0.01)

def f2():
    for _ in range(5):
        print("Ça va ?")
        sleep(0.01)

if __name__ == "__main__" :
    p1 = Thread(target=f1)
    p2 = Thread(target=f2)
    p1.start()
    p2.start()
À faire

Recopiez le code ci dessus dans un fichier que vous nommerez concurrente.py et exécutez le, plusieurs fois de préférence. Que remarquez vous ?

Cette fois ci, les «Bonjour !» et les «Ça va ?» se sont affichés de façon intercalée. Si vous avez exécuté le programme plusieurs fois, vous avez peut être vu qu'il n'y avait pas toujours le même ordre. Par exemple :


Bonjour !
Ça va ?
Bonjour !
Ça va ?
Ça va ?
Bonjour !
Bonjour !
Ça va ?
Bonjour !
Ça va ?

Étonnant non ?

Comment ça marche ?

Analysons le programme par étape

#!/usr/bin/python3
from threading import Thread
from time import sleep

Les deux premières lignes du programme se contentent d'importer la classe Thread du module threading et la fonction sleep du module time (nous avons besoin de ralentir le processus avec sleep sinon la première fonction s'éxécuterait trop vite et afficherait tout d'un coup).

def f1():
    for _ in range(5):
        print("Bonjour !")
        sleep(0.01)

def f2():
    for _ in range(5):
        print("Ça va ?")
        sleep(0.01)

Dans cette partie du programme, nous avons juste définie les fonctions f1 et f2 exactement de la même façon que dans la version séquentielle. Mais c'est dans la partie principale du programme que tout change.

p1 = Thread(target=f1)
p2 = Thread(target=f2)
p1.start()
p2.start()
p1.join()
p2.join()

Ici, nous n'éxécutons pas directement les fonctions f1 et f2. Nous créons deux objets de la classe Thread. Ce sont des processus légers qui vont partager l'espace mémoire de notre programme principal et s'éxécuter de façon parallèle. Les deux lignes suivantes appellent la méthode start sur les Thread, et va lancer leur exécution. Mais pendant que le premier s'éxécute, le programme continue et va lancer le second.

Enfin, nous utilisons la méthode join sur ces deux threads. En effet, le programme principal continue de s'exécuter pendant que les threads tournent, et si il se termine, il met fin à tous ses threads. La méthode join force le programme principal à attendre la fin des threads.

Quel est l'avantage ?

L'avantage principal, c'est de pouvoir faire plusieurs choses en même temps. Surtout si on travaille sur une machine qui a plusieurs processeur. Par exemple, on peut avoir une machine qui a 2 processeur, chaque processeur ayant quatre cœurs, chaque cœeurs pouvant lui même exécuter deux threads… on va pouvoir au total exécuter en parallèle 16 threads !

Calculons plus vite (ou comment les ennuis commencent)

Imaginons que j'ai un programme qui doit bêtement faire 400 calculs. On va simuler cela par un petit sleep avec le code suivant

#!/usr/bin/python3
from time import sleep

# Variable globale
compteur = 0 
limite = 400

def calcul():
    """Une fonction qui fait un calcul"""
    global compteur
    for c in range(limite):
        temp = compteur
        # simule un traitement nécessitant des calculs
        sleep(0.000000001)
        compteur = temp + 1

compteur = 0
calcul()
print(compteur)
À faire

Recopiez le code ci dessus dans un fichier que vous nommerez calcul.py et exécutez le. Vérifiez qu'il affiche bien 400.

Je sais que je suis sur une machine qui peut exécuter 8 threads de façon parallèle et je me dis qu'il serait plus rapide de faire 4 thread différents qui font chacun un quart des calculs. En faisant ça, le résultat devrait être donné environ 4 fois plus vite … Traduisons cela avec du code

#!/usr/bin/python3
from threading import Thread
from time import sleep

# Variable globale
compteur = 0 
limite = 100

def calcul():
    """Une fonction qui fait un calcul"""
    global compteur
    for c in range(limite):
        temp = compteur
        # simule un traitement nécessitant des calculs
        sleep(0.000000001)
        compteur = temp + 1

compteur = 0
mesThreads = []
for i in range(4): # Lance en parallèle 4 exécutions de calcul
    p = Thread(target = calcul)
    p.start()      # Lance calcul dans un processus léger à part.
    mesThreads.append(p)

# On attend la fin de l'exécution des threads.
for p in mesThreads :
        p.join()

print(compteur)
À faire

Recopiez le code ci dessus dans un fichier que vous nommerez calcul-concurrent.py et exécutez le, de préférence plusieurs fois.

Ah. Le résultat n'est pas du tout le résultat attendu. Chaque thread se lance et fait 100 calcul, mais mon compteur à la fin ne vaut en général même pas 100 ! Ce résultat est très perturbant quand on le rencontre pour la première fois, et cela explique la réticence de bien des développeurs à l'égard des threads. On peut lire sur bien des forums de développeur «Threads are EVIL, don't use them !». Mais non, il ne sont pas le diable, il faut juste être parfaitement conscient de ce qui se passe. Ils sont même indispensable dans bien des programmes, particulièrment tous les programmes client/serveur qui doivent répondre à un grand nombre de requêtes concurrentes.

Que s'est il passé ?

Il n'y a rien d'illogique ou d'aléatoire dans le fonctionnement de notre programme. Il faut simplement habituer notre esprit à l'exécution en parallèle :

  • quatre processus (appelons les P1, P2, P3 et P4) exécutent la fonction calcul simultanément. Celle-ci utilise une variable globale qui sera donc modifiée par chacun de ces processus et une variable locale temp qui sera spécifique à chacun de nos processus. Nous la désignerons par temp(P1) temp(P2) etc... Un scénario possible est le suivant : Imaginons au départ que compteur vaille 10.
  • P1 sauvegarde compteur dans temp(P1) --> temp(P1) vaut 10
  • P2 sauvegarde compteur dans temp(P2) --> temp(P2) vaut 10
  • P3 sauvegarde compteur dans temp(P3) --> temp(P3) vaut 10
  • P1 et P2 incrémentent temp et sauvegardent la réponse dans compteur --> compteur vaut 11
  • P4 sauvegarde compteur dans temp(P4) --> temp(P4) vaut 11
  • P3 et P4 incrémentent temp et sauvegardent la réponse dans compteur qui vaut donc maintenant 12

au final, compteur a été incrémenté 4 fois mais de fait de l'exécution en parallèle compteur ne vaut pas 14 mais 12 ! cela explique que notre compteur au final ne vaut pas 400 car sa sauvegarde dans des variables temporaires fait que la plupart des incrémentations ne sont pas prises en compte.

Le résultat est aléatoire par ce que les threads s'exécutent dans un ordre qui peut varier, comme nous l'avons vu sur l'exemple des salutations. C'est le principal problème avec les threads : on ne maîtrise absolument pas l'ordre dans lequel ils sont exécutés, et il faut en tenir compte dès la conception.

Peut on résoudre notre problème ?

Vous vous en doutez, la réponse est oui. Comment ? Il existe un mécanisme qui va nous ralentir potentiellement un peu mais qui évite ce genre de problèmes : les verrous. Ce sont des objets de la classe Lock du module threading. Dans notre cas, ils ont deux méthodes qui nous intéressent.

  • La méthode acquire s'accapare le verrou s'il est disponible, sinon elle attend qu'il se libère.
  • La méthode release libère le verrou.

Ce verrou sera une variable globale du programme principal qui sera partagé entre les threads. Notre programme devient alors le suivant :

#!/usr/bin/python3
from threading import Thread,Lock
from time import sleep

# Variable globale
compteur = 0 
limite = 100
verrou = Lock()

def calcul():
    """Une fonction qui fait un calcul"""
    global compteur
    for c in range(limite):
        # Début de la section critique
        verrou.acquire()
        temp = compteur
        # simule un traitement nécessitant des calculs
        sleep(0.000000001)
        compteur = temp + 1
        # fin de la section critique
        verrou.release()
        
compteur = 0
mesThreads = []
for i in range(4): # Lance en parallèle 4 exécutions de calcul
    p = Thread(target = calcul)
    p.start()      # Lance calcul dans un processus léger à part.
    mesThreads.append(p)

# On attend la fin de l'exécution des threads.
for p in mesThreads :
        p.join()

print(compteur)
À faire

Recopiez le code ci dessus dans un fichier que vous nommerez verrou.py et exécutez le. Vérifiez que cette fois le résultat est bien 400.

Alors ? Nos problèmes sont résolus ? En fait, non, ils ne font que commencer comme on va le voir dans la partie suivante sur l'interblocage.

À retenir
  • Un programme comme ceux que nous avons fait jusqu'à maintenant est dit séquentiel. Les instructions s'éxécutent les unes après les autres.
  • Une programme peut exécutez plusieurs fonctions en même temps. Ces exécutions parallèles s'appellent des threads.
  • En Python, on définit un thread en utilisant la classe Thread du module threading. Cette classe a trois méthodes que nous avons utilisé :
    • Le constructeur, qui prend un paramètre target qui indique la fonction que va exécuter le thread
    • la méthode start qui lance le thread
    • la méthode join qui force le programme principal à attendre la fin d'un thread.
  • Si plusieurs thread doivent accéder à une même ressource, on peut se protéger contre des modifications concurrentes en utilisant un verrou.
  • En Python, on définit un verrou en utilisant la classe Lock du module threading. Cette classe a trois méthodes que nous avons utilisé :
    • Le constructeur, que nous avons utilisé sans paramètre.
    • la méthode acquire qui s'accapare le verrou s'il est disponible et attend qu'il soit disponible sinon.
    • la méthode release qui libère le verrou.