Quais são as diferenças entre os módulos de threading e multiprocessing?

141

Estou aprendendo como usar o threadinge os multiprocessingmódulos em Python para executar certas operações em paralelo e acelerar o meu código.

Estou achando isso difícil (talvez porque eu não tenha nenhuma base teórica sobre isso) entender qual é a diferença entre um threading.Thread()objeto e multiprocessing.Process()um.

Além disso, não está totalmente claro para mim como instanciar uma fila de trabalhos e ter apenas 4 (por exemplo) deles executando paralelamente, enquanto o outro aguarda a liberação dos recursos antes de serem executados.

Acho os exemplos claros na documentação, mas não muito exaustivos; Assim que tento complicar um pouco as coisas, recebo muitos erros estranhos (como um método que não pode ser usado em conserva e assim por diante).

Então, quando devo usar os módulos threadinge multiprocessing?

Você pode me vincular a alguns recursos que explicam os conceitos por trás desses dois módulos e como usá-los adequadamente para tarefas complexas?

lucacerona
fonte
Há mais, também há o Threadmódulo (chamado _threadem python 3.x). Para ser honesto, eu nunca entendi as diferenças mim ...
Não sei
3
@ Dunno: Como a documentação Thread/ _threaddiz explicitamente, são "primitivas de baixo nível". Você pode usá-lo para objetos de sincronização de compilação personalizada, para controlar a ordem de uma árvore de tópicos, etc. participação Se você não pode imaginar por que você precisa usá-lo, não usá-lo, e ficar com threading.
precisa saber é o seguinte

Respostas:

260

O que Giulio Franco diz é verdadeiro para multithreading vs. multiprocessing em geral .

No entanto, o Python * tem um problema adicional: existe um bloqueio global para intérpretes que impede que dois threads no mesmo processo executem o código Python ao mesmo tempo. Isso significa que se você tiver 8 núcleos e alterar seu código para usar 8 threads, ele não poderá usar 800% da CPU e executar 8x mais rapidamente; ele usará a mesma CPU 100% e será executado na mesma velocidade. (Na realidade, o processo será um pouco mais lento, porque há sobrecarga extra na segmentação, mesmo que você não tenha nenhum dado compartilhado, mas ignore-o por enquanto.)

Há exceções para isto. Se a computação pesada do seu código não acontecer de fato no Python, mas em alguma biblioteca com código C personalizado que manipula corretamente o GIL, como um aplicativo numpy, você obterá o benefício esperado do desempenho da segmentação. O mesmo acontece se a computação pesada for feita por algum subprocesso que você executa e espera.

Mais importante, há casos em que isso não importa. Por exemplo, um servidor de rede passa a maior parte do tempo lendo pacotes fora da rede, e um aplicativo GUI passa a maior parte do tempo aguardando eventos do usuário. Um motivo para usar threads em um servidor de rede ou aplicativo GUI é permitir que você execute "tarefas em segundo plano" de longa execução sem impedir que o thread principal continue a atender pacotes de rede ou eventos da GUI. E isso funciona muito bem com threads Python. (Em termos técnicos, isso significa que os threads do Python oferecem concorrência, mesmo que não ofereçam paralelismo central.)

Mas se você estiver escrevendo um programa vinculado à CPU em Python puro, o uso de mais threads geralmente não é útil.

O uso de processos separados não apresenta problemas com o GIL, porque cada processo possui seu próprio GIL separado. É claro que você ainda tem as mesmas vantagens entre threads e processos que em qualquer outro idioma - é mais difícil e mais caro compartilhar dados entre processos do que entre threads, pode ser caro executar um grande número de processos ou criar e destruir frequentemente, etc. Mas o GIL pesa muito na balança em relação aos processos, de uma maneira que não é verdadeira para, digamos, C ou Java. Portanto, você se encontrará usando multiprocessamento com muito mais frequência em Python do que em C ou Java.


Enquanto isso, a filosofia "baterias incluídas" do Python traz boas notícias: É muito fácil escrever código que pode ser alternado entre threads e processos com uma alteração de uma linha.

Se você projetar seu código em termos de "trabalhos" independentes que não compartilham nada com outros trabalhos (ou o programa principal), exceto entrada e saída, você pode usar a concurrent.futuresbiblioteca para escrever seu código em um pool de threads como este:

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

Você pode até obter os resultados desses trabalhos e repassá-los para outros trabalhos, aguardar as coisas em ordem de execução ou ordem de conclusão, etc .; leia a seção sobre Futureobjetos para obter detalhes.

Agora, se o seu programa estiver constantemente usando 100% da CPU, e adicionar mais threads apenas o tornará mais lento, você estará enfrentando o problema do GIL e precisará mudar para os processos. Tudo o que você precisa fazer é alterar a primeira linha:

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

A única ressalva real é que os argumentos e os valores de retorno de seus trabalhos precisam ser selecionáveis ​​(e não levar muito tempo ou memória para separar) para serem utilizados no processo cruzado. Geralmente isso não é um problema, mas às vezes é.


Mas e se seus trabalhos não puderem ser independentes? Se você pode criar seu código em termos de trabalhos que transmitem mensagens de um para outro, ainda é muito fácil. Você pode ter que usar threading.Threadou em multiprocessing.Processvez de confiar em piscinas. E você terá que criar queue.Queueou multiprocessing.Queueobjetos explicitamente. (Existem muitas outras opções - tubos, soquetes, arquivos com bandos, ... mas o ponto é que você precisa fazer algo manualmente se a mágica automática de um Executor for insuficiente.)

Mas e se você não puder confiar na passagem de mensagens? E se você precisar de dois trabalhos para alterar a mesma estrutura e ver as mudanças um do outro? Nesse caso, você precisará fazer a sincronização manual (bloqueios, semáforos, condições etc.) e, se desejar usar processos, objetos explícitos de memória compartilhada para inicializar. É quando o multithreading (ou multiprocessing) fica difícil. Se você pode evitá-lo, ótimo; se você não puder, precisará ler mais do que alguém pode colocar em uma resposta do SO.


Em um comentário, você queria saber o que há de diferente entre threads e processos no Python. Realmente, se você ler a resposta de Giulio Franco e a minha e todos os nossos links, isso deverá cobrir tudo ... mas um resumo seria definitivamente útil, então aqui vai:

  1. Threads compartilham dados por padrão; processos não.
  2. Como conseqüência de (1), o envio de dados entre processos geralmente requer a decapagem e a remoção da seleção. **
  3. Como outra consequência de (1), o compartilhamento direto de dados entre processos geralmente exige colocá-los em formatos de baixo nível, como Valor, Matriz e ctypestipos.
  4. Os processos não estão sujeitos ao GIL.
  5. Em algumas plataformas (principalmente Windows), os processos são muito mais caros para criar e destruir.
  6. Existem algumas restrições extras nos processos, algumas das quais são diferentes em plataformas diferentes. Consulte Diretrizes de programação para obter detalhes.
  7. O threadingmódulo não possui alguns dos recursos do multiprocessingmódulo. (Você pode usar multiprocessing.dummypara obter a maior parte da API ausente sobre os encadeamentos, ou pode usar módulos de nível superior como concurrent.futurese não se preocupar com isso.)

* Na verdade, não é o Python, a linguagem, que tem esse problema, mas o CPython, a implementação "padrão" dessa linguagem. Algumas outras implementações não têm um GIL, como o Jython.

** Se você estiver usando o método fork start para multiprocessamento - que é possível na maioria das plataformas que não sejam Windows - cada processo filho obtém os recursos que o pai tinha quando o filho foi iniciado, o que pode ser outra maneira de passar dados para filhos.

abarnert
fonte
obrigado, mas não tenho certeza se entendi tudo. Enfim, estou tentando fazê-lo um pouco para fins de aprendizado, e um pouco porque, com um uso ingênuo de encadeamento, reduzi pela metade a velocidade do meu código (iniciando mais de 1000 encadeamentos ao mesmo tempo, cada um chamando um aplicativo externo. Isso satura cpu, ainda há um aumento de x2 na velocidade). Eu acho que gerir o fio de forma inteligente pode realmente melhorar a velocidade do meu código ..
lucacerone
3
@LucaCerone: Ah, se o seu código passa a maior parte do tempo aguardando em programas externos, sim, ele se beneficiará do encadeamento. Bom ponto. Deixe-me editar a resposta para explicar isso.
abarnert
2
@LucaCerone: Enquanto isso, que partes você não entende? Sem saber o nível de conhecimento com o qual você está começando, é difícil escrever uma boa resposta ... mas com alguns comentários, talvez possamos encontrar algo que seja útil para você e para os futuros leitores também.
precisa saber é o seguinte
3
@LucaCerone Você deve ler o PEP para multiprocessamento aqui . Ele fornece horários e exemplos de threads versus multiprocessamento.
precisa saber é o seguinte
1
@LucaCerone: Se o objeto ao qual o método está vinculado não possui nenhum estado complexo, a solução mais simples para a questão de decapagem é escrever uma função de invólucro estúpido que gera o objeto e chama seu método. Se ele não tem estado complexo, então você provavelmente precisa torná-lo picklable (que é muito fácil, os pickledocs explicá-lo), e, em seguida, na pior das hipóteses o seu invólucro estúpido é def wrapper(obj, *args): return obj.wrapper(*args).
abarnert
32

Vários encadeamentos podem existir em um único processo. Os encadeamentos que pertencem ao mesmo processo compartilham a mesma área de memória (podem ler e gravar nas mesmas variáveis ​​e podem interferir entre si). Pelo contrário, diferentes processos vivem em diferentes áreas da memória e cada um deles tem suas próprias variáveis. Para se comunicar, os processos precisam usar outros canais (arquivos, tubos ou soquetes).

Se você deseja paralelizar uma computação, provavelmente precisará de multithreading, porque provavelmente deseja que os threads cooperem na mesma memória.

Falando sobre desempenho, os threads são mais rápidos de criar e gerenciar do que processos (porque o sistema operacional não precisa alocar uma área de memória virtual totalmente nova), e a comunicação entre threads geralmente é mais rápida que a comunicação entre processos. Mas os threads são mais difíceis de programar. Os encadeamentos podem interferir uns com os outros e podem gravar na memória um do outro, mas a maneira como isso acontece nem sempre é óbvia (devido a vários fatores, principalmente a reordenação de instruções e o cache de memória) e, portanto, você precisará de primitivas de sincronização para controlar o acesso para suas variáveis.

Giulio Franco
fonte
12
Faltam algumas informações muito importantes sobre o GIL, o que o torna enganoso.
abarnert
1
@ Mr2ert: Sim, essa é a informação muito importante em poucas palavras. :) Mas é um pouco mais complicado que isso, e foi por isso que escrevi uma resposta separada.
abarnert
2
Eu pensei em comentar dizendo que @abarnert está certo, e eu esqueci o GIL em responder aqui. Portanto, esta resposta está errada, você não deve votar novamente.
Giulio Franco
6
Eu diminuí a votação desta resposta porque ela ainda não responde qual é a diferença entre Python threadinge multiprocessing.
Antti Haapala 07/02
Eu li que existe um GIL para cada processo. Mas todos os processos usam o mesmo intérprete python ou existe intérprete separado por thread?
variável
3

Acredito que este link responda à sua pergunta de maneira elegante.

Para ser breve, se um de seus subproblemas tiver que esperar enquanto outro termina, o multithreading é bom (em operações pesadas de E / S, por exemplo); por outro lado, se seus subproblemas realmente acontecerem ao mesmo tempo, o multiprocessamento é sugerido. No entanto, você não criará mais processos do que seu número de núcleos.

ehfaafzv
fonte
3

Citações da documentação do Python

Eu destaquei as principais citações da documentação do Python sobre Process vs Threads e o GIL em: O que é o bloqueio global de intérprete (GIL) no CPython?

Experiências de processo versus encadeamento

Fiz um pouco de benchmarking para mostrar a diferença mais concretamente.

No benchmark, cronometrei o trabalho vinculado da CPU e da E / S para vários números de threads em uma CPU com 8 hyperthread . O trabalho fornecido por rosca é sempre o mesmo, de modo que mais roscas signifiquem mais trabalho total fornecido.

Os resultados foram:

insira a descrição da imagem aqui

Plotar dados .

Conclusões:

  • Para o trabalho vinculado à CPU, o multiprocessamento é sempre mais rápido, provavelmente devido ao GIL

  • para trabalho vinculado de E / S. ambos são exatamente a mesma velocidade

  • os encadeamentos escalam apenas cerca de 4x em vez dos 8x esperados, pois estou em uma máquina com 8 hyperthread

    Compare isso com um trabalho vinculado à CPU C POSIX que atinja a aceleração de 8x esperada: o que 'real', 'user' e 'sys' significam na saída do tempo (1)?

    TODO: Não sei o motivo disso, deve haver outras ineficiências do Python entrando em jogo.

Código do teste:

#!/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))

Upstream do GitHub + código de plotagem no mesmo diretório .

Testado no Ubuntu 18.10, Python 3.6.7, em um laptop Lenovo ThinkPad P51 com CPU: CPU Intel Core i7-7820HQ (4 núcleos / 8 threads), RAM: 2x Samsung M471A2K43BB1-CRC (2x 16GiB), SSD: Samsung MZVLB512HAJQ- 000L7 (3.000 MB / s).

Visualize quais threads estão sendo executados em um determinado momento

Este post https://rohanvarma.me/GIL/ me ensinou que você pode executar um retorno de chamada sempre que um thread for agendado com o target=argumentothreading.Thread e o mesmo para multiprocessing.Process.

Isso nos permite ver exatamente qual thread é executado a cada momento. Quando isso for feito, veremos algo como (criei este gráfico específico):

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

o que mostraria que:

  • threads são totalmente serializados pelo GIL
  • processos podem ser executados em paralelo
Ciro Santilli adicionou uma nova foto
fonte
1

Aqui estão alguns dados de desempenho do python 2.6.x que questionam a noção de que o encadeamento é mais eficiente que o multiprocessamento em cenários vinculados à IO. Esses resultados são de um IBM System x3650 M4 BD de 40 processadores.

Processamento vinculado à IO: o conjunto de processos teve um desempenho melhor que o conjunto 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

Processamento vinculado à CPU: o conjunto de processos teve um desempenho melhor que o conjunto 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

Esses não são testes rigorosos, mas eles me dizem que o multiprocessamento não é totalmente ineficaz em comparação com o encadeamento.

Código usado no console python interativo para os testes acima

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')
Mario Aguilera
fonte
Eu usei o seu código (removi a parte glob ) e encontrei esses resultados interessantes com o 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

Bem, a maior parte da pergunta é respondida por Giulio Franco. Vou aprofundar o problema do consumidor-produtor, que suponho que o colocará no caminho certo para a sua solução usar um aplicativo 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)

Você pode ler mais sobre as primitivas de sincronização em:

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

O pseudocódigo está acima. Suponho que você deva procurar no problema produtor-consumidor para obter mais referências.

innosam
fonte
desculpe innosam, mas isso parece C ++ para mim? obrigado por as ligações :)
lucacerone
Na verdade, as idéias por trás do multiprocessamento e do multithreading são independentes da linguagem. A solução seria semelhante ao código acima.
Innosam
2
Isso não é C ++; é pseudocódigo (ou é o código para uma linguagem tipicamente dinâmica, com uma sintaxe do tipo C. Dito isto, acho mais útil escrever pseudocódigo do tipo Python para ensinar aos usuários de Python. (Especialmente porque o psuedocode do tipo Python geralmente acaba por ser de código executável, ou pelo menos próximo a ele, que é raramente verdadeiro para C-like pseudocódigo ...)
abarnert
Eu o reescrevi como pseudocódigo do tipo Python (também usando OO e passando parâmetros em vez de usar objetos globais); fique à vontade para reverter se achar que isso deixa as coisas menos claras.
abarnert
Além disso, vale a pena notar que o Python stdlib possui uma fila sincronizada que envolve todos esses detalhes, e suas APIs de pool de processos e threads abstraem ainda mais as coisas. Definitivamente, vale a pena entender como as filas sincronizadas funcionam escondidas, mas você raramente precisará escrever uma.
precisa saber é