Fil d'Ariane
Mis à jour le lundi 18 septembre 2017
  • 40 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

Ce cours existe en livre papier.

Ce cours existe en eBook.

Vous pouvez obtenir un certificat de réussite à l'issue de ce cours.

Vous pouvez être accompagné et mentoré par un professeur particulier par visioconférence sur ce cours.

J'ai tout compris !

La programmation parallèle avec threading

Connectez-vous ou inscrivez-vous pour bénéficier de toutes les fonctionnalités de ce cours !

Jusqu'ici, nous avons utilisé Python de façon linéaire : les instructions s'exécutaient dans l'ordre et, pour que la suivante s'exécute, celle d'avant devait être terminée.

Mais Python nous propose dans sa bibiliothèque standard plusieurs modules pour faire de la « programmation parallèle », c'est-à-dire que plusieurs instructions de code s'exécuteront en même temps, ou presque en même temps.

Nous allons regarder de plus près le module threading qui propose une interface simple pour créer des threads, c'est-à-dire des portions de notre code qui seront exécutées en même temps.

Pour suivre ce chapitre, vous aurez besoin de savoir comment créer des classes et connaître les bases de l'héritage.

Création de threads

Jusqu'ici, nous avons travaillé avec de la programmation « linéaire ». Considérez ce code :

import time
print("Avant le sleep...")
time.sleep(5)
print("Après le sleep.")

Si vous exécutez ce code, sans surprise, le premier message Avant le sleep... s'affiche, puis le programme pause pendant 5 secondes. Enfin, le second message Après le sleep. s'affiche.

Les threads permettent d'exécuter plusieurs instructions en même temps. On parle de « programmation parallèle », car au lieu de développer selon un seul flux d'instruction, on développe plusieurs flux en parallèle.

Premier exemple d'un thread

Voyons un code linéaire pour commencer. Je fais appel à plusieurs fonctions que vous n'avez peut-être jamais vues, mais pas de panique, je commente les lignes en question plus bas :

import random
import sys
import time

# Répète 20 fois
i = 0
while i < 20:
    sys.stdout.write("1")
    sys.stdout.flush()
    attente = 0.2
    attente += random.randint(1, 60) / 100
    # attente est à présent entre 0.2 et 0.8
    time.sleep(attente)
    i += 1
  1. D'abord, on importe les modules random, sys et time que nous allons utiliser par la suite ;

  2. ensuite on crée une boucle qui va s'exécuter 20 fois ;

  3. on affiche simplement le chiffre 1. On fait appel à sys.stdout.write() pour afficher le chiffre sur la sortie standard (l'écran, par défaut) et sys.stdout.flush() pour demander à Python d'afficher le chiffre tout de suite. Si vous oubliez cette seconde ligne, les chiffres n'apparaîtront qu'à la fin de l'exécution du programme ;

  4. on crée une variable attente et on la fait varier, grâce à random, entre 0.2 et 0.8 ;

  5. enfin, on appelle time.sleep() qui met en pause notre programme pendant le temps d'attente que nous avons configuré plus haut (c'est-à-dire entre 0,2 et 0,8 seconde).

Si vous exécutez ce code, vous devriez voir apparaître 20 fois le chiffre 1 sur la même ligne, mais entre chaque chiffre le programme se met en pause (la pause est de durée variable).

Approche parallèle

Maintenant, nous allons créer deux threads qui vont s'exécuter ensemble : le premier affichera des 1 sur l'écran, tandis que le second affichera des 2. Lancé en même temps, vous devriez voir plus clairement la façon dont ils s'exécutent.

Pour créer un thread, il faut créer une classe qui hérite de threading.Thread. On peut redéfinir son constructeur et la méthode run.

Cette seconde méthode est appelée au lancement du thread et contient le code qui doit s'exécuter en parallèle du reste du programme.

Voyons un exemple :

import random
import sys
from threading import Thread
import time

class Afficheur(Thread):

    """Thread chargé simplement d'afficher une lettre dans la console."""

    def __init__(self, lettre):
        Thread.__init__(self)
        self.lettre = lettre

    def run(self):
        """Code à exécuter pendant l'exécution du thread."""
        i = 0
        while i < 20:
            sys.stdout.write(self.lettre)
            sys.stdout.flush()
            attente = 0.2
            attente += random.randint(1, 60) / 100
            time.sleep(attente)
            i += 1

Au-dessus se trouve la définition d'un thread :

  • Le constructeur ne devrait pas trop vous surprendre. Il prend en paramètre la lettre à afficher (nous verrons des exemples plus loin). Il appelle le constructeur parent (Thread.__init__(self)) et c'est une étape importante, ne l'oubliez pas quand vous redéfinissez le constructeur de votre thread ;

  • la méthode run est également redéfinie. Le code qu'elle contient vous semble sans doute familier : c'est le code que nous avons utilisé dans notre exemple de programmation linéaire tout à l'heure.

Une fois encore, si vous exécutez ce code, vous obtenez... rien du tout ! Vous avez défini le thread, mais il nous reste à le créer. Ou plutôt, à les créer, car nous allons essayer de faire deux threads s'exécutant en même temps :

# Création des threads
thread_1 = Afficheur("1")
thread_2 = Afficheur("2")

# Lancement des threads
thread_1.start()
thread_2.start()

# Attend que les threads se terminent
thread_1.join()
thread_2.join()
  1. D'abord, on crée nos deux threads. Les objets Thread sont conservés dans notre variable thread_1 et thread_2. Notez qu'on passe en paramètre de nos deux threads des lettres différentes, pour pouvoir les différencier quand ils commenceront à afficher les informations dans la console ;

  2. ensuite, on appelle thread_1.start(). Cette méthode va créer un thread (une partie du code qui va pouvoir s'exécuter en parallèle) et exécuter la méthode run. Nos chiffres 1 commencent ainsi à s'afficher dans notre console. Mais la méthode start n'attend pas que tous les chiffres soient écrits avant de retourner et on passe tout de suite à la ligne suivante ;

  3. C'est au tour du second thread. Il est également lancé. Les deux threads s'exécutent en même temps ;

  4. Enfin, on appelle la méthode join() sur les deux threads. Cette méthode bloque et ne retourne que quand le thread est terminé. Si le programme se termine pendant que des threads tournent, les threads risquent d'être fermés brusquement.

Pour récapituler, voici le code complet :

import random
import sys
from threading import Thread
import time

class Afficheur(Thread):

    """Thread chargé simplement d'afficher une lettre dans la console."""

    def __init__(self, lettre):
        Thread.__init__(self)
        self.lettre = lettre

    def run(self):
        """Code à exécuter pendant l'exécution du thread."""
        i = 0
        while i < 20:
            sys.stdout.write(self.lettre)
            sys.stdout.flush()
            attente = 0.2
            attente += random.randint(1, 60) / 100
            time.sleep(attente)
            i += 1

# Création des threads
thread_1 = Afficheur("1")
thread_2 = Afficheur("2")

# Lancement des threads
thread_1.start()
thread_2.start()

# Attend que les threads se terminent
thread_1.join()
thread_2.join()

Quand vous exécutez ce programme, vous obtenez une ligne similaire :

1221121212122121211221122212121221121211

Comme vous le voyez, les deux threads s'exécutent en même temps. Puisque le temps de pause est variable, parfois on a un seul chiffre 1 qui s'affiche avant un chiffre 2, parfois on en a plusieurs. Au final, il y en a bien 20 de chaque.

Pour cette fois d'ailleurs, remarquez que le thread_1 est le plus long à s'exécuter (le dernier chiffre de la ligne est un 1, le dernier 2 est un peu avant). Vous pouvez essayer la même chose en créant plusieurs autres threads, 3 ou 4 ou 5 ou plus, si vous voulez.

La programmation parallèle peut être très pratique, mais elle a aussi ses pièges. Nous allons en voir certains à présent et les méthodes qui existent pour les éviter.

La synchronisation des threads

Programmer plusieurs flux d'instructions apporte son lot de difficultés. Au premier abord, cela semble très pratique d'avoir plusieurs parties de notre code qui s'exécutent en même temps. Pendant une tâche qui peut prendre longtemps à s'exécuter (peut-être le téléchargement d'une information depuis un site Internet) on peut faire autre chose, pas seulement attendre que la ressource soit téléchargée.

Mais le développement peut être plus compliqué en proportion. Il vous faut garder en tête que les différents flux d'instructions peuvent être avancés à différents points à un moment précis.

Opérations concurrentes

Considérez ce tout petit exemple :

nombre = 1
nombre += 1

C'est la deuxième ligne qui nous intéresse ici : nombre += 1. Si vous y faites appel dans un de vos threads et que nombre est partagé par plusieurs de vos threads, vous pourriez avoir des résultats étranges. Pas tout le temps. C'est tout le problème : la plupart du temps vous n'aurez aucun soucis, parfois vous aurez des résultats étranges.

Disons que ce nombre serve à compter une information (le nombre de fois où une certaine opération s'exécute, peut-être). Si vous n'avez pas de chance, deux threads accéderont à ce code mais nombre ne sera augmenté que de 1.

Cela est du au fait que nombre += 1 fait trois choses :

  1. Elle va récupérer la valeur de la variable nombre ;

  2. Elle va y ajouter 1 ;

  3. Elle va écrire le résultat dans la variable nombre.

Représentez-vous ces étapes sur une feuille. Maintenant, représentez-vous les mêmes étapes pour un second thread.

Admettons que le thread_1 et le thread_2 s'exécutent presque en même temps :

  • Le thread_1 commence à exécuter l'instruction. Il exécute l'étape 1 et 2 (c'est-à-dire qu'il va récupérer la valeur de la variable nombre) mais n'exécute pas encore l'étape 3 (c'est-à-dire que la variable nombre n'est pas encore modifiée) ;

  • et voici thread_2 qui exécute l'instruction (les trois étapes cette fois). Il récupère nombre, y ajoute 1 et écrit le résultat dans la variable ;

  • et notre thread_1 exécute l'étape 3 et écrit le résultat dans la variable. Mais cette valeur se base sur l'ancienne valeur de nombre (avant que thread_2 ne soit appelé). Au final, après l'exécution de nos deux threads, nombre n'a été incrémenté que de 1.

Comme vous le voyez ici, une ligne d'instruction très simple pourra avoir des résultats inattendus si elle est appelée au même moment par différents threads.

Accès simultané à des ressources

Le problème est encore plus flagrant quand vous voulez accéder à des ressources depuis différents threads. Par exemple, vous voulez écrire dans un fichier (le même fichier depuis différents threads).

Voici le code de nos threads un peu modifié pour qu'il affiche des mots complets dans la console au lieu de simples lettres. Regardez surtout la méthode run :

import random
import sys
from threading import Thread
import time

class Afficheur(Thread):

    """Thread chargé simplement d'afficher un mot dans la console."""

    def __init__(self, mot):
        Thread.__init__(self)
        self.mot = mot

    def run(self):
        """Code à exécuter pendant l'exécution du thread."""
        i = 0
        while i < 5:
            for lettre in self.mot:
                sys.stdout.write(lettre)
                sys.stdout.flush()
                attente = 0.2
                attente += random.randint(1, 60) / 100
                time.sleep(attente)
            i += 1

# Création des threads
thread_1 = Afficheur("canard")
thread_2 = Afficheur("TORTUE")

# Lancement des threads
thread_1.start()
thread_2.start()

# Attend que les threads se terminent
thread_1.join()
thread_2.join()
  • On veut afficher des mots au lieu de lettres, le constructeur est donc modifié en conséquence ;

  • On ne boucle que pendant 5 fois (au lieu de 20), ce sera suffisant pour que vous compreniez l'exemple ;

  • À l'intérieur de notre boucle, on boucle sur chaque lettre, l'affiche et fait une pause.

Et quand vous exécutez ce code, vous devriez voir quelque chose comme :

cTORanaTUrEdcTaOnRarTdUcEanTaOrRdTcUaEnTaORrdTcanUaErdTORTUE

J'ai mis le mot "canard" en minuscule et le mot "TORTUE" en majuscule, ce qui devrait vous aider à les identifier. Comme vous le voyez, nos mots sont complètement mélangés, ce qui n'est pas bien surprenant. Vous pouvez toujours suivre la partie en majuscule ou minuscule et vérifier que les mots s'affichent bien, mais puisque nous écrivons sur la même ressource partagée (la console, ici), le résultat s'affiche mélangé.

Les locks à la rescousse

Il existe plusieurs moyens de « synchroniser » nos threads, c'est-à-dire de faire en sorte qu'une partie du code ne s'exécute que si personne n'utilise la ressource partagée. Le mécanisme de synchronisation le plus simple est le lock (verrou en anglais).

C'est un objet proposé par threading qui est extrêmement simple à utiliser : au début de nos instructions qui utilisent notre ressource partagée, on dit au lock de bloquer pour les autres threads. Si un autre thread veut faire appel à cette ressource, il doit patienter jusqu'à ce qu'elle soit libérée.

Plutôt qu'un long discours, je vous propose notre code légèrement modifié pour utiliser les locks.

import random
import sys
from threading import Thread, RLock
import time

verrou = RLock()

class Afficheur(Thread):

    """Thread chargé simplement d'afficher un mot dans la console."""

    def __init__(self, mot):
        Thread.__init__(self)
        self.mot = mot

    def run(self):
        """Code à exécuter pendant l'exécution du thread."""
        i = 0
        while i < 5:
            with verrou:
                for lettre in self.mot:
                    sys.stdout.write(lettre)
                    sys.stdout.flush()
                    attente = 0.2
                    attente += random.randint(1, 60) / 100
                    time.sleep(attente)
            i += 1

# Création des threads
thread_1 = Afficheur("canard")
thread_2 = Afficheur("TORTUE")

# Lancement des threads
thread_1.start()
thread_2.start()

# Attend que les threads se terminent
thread_1.join()
thread_2.join()
  1. On importe RLock du module threading ;

  2. on crée un lock que l'on place dans notre variable verrou ;

  3. dans notre méthode run, on verrouille une partie de notre thread.

    with verrou:
        for lettre in self.mot:
            sys.stdout.write(lettre)
            sys.stdout.flush()
            attente = 0.2
            attente += random.randint(1, 60) / 100
            time.sleep(attente)

    On utilise là encore un context manager pour indiquer quand bloquer le lock. Le lock se débloque à la fin du bloc with.

La partie verrouillée de notre code ne s'exécute qu'un thread à la fois.

  1. D'abord, thread_1 est lancé. Il verrouille le lock et commence à afficher les lettres de son mot ("canard") ;

  2. thread_2 est lancé entre temps, mais il bloque au moment d'afficher son propre mot, car le verrou est détenu par thread_1. Ce n'est que quand thread_1 relâche le verrou (à la fin du bloc with) qu'il peut commencer à s'exécuter ;

  3. ... Et ainsi de suite jusqu'à la fin des deux threads.

Si vous exécutez ce code, vous pourrez voir quelque chose comme :

canardcanardTORTUETORTUEcanardcanardcanardTORTUETORTUETORTUE

Comme vous le voyez, cette fois les mots ne sont plus mélangés, mais le reste du code s'exécute bien en parallèle (notez que les mots apparaissent dans un ordre aléatoire, même si il y en a bien 5 de chaque).

Il existe d'autres méthodes de synchronisation et la programmation parallèle en tant que telle mérite plus un livre entier qu'un chapitre d'introduction. Vous avez pu cependant voir ici les bases de ce type de programmation. Si vous voulez plus d'informations sur les mécanismes de synchronisation (ainsi que d'autres informations générales sur les threads), vous pouvez lire la documentation officielle du module threading.

En résumé

  • Il existe plusieurs mécanismes de programmation parallèle, dont les threads proposés dans le module threading de la bibliothèque standard ;

  • Créer un thread se fait en redéfinissant une classe héritée de threading.Thread et en appelant sa méthode start ;

  • On peut utiliser les locks pour synchroniser nos threads et faire en sorte que certaines parties de notre code s'exécutent bien à la suite des autres.

Exemple de certificat de réussite
Exemple de certificat de réussite