DEV Community

Cover image for Multiprocessing en Python
DURAND Malo
DURAND Malo

Posted on

Multiprocessing en Python

Bonjour à vous. Aujourd'hui, on s'intéresse à la programmation parallèle grâce au module multiprocessing en Python.

Introduction :

La programmation parallèle permet d'effectuer plusieurs tâches de façon simultanée, permettant ainsi un gain de temps colossal.

Mise en situation :

Supposez qu'aujourd'hui, une longue journée vous attende. Vous devez :

  • réceptionner un colis,
  • faire le ménage,
  • rédiger un mail,
  • cuisiner un gâteau,
  • mettre des lessives en route.

Vous n'allez pas attendre qu'une tâche soit terminée pour en faire une autre.

D'abord, vous allez mettre des lessives en route. En même temps, vous allez faire le ménage. Vous entendez quelqu'un toquer à la porte : vous réceptionnez votre colis.

Enfin, vous commencez à préparer votre gâteau et pendant qu'il cuit, vous rédigez votre mail.

Vous avez effectuer plusieurs tâches en simultané. C'est exactement ce que nous allons faire en Python avec le module multiprocessing.

À l'abordage :

Le module multiprocessing va créer un processus enfant au processus parent (votre programme principal) qui s'occupera d'effectuer les opérations en parallèle.

Si vous avez déjà utilisé l'appel système fork en C, les notions que nous allons aborder vous sembleront plutôt simple à comprendre.

Pour illustrer ce schéma parent/enfant, voici un exemple d'utilisation :

import os
import time


def ma_fonction(numero_processus_enfant: int):
    """ Fonction appelée par le processus `enfant` """

    # Pause de 5 secondes pour simuler une tâche chronophage.
    time.sleep(5)

    # Affichage de :
    # Numéro de création du processus enfant
    # Identifiant du processus parent
    # Identifiant du processus enfant

    print(
        f"Processus enfant numéro {numero_processus_enfant} :\n"
        f"\tID du processus parent : {os.getppid()}\n"
        f"\tID du processus enfant {os.getpid()}\n"
    )
Enter fullscreen mode Exit fullscreen mode

Le but de la fonction ma_fonction est de simuler une tâche chronophage dont on souhaiterait parallèliser l'exécution.

Après le traitement long d'une tâche, elle affiche le numéro du processus enfant entrain de l'exécuter, ainsi que l'identifiant du processus parent et l'identifiant du processus enfant.

L'identifiant du processus enfant n'est pas à confondre avec le numéro du processus enfant, ce dernier correspondant à l'ordre dans lequel le processus a été initialisé.

from multiprocessing import Process


def main():
    processus = [Process(target=ma_fonction, args=(i,)) for i in range(4)]
    for p in processus:
        p.start()
    for p in processus:
        p.join()
Enter fullscreen mode Exit fullscreen mode

La fonction main créée une liste de 4 processus. Chacun d'entre eux devra exécuter la fonction ma_fonction en lui passant le paramètre i dans la bouche, qui correspond au numéro de création d'un processus.

Une fois cette liste créée, on la parcours. Pour chaque processus rencontré, on le lance.

On parcours une seconde fois la liste et, pour chaque élément, on appelle la méthode join. Cet appel nous permet d'attendre la fin d'exécution du processus avant de retourner à l'exécution linéaire.

Enfin, pour éviter une erreur de type RuntimeError, due à une mauvaise gestion de la fonction fork appelée à l'intérieur du module multiprocessing, nous devons rajouter l'emblématique :

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Si vous exécutez le programme, au bout de 5 secondes, vous devriez avoir un affichage similaire à celui-ci :

Processus enfant numéro 1 :
        ID du processus parent : 20340
        ID du processus enfant 20343

Processus enfant numéro 2 :
        ID du processus parent : 20340
        ID du processus enfant 20344

Processus enfant numéro 3 :
        ID du processus parent : 20340
        ID du processus enfant 20345

Processus enfant numéro 0 :
        ID du processus parent : 20340
        ID du processus enfant 20342

Enter fullscreen mode Exit fullscreen mode

Les valeurs des identifiants et l'ordre des numéros d'enfant devraient être différents de ceux que vous voyez ici.

Ce qui est important c'est de remarquer que chaque "ID du processus enfant" est rattaché au même "ID du processus parent", puisque c'est le même programme qui a initialisé les 4 processus.

L'avantage de l'utilisation des processus enfant ici est que le coût en temps des tâches est divisé par 4.

Si nous avions exécuté de façon linéaire les instructions, nous aurions du attendre environ 5 secondes x 4 exécutions de la fonction ma_fonction, soit 20 secondes.

Pour les personnes ayant déjà utilisé le module threading, les méthodes start et join vous semblent familière, de même que les arguments target et args pour la fonction multiprocessing.Process et c'est tout à fait normal. L'API de multiprocessing essaie d'aborder les mêmes concepts pour faciliter la transposition entre les deux APIs.

L'object multiprocessing.Pool :

Nous avons vu comment créer un processus enfant pour faire nos calculs, mais cette tâche demande de la réflexion et de l'organisation.

Supposons que nous avons une grande liste à traiter. Nous devons appliquer une fonction à chaque éléments de la liste. Rapidement, on pourrait penser à utiliser la fonction map qui répond exactement à notre demande.

Cependant, nous pouvons initialiser un objet multiprocessing.Pool qui va gérer un nombre de processus déterminé, sans que nous ayons besoin d'intervenir, et qui répartira les tâches de calculs entre ces processus.

Voici un petit exemple :

def ma_fonction(nombre: int):
    """ Fonction appelée par la fonction `map` de l'object `Pool` """

    return nombre ** nombre
Enter fullscreen mode Exit fullscreen mode

Cette fonction sera appliquée à chaque élément d'une liste quelconque.

import time
from multiprocessing import Pool, cpu_count

def main():
    ma_liste = range(10_000)

    with Pool(processes=cpu_count() - 1) as pool:
        debut = time.perf_counter()
        ma_liste_traitee = pool.map(ma_fonction, ma_liste)
        fin = time.perf_counter()
        print(f"Traitement de la liste avec les processus enfants terminé en {fin - debut:.2f} secondes.")

    _debut = time.perf_counter()
    _ma_liste_traitee = list(map(ma_fonction, ma_liste))
    _fin = time.perf_counter()
    print(f"Traitement de la liste sans les processus enfants terminé en {_fin - _debut:.2f} secondes.")

    assert _ma_liste_traitee == ma_liste_traitee, "Les deux listes sont différentes."
    print("Les deux listes traitées sont égales.")
Enter fullscreen mode Exit fullscreen mode

Ici, nous créons une liste ma_liste de 10 000 nombres de 0 à 9 999.

On créé un objet Pool que vous pourriez vous représenter comme une piscine dans laquelle nagent des processus qui attendent d'être utilisés. Pour créer l'objet Pool, vous pouvez passer un argument optionnel appelé processes qui représente le nombre de processus qui pourront être appelés pour effectuer les calculs.

Faites bien attention à mettre une valeur relativement basse, au risque d'observer des ralentissements de votre ordinateur, pouvant aller jusqu'à devoir le redémarrer.

Vous devriez pouvoir faire comme dans cet exemple, et utiliser la fonction cpu_count() pour obtenir le nombre de processus utilisables sur votre ordinateur, et y soustraire 1 pour assurer un minimum vos arrières.

Une fois créé, on se sert de la pool pour appliquer une fonction map à notre liste de nombre ma_liste de telle sorte à obtenir une nouvelle liste avec le résultat de toutes les opérations ma_fonction.

On stock le temps d'exécution de la méthode pool.map avec la fonction time.perf_counter, et on l'affiche.

Enfin, pour comparer, on réitère l'opération mais cette fois-ci, comme on l'aurait fait en programmant de façon linéaire, c'est-à-dire sans utiliser de multiprocessing.

Pour ça, on peut utiliser la builtin map et lui passer la fonction à appliquer (ma_fonction) ainsi que la liste sur laquelle itérer (ma_liste).

Comme au dessus, on stock le temps d'exécution avec time.perf_counter.

On compare ensuite les deux listes pour vérifier qu'elles sont bien égales.

Bien sûr, comme tout à l'heure, on rajoute :

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Pour ne pas avoir de soucis avec l'exécution de la fonction fork.

Voici un résultat possible :

Traitement de la liste avec les processus enfants terminé en 1.43 secondes.
Traitement de la liste sans les processus enfants terminé en 5.85 secondes.
Les deux listes traitées sont égales.
Enter fullscreen mode Exit fullscreen mode

On remarque assez rapidement que le traitement de la liste avec les processus enfants s'est terminée 4 fois plus rapidement que le traitement sans les processus enfants.

Conclusion :

Vous voilà armé avec les outils de multiprocessing pour faire de la programmation parallèle. Vous pourrez diviser vos tâches en processus enfants pour obtenir vos résultats plus rapidement.

Ce module peut faire penser au module threading, que je couvrirai plus tard, mais il faut retenir que multiprocessing utilise des processus supplémentaires pour fonctionner, alors que le module threading utilise des threads.

Faites bien attention lorsque vous utilisez le module multiprocessing de ne pas surcharger le nombre de processus que vous souhaitez utiliser, notamment dans l'initialisation de l'objet Pool.

Si vous mettez trop de processus, vous risquez d'observer un fort ralentissement de votre ordinateur pouvant vous amener à devoir le redémarrer.

Si vous souhaitez en apprendre davantage sur le module, vous pouvez consulter sa documentation à l'URL suivante : https://docs.python.org/3/library/multiprocessing.html

Top comments (0)