PyCharm
The only Python IDE you need.
Être plus rapide avec Python : concurrence avec async/await et threading

Si vous programmez avec Python depuis un certain temps, en particulier si vous avez utilisé des frameworks et des bibliothèques tels que FastAPI et discord.py, alors vous avez probablement utilisé async/await ou asyncio. Vous avez peut-être entendu dire que « le multithreading en Python n’est pas réel », ou vous connaissez peut-être le fameux (ou tristement célèbre) GIL de Python. Étant donné ce scepticisme à l’égard du multithreading en Python, vous pourriez vous demander en quoi async/await et le multithreading diffèrent réellement, notamment dans le contexte de la programmation Python. Si c’est le cas, cet article est fait pour vous !
Qu’est-ce que le multithreading ?
En programmation, le multithreading désigne la capacité d’un programme à exécuter plusieurs tâches séquentielles (appelées threads) simultanément. Ces threads peuvent s’exécuter sur un seul cœur de processeur ou sur plusieurs cœurs. Cependant, en Python, le multithreading n’est géré que sur un seul cœur en raison des limitations du Global Interpreter Lock (GIL). L’exception est Python nogil (également appelé sans thread), qui supprime le GIL et sera abordé dans la deuxième partie de cette série d’articles. Pour cet article, nous supposerons que le GIL est toujours présent.
Qu’est-ce que la concurrence ?
En programmation, la concurrence signifie que l’ordinateur fait plusieurs choses à la fois, ou semble faire plusieurs choses à la fois, même si les différentes tâches sont exécutées sur un seul processeur. En gérant les ressources et les interactions entre les différentes parties d’un programme, différentes tâches sont autorisées à progresser indépendamment et sur des plages de temps qui se chevauchent.
asyncio et threading présentent toutes deux une capacité de concurrence en Python
En gros, les bibliothèques Python asyncio et threading prennent en charge la concurrence. Cependant, vos processeurs ne font pas plusieurs choses exactement en même temps. On dirait seulement que c’est le cas.
Imaginez que vous organisez un dîner avec plusieurs plats pour des invités. Certains plats prennent du temps à préparer, par exemple, la tarte qui doit être cuite au four ou la soupe qui doit mijoter sur la cuisinière. En attendant qu’ils soient prêts, nous ne nous contentons pas d’attendre. Nous faisons autre chose entre-temps. C’est un peu pareil pour la concurrence en Python. Parfois, votre processus Python attend que quelque chose soit fait. Par exemple, certains processus d’entrée/sortie (I/O) sont gérés par le système d’exploitation, et pendant ce temps, le processus Python est simplement en attente. Dans ce cas, nous pouvons utiliser async pour laisser un autre processus Python s’exécuter pendant cette attente.

La différence est qui a le contrôle
Si asyncio et threading permettent toutes les deux la concurrence, quelle est la différence entre elles ? Eh bien, la principale différence porte sur qui décide quel processus s’exécute et quand. Pour async/await, l’approche est parfois appelée concurrence coopérative. Une coroutine ou un objet Future peut passer le contrôle à une autre coroutine ou un autre Future permettant ainsi à un autre processus de s’exécuter. D’un autre côté, avec threading, c’est le gestionnaire du système d’exploitation qui décide quel processus s’exécute.
La concurrence coopérative est comme une réunion dans laquelle un micro passe dans l’assemblée pour que les gens s’expriment. La personne qui a le micro peut parler, et quand elle a terminé ou n’a plus rien d’autre à dire, elle passe le micro à la personne suivante. En revanche, le multithreading est une réunion dans laquelle un président détermine qui a la parole à un moment donné.
Écrire du code concurrent en Python
Voyons comment fonctionne la concurrence en Python en écrivant un exemple de code. Nous allons créer une simulation de restauration rapide en utilisant asyncio et threading.
Comment fonctionne async/await en Python ?
Le paquet asyncio a été introduit dans Python 3.4, tandis que les mots-clés async et await ont été introduits dans Python 3.5. L’un des principaux éléments rendant async/await possible est l’utilisation de coroutines. Les coroutines en Python sont en fait des générateurs repensés afin de pouvoir faire une pause et de revenir à la fonction principale.
Imaginez maintenant un restaurant de burgers dans lequel un seul membre du personnel travaille. Les commandes sont préparées selon la méthode premier arrivé, premier servi, et aucune opération asynchrone ne peut être effectuée :
import time
def make_burger(order_num):
print(f"Preparing burger #{order_num}...")
time.sleep(5) # time for making the burger
print(f"Burger made #{order_num}")
def main():
for i in range(3):
make_burger(i)
if __name__ == "__main__":
s = time.perf_counter()
main()
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
Cela prendra un certain temps :
Preparing burger #0... Burger made #0 Preparing burger #1... Burger made #1 Preparing burger #2... Burger made #2 Orders completed in 15.01 seconds.
Maintenant, imaginez que le restaurant embauche plus de personnel, afin de pouvoir effectuer des tâches simultanément :
import asyncio
import time
async def make_burger(order_num):
print(f"Preparing burger #{order_num}...")
await asyncio.sleep(5) # time for making the burger
print(f"Burger made #{order_num}")
async def main():
order_queue = []
for i in range(3):
order_queue.append(make_burger(i))
await asyncio.gather(*(order_queue))
if __name__ == "__main__":
s = time.perf_counter()
asyncio.run(main())
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
Nous voyons la différence entre les deux :
Preparing burger #0... Preparing burger #1... Preparing burger #2... Burger made #0 Burger made #1 Burger made #2 Orders completed in 5.00 seconds.
En utilisant les fonctions fournies par asyncio, comme run et gather, et les mots-clés async et await, nous avons créé des coroutines capables de faire des burgers simultanément.
Allons maintenant plus loin pour créer une simulation plus compliquée. Imaginez que nous n’avons que deux employés et que nous ne pouvons faire que deux burgers à la fois.
import asyncio
import time
order_queue = asyncio.Queue()
def take_order():
for i in range(3):
order_queue.put_nowait(make_burger(i))
async def make_burger(order_num):
print(f"Preparing burger #{order_num}...")
await asyncio.sleep(5) # time for making the burger
print(f"Burger made #{order_num}")
class Staff:
def __init__(self, name):
self.name = name
async def working(self):
while order_queue.qsize() > 0:
print(f"{self.name} is working...")
task = await order_queue.get()
await task
print(f"{self.name} finished a task...")
async def main():
staff1 = Staff(name="John")
staff2 = Staff(name="Jane")
take_order()
await asyncio.gather(staff1.working(), staff2.working())
if __name__ == "__main__":
s = time.perf_counter()
asyncio.run(main())
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
Ici, nous utiliserons une file d’attente pour stocker les tâches, et le personnel les récupérera.
John is working... Preparing burger #0... Jane is working... Preparing burger #1... Burger made #0 John finished a task... John is working... Preparing burger #2... Burger made #1 Jane finished a task... Burger made #2 John finished a task... Orders completed in 10.00 seconds.
Dans cet exemple, nous utilisons asyncio.Queue pour stocker les tâches, mais ce sera plus utile si nous avons plusieurs types de tâches, comme dans l’exemple suivant.
import asyncio
import time
task_queue = asyncio.Queue()
order_num = 0
async def take_order():
global order_num
order_num += 1
print(f"Order burger and fries for order #{order_num:04d}:")
burger_num = input("Number of burgers:")
for i in range(int(burger_num)):
await task_queue.put(make_burger(f"{order_num:04d}-burger{i:02d}"))
fries_num = input("Number of fries:")
for i in range(int(fries_num)):
await task_queue.put(make_fries(f"{order_num:04d}-fries{i:02d}"))
print(f"Order #{order_num:04d} queued.")
await task_queue.put(take_order())
async def make_burger(order_num):
print(f"Preparing burger #{order_num}...")
await asyncio.sleep(5) # time for making the burger
print(f"Burger made #{order_num}")
async def make_fries(order_num):
print(f"Preparing fries #{order_num}...")
await asyncio.sleep(2) # time for making fries
print(f"Fries made #{order_num}")
class Staff:
def __init__(self, name):
self.name = name
async def working(self):
while True:
if task_queue.qsize() > 0:
print(f"{self.name} is working...")
task = await task_queue.get()
await task
print(f"{self.name} finish task...")
else:
await asyncio.sleep(1) #rest
async def main():
task_queue.put_nowait(take_order())
staff1 = Staff(name="John")
staff2 = Staff(name="Jane")
await asyncio.gather(staff1.working(), staff2.working())
if __name__ == "__main__":
s = time.perf_counter()
asyncio.run(main())
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
Dans cet exemple, il y a plusieurs tâches, notamment la préparation des frites, qui prend moins de temps, et la prise de commandes, qui implique d’obtenir des informations de l’utilisateur.
Notez que le programme s’arrête pour attendre l’entrée de l’utilisateur, et même les autres membres du personnel (qui ne prennent pas la commande) cessent de travailler en arrière-plan. Ceci est dû au fait que la fonction input n’est pas asynchrone et n’est donc pas attendue. N’oubliez pas que le contrôle dans le code asynchrone n’est libéré que lorsqu’il est attendu. Pour résoudre ce problème, nous pouvons remplacer :
input("Number of burgers:")
Par
await asyncio.to_thread(input, "Number of burgers:")
Et nous faisons de même pour les frites ; regardez le code ci-dessous. Notez que maintenant le programme fonctionne dans une boucle infinie. Si nous devons l’arrêter, nous pouvons délibérément interrompre le programme avec une entrée invalide.
import asyncio
import time
task_queue = asyncio.Queue()
order_num = 0
async def take_order():
global order_num
order_num += 1
print(f"Order burger and fries for order #{order_num:04d}:")
burger_num = await asyncio.to_thread(input, "Number of burgers:")
for i in range(int(burger_num)):
await task_queue.put(make_burger(f"{order_num:04d}-burger{i:02d}"))
fries_num = await asyncio.to_thread(input, "Number of fries:")
for i in range(int(fries_num)):
await task_queue.put(make_fries(f"{order_num:04d}-fries{i:02d}"))
print(f"Order #{order_num:04d} queued.")
await task_queue.put(take_order())
async def make_burger(order_num):
print(f"Preparing burger #{order_num}...")
await asyncio.sleep(5) # time for making the burger
print(f"Burger made #{order_num}")
async def make_fries(order_num):
print(f"Preparing fries #{order_num}...")
await asyncio.sleep(2) # time for making fries
print(f"Fries made #{order_num}")
class Staff:
def __init__(self, name):
self.name = name
async def working(self):
while True:
if task_queue.qsize() > 0:
print(f"{self.name} is working...")
task = await task_queue.get()
await task
print(f"{self.name} finish task...")
else:
await asyncio.sleep(1) #rest
async def main():
task_queue.put_nowait(take_order())
staff1 = Staff(name="John")
staff2 = Staff(name="Jane")
await asyncio.gather(staff1.working(), staff2.working())
if __name__ == "__main__":
s = time.perf_counter()
asyncio.run(main())
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
En utilisant asyncio.to_thread, nous avons placé la fonction input dans un thread séparé (voir cette référence). Notez toutefois que cette astuce ne débloque les tâches associées aux I/O que si le GIL Python est présent.
Si vous exécutez le code ci-dessus, vous pouvez également voir que les I/O standard du terminal sont mélangées. Les I/O de l’utilisateur et l’enregistrement de ce qui se passe doivent être séparés. Nous pouvons enregistrer ces informations dans un fichier journal pour les consulter plus tard.
import asyncio
import logging
import time
logger = logging.getLogger(__name__)
logging.basicConfig(filename='pyburger.log', level=logging.INFO)
task_queue = asyncio.Queue()
order_num = 0
closing = False
async def take_order():
global order_num, closing
try:
order_num += 1
logger.info(f"Taking Order #{order_num:04d}...")
print(f"Order burger and fries for order #{order_num:04d}:")
burger_num = await asyncio.to_thread(input, "Number of burgers:")
for i in range(int(burger_num)):
await task_queue.put(make_burger(f"{order_num:04d}-burger{i:02d}"))
fries_num = await asyncio.to_thread(input, "Number of fries:")
for i in range(int(fries_num)):
await task_queue.put(make_fries(f"{order_num:04d}-fries{i:02d}"))
logger.info(f"Order #{order_num:04d} queued.")
print(f"Order #{order_num:04d} queued, please wait.")
await task_queue.put(take_order())
except ValueError:
print("Goodbye!")
logger.info("Closing down... stop taking orders and finish all tasks.")
closing = True
async def make_burger(order_num):
logger.info(f"Preparing burger #{order_num}...")
await asyncio.sleep(5) # time for making the burger
logger.info(f"Burger made #{order_num}")
async def make_fries(order_num):
logger.info(f"Preparing fries #{order_num}...")
await asyncio.sleep(2) # time for making fries
logger.info(f"Fries made #{order_num}")
class Staff:
def __init__(self, name):
self.name = name
async def working(self):
while True:
if task_queue.qsize() > 0:
logger.info(f"{self.name} is working...")
task = await task_queue.get()
await task
task_queue.task_done()
logger.info(f"{self.name} finish task.")
elif closing:
return
else:
await asyncio.sleep(1) #rest
async def main():
global task_queue
task_queue.put_nowait(take_order())
staff1 = Staff(name="John")
staff2 = Staff(name="Jane")
print("Welcome to Pyburger!")
logger.info("Ready for business!")
await asyncio.gather(staff1.working(), staff2.working())
logger.info("All tasks finished. Closing now.")
if __name__ == "__main__":
s = time.perf_counter()
asyncio.run(main())
elapsed = time.perf_counter() - s
logger.info(f"Orders completed in {elapsed:0.2f} seconds.")
Dans ce dernier bloc de code, nous avons consigné les informations de simulation dans pyburger.log et réservé le terminal pour les messages destinés aux clients. Nous détectons également les entrées non valides pendant le processus de commande et basculons un indicateur closing sur True si l’entrée n’est pas valide, en supposant que l’utilisateur souhaite quitter. Une fois que l’indicateur closing est défini sur True, l’employé passe à return, ce qui met fin à la boucle while infinie de la coroutine.
Comment fonctionne le threading en Python ?
Dans l’exemple ci-dessus, nous plaçons une tâche associée aux I/O dans un autre thread. Vous vous demandez peut-être si nous pouvons placer toutes les tâches dans des threads séparés et les laisser s’exécuter simultanément. Essayons d’utiliser threading au lieu d’asyncio.
Prenez le code que nous avons, comme indiqué ci-dessous, où nous créons des burgers simultanément sans avoir mis en place aucune limitation :
import asyncio
import time
async def make_burger(order_num):
print(f"Preparing burger #{order_num}...")
await asyncio.sleep(5) # time for making the burger
print(f"Burger made #{order_num}")
async def main():
order_queue = []
for i in range(3):
order_queue.append(make_burger(i))
await asyncio.gather(*(order_queue))
if __name__ == "__main__":
s = time.perf_counter()
asyncio.run(main())
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
```
Instead of creating async coroutines to make the burgers, we can just send functions down different threads like this:
```
import threading
import time
def make_burger(order_num):
print(f"Preparing burger #{order_num}...")
time.sleep(5) # time for making the burger
print(f"Burger made #{order_num}")
def main():
order_queue = []
for i in range(3):
task = threading.Thread(target=make_burger, args=(i,))
order_queue.append(task)
task.start()
for task in order_queue:
task.join()
if __name__ == "__main__":
s = time.perf_counter()
main()
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
Dans la première boucle for dans main, les tâches sont créées dans différents threads et peuvent démarrer rapidement. La deuxième boucle for s’assure que tous les burgers sont faits avant que le programme ne passe à la suite (c’est-à-dire avant qu’il ne revienne à main).
C’est plus compliqué lorsque nous n’avons que deux employés. Chaque membre du personnel est représenté par un thread, et ils prennent des tâches à partir d’une liste normale où elles sont toutes stockées.
import threading
import time
order_queue = []
def take_order():
for i in range(3):
order_queue.append(make_burger(i))
def make_burger(order_num):
def making_burger():
print(f"Preparing burger #{order_num}...")
time.sleep(5) # time for making the burger
print(f"Burger made #{order_num}")
return making_burger
def working():
while len(order_queue) > 0:
print(f"{threading.current_thread().name} is working...")
task = order_queue.pop(0)
task()
print(f"{threading.current_thread().name} finish task...")
def main():
take_order()
staff1 = threading.Thread(target=working, name="John")
staff1.start()
staff2 = threading.Thread(target=working, name="Jane")
staff2.start()
staff1.join()
staff2.join()
if __name__ == "__main__":
s = time.perf_counter()
main()
elapsed = time.perf_counter() - s
print(f"Orders completed in {elapsed:0.2f} seconds.")
Lorsque vous exécutez le code ci-dessus, une erreur peut se produire dans l’un des threads, indiquant qu’il essaie d’obtenir une tâche à partir d’une liste vide. Vous vous demandez peut-être pourquoi c’est le cas, puisque nous avons une condition dans la boucle while qui fait qu’elle continue uniquement si la file d’attente task_queue n’est pas vide. Mais nous obtenons quand même une erreur, car nous avons rencontré des conditions de concurrence.
Conditions de concurrence
Des conditions de concurrence (race conditions) peuvent se produire lorsque plusieurs threads tentent d’accéder à la même ressource ou aux mêmes données en même temps et causent des problèmes dans le système. Le moment et l’ordre d’accès à la ressource sont importants pour la logique du programme, et une chronologie imprévisible ou l’entrelacement de plusieurs threads accédant à des données partagées pour les modifier peuvent provoquer des erreurs.
Pour résoudre la condition de concurrence dans notre programme, nous allons déployer un verrou sur la file d’attente task_queue :
queue_lock = threading.Lock()
Pour que cela fonctionne, nous devons nous assurer de disposer des autorisations d’accès nécessaires pour vérifier la longueur de la file d’attente et récupérer les tâches qui en sont extraites. Tant que nous avons les droits, les autres threads ne peuvent pas accéder à la file d’attente :
def working():
while True:
with queue_lock:
if len(order_queue) == 0:
return
else:
task = order_queue.pop(0)
print(f"{threading.current_thread().name} is working...")
task()
print(f"{threading.current_thread().name} finish task...")
```
Based on what we have learned so far, we can complete our final code with threading like this:
```
import logging
import threading
import time
logger = logging.getLogger(__name__)
logging.basicConfig(filename="pyburger_threads.log", level=logging.INFO)
queue_lock = threading.Lock()
task_queue = []
order_num = 0
closing = False
def take_order():
global order_num, closing
try:
order_num += 1
logger.info(f"Taking Order #{order_num:04d}...")
print(f"Order burger and fries for order #{order_num:04d}:")
burger_num = input("Number of burgers:")
for i in range(int(burger_num)):
with queue_lock:
task_queue.append(make_burger(f"{order_num:04d}-burger{i:02d}"))
fries_num = input("Number of fries:")
for i in range(int(fries_num)):
with queue_lock:
task_queue.append(make_fries(f"{order_num:04d}-fries{i:02d}"))
logger.info(f"Order #{order_num:04d} queued.")
print(f"Order #{order_num:04d} queued, please wait.")
with queue_lock:
task_queue.append(take_order)
except ValueError:
print("Goodbye!")
logger.info("Closing down... stop taking orders and finish all tasks.")
closing = True
def make_burger(order_num):
def making_burger():
logger.info(f"Preparing burger #{order_num}...")
time.sleep(5) # time for making the burger
logger.info(f"Burger made #{order_num}")
return making_burger
def make_fries(order_num):
def making_fries():
logger.info(f"Preparing fried #{order_num}...")
time.sleep(2) # time for making fries
logger.info(f"Fries made #{order_num}")
return making_fries
def working():
while True:
with queue_lock:
if len(task_queue) == 0:
if closing:
return
else:
task = None
else:
task = task_queue.pop(0)
if task:
logger.info(f"{threading.current_thread().name} is working...")
task()
logger.info(f"{threading.current_thread().name} finish task...")
else:
time.sleep(1) # rest
def main():
print("Welcome to Pyburger!")
logger.info("Ready for business!")
task_queue.append(take_order)
staff1 = threading.Thread(target=working, name="John")
staff1.start()
staff2 = threading.Thread(target=working, name="Jane")
staff2.start()
staff1.join()
staff2.join()
logger.info("All tasks finished. Closing now.")
if __name__ == "__main__":
s = time.perf_counter()
main()
elapsed = time.perf_counter() - s
logger.info(f"Orders completed in {elapsed:0.2f} seconds.")
Si vous comparez les deux extraits de code utilisant asyncio et threading, leurs résultats devraient être similaires. Vous vous demandez peut-être lequel est le meilleur et pourquoi choisir l’un plutôt que l’autre.
En pratique, l’écriture de code asyncio est plus facile que le multithreading, car nous n’avons pas à nous occuper nous-mêmes des conditions de concurrence potentielles et des blocages. Par défaut, les contrôles sont transmis entre les coroutines, donc aucun verrou n’est nécessaire. Les threads Python ont toutefois la possibilité de fonctionner en parallèle, mais pas la plupart du temps si le GIL est en place. Nous pourrons revenir sur ce point lorsque nous parlerons de nogil (sans thread) Python dans le prochain article.
Bénéficier de la concurrence
Pourquoi utiliser la concurrence en programmation ? Principalement pour une raison : la vitesse. Comme nous l’avons illustré ci-dessus, les tâches s’accomplissent plus rapidement si nous pouvons réduire le temps d’attente. Plusieurs types d’attente existent en informatique, et pour chacun d’entre eux, nous avons tendance à utiliser des méthodes différentes pour gagner du temps.
Tâches associées aux I/O
Une tâche ou un programme est considéré comme associé aux entrées/sorties (I/O) lorsque sa vitesse d’exécution est principalement limitée par la vitesse des opérations d’I/O (par exemple la lecture à partir d’un fichier ou d’un réseau, ou l’attente d’une entrée utilisateur). Les opérations d’I/O sont généralement plus lentes que les autres opérations du processeur et, par conséquent, les tâches qui en impliquent un grand nombre peuvent prendre beaucoup plus de temps. Parmi ces tâches, on peut citer la lecture de données à partir d’une base de données, la gestion de requêtes web ou le recours à des fichiers volumineux.
L’utilisation de la concurrence async/await peut aider à optimiser le temps d’attente pendant les tâches liées aux I/O en débloquant la séquence de traitement pour que d’autres tâches soient prises en charge pendant l’attente.
La concurrence async/await est bénéfique dans de nombreuses applications Python, telles que les applications web qui impliquent beaucoup de communication avec des bases de données et la gestion de requêtes web. Les interfaces utilisateur graphiques (GUI) peuvent également bénéficier de la simultanéité async/await en permettant d’effectuer des tâches en arrière-plan pendant que l’utilisateur interagit avec l’application.
Tâches associées au processeur
Une tâche ou un programme est considéré comme lié au processeur lorsque sa vitesse d’exécution est principalement limitée par la vitesse du CPU. Parmi les exemples typiques, on trouve le traitement d’images ou de vidéos, comme le redimensionnement ou l’édition, et les calculs mathématiques complexes, tels que la multiplication matricielle ou l’entraînement de modèles de machine learning.
Contrairement aux tâches associées aux I/O, les tâches liées au processeur peuvent rarement être optimisées à l’aide de la concurrence async/await, car le processeur est déjà occupé à travailler sur les tâches. Si vous avez plusieurs processeurs dans votre machine, ou si vous pouvez décharger certaines de ces tâches sur un ou plusieurs GPU, les tâches liées au processeur peuvent être terminées plus rapidement grâce à la création de threads supplémentaires pour effectuer un multitraitement. Le multitraitement peut optimiser l’utilisation de ces CPU et GPU, ce qui explique également pourquoi de nombreux modèles de machine learning et d’IA sont aujourd’hui entraînés sur plusieurs GPU.
Cela reste toutefois difficile à réaliser avec du code Python pur, car Python lui-même est conçu pour fournir des couches abstraites afin que les utilisateurs n’aient pas à contrôler les processus de calcul de niveau inférieur. De plus, le GIL de Python limite le partage des ressources Python entre plusieurs threads sur votre ordinateur. Récemment, Python 3.13 a permis de supprimer le GIL, pour permettre un véritable multithreading. Nous parlerons du GIL et de la possibilité de s’en passer dans un prochain article de blog.
Parfois, aucune des méthodes que nous avons mentionnées ci-dessus n’est capable d’accélérer suffisamment les tâches liées au CPU. Dans ces cas, il peut être nécessaire de diviser les tâches liées au processeur en plus petites tâches afin de pouvoir les exécuter simultanément sur plusieurs threads, plusieurs processeurs ou même plusieurs machines. Il s’agit du traitement parallèle, et il peut être nécessaire de réécrire complètement votre code pour l’implémenter. En Python, le paquet multiprocessing offre une concurrence locale et distante, qui peut être utilisée pour contourner la limitation du GIL. Nous en examinerons également quelques exemples dans un prochain article de blog.
Débogage du code concurrent dans PyCharm
Le débogage de code asynchrone ou concurrent peut s’avérer compliqué, car le programme n’est ps exécuté de manière séquentielle, ce qui rend difficile de voir où et quand le code est exécuté. De nombreux développeurs utilisent print pour aider à tracer le flux du code, mais cette approche n’est pas recommandée, car elle est très maladroite, et l’utiliser pour étudier un programme complexe, comme un programme concurrent, n’est pas facile. De plus, remettre le code en ordre après cela est fastidieux.
De nombreux IDE fournissent des débogueurs, qui sont parfaits pour inspecter les variables et le flux du programme. Les débogueurs fournissent également une trace de pile claire sur plusieurs threads. Voyons comment suivre les files d’attente task_queue de notre exemple de simulation de restaurant dans PyCharm.
Tout d’abord, nous allons placer quelques points d’arrêt dans notre code. Pour ce faire, cliquez sur le numéro de la ligne sur laquelle vous souhaitez que le débogueur s’arrête. Le numéro de la ligne se transforme en point rouge, indiquant qu’un point d’arrêt y est défini. Nous allons placer des points d’arrêt aux lignes 23, 27 et 65, où la file d’attente des tâches task_queue est modifiée dans plusieurs threads.


Nous pouvons ensuite exécuter le programme en mode débogage en cliquant sur la petite icône d’insecte en haut à droite.

Un clic sur l’icône ouvre la fenêtre Debug. Le programme s’exécute alors jusqu’à atteindre le premier point d’arrêt mis en évidence dans le code.

Ici, nous voyons que le thread John essaie de reprendre la tâche, et la ligne 65 est mise en évidence. À ce stade, la ligne en surbrillance n’a pas encore été exécutée. Cela nous permet d’inspecter les variables avant d’entrer dans le point d’arrêt.
Voyons ce que contient la file d’attente task_queue. Pour cela, vous pouvez simplement commencer à taper dans la fenêtre Debug, comme indiqué ci-dessous.

Sélectionnez ou tapez « task_queue », puis appuyez sur Entrée. Vous verrez que la tâche take_order se trouve dans la file d’attente.

Maintenant, exécutons le point d’arrêt en cliquant sur le bouton Step in, comme indiqué ci-dessous.

Après avoir cliqué dessus et inspecté la fenêtre Special Variables qui s’affiche alors, nous voyons que la variable task est maintenant take_order dans le thread John.

Lorsque nous interrogeons à nouveau la task_queue, nous constatons que la liste est vide.

Cliquons maintenant sur le bouton Resume Program et laissons le programme s’exécuter.

Lorsque le programme atteint la partie d’entrée par l’utilisateur, PyCharm nous amène à la fenêtre Console afin que nous puissions fournir l’entrée. Supposons que nous voulons deux burgers. Tapez « 2 » et appuyez sur Entrée.

Nous atteignons maintenant le deuxième point d’arrêt. Si nous cliquons sur Threads & Variables pour revenir à cette fenêtre, nous verrons que burger_num est égal à deux, comme nous l’avons demandé.

Entrons maintenant dans le point d’arrêt et inspectons la task_queue, comme précédemment. Nous voyons qu’une tâche make_burger a été ajoutée.

Nous laissons le programme s’exécuter à nouveau, et si nous avançons pas à pas dans le point d’arrêt lorsqu’il s’arrête, nous voyons que Jane reprend la tâche.

Vous pouvez inspecter le reste du code vous-même. Lorsque vous avez terminé, appuyez simplement sur le bouton rouge Stop en haut de la fenêtre.

Avec le débogueur de PyCharm, vous pouvez très facilement suivre l’exécution de votre programme sur différents threads et inspecter les différentes variables.
Conclusion
Nous avons vu dans cet article les bases de la concurrence en Python, et j’espère que vous serez en mesure de les maîtriser avec de la pratique. Dans le prochain article, nous examinerons le GIL Python, son rôle et ce qui change en son absence.
PyCharm fournit des outils puissants pour travailler avec du code Python concurrent. Comme nous l’avons vu, le débogueur permet inspection étape par étape du code asynchrone et multithread, ce qui vous aide à suivre le flux d’exécution, à surveiller les ressources partagées et à détecter les problèmes. Avec des points d’arrêt intuitifs, des vues de variables en temps réel, une intégration transparente de la console pour la saisie utilisateur et une prise en charge robuste de la journalisation, PyCharm facilite l’écriture, le test et le débogage des applications de façon claire et assurée.
Auteur de l’article original en anglais :
