Multithreading dans Python avec exemple : Apprenez GIL dans Python
Qu'est-ce qu'un fil ?
Un thread est une unité d'exécution sur la programmation concurrente. Le multithreading est une technique qui permet à un processeur d'exécuter plusieurs tâches d'un même processus en même temps. Ces threads peuvent s'exécuter individuellement tout en partageant leurs ressources de processus.
Qu'est-ce qu'un processus ?
Un processus est essentiellement le programme en exécution. Lorsque vous démarrez une application sur votre ordinateur (comme un navigateur ou un éditeur de texte), le système d'exploitation crée un processus.
Qu’est-ce que le multithreading Python?
Multithreading dans Python la programmation est une technique bien connue dans laquelle plusieurs threads d'un processus partagent leur espace de données avec le thread principal, ce qui rend le partage d'informations et la communication au sein des threads faciles et efficaces. Les fils sont plus légers que les processus. Plusieurs threads peuvent s'exécuter individuellement tout en partageant leurs ressources de processus. Le but du multithreading est d’exécuter plusieurs tâches et cellules fonctionnelles en même temps.
Qu’est-ce que le multitraitement ?
Multitraitement vous permet d’exécuter simultanément plusieurs processus non liés. Ces processus ne partagent pas leurs ressources et ne communiquent pas via IPC.
Python Multithreading vs multitraitement
Pour comprendre les processus et les threads, envisagez ce scénario : Un fichier .exe sur votre ordinateur est un programme. Lorsque vous l'ouvrez, le système d'exploitation le charge en mémoire et le processeur l'exécute. L'instance du programme en cours d'exécution est appelée le processus.
Chaque processus comportera 2 éléments fondamentaux :
- Le code
- Les données
Désormais, un processus peut contenir une ou plusieurs sous-parties appelées threads. Cela dépend de l'architecture du système d'exploitation. Vous pouvez considérer un thread comme une section du processus qui peut être exécutée séparément par le système d'exploitation.
En d’autres termes, il s’agit d’un flux d’instructions qui peuvent être exécutées indépendamment par le système d’exploitation. Les threads au sein d'un même processus partagent les données de ce processus et sont conçus pour fonctionner ensemble pour faciliter le parallélisme.
Pourquoi utiliser le multithreading ?
Le multithreading vous permet de décomposer une application en plusieurs sous-tâches et d'exécuter ces tâches simultanément. Si vous utilisez correctement le multithreading, la vitesse, les performances et le rendu de votre application peuvent tous être améliorés.
Python MultiThreading
Python prend en charge les constructions pour le multitraitement et le multithreading. Dans ce didacticiel, vous vous concentrerez principalement sur la mise en œuvre multithread applications avec Python. Il existe deux modules principaux qui peuvent être utilisés pour gérer les threads dans Python:
- Les fil module, et
- Les filetage module
Cependant, en Python, il existe également ce qu'on appelle un verrou d'interpréteur global (GIL). Cela ne permet pas beaucoup de gain de performances et peut même réduire les performances de certaines applications multithread. Vous apprendrez tout cela dans les prochaines sections de ce didacticiel.
Les modules Thread et Threading
Les deux modules que vous découvrirez dans ce tutoriel sont les module de fil et les module de filetage.
Cependant, le module thread est obsolète depuis longtemps. En commençant par Python 3, il a été désigné comme obsolète et n'est accessible que sous forme de __fil pour la compatibilité descendante.
Vous devriez utiliser le niveau supérieur filetage module pour les applications que vous avez l'intention de déployer. Le module de fil de discussion n'a été abordé ici qu'à des fins éducatives.
Le module de fil de discussion
La syntaxe pour créer un nouveau thread à l'aide de ce module est la suivante :
thread.start_new_thread(function_name, arguments)
Très bien, vous avez maintenant couvert la théorie de base pour commencer à coder. Alors, ouvrez votre IDLE ou un bloc-notes et tapez ce qui suit :
import time import _thread def thread_test(name, wait): i = 0 while i <= 3: time.sleep(wait) print("Running %s\n" %name) i = i + 1 print("%s has finished execution" %name) if __name__ == "__main__": _thread.start_new_thread(thread_test, ("First Thread", 1)) _thread.start_new_thread(thread_test, ("Second Thread", 2)) _thread.start_new_thread(thread_test, ("Third Thread", 3))
Enregistrez le fichier et appuyez sur F5 pour exécuter le programme. Si tout a été fait correctement, voici le résultat que vous devriez voir :
Vous en apprendrez davantage sur les conditions de course et comment les gérer dans les sections à venir.
EXPLICATION DES CODES
- Ces instructions importent le module time et thread qui sont utilisés pour gérer l'exécution et le retardement du Python threads.
- Ici, vous avez défini une fonction appelée fil_test, qui sera appelé par le start_new_thread méthode. La fonction exécute une boucle while pendant quatre itérations et imprime le nom du thread qui l'a appelée. Une fois l'itération terminée, il imprime un message indiquant que le thread a terminé son exécution.
- Il s'agit de la section principale de votre programme. Ici, vous appelez simplement le start_new_thread méthode avec le fil_test fonction comme argument. Cela créera un nouveau thread pour la fonction que vous passez en argument et commencera à l’exécuter. Notez que vous pouvez remplacer ceci (thread_test) avec toute autre fonction que vous souhaitez exécuter en tant que thread.
Le module de filetage
Ce module est l'implémentation de haut niveau du threading en python et le standard de facto pour la gestion des applications multithread. Il offre un large éventail de fonctionnalités par rapport au module thread.
Voici une liste de quelques fonctions utiles définies dans ce module :
Nom de la fonction | Description |
---|---|
actifCount() | Renvoie le nombre de Fil à coudre objets encore vivants |
fil en cours() | Renvoie l'objet actuel de la classe Thread. |
énumérer() | Répertorie tous les objets Thread actifs. |
estDémon() | Renvoie vrai si le thread est un démon. |
est vivant() | Renvoie vrai si le thread est toujours actif. |
Méthodes de classe de thread | |
démarrer() | Démarre l'activité d'un fil. Il ne doit être appelé qu'une seule fois pour chaque thread car il générera une erreur d'exécution s'il est appelé plusieurs fois. |
Cours() | Cette méthode dénote l'activité d'un thread et peut être remplacée par une classe qui étend la classe Thread. |
joindre() | Il bloque l'exécution d'autres codes jusqu'à ce que le thread sur lequel la méthode join() a été appelée soit terminé. |
Histoire : la classe Thread
Avant de commencer à coder des programmes multithread à l'aide du module threading, il est crucial de comprendre la classe Thread. La classe thread est la classe principale qui définit le modèle et les opérations d'un thread en python.
La manière la plus courante de créer une application Python multithread consiste à déclarer une classe qui étend la classe Thread et remplace sa méthode run().
La classe Thread, en résumé, signifie une séquence de code qui s'exécute dans un environnement distinct. fil de contrôle.
Ainsi, lors de l’écriture d’une application multithread, vous effectuerez les opérations suivantes :
- définir une classe qui étend la classe Thread
- Remplacer le __init__ constructeur
- Remplacer le Cours() méthode
Une fois qu'un objet thread a été créé, le démarrer() méthode peut être utilisée pour commencer l’exécution de cette activité et la joindre() La méthode peut être utilisée pour bloquer tous les autres codes jusqu'à la fin de l'activité en cours.
Essayons maintenant d'utiliser le module threading pour implémenter votre exemple précédent. Encore une fois, allumez votre IDLE et saisissez ce qui suit :
import time import threading class threadtester (threading.Thread): def __init__(self, id, name, i): threading.Thread.__init__(self) self.id = id self.name = name self.i = i def run(self): thread_test(self.name, self.i, 5) print ("%s has finished execution " %self.name) def thread_test(name, wait, i): while i: time.sleep(wait) print ("Running %s \n" %name) i = i - 1 if __name__=="__main__": thread1 = threadtester(1, "First Thread", 1) thread2 = threadtester(2, "Second Thread", 2) thread3 = threadtester(3, "Third Thread", 3) thread1.start() thread2.start() thread3.start() thread1.join() thread2.join() thread3.join()
Ce sera le résultat lorsque vous exécuterez le code ci-dessus :
EXPLICATION DES CODES
- Cette partie est la même que notre exemple précédent. Ici, vous importez le module time et thread qui sont utilisés pour gérer l'exécution et les délais de l' Python threads.
- Dans ce bit, vous créez une classe appelée threadtester, qui hérite ou étend le Fil à coudre classe du module de threading. C'est l'une des façons les plus courantes de créer des threads en python. Cependant, vous ne devez remplacer que le constructeur et le Cours() méthode dans votre application. Comme vous pouvez le voir dans l'exemple de code ci-dessus, le __init__ La méthode (constructeur) a été remplacée. De même, vous avez également remplacé le Cours() méthode. Il contient le code que vous souhaitez exécuter dans un thread. Dans cet exemple, vous avez appelé la fonction thread_test().
- C'est la méthode thread_test() qui prend la valeur de i comme argument, le diminue de 1 à chaque itération et parcourt le reste du code jusqu'à ce que i devienne 0. À chaque itération, il imprime le nom du thread en cours d'exécution et se met en veille pendant quelques secondes d'attente (ce qui est également pris comme argument ).
- thread1 = threadtester(1, « First Thread », 1) Ici, nous créons un thread et transmettons les trois paramètres que nous avons déclarés dans __init__. Le premier paramètre est l'identifiant du thread, le deuxième paramètre est le nom du thread et le troisième paramètre est le compteur, qui détermine combien de fois la boucle while doit être exécutée.
- thread2.start()La méthode start est utilisée pour démarrer l'exécution d'un thread. En interne, la fonction start() appelle la méthode run() de votre classe.
- thread3.join() La méthode join() bloque l'exécution d'autres codes et attend la fin du thread sur lequel elle a été appelée.
Comme vous le savez déjà, les threads qui sont dans le même processus ont accès à la mémoire et aux données de ce processus. Par conséquent, si plusieurs threads tentent de modifier ou d’accéder aux données simultanément, des erreurs peuvent s’infiltrer.
Dans la section suivante, vous verrez les différents types de complications qui peuvent apparaître lorsque les threads accèdent aux données et à la section critique sans vérifier les transactions d'accès existantes.
Impasses et conditions de course
Avant d'en savoir plus sur les blocages et les conditions de concurrence, il sera utile de comprendre quelques définitions de base liées à la programmation simultanée :
- Section critiqueIl s'agit d'un fragment de code qui accède ou modifie les variables partagées et doit être effectué comme une transaction atomique.
- Changement de contexteC'est le processus qu'un processeur suit pour stocker l'état d'un thread avant de passer d'une tâche à une autre afin qu'il puisse être repris au même point plus tard.
Les impasses
Les impasses sont le problème le plus redouté auquel les développeurs sont confrontés lors de l'écriture d'applications simultanées/multithread en python. La meilleure façon de comprendre les blocages est d'utiliser l'exemple de problème informatique classique connu sous le nom de La Cuisine PhiloProblème des Sophers.
L’énoncé du problème pour les philosophes de la restauration est le suivant :
Cinq philosophes sont assis sur une table ronde avec cinq assiettes de spaghetti (un type de pâtes) et cinq fourchettes, comme le montre le schéma.
À un moment donné, un philosophe doit soit manger, soit réfléchir.
De plus, un philosophe doit prendre les deux fourchettes adjacentes à lui (c'est-à-dire les fourchettes gauche et droite) avant de pouvoir manger les spaghettis. Le problème de l’impasse survient lorsque les cinq philosophes prennent simultanément leur bonne fourchette.
Puisque chacun des philosophes possède une fourchette, ils attendront tous que les autres posent leur fourchette. Résultat : aucun d’entre eux ne pourra manger des spaghettis.
De même, dans un système concurrent, un blocage se produit lorsque différents threads ou processus (philosophes) tentent d'acquérir les ressources système partagées (forks) en même temps. En conséquence, aucun des processus n’a la possibilité de s’exécuter car ils attendent une autre ressource détenue par un autre processus.
Conditions de course
Une condition de concurrence critique est un état indésirable d'un programme qui se produit lorsqu'un système exécute deux ou plusieurs opérations simultanément. Par exemple, considérons cette simple boucle for :
i=0; # a global variable for x in range(100): print(i) i+=1;
Si vous créez n nombre de threads qui exécutent ce code à la fois, vous ne pouvez pas déterminer la valeur de i (qui est partagée par les threads) lorsque le programme termine son exécution. En effet, dans un environnement multithread réel, les threads peuvent se chevaucher et la valeur de i qui a été récupérée et modifiée par un thread peut changer entre-temps lorsqu'un autre thread y accède.
Ce sont les deux principales classes de problèmes pouvant survenir dans une application Python multithread ou distribuée. Dans la section suivante, vous apprendrez comment surmonter ce problème en synchronisant les threads.
Syncfils de discussion
Pour gérer les conditions de concurrence, les blocages et autres problèmes liés aux threads, le module de threading fournit le Verrouillage objet. L'idée est que lorsqu'un thread souhaite accéder à une ressource spécifique, il acquiert un verrou pour cette ressource. Une fois qu'un thread verrouille une ressource particulière, aucun autre thread ne peut y accéder jusqu'à ce que le verrou soit libéré. En conséquence, les modifications apportées à la ressource seront atomiques et les conditions de concurrence seront évitées.
Un verrou est une primitive de synchronisation de bas niveau implémentée par le __fil module. À tout moment, une serrure peut être dans l'un des 2 états suivants : fermé or déverrouillé. Il prend en charge deux méthodes :
- acquérir()Lorsque l'état de verrouillage est déverrouillé, l'appel de la méthode acquire() changera l'état en verrouillé et reviendra. Cependant, si l'état est verrouillé, l'appel à acquire() est bloqué jusqu'à ce que la méthode release() soit appelée par un autre thread.
- Libération()La méthode release() est utilisée pour définir l'état sur déverrouillé, c'est-à-dire pour libérer un verrou. Il peut être appelé par n'importe quel thread, pas nécessairement celui qui a acquis le verrou.
Voici un exemple d'utilisation de verrous dans vos applications. Allumez votre IDLE et tapez ce qui suit :
import threading lock = threading.Lock() def first_function(): for i in range(5): lock.acquire() print ('lock acquired') print ('Executing the first funcion') lock.release() def second_function(): for i in range(5): lock.acquire() print ('lock acquired') print ('Executing the second funcion') lock.release() if __name__=="__main__": thread_one = threading.Thread(target=first_function) thread_two = threading.Thread(target=second_function) thread_one.start() thread_two.start() thread_one.join() thread_two.join()
Maintenant, appuyez sur F5. Vous devriez voir une sortie comme celle-ci :
EXPLICATION DES CODES
- Ici, vous créez simplement un nouveau verrou en appelant le threading.Lock() fonction d'usine. En interne, Lock() renvoie une instance de la classe Lock concrète la plus efficace maintenue par la plate-forme.
- Dans la première instruction, vous obtenez le verrou en appelant la méthode acquire(). Lorsque le verrouillage a été accordé, vous imprimez « serrure acquise » à la console. Une fois que tout le code que vous souhaitez que le thread exécute est terminé, vous libérez le verrou en appelant la méthode release().
La théorie est bonne, mais comment savoir si la serrure a réellement fonctionné ? Si vous regardez le résultat, vous verrez que chacune des instructions d'impression imprime exactement une ligne à la fois. Rappelez-vous que, dans un exemple précédent, les sorties de print étaient aléatoires car plusieurs threads accédaient à la méthode print() en même temps. Ici, la fonction d'impression n'est appelée qu'après l'acquisition du verrou. Ainsi, les sorties sont affichées une par une et ligne par ligne.
Outre les verrous, Python prend également en charge d'autres mécanismes pour gérer la synchronisation des threads, comme indiqué ci-dessous :
- RLocks
- Semaphores
- Conditions
- événements, et
- Barrières
Verrouillage global de l'interprète (et comment y faire face)
Avant d'entrer dans les détails du GIL de python, définissons quelques termes qui seront utiles pour comprendre la section à venir :
- Code lié au CPU : il s'agit de tout morceau de code qui sera directement exécuté par le CPU.
- Code lié aux E/S : il peut s'agir de n'importe quel code qui accède au système de fichiers via le système d'exploitation
- CPython: c'est la référence la mise en oeuvre of Python et peut être décrit comme l'interpréteur écrit en C et Python (langage de programmation).
Qu'est-ce que GIL dans Python?
Verrouillage d'interprète global (GIL) en python est un verrou de processus ou un mutex utilisé lors du traitement des processus. Il garantit qu'un thread peut accéder à une ressource particulière à la fois et empêche également l'utilisation simultanée d'objets et de bytecodes. Cela profite aux programmes monothread en termes d’augmentation des performances. GIL en python est très simple et facile à mettre en œuvre.
Un verrou peut être utilisé pour garantir qu'un seul thread a accès à une ressource particulière à un moment donné.
L'une des caractéristiques de Python c'est qu'il utilise un verrou global sur chaque processus d'interpréteur, ce qui signifie que chaque processus traite l'interpréteur Python lui-même comme une ressource.
Par exemple, supposons que vous ayez écrit un programme Python qui utilise deux threads pour effectuer à la fois les opérations CPU et « E/S ». Lorsque vous exécutez ce programme, voici ce qui se passe :
- L'interpréteur Python crée un nouveau processus et génère les threads
- Lorsque le thread-1 démarre, il acquiert d'abord le GIL et le verrouille.
- Si le thread-2 veut s'exécuter maintenant, il devra attendre que le GIL soit libéré même si un autre processeur est libre.
- Supposons maintenant que le thread 1 attend une opération d’E/S. À ce moment-là, il libérera le GIL et le thread-2 l'acquérira.
- Après avoir terminé les opérations d'E/S, si le thread-1 veut s'exécuter maintenant, il devra à nouveau attendre que le GIL soit libéré par le thread-2.
Pour cette raison, un seul thread peut accéder à l'interpréteur à tout moment, ce qui signifie qu'il n'y aura qu'un seul thread exécutant le code Python à un moment donné.
Cela convient dans un processeur monocœur car cela utiliserait le découpage temporel (voir la première section de ce didacticiel) pour gérer les threads. Cependant, dans le cas de processeurs multicœurs, une fonction liée au processeur s'exécutant sur plusieurs threads aura un impact considérable sur l'efficacité du programme puisqu'il n'utilisera pas réellement tous les cœurs disponibles en même temps.
Pourquoi GIL était-il nécessaire ?
Les CPython Le garbage collector utilise une technique efficace de gestion de la mémoire appelée comptage de références. Voici comment cela fonctionne : chaque objet en Python a un nombre de références, qui augmente lorsqu'il est assigné à un nouveau nom de variable ou ajouté à un conteneur (comme les tuples, les listes, etc.). De même, le nombre de références diminue lorsque la référence sort de la portée ou lorsque l'instruction del est appelée. Lorsque le nombre de références d'un objet atteint 0, il est récupéré et la mémoire allouée est libérée.
Mais le problème est que la variable de comptage de références est sujette à des conditions de concurrence comme toute autre variable globale. Pour résoudre ce problème, les développeurs de Python ont décidé d'utiliser le verrou global de l'interpréteur. L'autre option consistait à ajouter un verrou à chaque objet, ce qui aurait entraîné des blocages et une augmentation de la surcharge due aux appels acquire() et release().
Par conséquent, GIL constitue une restriction importante pour les programmes Python multithread exécutant des opérations lourdes liées au processeur (ce qui les rend effectivement monothread). Si vous souhaitez utiliser plusieurs cœurs de processeur dans votre application, utilisez l'option multitraitement module à la place.
Résumé
- Python prend en charge 2 modules pour le multithreading :
- __fil module : il fournit une implémentation de bas niveau pour le threading et est obsolète.
- module de filetage: Il fournit une implémentation de haut niveau pour le multithreading et constitue le standard actuel.
- Pour créer un thread à l'aide du module threading, vous devez procéder comme suit :
- Créez une classe qui étend le Fil à coudre classe.
- Remplacez son constructeur (__init__).
- Remplacer son Cours() méthode.
- Créez un objet de cette classe.
- Un thread peut être exécuté en appelant le démarrer() méthode.
- Les joindre() La méthode peut être utilisée pour bloquer d’autres threads jusqu’à ce que ce thread (celui sur lequel join a été appelé) termine son exécution.
- Une condition de concurrence critique se produit lorsque plusieurs threads accèdent ou modifient une ressource partagée en même temps.
- Cela peut être évité en Syncfils chromisants.
- Python prend en charge 6 façons de synchroniser les threads :
- Verrouillage
- RLocks
- Semaphores
- Conditions
- événements, et
- Barrières
- Les verrous permettent uniquement à un thread particulier qui a acquis le verrou d'entrer dans la section critique.
- Un verrou a 2 méthodes principales :
- acquérir(): Il définit l'état de verrouillage sur fermé à clef. S'il est appelé sur un objet verrouillé, il bloque jusqu'à ce que la ressource soit libre.
- Libération(): Il définit l'état de verrouillage sur déverrouillé et revient. S'il est appelé sur un objet déverrouillé, il renvoie false.
- Le verrouillage global de l'interpréteur est un mécanisme par lequel seul 1 CPython le processus d'interprétation peut s'exécuter à la fois.
- Il a été utilisé pour faciliter la fonctionnalité de comptage de références de CPythonle ramasse-miettes de s.
- Pour faire Python pour les applications nécessitant beaucoup d'opérations CPU, vous devez utiliser le module multitraitement.