Quelles sont les différences entre les modules de thread et de multitraitement?


141

J'apprends comment utiliser threadinget les multiprocessingmodules en Python pour exécuter certaines opérations en parallèle et accélérer mon code.

Je trouve cela difficile (peut-être parce que je n'ai aucune base théorique à ce sujet) pour comprendre quelle est la différence entre un threading.Thread()objet et un objet multiprocessing.Process().

De plus, il ne m'est pas tout à fait clair de savoir comment instancier une file d'attente de jobs et n'en avoir que 4 (par exemple) s'exécutant en parallèle, tandis que les autres attendent que les ressources se libèrent avant d'être exécutées.

Je trouve les exemples dans la documentation clairs, mais pas très exhaustifs; dès que j'essaye de compliquer un peu les choses, je reçois beaucoup d'erreurs bizarres (comme une méthode qui ne peut pas être décapée, etc.).

Alors, quand dois-je utiliser les modules threadinget multiprocessing?

Pouvez-vous me relier à quelques ressources qui expliquent les concepts derrière ces deux modules et comment les utiliser correctement pour des tâches complexes?


Il y a plus, il y a aussi le Threadmodule (appelé _threaden python 3.x). Pour être honnête, je n'ai jamais compris les différences moi-même ...
Dunno

3
@Dunno: Comme le dit explicitement la Thread/ _threaddocumentation, ce sont des "primitives de bas niveau". Vous pouvez l'utiliser pour créer des objets de synchronisation personnalisés, pour contrôler l'ordre de jointure d'un arbre de threads, etc. Si vous ne pouvez pas imaginer pourquoi vous devez l'utiliser, ne l'utilisez pas et tenez-vous-en threading.
abarnert

Réponses:


260

Ce que dit Giulio Franco est vrai pour le multithreading par rapport au multiprocessing en général .

Cependant, Python * a un problème supplémentaire: il existe un verrou d'interprétation global qui empêche deux threads du même processus d'exécuter du code Python en même temps. Cela signifie que si vous avez 8 cœurs et que vous modifiez votre code pour utiliser 8 threads, il ne pourra pas utiliser 800% de CPU et fonctionner 8 fois plus vite; il utilisera le même processeur à 100% et fonctionnera à la même vitesse. (En réalité, cela fonctionnera un peu plus lentement, car il y a une surcharge supplémentaire du threading, même si vous n'avez pas de données partagées, mais ignorez cela pour le moment.)

Il y a des exceptions à cela. Si le calcul lourd de votre code ne se produit pas réellement en Python, mais dans une bibliothèque avec du code C personnalisé qui gère correctement GIL, comme une application numpy, vous obtiendrez les performances attendues du threading. La même chose est vraie si le calcul lourd est effectué par un sous-processus que vous exécutez et attendez.

Plus important encore, il y a des cas où cela n'a pas d'importance. Par exemple, un serveur réseau passe la plupart de son temps à lire des paquets hors du réseau, et une application GUI passe le plus clair de son temps à attendre les événements utilisateur. Une des raisons d'utiliser des threads dans un serveur réseau ou une application GUI est de vous permettre d'effectuer des «tâches d'arrière-plan» de longue durée sans empêcher le thread principal de continuer à entretenir des paquets réseau ou des événements GUI. Et cela fonctionne très bien avec les threads Python. (En termes techniques, cela signifie que les threads Python vous offrent la concurrence, même s'ils ne vous donnent pas le parallélisme de cœur.)

Mais si vous écrivez un programme lié au processeur en Python pur, utiliser plus de threads n'est généralement pas utile.

L'utilisation de processus séparés ne pose aucun problème avec le GIL, car chaque processus a son propre GIL distinct. Bien sûr, vous avez toujours les mêmes compromis entre les threads et les processus que dans tous les autres langages - il est plus difficile et plus coûteux de partager des données entre les processus qu'entre les threads, il peut être coûteux d'exécuter un grand nombre de processus ou de créer et de détruire fréquemment, etc. Mais le GIL pèse lourdement sur la balance vers les processus, d'une manière qui n'est pas vraie pour, par exemple, C ou Java. Ainsi, vous vous retrouverez à utiliser le multitraitement beaucoup plus souvent en Python qu'en C ou Java.


Pendant ce temps, la philosophie «batteries incluses» de Python apporte de bonnes nouvelles: il est très facile d'écrire du code qui peut être commuté entre les threads et les processus avec un changement d'une seule ligne.

Si vous concevez votre code en termes de "travaux" autonomes qui ne partagent rien avec d'autres travaux (ou le programme principal) sauf l'entrée et la sortie, vous pouvez utiliser la concurrent.futuresbibliothèque pour écrire votre code autour d'un pool de threads comme ceci:

with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
    executor.submit(job, argument)
    executor.map(some_function, collection_of_independent_things)
    # ...

Vous pouvez même obtenir les résultats de ces travaux et les transmettre à d'autres travaux, attendre les choses dans l'ordre d'exécution ou dans l'ordre d'achèvement, etc. lisez la section sur les Futureobjets pour plus de détails.

Maintenant, s'il s'avère que votre programme utilise constamment 100% du processeur et que l'ajout de threads le rend plus lent, alors vous rencontrez le problème GIL, vous devez donc passer aux processus. Tout ce que vous avez à faire est de changer cette première ligne:

with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:

La seule vraie mise en garde est que les arguments et les valeurs de retour de vos travaux doivent être picklable (et ne pas prendre trop de temps ou de mémoire à pickler) pour être utilisables cross-process. Ce n'est généralement pas un problème, mais parfois c'est le cas.


Mais que faire si vos emplois ne peuvent pas être autonomes? Si vous pouvez concevoir votre code en termes de tâches qui transmettent des messages de l'un à l'autre, c'est toujours assez facile. Vous devrez peut-être utiliser threading.Threadou multiprocessing.Processau lieu de compter sur des pools. Et vous devrez créer des objets queue.Queueou des multiprocessing.Queueobjets explicitement. (Il existe de nombreuses autres options - tuyaux, sockets, fichiers avec flocks,… mais le fait est que vous devez faire quelque chose manuellement si la magie automatique d'un exécuteur est insuffisante.)

Mais que faire si vous ne pouvez même pas compter sur la transmission de messages? Que faire si vous avez besoin de deux emplois pour faire muter la même structure et voir les changements de chacun? Dans ce cas, vous devrez effectuer une synchronisation manuelle (verrous, sémaphores, conditions, etc.) et, si vous souhaitez utiliser des processus, des objets de mémoire partagée explicites pour démarrer. C'est à ce moment que le multithreading (ou multiprocessing) devient difficile. Si vous pouvez l'éviter, tant mieux; si vous ne pouvez pas, vous aurez besoin de lire plus que ce que quelqu'un peut mettre dans une réponse SO.


À partir d'un commentaire, vous vouliez savoir ce qui est différent entre les threads et les processus en Python. Vraiment, si vous lisez la réponse de Giulio Franco et la mienne et tous nos liens, cela devrait tout couvrir ... mais un résumé serait certainement utile, alors voici:

  1. Les threads partagent des données par défaut; les processus ne le font pas.
  2. En conséquence de (1), l'envoi de données entre les processus nécessite généralement un décapage et un décapage. **
  3. Comme autre conséquence de (1), le partage direct des données entre les processus nécessite généralement de les mettre dans des formats de bas niveau tels que Value, Array et ctypestypes.
  4. Les processus ne sont pas soumis au GIL.
  5. Sur certaines plates-formes (principalement Windows), les processus sont beaucoup plus coûteux à créer et à détruire.
  6. Il existe des restrictions supplémentaires sur les processus, dont certaines sont différentes selon les plates-formes. Voir les directives de programmation pour plus de détails.
  7. Le threadingmodule ne possède pas certaines des fonctionnalités du multiprocessingmodule. (Vous pouvez utiliser multiprocessing.dummypour obtenir la plupart de l'API manquante par-dessus les threads, ou vous pouvez utiliser des modules de niveau supérieur comme concurrent.futureset ne pas vous en soucier.)

* Ce n'est pas réellement Python, le langage, qui a ce problème, mais CPython, l'implémentation "standard" de ce langage. Certaines autres implémentations n'ont pas de GIL, comme Jython.

** Si vous utilisez la méthode de démarrage de la fourche pour le multitraitement - ce que vous pouvez sur la plupart des plates-formes non Windows - chaque processus enfant obtient toutes les ressources dont le parent disposait au démarrage de l'enfant, ce qui peut être une autre façon de transmettre des données aux enfants.


merci, mais je ne suis pas sûr d'avoir tout compris. Quoi qu'il en soit j'essaye de le faire un peu à des fins d'apprentissage, et un peu parce qu'avec une utilisation naïve du thread j'ai divisé par deux la vitesse de mon code (en commençant plus de 1000 threads en même temps, chacun appelant une application externe .. cela sature le processeur, mais il y a une augmentation de vitesse x2). Je pense que la gestion intelligente du thread pourrait vraiment améliorer la vitesse de mon code ..
lucacerone

3
@LucaCerone: Ah, si votre code passe le plus clair de son temps à attendre des programmes externes, alors oui, il bénéficiera du threading. Bon point. Permettez-moi de modifier la réponse pour expliquer cela.
abarnert

2
@LucaCerone: En attendant, quelles parties ne comprenez-vous pas? Sans connaître le niveau de connaissances avec lequel vous commencez, il est difficile d'écrire une bonne réponse… mais avec quelques retours, peut-être pourrons-nous vous proposer quelque chose qui vous sera utile, ainsi qu'aux futurs lecteurs.
abarnert

3
@LucaCerone Vous devriez lire le PEP pour le multi-traitement ici . Il donne des timings et des exemples de threads vs multiprocessing.
mr2ert

1
@LucaCerone: Si l'objet auquel la méthode est liée n'a pas d'état complexe, la solution de contournement la plus simple pour le problème de décapage consiste à écrire une fonction wrapper stupide qui génère l'objet et appelle sa méthode. Si elle ne possède l' état complexe, alors vous avez probablement besoin de le faire picklable ( ce qui est assez facile, les pickledocuments expliquent), puis au pire votre emballage est stupide def wrapper(obj, *args): return obj.wrapper(*args).
abarnert

32

Plusieurs threads peuvent exister dans un seul processus. Les threads qui appartiennent au même processus partagent la même zone de mémoire (peuvent lire et écrire dans les mêmes variables, et peuvent interférer les uns avec les autres). Au contraire, différents processus vivent dans des zones de mémoire différentes, et chacun d'eux a ses propres variables. Pour communiquer, les processus doivent utiliser d'autres canaux (fichiers, tubes ou sockets).

Si vous souhaitez paralléliser un calcul, vous aurez probablement besoin du multithreading, car vous souhaitez probablement que les threads coopèrent sur la même mémoire.

En ce qui concerne les performances, les threads sont plus rapides à créer et à gérer que les processus (car le système d'exploitation n'a pas besoin d'allouer une toute nouvelle zone de mémoire virtuelle), et la communication inter-thread est généralement plus rapide que la communication inter-processus. Mais les threads sont plus difficiles à programmer. Les threads peuvent interférer les uns avec les autres et peuvent s'écrire dans la mémoire de l'autre, mais la façon dont cela se produit n'est pas toujours évidente (en raison de plusieurs facteurs, principalement la réorganisation des instructions et la mise en cache de la mémoire), et vous aurez donc besoin de primitives de synchronisation pour contrôler l'accès à vos variables.


12
Il manque des informations très importantes sur le GIL, ce qui le rend trompeur.
abarnert

1
@ mr2ert: Oui, ce sont les informations très importantes en un mot. :) Mais c'est un peu plus compliqué que ça, c'est pourquoi j'ai écrit une réponse séparée.
abarnert

2
Je pensais avoir commenté en disant que @abarnert avait raison, et j'ai oublié le GIL en répondant ici. Donc, cette réponse est fausse, vous ne devriez pas la voter.
Giulio Franco

6
J'ai décliné cette réponse car elle ne répond toujours pas du tout quelle est la différence entre Python threadinget multiprocessing.
Antti Haapala

J'ai lu qu'il existe un GIL pour chaque processus. Mais tous les processus utilisent-ils le même interpréteur python ou y a-t-il un interpréteur séparé par thread?
variable du

3

Je pense que ce lien répond à votre question d'une manière élégante.

Pour être bref, si l'un de vos sous-problèmes doit attendre pendant qu'un autre se termine, le multithreading est bon (dans les opérations lourdes d'E / S, par exemple); par contre, si vos sous-problèmes peuvent vraiment survenir en même temps, le multi-traitement est suggéré. Cependant, vous ne créerez pas plus de processus que votre nombre de cœurs.


3

Citations de la documentation Python

J'ai mis en évidence les citations clés de la documentation Python sur Process vs Threads et le GIL à: Qu'est-ce que le verrou d'interpréteur global (GIL) dans CPython?

Expériences de processus vs threads

J'ai fait un peu de benchmarking afin de montrer la différence plus concrètement.

Dans le benchmark, j'ai chronométré le travail lié au processeur et aux E / S pour différents nombres de threads sur un processeur à 8 hyperthread . Le travail fourni par thread est toujours le même, de sorte que plus de threads signifie plus de travail total fourni.

Les résultats ont été:

entrez la description de l'image ici

Tracer les données .

Conclusions:

  • pour le travail lié au processeur, le multitraitement est toujours plus rapide, probablement grâce au GIL

  • pour les travaux liés aux E / S. les deux ont exactement la même vitesse

  • les threads ne s'adaptent qu'à environ 4x au lieu des 8x attendus puisque je suis sur une machine à 8 hyperthread.

    Comparez cela avec un travail lié au processeur C POSIX qui atteint la vitesse de 8x attendue: que signifient «réel», «utilisateur» et «sys» dans la sortie de time (1)?

    TODO: Je ne connais pas la raison de cela, il doit y avoir d'autres inefficacités Python en jeu.

Code de test:

#!/usr/bin/env python3

import multiprocessing
import threading
import time
import sys

def cpu_func(result, niters):
    '''
    A useless CPU bound function.
    '''
    for i in range(niters):
        result = (result * result * i + 2 * result * i * i + 3) % 10000000
    return result

class CpuThread(threading.Thread):
    def __init__(self, niters):
        super().__init__()
        self.niters = niters
        self.result = 1
    def run(self):
        self.result = cpu_func(self.result, self.niters)

class CpuProcess(multiprocessing.Process):
    def __init__(self, niters):
        super().__init__()
        self.niters = niters
        self.result = 1
    def run(self):
        self.result = cpu_func(self.result, self.niters)

class IoThread(threading.Thread):
    def __init__(self, sleep):
        super().__init__()
        self.sleep = sleep
        self.result = self.sleep
    def run(self):
        time.sleep(self.sleep)

class IoProcess(multiprocessing.Process):
    def __init__(self, sleep):
        super().__init__()
        self.sleep = sleep
        self.result = self.sleep
    def run(self):
        time.sleep(self.sleep)

if __name__ == '__main__':
    cpu_n_iters = int(sys.argv[1])
    sleep = 1
    cpu_count = multiprocessing.cpu_count()
    input_params = [
        (CpuThread, cpu_n_iters),
        (CpuProcess, cpu_n_iters),
        (IoThread, sleep),
        (IoProcess, sleep),
    ]
    header = ['nthreads']
    for thread_class, _ in input_params:
        header.append(thread_class.__name__)
    print(' '.join(header))
    for nthreads in range(1, 2 * cpu_count):
        results = [nthreads]
        for thread_class, work_size in input_params:
            start_time = time.time()
            threads = []
            for i in range(nthreads):
                thread = thread_class(work_size)
                threads.append(thread)
                thread.start()
            for i, thread in enumerate(threads):
                thread.join()
            results.append(time.time() - start_time)
        print(' '.join('{:.6e}'.format(result) for result in results))

GitHub en amont + code de traçage sur le même répertoire .

Testé sur Ubuntu 18.10, Python 3.6.7, dans un ordinateur portable Lenovo ThinkPad P51 avec CPU: CPU Intel Core i7-7820HQ (4 cœurs / 8 threads), RAM: 2x Samsung M471A2K43BB1-CRC (2x 16GiB), SSD: Samsung MZVLB512HAJQ- 000L7 (3 000 Mo / s).

Visualisez quels threads sont en cours d'exécution à un moment donné

Ce post https://rohanvarma.me/GIL/ m'a appris que vous pouvez exécuter un rappel chaque fois qu'un thread est planifié avec l' target=argument dethreading.Thread et de même pour multiprocessing.Process.

Cela nous permet de voir exactement quel thread s'exécute à chaque fois. Lorsque cela est fait, nous verrons quelque chose comme (j'ai créé ce graphique particulier):

            +--------------------------------------+
            + Active threads / processes           +
+-----------+--------------------------------------+
|Thread   1 |********     ************             |
|         2 |        *****            *************|
+-----------+--------------------------------------+
|Process  1 |***  ************** ******  ****      |
|         2 |** **** ****** ** ********* **********|
+-----------+--------------------------------------+
            + Time -->                             +
            +--------------------------------------+

ce qui montrerait que:

  • les threads sont entièrement sérialisés par le GIL
  • les processus peuvent s'exécuter en parallèle

1

Voici quelques données de performances pour python 2.6.x qui remettent en question la notion selon laquelle le threading est plus performant que le multitraitement dans les scénarios liés aux E / S. Ces résultats proviennent d'un IBM System x3650 M4 BD à 40 processeurs.

Traitement lié aux E / S: le pool de processus fonctionne mieux que le pool de threads

>>> do_work(50, 300, 'thread','fileio')
do_work function took 455.752 ms

>>> do_work(50, 300, 'process','fileio')
do_work function took 319.279 ms

Traitement lié au processeur: le pool de processus a mieux fonctionné que le pool de threads

>>> do_work(50, 2000, 'thread','square')
do_work function took 338.309 ms

>>> do_work(50, 2000, 'process','square')
do_work function took 287.488 ms

Ce ne sont pas des tests rigoureux, mais ils me disent que le multitraitement n'est pas totalement inefficace par rapport au threading.

Code utilisé dans la console Python interactive pour les tests ci-dessus

from multiprocessing import Pool
from multiprocessing.pool import ThreadPool
import time
import sys
import os
from glob import glob

text_for_test = str(range(1,100000))

def fileio(i):
 try :
  os.remove(glob('./test/test-*'))
 except : 
  pass
 f=open('./test/test-'+str(i),'a')
 f.write(text_for_test)
 f.close()
 f=open('./test/test-'+str(i),'r')
 text = f.read()
 f.close()


def square(i):
 return i*i

def timing(f):
 def wrap(*args):
  time1 = time.time()
  ret = f(*args)
  time2 = time.time()
  print '%s function took %0.3f ms' % (f.func_name, (time2-time1)*1000.0)
  return ret
 return wrap

result = None

@timing
def do_work(process_count, items, process_type, method) :
 pool = None
 if process_type == 'process' :
  pool = Pool(processes=process_count)
 else :
  pool = ThreadPool(processes=process_count)
 if method == 'square' : 
  multiple_results = [pool.apply_async(square,(a,)) for a in range(1,items)]
  result = [res.get()  for res in multiple_results]
 else :
  multiple_results = [pool.apply_async(fileio,(a,)) for a in range(1,items)]
  result = [res.get()  for res in multiple_results]


do_work(50, 300, 'thread','fileio')
do_work(50, 300, 'process','fileio')

do_work(50, 2000, 'thread','square')
do_work(50, 2000, 'process','square')

J'ai utilisé votre code (supprimé la partie glob ) et j'ai trouvé ces résultats intéressants avec Python 2.6.6:>>> do_work(50, 300, 'thread', 'fileio') --> 237.557 ms >>> do_work(50, 300, 'process', 'fileio') --> 323.963 ms >>> do_work(50, 2000, 'thread', 'square') --> 232.082 ms >>> do_work(50, 2000, 'process', 'square') --> 282.785 ms
Alan Garrido

-5

Eh bien, la plupart de la question est répondue par Giulio Franco. Je vais approfondir le problème du consommateur-producteur, qui, je suppose, vous mettra sur la bonne voie pour votre solution à l'utilisation d'une application multithread.

fill_count = Semaphore(0) # items produced
empty_count = Semaphore(BUFFER_SIZE) # remaining space
buffer = Buffer()

def producer(fill_count, empty_count, buffer):
    while True:
        item = produceItem()
        empty_count.down();
        buffer.push(item)
        fill_count.up()

def consumer(fill_count, empty_count, buffer):
    while True:
        fill_count.down()
        item = buffer.pop()
        empty_count.up()
        consume_item(item)

Vous pouvez en savoir plus sur les primitives de synchronisation à partir de:

 http://linux.die.net/man/7/sem_overview
 http://docs.python.org/2/library/threading.html

Le pseudocode est ci-dessus. Je suppose que vous devriez rechercher le problème producteur-consommateur pour obtenir plus de références.


désolé innosam, mais cela me semble C ++? merci pour les liens :)
lucacerone

En fait, les idées derrière le multitraitement et le multithreading sont indépendantes du langage. La solution serait similaire au code ci-dessus.
innosam

2
Ce n'est pas du C ++; c'est un pseudocode (ou c'est du code pour un langage typé principalement dynamiquement avec une syntaxe de type C. Cela étant dit, je pense qu'il est plus utile d'écrire un pseudocode de type Python pour enseigner aux utilisateurs de Python. (Surtout que le psuedocode de type Python souvent s'avère être du code exécutable, ou du moins proche de celui-ci, ce qui est rarement vrai pour un pseudocode de type C…)
abarnert

Je l'ai réécrit comme pseudocode de type Python (en utilisant également OO et en passant des paramètres au lieu d'utiliser des objets globaux); n'hésitez pas à revenir en arrière si vous pensez que cela rend les choses moins claires.
abarnert

En outre, il convient de noter que Python stdlib a une file d'attente synchronisée intégrée qui résume tous ces détails, et que ses API de thread et de pool de processus abstruisent encore plus les choses. Cela vaut vraiment la peine de comprendre comment les files d'attente synchronisées fonctionnent sous les couvertures, mais vous aurez rarement besoin d'en écrire une vous-même.
abarnert
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.