Multiprocessing en Python
Tags: python, multiprocessing, threads, programming
Published: 2023-10-10 08:41:51

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 :


```python
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"
    )
```


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é.


```python
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()
```

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 :


```python
if __name__ == "__main__":
    main()
```


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

```


*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 :

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

    return nombre ** nombre
```


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


```python
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.")
```


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 :

```python
if __name__ == "__main__":
    main()
```


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.
```


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](https://docs.python.org/3/library/multiprocessing.html)