multiprocessing

Par Laurent Gander 4

Introduction

Le module multiprocessing utilise les processus plutôt que les threads. Il nous permet de créer plusieurs processus séparés.

Bien que la plupart des CPUs modernes comportent plusieurs coeurs, le code que l’on écrit doit aussi être formatté adéquatement afin d’en tirer pleinement avantage. 1

multiprocessing évite d’être bloqué par le GIL (Global Interpreter Lock) en utilisant des sous-processus au lieu des threads et offre de la concurrence locale et distante. De ce fait, le module multiprocessing permet au programmeur d’exploiter pleinement plusieurs processeurs sur une machine donnée. Il fonctionne sur Unix et Windows.

GIL?

Le Global Interpreter Lock est un verrou (lock) que l’interpréteur demande afin d’être thread-safe sur le comptage des références du ramasse-miette. La connaissance du GIL est indispensable lorsque vous travaillez avec plusieurs threads. Le GIL a été connu pour dégrader la performance des programmes. Un exemple est que cela peut prendre plus de temps pour deux threads d’appeler la même fonction qu’un thread appelant deux fois la fonction. 2

Les classes

Process

multiprocessing contient plusieurs classes très utiles, je vais vous en introduire quelques unes.

Prenons tout d’abord la classe multiprocessing.Process, la classe multiprocessing.Process nous permet de créer des processus en créant un objets multiprocessing.Process, en appelant sa méthode start().

Dans l’exemple suivant nous avons deux fonctions en plus du main, dans la première fonction “info” nous affichons le titre passé en paramètre ainsi que le nom du module, le numero du processus parent et le numero du processus. La deuxième fonction appelle la première fonction et ensuite écrit “hello + le nom passé en paramètre”. Dans le main, nous appelons la fonction info puis, nous créons un objet Process avec comme arguments “target = f” qui est la fonction que le processus exécutera et les arguments de la fonction “args=(“bob”)”, ensuite nous lancons le processus avec p.start() et p.join().

"""Fichier test processus."""
import os
from multiprocessing import Process


def info(title, name):
    """Affiche les informations liées au processus, id et parent id."""
    print(title)
    print('hello', name)
    print('module name:', __name__)
    print('parent process:', os.getppid())
    print('process id:', os.getpid())


if __name__ == '__main__':

    p = Process(target=info, args=('main line', 'bob'))
    p.start()
    p.join()

Résultat de l’exemple ci-dessus :

main line
hello bob
module name: __mp_main__
parent process: 1664
process id: 14628

Queues

La classe Queue permet de créer un canal de communication entre plusieurs processus.

"""Exemple de la classe Queue."""
from multiprocessing import Process, Queue


def f(q):
    """Fonction qui envoie 42 None hello."""
    q.put([42, None, 'hello'])


if __name__ == '__main__':

    q = Queue()
    p = Process(target=f, args=(q,))
    p.start()
    print(q.get())
    p.join()

L’exemple ci-dessus nous affiche :

[42, None, 'hello']

Avertissement

Si un processus est « tué » à l’aide des fonctions terminate() ou de os.kill(), les données risquent d’être corrompues dans la queue, en effet on ne sait pas si le processus est finit, des commandes risquent d’être encore à l’interieur. Ce qui signifie qu’un autre processus qui tenterait d’accéder à la queue risquerait de soulever une exception.

Pipe

La classe Pipe permet aussi la création d’un canal bidirectionnel entre deux processus. Le constructeur du pipe retourne deux objets de connexion qui sont l’entrée et la sortie du canal.

"""Exemple de la classe Pipe."""
from multiprocessing import Pipe, Process


def f(conn):
    """Fonction qui envoie 42 None hello."""
    conn.send([42, None, 'hello'])
    conn.close()


if __name__ == '__main__':

    parent_conn, child_conn = Pipe()
    p = Process(target=f, args=(child_conn,))
    p.start()
    print(parent_conn.recv())
    p.join()

L’exemple ci-dessus nous affiche : .. code-block:: console

[42, None, “hello”]

Les objets de connexions ont deux méthodes : recv() et send() qui leurs permet de lire et d’écrire dans un canal. Les données dans un tuyau peuvent être corrompues si deux processus (ou threads) tentent de lire ou d’écrire à la même extrémité du tuyau en même temps.

Contexte et méthodes de démarrage

Il y a plusieurs façons de démarrer un processus, le multiprocessing en contient trois :
spawn

L’interpréteur Python sera démarré par le processus parent, son enfant n’héritera que des ressources nécessaires pour executer la méthode run().

fork

os.fork() est utilisé par le processus parent pour fork l’interpreteur Python. Quand le processus enfant est lancé, ses ressources sont identiques au processus parent. fork est disponible uniquement sur Unix.

forkserver

Quand le programme est lancé et lance la méthode forkserver.start(), depuis ce moment, chaque fois qu’un processus est nécessaire, un processus est demandé au serveur par le processus parent. forkserver fonctionne que sur Unix.

Synchronisation entre les processus

multiprocessing contient les mêmes méthodes que la classe threading. Comme pour un thread, nous pouvons nous assurer qu’un processus accède à une ressource de manière atomique.

"""Exemple de synchronisation."""
from multiprocessing import Lock, Process


def task(lock, i):
    """Acquire le lock, affiche hello i, puis relâche le lock."""
    lock.acquire()
    try:
        print('hello world', i)
    finally:
        lock.release()


def main():
    """Main program."""
    lock = Lock()

    for num in range(10):
        Process(target=task, args=(lock, num)).start()


if __name__ == '__main__':
    main()

Résultat de l’exemple :

hello world 1
hello world 2
hello world 0
hello world 3
hello world 5
hello world 6
hello world 7
hello world 4
hello world 8
hello world 9

Partage de ressources entre processus

En programmation multi-processus, il est souvent utile de pouvoir partager des ressources entre nos processus. Pour cela multiprocessing offre différentes manières de partager des ressources.

La mémoire :

On peut partager de la mémoire en utilisant les fonctions:

Exemple :

"""Exemple de mémoire partagé."""
from multiprocessing import Array, Process, Value


def f(n, a):
    """Fonction qui assigne n, rend negatif les valeurs de a[]."""
    n.value = 3.1415927
    for i in range(len(a)):
        a[i] = -a[i]


if __name__ == '__main__':

    num = Value('d', 0.0)
    arr = Array('i', range(10))

    p = Process(target=f, args=(num, arr))
    p.start()
    p.join()

    print(num.value)
    print(arr[:])

Résultat :

3.1415927
[0, -1, -2, -3, -4, -5, -6, -7, -8, -9]

Serveur de processus :

Un objet renvoyé par Manager() contrôle un serveur de processus qui contient des objets Python et permet à d’autres processus de les manipuler à l’aide de proxies.

Exemple :

"""Exemple de la classe Manager."""
from multiprocessing import Manager, Process


def task(payload, lst):
    """Associe 3 valeurs à leurs clé et inverse l'ordre de lst[]."""
    payload[1] = '1'
    payload['2'] = 2
    payload[0.25] = None
    lst.reverse()


if __name__ == '__main__':
    with Manager() as manager:
        d = manager.dict()
        e = manager.list(range(10))

        p = Process(target=task, args=(d, e))
        p.start()
        p.join()

        print(d)
        print(e)

Résultat :

{1: '1', '2': 2, 0.25: None}
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

Conclusion

Pour conclure, le module de python sur le multiprocessing nous permet de contourner le problème des threads en python, effectivement l’interpreteur Python n’est pas fait pour le multi-threading à cause du GIL, vu qu’il impose en pratique qu’un seul coeur travaille en même temps. Le langage python n’a qu’un seul fil d’exécution, donc il n’est pas possible d’utiliser tous les coeurs en n’utilisant que des threads. C’est pourquoi, on utilise plutôt le module multiprocessing, en effet mettre chaque travail dans un thread séparé pourrait l’aider un peu parce que, lorsqu’une connexion est inactif, on peut obtenir un certain temps CPU, mais le traitement ne se fera pas en parallèle à cause du GIL. En mettant chaque travail dans un processus, chacun peut s’exécuter sur son propre processeur et s’exécuter à plein rendementm malgré le problème du partage de mémoire entre les processus qui malgré tout sont gérés par les fonctions Value, Array et Manager ou en faisant de l’asynchrome avec asyncio.

Pour de plus amples informations :