Por que, quando e como usar multithreading e multiprocessing em Python

Saudação, Khabrovsk. No momento, a OTUS abriu um conjunto para o curso de aprendizado de máquina . Em conexão com isso, traduzimos para você um "conto de fadas" muito interessante. Vai.




Era uma vez, em uma galáxia distante e distante ... Um

mago sábio e poderoso vivia em uma pequena vila no meio do deserto. E o nome deles era Dumbledalf. Ele não era apenas sábio e poderoso, mas também ajudou as pessoas que vinham de terras distantes a pedir ajuda a um mago. Nossa história começou quando um viajante trouxe ao mago um pergaminho mágico. O viajante não sabia o que havia no pergaminho, ele sabia apenas que se alguém pudesse revelar todos os segredos do pergaminho, então esse era Dumbledalf.

Capítulo 1: Processo único de thread único


Se você ainda não adivinhou, fiz uma analogia com o processador e suas funções. Nosso assistente é um processador, e um pergaminho é uma lista de links que levam ao poder e ao conhecimento do Python para dominá-lo.

O primeiro pensamento do mago que decifrou a lista sem nenhuma dificuldade foi enviar seu fiel amigo (Garrigorn? Eu sei, eu sei que isso soa horrível) para cada um dos lugares que foram descritos no pergaminho para encontrar e trazer o que ele poderia encontrar lá.

In [1]:
import urllib.request
from concurrent.futures import ThreadPoolExecutor
In [2]:
urls = [
  'http://www.python.org',
  'https://docs.python.org/3/',
  'https://docs.python.org/3/whatsnew/3.7.html',
  'https://docs.python.org/3/tutorial/index.html',
  'https://docs.python.org/3/library/index.html',
  'https://docs.python.org/3/reference/index.html',
  'https://docs.python.org/3/using/index.html',
  'https://docs.python.org/3/howto/index.html',
  'https://docs.python.org/3/installing/index.html',
  'https://docs.python.org/3/distributing/index.html',
  'https://docs.python.org/3/extending/index.html',
  'https://docs.python.org/3/c-api/index.html',
  'https://docs.python.org/3/faq/index.html'
  ]
In [3]:
%%time

results = []
for url in urls:
    with urllib.request.urlopen(url) as src:
        results.append(src)

CPU times: user 135 ms, sys: 283 µs, total: 135 ms
Wall time: 12.3 s
In [ ]:

Como você pode ver, simplesmente iteramos os URLs um a um usando o loop for e lemos a resposta. Graças ao %% time e à magia do IPython , podemos ver que demorou cerca de 12 segundos na minha triste internet.

Capítulo 2: Multithreading


Não era sem razão que o mago era famoso por sua sabedoria; ele foi rapidamente capaz de encontrar uma maneira muito mais eficaz. Em vez de enviar uma pessoa para cada lugar em ordem, por que não reunir um esquadrão de associados de confiança e enviá-los para diferentes partes do mundo ao mesmo tempo! O mago será capaz de unir todo o conhecimento que eles trazem de uma vez!

É isso mesmo, em vez de visualizar a lista em um loop em sequência, podemos usar o multithreading para acessar vários URLs de uma só vez.

In [1]:
import urllib.request
from concurrent.futures import ThreadPoolExecutor
In [2]:
urls = [
  'http://www.python.org',
  'https://docs.python.org/3/',
  'https://docs.python.org/3/whatsnew/3.7.html',
  'https://docs.python.org/3/tutorial/index.html',
  'https://docs.python.org/3/library/index.html',
  'https://docs.python.org/3/reference/index.html',
  'https://docs.python.org/3/using/index.html',
  'https://docs.python.org/3/howto/index.html',
  'https://docs.python.org/3/installing/index.html',
  'https://docs.python.org/3/distributing/index.html',
  'https://docs.python.org/3/extending/index.html',
  'https://docs.python.org/3/c-api/index.html',
  'https://docs.python.org/3/faq/index.html'
  ]
In [4]:
%%time

with ThreadPoolExecutor(4) as executor:
    results = executor.map(urllib.request.urlopen, urls)

CPU times: user 122 ms, sys: 8.27 ms, total: 130 ms
Wall time: 3.83 s
In [5]:
%%time

with ThreadPoolExecutor(8) as executor:
    results = executor.map(urllib.request.urlopen, urls)

CPU times: user 122 ms, sys: 14.7 ms, total: 137 ms
Wall time: 1.79 s
In [6]:
%%time

with ThreadPoolExecutor(16) as executor:
    results = executor.map(urllib.request.urlopen, urls)

CPU times: user 143 ms, sys: 3.88 ms, total: 147 ms
Wall time: 1.32 s
In [ ]:

Muito melhor! Quase como ... mágica. O uso de vários encadeamentos pode acelerar significativamente muitas tarefas de E / S. No meu caso, a maior parte do tempo gasto lendo URLs é devido à latência da rede. Os programas vinculados à E / S passam a maior parte de sua vida esperando, você adivinhou, por entrada ou saída (assim como um assistente espera que seus amigos vão a lugares do pergaminho e retornam). Pode ser entrada / saída da rede, banco de dados, arquivo ou do usuário. Essa E / S geralmente leva muito tempo, porque a fonte pode precisar executar o pré-processamento antes de transferir os dados para a E / S. Por exemplo, um processador conta muito mais rápido do que uma conexão de rede transmite dados (a uma velocidade de aproximadamentecomo Flash vs sua avó).

Nota : multithreadingpode ser muito útil em tarefas como limpar páginas da web.

Capítulo 3: Multiprocessamento


Anos se passaram, a fama do bom mago cresceu e, com ela, a inveja de um mago sombrio e imparcial (Sarumort? Ou talvez Volandeman?). Armado com astúcia incomensurável e impulsionado pela inveja, o bruxo das trevas lançou uma terrível maldição sobre Dumbledalf. Quando a maldição tomou conta dele, Dumbledalf percebeu que ele tinha apenas alguns momentos para afastá-lo. Desesperado, ele vasculhou seus livros de feitiços e rapidamente encontrou um contra-feitiço que deveria funcionar. O único problema era que o mago precisava calcular a soma de todos os números primos menores que 1.000.000.Um feitiço estranho, é claro, mas o que temos.

O mago sabia que calcular o valor seria trivial se ele tivesse tempo suficiente, mas ele não tinha esse luxo. Apesar de ser um grande mago, ele é limitado por sua humanidade e pode verificar a simplicidade apenas um número de cada vez. Se ele decidisse simplesmente adicionar os números primos um após o outro, levaria muito tempo. Quando restaram apenas alguns segundos antes de aplicar a contra-mágica , de repente ele se lembrou da magia de multiprocessamento , que havia aprendido com o pergaminho mágico muitos anos atrás. Esse feitiço permitirá que ele se copie para distribuir números entre suas cópias e verificar vários ao mesmo tempo. E no final, tudo o que ele precisa fazer é simplesmente somar os números que ele e suas cópias descobrirão.

In [1]:
from multiprocessing import Pool
In [2]:
def if_prime(x):
    if x <= 1:
        return 0
    elif x <= 3:
        return x
    elif x % 2 == 0 or x % 3 == 0:
        return 0
    i = 5
    while i**2 <= x:
        if x % i == 0 or x % (i + 2) == 0:
            return 0
        i += 6
    return x
In [17]:
%%time

answer = 0

for i in range(1000000):
    answer += if_prime(i)

CPU times: user 3.48 s, sys: 0 ns, total: 3.48 s
Wall time: 3.48 s
In [18]:
%%time

if __name__ == '__main__':
    with Pool(2) as p:
        answer = sum(p.map(if_prime, list(range(1000000))))

CPU times: user 114 ms, sys: 4.07 ms, total: 118 ms
Wall time: 1.91 s
In [19]:
%%time

if __name__ == '__main__':
    with Pool(4) as p:
        answer = sum(p.map(if_prime, list(range(1000000))))

CPU times: user 99.5 ms, sys: 30.5 ms, total: 130 ms
Wall time: 1.12 s
In [20]:
%%timeit

if __name__ == '__main__':
    with Pool(8) as p:
        answer = sum(p.map(if_prime, list(range(1000000))))

729 ms ± 3.02 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [21]:
%%timeit

if __name__ == '__main__':
    with Pool(16) as p:
        answer = sum(p.map(if_prime, list(range(1000000))))

512 ms ± 39.8 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [22]:
%%timeit

if __name__ == '__main__':
    with Pool(32) as p:
        answer = sum(p.map(if_prime, list(range(1000000))))

518 ms ± 13.3 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [23]:
%%timeit

if __name__ == '__main__':
    with Pool(64) as p:
        answer = sum(p.map(if_prime, list(range(1000000))))

621 ms ± 10.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [ ]:

Os processadores modernos têm mais de um núcleo, para que possamos acelerar as tarefas usando o multiprocessamento do módulo de processamento multiprocesso . As tarefas associadas ao processador são programas que, na maioria das vezes em que trabalham, realizam cálculos no processador (cálculos matemáticos triviais, processamento de imagem etc.). Se os cálculos puderem ser executados independentemente, teremos a oportunidade de dividi-los entre os núcleos de processador disponíveis, obtendo assim um aumento significativo na velocidade de processamento.

Tudo o que tem a fazer é:

  1. Determine a função a ser aplicada.
  2. Prepare uma lista de elementos aos quais a função será aplicada;
  3. multiprocessing.Pool. , Pool(), . with , .
  4. Pool map. map , , .

Nota : Uma função pode ser definida para executar qualquer tarefa que possa ser executada em paralelo. Por exemplo, uma função pode conter código para gravar o resultado de um cálculo em um arquivo.

Então, por que precisamos nos separarmultiprocessingemultithreading? Se você já tentou melhorar o desempenho de uma tarefa no processador usando multithreading, o resultado é exatamente o oposto. Isso é terrível! Vamos ver como aconteceu.

Assim como um assistente é limitado por sua natureza humana e só pode calcular um número por unidade de tempo, o Python vem com uma coisa chamada Global Interpreter Lock (GIL) . O Python tem o prazer de permitir que você crie quantos threads quiser, mas o GILgarante que apenas um desses threads seja executado a qualquer momento.

Para uma tarefa relacionada à E / S, essa situação é completamente normal. Um segmento envia uma solicitação para um URL e aguarda uma resposta; somente então esse segmento pode ser substituído por outro, o que enviará outra solicitação para um URL diferente. Como o encadeamento não precisa fazer nada até receber uma resposta, não faz diferença que no momento especificado apenas um encadeamento esteja em execução.

Para tarefas executadas no processador, ter vários threads é quase tão inútil quanto mamilos de armadura. Como apenas um encadeamento pode ser executado por unidade de tempo, mesmo se você gerar vários encadeamentos, cada um dos quais receberá um número para verificar a simplicidade, o processador ainda funcionará com apenas um encadeamento. De fato, esses números ainda serão verificados um por um. E a sobrecarga de trabalhar com vários encadeamentos ajudará a reduzir o desempenho, que você pode observar apenas quando usado multithreadingem tarefas executadas no processador.

Para contornar essa "restrição", usamos o módulomultiprocessing. Em vez de usar threads, o multiprocessamento usa, como você diz ... vários processos. Cada processo recebe seu próprio intérprete pessoal e espaço de memória, para que o GIL não o limite. De fato, cada processo usará seu próprio núcleo de processador e trabalhará com seu próprio número exclusivo, e isso será realizado simultaneamente com o trabalho de outros processos. Que doce deles!

Você pode notar que a carga da CPU será maior quando você a usar multiprocessing, em comparação com um loop for regular ou mesmo multithreading. Isso acontece porque seu programa usa não um núcleo, mas vários. E isso é bom!

lembre-se dissomultiprocessingEle tem sua própria sobrecarga de gerenciar vários processos, o que geralmente é mais sério do que o custo do multithreading. (O multiprocessamento gera intérpretes separados e atribui sua própria área de memória a cada processo, então sim!) Ou seja, como regra, é melhor usar uma versão leve do multithreading quando quiser sair dessa maneira (lembre-se das tarefas relacionadas à E / S). Mas quando o cálculo no processador se torna um gargalo, chega a hora do módulo multiprocessing. Mas lembre-se de que com grande poder vem uma grande responsabilidade.

Se você gerar mais processos do que seu processador pode processar por unidade de tempo, notará que o desempenho começará a diminuir. Isso acontece porque o sistema operacional precisa fazer mais trabalho alterando os processos entre os núcleos do processador, porque há mais processos. Na realidade, tudo pode ser ainda mais complicado do que eu disse hoje, mas transmiti a idéia principal. Por exemplo, no meu sistema, o desempenho diminuirá quando o número de processos for 16. Isso ocorre porque existem apenas 16 núcleos lógicos no meu processador.

Capítulo 4: Conclusão


  • Nas tarefas relacionadas à E / S, multithreadingpode melhorar o desempenho.
  • Nas tarefas relacionadas à E / S, multiprocessingtambém pode aumentar a produtividade, mas os custos, em regra, são mais altos do que quando usados multithreading.
  • A existência do Python GIL deixa claro para nós que, a qualquer momento em um programa, apenas um thread pode ser executado.
  • Nas tarefas relacionadas ao processador, o uso multithreadingpode reduzir o desempenho.
  • Nas tarefas relacionadas ao processador, o uso multiprocessingpode melhorar o desempenho.
  • Os assistentes são incríveis!

É aí que terminaremos nossa introdução hoje multithreadinge multiprocessingno Python. Agora vá e ganhe!



"Modelando COVID-19 usando análise de gráficos e análise de dados abertos." Lição grátis.

All Articles