Concorrência é lidar com muitas coisas ao mesmo tempo.
Paralelismo é fazer muitas coisas ao mesmo tempo.
Não são a mesma coisa, mas estão relacionados.
Uma é sobre estrutura, outro é sobre execução.
A concorrência fornece uma maneira de estruturar uma solução para resolver um problema que pode (mas não necessariamente) ser paralelizado.[1]
Co-criador da linguagem Go
Este capítulo é sobre como fazer o Python "lidar com muitas coisas ao mesmo tempo." Isso pode envolver programação concorrente ou paralela—e mesmo os acadêmicos rigorosos com terminologia discordam sobre o uso dessas palavras. Vou adotar as definições informais de Rob Pike, na epígrafe desse capítulo, mas saiba que encontrei artigos e livros que dizem ser sobre computação paralela mas são quase que inteiramente sobre concorrência.[2]
O paralelismo é, na perspectiva de Pike, um caso especial de concorrência. Todos sistema paralelo é concorrente, mas nem todo sistema concorrente é paralelo. No início dos anos 2000, usávamos máquinas GNU Linux de um único núcleo, que rodavam 100 processos ao mesmo tempo. Um laptop moderno com quatro núcleos de CPU rotineiramente está executando mais de 200 processos a qualquer momento, sob uso normal, casual. Para executar 200 tarefas em paralelo, você precisaria de 200 núcleos. Assim, na prática, a maior parte da computação é concorrente e não paralela. O SO administra centenas de processos, assegurando que cada um tenha a oportunidade de progredir, mesmo se a CPU em si não possa fazer mais que quatro coisas ao mesmo tempo.
Este capítulo não assume que você tenha qualquer conhecimento prévio de programação concorrente ou paralela.
Após uma breve introdução conceitual, vamos estudar exemplos simples,
para apresentar e comparar os principais pacotes da biblioteca padrão de Python dedicados a programação concorrente:
threading
, multiprocessing
, e asyncio
.
O último terço do capítulo é uma revisão geral de ferramentas, servidores de aplicação e filas de tarefas distribuídas (distributed task queues) de vários desenvolvedores, capazes de melhorar o desempenho e a escalabilidade de aplicações Python. Todos esses são tópicos importantes, mas fogem do escopo de um livro focado nos recursos fundamentais da linguagem Python. Mesmo assim, achei importante mencionar esses temas nessa segunda edição do Python Fluente, porque a aptidão do Python para computação concorrente e paralela não está limitada ao que a biblioteca padrão oferece. Por isso YouTube, DropBox, Instagram, Reddit e outros foram capazes de atingir alta escalabilidade quando começaram, usando Python como sua linguagem primária—apesar das persistentes alegações de que "O Python não escala."
Este capítulo é novo, escrito para a segunda edição do Python Fluente. Os exemplos com os caracteres giratórios no Um "Olá mundo" concorrente antes estavam no capítulo sobre asyncio. Aqui eles foram revisados, e apresentam uma primeira ilustração das três abordagens do Python à concorrência: threads, processos e corrotinas nativas.
O restante do conteúdo é novo, exceto por alguns parágrafos, que apareciam originalmente nos capítulos sobre concurrent.futures
e asyncio.
A Python no mundo multi-núcleo. é diferente do resto do livro: não há código exemplo. O objetivo ali é apresentar brevemente ferramentas importantes, que você pode querer estudar para conseguir concorrência e paralelismo de alto desempenho, para além do que é possível com a biblioteca padrão do Python.
Há muitos fatores que tornam a programação concorrente difícil, mas quero tocar no mais básico deles: iniciar threads ou processos é fácil, mas como administrá-los?[3]
Quando você chama uma função, o código que origina a chamada fica bloqueado até que função retorne.
Então você sabe que a função terminou, e pode facilmente acessar o valor devolvido por ela.
Se a função lançar uma exceção, o código de origem pode cercar aquela chamada com um bloco try/except
para tratar o erro.
Essas opções não existem quando você inicia threads ou um processo: você não sabe automaticamente quando eles terminaram, e obter os resultados ou os erros requer criar algum canal de comunicação, tal como uma fila de mensagens.
Além disso, criar uma thread ou um processo não é barato, você não quer iniciar uma delas apenas para executar uma única computação e desaparecer. Muitas vezes queremos amortizar o custo de inicialização transformando cada thread ou processo em um "worker" ou "unidade de trabalho", que entra em um loop e espera por dados para processar. Isso complica ainda mais a comunicação e introduz mais perguntas. Como terminar um "worker" quando ele não é mais necessário? E como fazer para encerrá-lo sem interromper uma tarefa inacabada, deixando dados inconsistentes e recursos não liberados—tal como arquivos abertos? A resposta envolve novamente mensagens e filas.
Uma corrotina é fácil de iniciar.
Se você inicia uma corrotina usando a palavra-chave await
,
é fácil obter o valor de retorno e há um local óbvio para interceptar exceções.
Mas corrotinas muitas vezes são iniciadas pelo framework assíncrono,
e isso pode torná-las tão difíceis de monitorar quanto threads ou processos.
Por fim, as corrotinas e threads do Python não são adequadas para tarefas de uso intensivo da CPU, como veremos.
É por isso tudo que programação concorrente exige aprender novos conceitos e novos modelos de programação. Então vamos primeiro garantir que estamos na mesma página em relação a alguns conceitos centrais.
Aqui estão alguns termos que vou usar pelo restante desse capítulo e nos dois seguintes:
- Concorrência
-
A habilidade de lidar com múltiplas tarefas pendentes, fazendo progredir uma por vez ou várias em paralelo (se possível), de forma que cada uma delas avance até terminar com sucesso ou falha. Uma CPU de núcleo único é capaz de concorrência se rodar um "agendador" (scheduler) do sistema operacional, que intercale a execução das tarefas pendentes. Também conhecida como multitarefa (multitasking).
- Paralelismo
-
A habilidade de executar múltiplas operações computacionais ao mesmo tempo. Isso requer uma CPU com múltiplos núcleos, múltiplas CPUs, uma GPU, ou múltiplos computadores em um cluster (agrupamento)).
- Unidades de execução
-
Termo genérico para objetos que executam código de forma concorrente, cada um com um estado e uma pilha de chamada independentes. O Python suporta de forma nativa três tipos de unidade de execução: processos, threads, e corrotinas.
- Processo
-
Uma instância de um programa de computador em execução, usando memória e uma fatia do tempo da CPU. Sistemas operacionais modernos em nossos computadores e celulares rotineiramente mantém centenas de processos de forma concorrente, cada um deles isolado em seu próprio espaço de memória privado. Processos se comunicam via pipes, soquetes ou arquivos mapeados da memória. Todos esses métodos só comportam bytes puros. Objetos Python precisam ser serializados (convertidos em sequências de bytes) para passarem de um processo a outro. Isto é caro, e nem todos os objetos Python podem ser serializados. Um processo pode gerar subprocessos, chamados "processos filhos". Estes também rodam isolados entre si e do processo original. Os processos permitem multitarefa preemptiva: o agendador do sistema operacional exerce preempção—isto é, suspende cada processo em execução periodicamente, para permitir que outro processos sejam executados. Isto significa que um processo paralisado não pode paralisar todo o sistema—em teoria.
- Thread
-
Uma unidade de execução dentro de um único processo. Quando um processo se inicia, ele tem uma única thread: a thread principal. Um processo pode chamar APIs do sistema operacional para criar mais threads para operar de forma concorrente. Threads dentro de um processo compartilham o mesmo espaço de memória, onde são mantidos objetos Python "vivos" (não serializados). Isso facilita o compartilhamento de informações entre threads, mas pode também levar a corrupção de dados, se mais de uma thread atualizar concorrentemente o mesmo objeto. Como os processos, as threads também possibilitam a multitarefa preemptiva sob a supervisão do agendador do SO. Uma thread consome menos recursos que um processo para realizar a mesma tarefa.
- Corrotina
-
Uma função que pode suspender sua própria execução e continuar depois. Em Python, corrotinas clássicas são criadas a partir de funções geradoras, e corrotinas nativas são definidas com
async def
. A [classic_coroutines_sec] introduziu o conceito, e [async_ch] trata do uso de corrotinas nativas. As corrotinas do Python normalmente rodam dentro de uma única thread, sob a supervisão de um loop de eventos, também na mesma thread. Frameworks de programação assíncrona como asyncio, Curio, ou Trio fornecem um loop de eventos e bibliotecas de apoio para E/S não-bloqueante baseado em corrotinas. Corrotinas permitem multitarefa cooperativa: cada corrotina deve ceder explicitamente o controle com as palavras-chaveyield
ouawait
, para que outra possa continuar de forma concorrente (mas não em paralelo). Isso significa que qualquer código bloqueante em uma corrotina bloqueia a execução do loop de eventos e de todas as outras corrotinas—ao contrário da multitarefa preemptiva suportada por processos e threads. Por outro lado, cada corrotina consome menos recursos para executar o mesmo trabalho de uma thread ou processo. - Fila (queue)
-
Uma estrutura de dados que nos permite adicionar e retirar itens, normalmente na ordem FIFO: o primeiro que entra é o primeiro que sai.[4] Filas permitem que unidades de execução separadas troquem dados da aplicação e mensagens de controle, tais como códigos de erro e sinais de término. A implementação de uma fila varia de acordo com o modelo de concorrência subjacente: o pacote
queue
na biblioteca padrão do Python fornece classes de fila para suportar threads, já os pacotesmultiprocessing
easyncio
implementam suas próprias classes de fila. Os pacotesqueue
easyncio
também incluem filas não FIFO:LifoQueue
ePriorityQueue
. - Trava (lock)
-
Um objeto que as unidades de execução podem usar para sincronizar suas ações e evitar corrupção de dados. Ao atualizar uma estrutura de dados compartilhada, o código em execução deve manter uma trava associada a tal estrutura. Isso sinaliza a outras partes do programa que elas devem aguardar até que a trava seja liberada, antes de acessar a mesma estrutura de dados. O tipo mais simples de trava é conhecida também como mutex (de mutual exclusion, exclusão mútua). A implementação de uma trava depende do modelo de concorrência subjacente.
- Contenda (contention)
-
Disputa por um recurso limitado. Contenda por recursos ocorre quando múltiplas unidades de execução tentam acessar um recurso compartilhado — tal como uma trava ou o armazenamento. Há também contenda pela CPU, quando processos ou threads de computação intensiva precisam aguardar até que o agendador do SO dê a eles uma quota do tempo da CPU.
Agora vamos usar um pouco desse jargão para entender o suporte à concorrência no Python.
Veja como os conceitos que acabamos de tratar se aplicam ao Python, em dez pontos:
-
Cada instância do interpretador Python é um processo. Você pode iniciar processos Python adicionais usando as bibliotecas multiprocessing ou concurrent.futures. A biblioteca subprocess do Python foi projetada para rodar programas externos, independente das linguagens usadas para escrever tais programas.
-
O interpretador Python usa uma única thread para rodar o programa do usuário e o coletor de lixo da memória. Você pode iniciar threads Python adicionais usando as bibliotecas threading ou concurrent.futures.
-
O acesso à contagem de referências a objetos e outros estados internos do interpretador é controlado por uma trava, a Global Interpreter Lock (GIL) ou Trava Global do Interpretador. A qualquer dado momento, apenas uma thread do Python pode reter a trava. Isso significa que apenas uma thread pode executar código Python a cada momento, independente do número de núcleos da CPU.
-
Para evitar que uma thread do Python segure a GIL indefinidamente, o interpretador de bytecode do Python pausa a thread Python corrente a cada 5ms por default,[5] liberando a GIL. A thread pode então tentar readquirir a GIL, mas se existirem outras threads esperando, o agendador do SO pode escolher uma delas para continuar.
-
Quando escrevemos código Python, não temos controle sobre a GIL. Mas uma função embutida ou uma extensão escrita em C—ou qualquer linguagem que trabalhe no nível da API Python/C—pode liberar a GIL enquanto estiver rodando alguma tarefa longa.
-
Toda função na biblioteca padrão do Python que executa uma syscall[6] libera a GIL. Isso inclui todas as funções que executam operações de escrita e leitura em disco, escrita e leitura na rede, e
time.sleep()
. Muitas funções de uso intensivo da CPU nas bibliotecas NumPy/SciPy, bem como as funções de compressão e descompressão dos móduloszlib
andbz2
, também liberam a GIL.[7] -
Extensões que se integram no nível da API Python/C também podem iniciar outras threads não-Python, que não são afetadas pela GIL. Essas threads fora do controle da GIL normalmente não podem modificar objetos Python, mas podem ler e escrever na memória usada por objetos que suportam o buffer protocol (EN), como
bytearray
,array.array
, e arrays do NumPy. -
O efeito da GIL sobre a programação de redes com threads Python é relativamente pequeno, porque as funções de E/S liberam a GIL, e ler e escrever na rede sempre implica em alta latência—comparado a ler e escrever na memória. Consequentemente, cada thread individual já passa muito tempo esperando mesmo, então sua execução pode ser intercalada sem maiores impactos no desempenho geral. Por isso David Beazley diz: "As threads do Python são ótimas em fazer nada."[8]
-
As contendas pela GIL desaceleram as threads Python de processamento intensivo. Código sequencial de uma única thread é mais simples e mais rápido para esse tipo de tarefa.
-
Para rodar código Python de uso intensivo da CPU em múltiplos núcleos, você tem que usar múltiplos processos Python.
Aqui está um bom resumo, da documentação do módulo threading
:[9]
Detalhe de implementação do CPython: Em CPython, devido à Trava Global do Interpretador, apenas uma thread pode executar código Python de cada vez (mas certas bibliotecas orientadas ao desempenho podem superar essa limitação). Se você quer que sua aplicação faça melhor uso dos recursos computacionais de máquinas com CPUs de múltiplos núcleos, aconselha-se usar
multiprocessing
ouconcurrent.futures.ProcessPoolExecutor
.Entretanto, threads ainda são o modelo adequado se você deseja rodar múltiplas tarefas ligadas a E/S simultaneamente.
O parágrafo anterior começa com "Detalhe de implementação do CPython" porque a GIL não é parte da definição da linguagem Python. As implementações Jython e o IronPython não tem uma GIL. Infelizmente, ambas estão ficando para trás, ainda compatíveis apenas com Python 2.7 e 3.4, respectivamente. O interpretador de alto desempenho PyPy também tem uma GIL em suas versões 2.7, 3.8 e 3.9 (a mais recente em março de 2021).
Note
|
Essa seção não mencionou corrotinas, pois por default elas compartilham a mesma thread Python entre si e com o loop de eventos supervisor fornecido por um framework assíncrono. Assim, a GIL não as afeta. É possível usar múltiplas threads em um programa assíncrono, mas a melhor prática é ter uma thread rodando o loop de eventos e todas as corrotinas, enquanto as threads adicionais executam tarefas específicas. Isso será explicado na [delegating_to_executors_sec]. |
Mas chega de conceitos por agora. Vamos ver algum código.
Durante uma discussão sobre threads e sobre como evitar a GIL, o contribuidor do Python Michele Simionato postou um exemplo que é praticamente um "Olá Mundo" concorrente: o programa mais simples possível mostrando como o Python pode "mascar chiclete e subir a escada ao mesmo tempo".
O programa de Simionato usa multiprocessing
,
mas eu o adaptei para apresentar também threading
e asyncio
.
Vamos começar com a versão threading
, que pode parecer familiar se você já estudou threads em Java ou C.
A ideia dos próximos exemplos é simples: iniciar uma função que pausa por 3 segundos enquanto anima caracteres no terminal, para deixar o usuário saber que o programa está "pensando" e não congelado.
O script cria uma animação giratória e mostra em sequência cada caractere da string "\|/-"
na mesma posição da tela.[10] Quando a computação lenta termina, a linha com a animação é apagada e o resultado é apresentado: Answer: 42
.
Figura 1 mostra a saída de duas versões do exemplo: primeiro com threads, depois com corrotinas. Se você estiver longe do computador, imagine que o \
na última linha está girando.
Vamos revisar o script spinner_thread.py primeiro. O Exemplo 1 lista as duas primeiras funções no script, e o Exemplo 2 mostra o restante.
spin
e slow
link:code/19-concurrency/spinner_thread.py[role=include]
-
Essa função vai rodar em uma thread separada. O argumento
done
é uma instância dethreading.Event
, um objeto simples para sincronizar threads. -
Isso é um loop infinito, porque
itertools.cycle
produz um caractere por vez, circulando pela string para sempre. -
O truque para animação em modo texto: mova o cursor de volta para o início da linha com o caractere de controle ASCII de retorno (
'\r'
). -
O método
Event.wait(timeout=None)
retornaTrue
quando o evento é acionado por outra thread; se otimeout
passou, ele retornaFalse
. O tempo de 0,1s estabelece a "velocidade" da animação para 10 FPS. Se você quiser que uma animação mais rápida, use um tempo menor aqui. -
Sai do loop infinito.
-
Sobrescreve a linha de status com espaços para limpá-la e move o cursor de volta para o início.
-
slow()
será chamada pela thread principal. Imagine que isso é uma chamada de API lenta, através da rede. Chamarsleep
bloqueia a thread principal, mas a GIL é liberada e a thread da animação pode continuar.
Tip
|
O primeiro detalhe importante deste exemplo é que |
As funções spin
e slow
serão executadas de forma concorrente.
A thread principal—a única thread quando o programa é iniciado—vai iniciar uma nova thread para rodar spin
e então chamará slow
.
Propositalmente, não há qualquer API para terminar uma thread em Python.
É preciso enviar uma mensagem para encerrar uma thread.
A classe threading.Event
é o mecanismo de sinalização mais simples do Python para coordenar threads.
Uma instância de Event
tem uma flag booleana interna que começa como False
.
Uma chamada a Event.set()
muda a flag para True
.
Enquanto a flag for falsa, se uma thread chamar Event.wait()
, ela será bloqueada até que outra thread chame Event.set()
, quando então Event.wait()
retorna True
.
Se um tempo de espera (timeout) em segundos é passado para Event.wait(s)
, essa chamada retorna False
quando aquele tempo tiver passado, ou retorna True
assim que Event.set()
é chamado por outra thread.
A função supervisor
, que aparece no Exemplo 2, usa um Event
para sinalizar para a função spin
que ela deve encerrar.
supervisor
e main
link:code/19-concurrency/spinner_thread.py[role=include]
-
supervisor
irá retornar o resultado deslow
. -
A instância de
threading.Event
é a chave para coordenar as atividades das threadsmain
espinner
, como explicado abaixo. -
Para criar uma nova
Thread
, forneça uma função como argumento palavra-chavetarget
, e argumentos posicionais para atarget
como uma tupla passada viaargs
. -
Mostra o objeto
spinner
. A saída é<Thread(Thread-1, initial)>
, ondeinitial
é o estado da thread—significando aqui que ela ainda não foi iniciada. -
Inicia a thread
spinner
. -
Chama
slow
, que bloqueia a thread principal. Enquanto isso, a thread secundária está rodando a animação. -
Muda a flag de
Event
paraTrue
; isso vai encerrar o loopfor
dentro da funçãospin
. -
Espera até que a thread
spinner
termine. -
Roda a função
supervisor
. Escrevimain
esupervisor
como funções separadas para deixar esse exemplo mais parecido com a versãoasyncio
no Exemplo 4.
Quando a thread main
aciona o evento done
, a thread spinner
acabará notando e encerrando corretamente.
Agora vamos ver um exemplo similar usando o pacote multiprocessing
.
O pacote multiprocessing
permite executar tarefas concorrentes em processos Python separados em vez de threads.
Quando você cria uma instância de multiprocessing.Process
, todo um novo interpretador Python é iniciado como um processo filho, em segundo plano.
Como cada processo Python tem sua própria GIL, isto permite que seu programa use todos os núcleos de CPU disponíveis—mas isso depende, em última instância, do agendador do sistema operacional.
Veremos os efeitos práticos em Um pool de processos caseiro, mas para esse programa simples não faz grande diferença.
O objetivo dessa seção é apresentar o multiprocessing
e mostrar como sua API emula a API de threading
, tornando fácil converter programas simples de threads para processos, como mostra o spinner_proc.py (Exemplo 3).
link:code/19-concurrency/spinner_proc.py[role=include]
# [snip] the rest of spin and slow functions are unchanged from spinner_thread.py
link:code/19-concurrency/spinner_proc.py[role=include]
# [snip] main function is unchanged as well
-
A API básica de
multiprocessing
imita a API dethreading
, mas as dicas de tipo e o Mypy mostram essa diferença:multiprocessing.Event
é uma função (e não uma classe comothreading.Event
) que retorna uma instância desynchronize.Event
… -
…nos obrigando a importar
multiprocessing.synchronize
… -
…para escrever essa dica de tipo.
-
O uso básico da classe
Process
é similar ao da classeThread
. -
O objeto
spinner
aparece como <Process name='Process-1' parent=14868 initial>`, onde14868
é o ID do processo da instância de Python que está executando o spinner_proc.py.
As APIs básicas de threading
e multiprocessing
são similares,
mas sua implementação é muito diferente, e multiprocessing
tem uma API muito maior, para dar conta da complexidade adicional da programação multiprocessos.
Por exemplo, um dos desafios ao converter um programa de threads para processos é a comunicação entre processos, que estão isolados pelo sistema operacional e não podem compartilhar objetos Python.
Isso significa que objetos cruzando fronteiras entre processos tem que ser serializados e deserializados, criando custos adicionais.
No Exemplo 3, o único dado que cruza a fronteira entre os processos é o estado de Event
, que é implementado com um semáforo de baixo nível do SO, no código em C sob o módulo multiprocessing
.[11]
Tip
|
Desde o Python 3.8, há o pacote |
Agora vamos ver como o mesmo comportamento pode ser obtido com corrotinas em vez de threads ou processos.
Note
|
O [async_ch] é inteiramente dedicado à programação assíncrona com corrotinas. Essa seção é apenas um introdução rápida, para contrastar essa abordagem com as threads e os processos. Assim, vamos ignorar muitos detalhes. |
Alocar tempo da CPU para a execução de threads e processos é trabalho dos agendadores do SO. As corrotinas, por outro lado, são controladas por um loop de evento no nível da aplicação, que gerencia uma fila de corrotinas pendentes, as executa uma por vez, monitora eventos disparados por operações de E/S iniciadas pelas corrotinas, e passa o controle de volta para a corrotina correspondente quando cada evento acontece. O loop de eventos e as corrotinas da biblioteca e as corrotinas do usuário todas rodam em uma única thread. Assim, o tempo gasto em uma corrotina desacelera loop de eventos—e de todas as outras corrotinas.
A versão com corrotinas do programa de animação é mais fácil de entender se começarmos por uma função main
, e depois olharmos a supervisor
.
É isso que o Exemplo 4 mostra.
main
e a corrotina supervisor
link:code/19-concurrency/spinner_async.py[role=include]
-
main
é a única função regular definida nesse programa—as outras são corrotinas. -
A função`asyncio.run` inicia o loop de eventos para controlar a corrotina que irá em algum momento colocar as outras corrotinas em movimento. A função
main
ficará bloqueada até quesupervisor
retorne. O valor de retorno desupervisor
será o valor de retorno deasyncio.run
. -
Corrotinas nativas são definidas com
async def
. -
asyncio.create_task
agenda a execução futura despin
, retornando imediatamente uma instância deasyncio.Task
. -
O
repr
do objetospinner
se parece com<Task pending name='Task-2' coro=<spin() running at /path/to/spinner_async.py:11>>
. -
A palavra-chave
await
chamaslow
, bloqueandosupervisor
até queslow
retorne. O valor de retorno deslow
será atribuído aresult
. -
O método
Task.cancel
lança uma exceçãoCancelledError
dentro da corrotina, como veremos no Exemplo 5.
O Exemplo 4 demonstra as três principais formas de rodar uma corrotina:
asyncio.run(coro())
-
É chamado a partir de uma função regular, para controlar o objeto corrotina, que é normalmente o ponto de entrada para todo o código assíncrono no programa, como a
supervisor
nesse exemplo. Esta chamada bloqueia a função até quecoro
retorne. O valor de retorno da chamada arun()
é o que quer quecoro
retorne. asyncio.create_task(coro())
-
É chamado de uma corrotina para agendar a execução futura de outra corrotina. Essa chamada não suspende a corrotina atual. Ela retorna uma instância de
Task
, um objeto que contém o objeto corrotina e fornece métodos para controlar e consultar seu estado. await coro()
-
É chamado de uma corrotina para transferir o controle para o objeto corrotina retornado por
coro()
. Isso suspende a corrotina atual até quecoro
retorne. O valor da expressãoawait
será é o que quer quecoro
retorne.
Note
|
Lembre-se: invocar uma corrotina como |
Vamos estudar agora as corrotinas spin
e slow
no Exemplo 5.
spin
e slow
link:code/19-concurrency/spinner_async.py[role=include]
-
Não precisamos do argumento
Event
, que era usado para sinalizar queslow
havia terminado de rodar no spinner_thread.py (Exemplo 1). -
Use
await asyncio.sleep(.1)
em vez detime.sleep(.1)
, para pausar sem bloquear outras corrotinas. Veja o experimento após o exemplo. -
asyncio.CancelledError
é lançada quando o métodocancel
é chamado naTask
que controla essa corrotina. É hora de sair do loop. -
A corrotina
slow
também usaawait asyncio.sleep
em vez detime.sleep
.
Aqui está um experimento que recomendo para entender como spinner_async.py funciona. Importe o módulo time
, daí vá até a corrotina slow
e substitua a linha await asyncio.sleep(3)
por uma chamada a time.sleep(3)
, como no Exemplo 6.
await asyncio.sleep(3)
por time.sleep(3)
async def slow() -> int:
time.sleep(3)
return 42
Assistir o comportamento é mais memorável que ler sobre ele. Vai lá, eu espero.
Quando você roda o experimento, você vê isso:
-
O objeto
spinner
aparece:<Task pending name='Task-2' coro=<spin() running at …/spinner_async.py:12>>
. -
A animação nunca aparece. O programa trava por 3 segundos.
-
Answer: 42
aparece e o programa termina.
Para entender o que está acontecendo, lembre-se que o código Python que está usando asyncio
tem apenas um fluxo de execução,
a menos que você inicie explicitamente threads ou processos adicionais.
Isso significa que apenas uma corrotina é executada a qualquer dado momento.
A concorrência é obtida controlando a passagem de uma corrotina a outra.
No Exemplo 7, vamos nos concentrar no que ocorre nas corrotinas supervisor
e slow
durante o experimento proposto.
supervisor
e slow
link:code/19-concurrency/spinner_async_experiment.py[role=include]
-
A tarefa
spinner
é criada para, no futuro, controlar a execução despin
. -
O display mostra que
Task
está "pending"(em espera). -
A expressão
await
transfere o controle para a corrotinaslow
. -
time.sleep(3)
bloqueia tudo por 3 segundos; nada pode acontecer no programa, porque a thread principal está bloqueada—e ela é a única thread. O sistema operacional vai seguir com outras atividades. Após 3 segundos,sleep
desbloqueia, eslow
retorna. -
Logo após
slow
retornar, a tarefaspinner
é cancelada. O fluxo de controle jamais chegou ao corpo da corrotinaspin
.
O spinner_async_experiment.py ensina uma lição importante, como explicado no box abaixo.
Warning
|
Nunca use |
Ao discutir concorrência com corrotinas,
é importante mencionar o pacote greenlet,
que já existe há muitos anos e é muito usado.[12]
O pacote suporta multitarefa cooperativa através de corrotinas leves—chamadas greenlets—que não exigem qualquer sintaxe especial tal como yield
ou await
,
e assim são mais fáceis de integrar a bases de código sequencial existentes.
O SQL Alchemy 1.4 ORM usa greenlets
internamente para implementar sua nova
API assíncrona compatível com asyncio.
A biblioteca de programação de redes
gevent modifica, através de monkey patches, o módulo socket
padrão do Python, tornando-o não-bloqueante, substituindo parte do código daquele módulo por greenlets.
Na maior parte dos casos, gevent é transparente para o código em seu entorno, tornando mais fácil adaptar aplicações e bibliotecas sequenciais—tal como drivers de bancos de dados—para executar E/S de rede de forma concorrente.
Inúmeros projetos open source
usam gevent, incluindo o muito usado
Gunicorn—mencionado em Servidores de aplicação WSGI.
O número de linhas de spinner_thread.py e spinner_async.py é quase o mesmo. As funções supervisor
são o núcleo desses exemplos. Vamos compará-las mais detalhadamente. O Exemplo 8 mostra apenas a supervisor
do Exemplo 2.
supervisor
com threadsdef supervisor() -> int:
done = Event()
spinner = Thread(target=spin,
args=('thinking!', done))
print('spinner object:', spinner)
spinner.start()
result = slow()
done.set()
spinner.join()
return result
supervisor
async def supervisor() -> int:
spinner = asyncio.create_task(spin('thinking!'))
print('spinner object:', spinner)
result = await slow()
spinner.cancel()
return result
Aqui está um resumo das diferenças e semelhanças notáveis entre as duas implementações de supervisor
:
-
Uma
asyncio.Task
é aproximadamente equivalente athreading.Thread
. -
Uma
Task
aciona um objeto corrotina, e umaThread
invoca um callable. -
Uma corrotina passa o controle explicitamente com a palavra-chave
await
-
Você não instancia objetos
Task
diretamente, eles são obtidos passando uma corrotina paraasyncio.create_task(…)
. -
Quando
asyncio.create_task(…)
retorna um objetoTask
, ele já esta agendado para rodar, mas uma instância deThread
precisa ser iniciada explicitamente através de uma chamada a seu métodostart
. -
Na
supervisor
da versão com threads,slow
é uma função comum e é invocada diretamente pela thread principal. Na versão assíncrona dasupervisor
,slow
é uma corrotina guiada porawait
. -
Não há API para terminar uma thread externamente; em vez disso, é preciso enviar um sinal—como acionar o
done
no objetoEvent
. Para objetosTask
, há o método de instânciaTask.cancel()
, que dispara umCancelledError
na expressãoawait
na qual o corpo da corrotina está suspensa naquele momento. -
A corrotina
supervisor
deve ser iniciada comasyncio.run
na funçãomain
.
Essa comparação ajuda a entender como a concorrência é orquestrada com asyncio,
em contraste com como isso é feito com o módulo Threading
, possivelmente mais familiar ao leitor.
Um último ponto relativo a threads versus corrotinas: quem já escreveu qualquer programa não-trivial com threads sabe quão desafiador é estruturar o programa, porque o agendador pode interromper uma thread a qualquer momento. É preciso lembrar de manter travas para proteger seções críticas do programa, para evitar ser interrompido no meio de uma operação de muitas etapas—algo que poderia deixar dados em um estado inválido.
Com corrotinas, seu código está protegido de interrupções arbitrárias.
É preciso chamar await
explicitamente para deixar o resto do programa rodar.
Em vez de manter travas para sincronizar as operações de múltiplas threads,
corrotinas são "sincronizadas" por definição:
apenas uma delas está rodando em qualquer momento.
Para entregar o controle, você usa await
para passar o controle de volta ao agendador.
Por isso é possível cancelar uma corrotina de forma segura:
por definição, uma corrotina só pode ser cancelada quando está suspensa em uma expressão await
, então é possível realizar qualquer limpeza necessária capturando a exceção CancelledError
.
A chamada time.sleep()
bloqueia mas não faz nada. Vamos agora experimentar com uma chamada de uso intensivo da CPU, para entender melhor a GIL, bem como o efeito de funções de processamento intensivo sobre código assíncrono.
Na versão com threads(Exemplo 1),
você pode substituir a chamada time.sleep(3)
na função slow
por um requisição de cliente HTTP de sua biblioteca favorita, e a animação continuará girando.
Isso acontece porque uma biblioteca de programação para rede bem desenhada liberará a GIL enquanto estiver esperando uma resposta.
Você também pode substituir a expressão asyncio.sleep(3)
na corrotina slow
para que await
espere pela resposta de uma biblioteca bem desenhada de acesso assíncrono à rede, pois tais bibliotecas fornecem corrotinas que devolvem o controle para o loop de eventos enquanto esperam por uma resposta da rede.
Enquanto isso, a animação seguirá girando.
Com código de uso intensivo da CPU, a história é outra.
Considere a função is_prime
no Exemplo 10,
que retorna True
se o argumento for um número primo, False
se não for.
ProcessPoolExecutor
na documentação do Pythonlink:code/19-concurrency/primes/primes.py[role=include]
A chamada is_prime(5_000_111_000_222_021)
leva cerca de 3.3s no laptop da empresa que estou usando agora.[13]
Dado o que vimos até aqui, pare um instante para pensar sobre a seguinte questão, de três partes. Uma das partes da resposta é um pouco mais complicada (pelo menos para mim foi).
O quê aconteceria à animação se fossem feitas as seguintes modificações, presumindo que
n = 5_000_111_000_222_021
—aquele mesmo número primo que minha máquina levou 3,3s para checar:
Em spinner_proc.py, substitua
time.sleep(3)
com uma chamada ais_prime(n)
?Em spinner_thread.py, substitua
time.sleep(3)
com uma chamada ais_prime(n)
?Em spinner_async.py, substitua
await asyncio.sleep(3)
com uma chamada ais_prime(n)
?
Antes de executar o código ou continuar lendo, recomendo chegar as respostas por você mesmo. Depois, copie e modifique os exemplos spinner_*.py como sugerido.
Agora as respostas, da mais fácil para a mais difícil.
A animação é controlada por um processo filho, então continua girando enquanto o teste de números primos é computado no processo raiz.[14]
A animação é controlada por uma thread secundária, então continua girando enquanto o teste de número primo é computado na thread principal.
Não acertei essa resposta inicialmente: Esperava que a animação congelasse, porque superestimei o impacto da GIL.
Nesse exemplo em particular, a animação segue girando porque o Python suspende a thread em execução a cada 5ms (por default), tornando a GIL disponível para outras threads pendentes.
Assim, a thread principal executando is_prime
é interrompida a cada 5ms, permitindo à thread secundária acordar e executar uma vez o loop for
, até chamar o método wait
do evento done
, quando então ela liberará a GIL.
A thread principal então pegará a GIL, e o cálculo de is_prime
continuará por mais 5 ms.
Isso não tem um impacto visível no tempo de execução deste exemplo específico, porque a função spin
rapidamente realiza uma iteração e libera a GIL, enquanto espera pelo evento done
, então não há muita disputa pela GIL.
A thread principal executando is_prime
terá a GIL na maior parte do tempo.
Conseguimos nos safar usando threads para uma tarefa de processamento intensivo nesse experimento simples porque só temos duas threads: uma ocupando a CPU, e a outra acordando apenas 10 vezes por segundo para atualizar a animação.
Mas se você tiver duas ou mais threads disputando por mais tempo da CPU, seu programa será mais lento que um programa sequencial.
Se você chamar is_prime(5_000_111_000_222_021)
na corrotina slow
do exemplo spinner_async.py,
a animação nunca vai aparecer.
O efeito seria o mesmo que vimos no Exemplo 6,
quando substituímos await asyncio.sleep(3)
por time.sleep(3)
:
nenhuma animação.
O fluxo de controle vai passar da supervisor
para slow
, e então para is_prime
.
Quando is_prime
retornar, slow
vai retornar também, e supervisor
retomará a execução, cancelando a tarefa spinner
antes dela ser executada sequer uma vez.
O programa parecerá congelado por aproximadamente 3s, e então mostrará a resposta.
Uma maneira de manter a animação funcionando é reescrever is_prime
como uma corrotina,
e periodicamente chamar asyncio.sleep(0)
em uma expressão await
, para passar o controle de volta para o loop de eventos, como no Exemplo 11.
is_prime
agora é uma corrotinalink:code/19-concurrency/primes/spinner_prime_async_nap.py[role=include]
-
Vai dormir a cada 50.000 iterações (porque o argumento
step
emrange
é 2).
O Issue #284 (EN) no repositório do asyncio
tem uma discussão informativa sobre o uso de asyncio.sleep(0)
.
Entretanto, observe que isso vai tornar is_prime
mais lento, e—mais importante—vai também tornar o loop de eventos e o seu programa inteiro mais lentos.
Quando eu usei await asyncio.sleep(0)
a cada 100.000 iterações, a animação foi suave mas o programa rodou por 4,9s na minha máquina, quase 50% a mais que a função primes.is_prime
rodando sozinha com o mesmo argumento (5_000_111_000_222_021
).
Usar await asyncio.sleep(0)
deve ser considerada uma medida paliativa até o código assíncrono ser refatorado para delegar computações de uso intensivo da CPU para outro processo.
Veremos uma forma de fazer isso com o asyncio.loop.run_in_executor
, abordado no [async_ch]. Outra opção seria uma fila de tarefas, que vamos discutir brevemente na Filas de tarefas distribuídas.
Até aqui experimentamos com uma única chamada para uma função de uso intensivo de CPU. A próxima seção apresenta a execução concorrente de múltiplas chamadas de uso intensivo da CPU.
Note
|
Escrevi
essa seção para mostrar o uso de múltiplos processos em cenários de uso intensivo de CPU,
e o padrão comum de usar filas para distribuir tarefas e coletar resultados.
O [futures_ch] apresenta uma forma mais simples de distribuir tarefas para processos:
um |
Nessa seção vamos escrever programas para verificar se os números dentro de uma amostra de 20 inteiros são primos. Os números variam de 2 até 9.999.999.999.999.999—isto é, 1016 – 1, ou mais de 253. A amostra inclui números primos pequenos e grandes, bem como números compostos com fatores primos grandes e pequenos.
O programa sequential.py fornece a linha base de desempenho. Aqui está o resultado de uma execução de teste:
$ python3 sequential.py
2 P 0.000001s
142702110479723 P 0.568328s
299593572317531 P 0.796773s
3333333333333301 P 2.648625s
3333333333333333 0.000007s
3333335652092209 2.672323s
4444444444444423 P 3.052667s
4444444444444444 0.000001s
4444444488888889 3.061083s
5555553133149889 3.451833s
5555555555555503 P 3.556867s
5555555555555555 0.000007s
6666666666666666 0.000001s
6666666666666719 P 3.781064s
6666667141414921 3.778166s
7777777536340681 4.120069s
7777777777777753 P 4.141530s
7777777777777777 0.000007s
9999999999999917 P 4.678164s
9999999999999999 0.000007s
Total time: 40.31
Os resultados aparecem em três colunas:
-
O número a ser verificado.
-
P
se é um número primo, caso contrária, vazia. -
Tempo decorrido para verificar se aquele número específico é primo.
Neste exemplo, o tempo total é aproximadamente a soma do tempo de cada verificação, mas está computado separadamente, como se vê no Exemplo 12.
link:code/19-concurrency/primes/sequential.py[role=include]
-
A função
check
(na próxima chamada) retorna uma tuplaResult
com o valor booleano da chamada ais_prime
e o tempo decorrido. -
check(n)
chamais_prime(n)
e calcula o tempo decorrido para retornar umResult
. -
Para cada número na amostra, chamamos
check
e apresentamos o resultado. -
Calcula e mostra o tempo total decorrido.
O próximo exemplo, procs.py, mostra o uso de múltiplos processos para distribuir a verificação de números primos por muitos núcleos da CPU. Esses são os tempos obtidos com procs.py:
$ python3 procs.py
Checking 20 numbers with 12 processes:
2 P 0.000002s
3333333333333333 0.000021s
4444444444444444 0.000002s
5555555555555555 0.000018s
6666666666666666 0.000002s
142702110479723 P 1.350982s
7777777777777777 0.000009s
299593572317531 P 1.981411s
9999999999999999 0.000008s
3333333333333301 P 6.328173s
3333335652092209 6.419249s
4444444488888889 7.051267s
4444444444444423 P 7.122004s
5555553133149889 7.412735s
5555555555555503 P 7.603327s
6666666666666719 P 7.934670s
6666667141414921 8.017599s
7777777536340681 8.339623s
7777777777777753 P 8.388859s
9999999999999917 P 8.117313s
20 checks in 9.58s
A última linha dos resultados mostra que procs.py foi 4,2 vezes mais rápido que sequential.py.
Observe que o tempo decorrido na primeira coluna é o tempo para verificar aquele número específico. Por exemplo, is_prime(7777777777777753)
demorou quase 8,4s para retornar True
. Enquanto isso, outros processos estavam verificando outros números em paralelo.
Há 20 números para serem verificados. Escrevi procs.py para iniciar um número de processos de trabalho igual ao número de núcleos na CPU, como determinado por multiprocessing.cpu_count()
.
O tempo total neste caso é muito menor que a soma dos tempos decorridos para cada verificação individual. Há algum tempo gasto em iniciar processos e na comunicação entre processos, então o resultado final é que a versão multiprocessos é apenas cerca de 4,2 vezes mais rápida que a sequencial. Isso é bom, mas um pouco desapontador, considerando que o código inicia 12 processos, para usar todos os núcleos desse laptop.
Note
|
A função |
Quando delegamos processamento para threads e processos, nosso código não chama a função de trabalho diretamente, então não conseguimos simplesmente retornar um resultado. Em vez disso, a função de trabalho é guiada pela biblioteca de threads ou processos, e por fim produz um resultado que precisa ser armazenado em algum lugar. Coordenar threads ou processos de trabalho e coletar resultados são usos comuns de filas em programação concorrente—e também em sistemas distribuídos.
Muito do código novo em procs.py se refere a configurar e usar filas. O início do arquivo está no Exemplo 13.
Warning
|
|
link:code/19-concurrency/primes/procs.py[role=include]
-
Na tentativa de emular
threading
,multiprocessing
fornecemultiprocessing.SimpleQueue
, mas esse é um método vinculado a uma instância pré-definida de uma classe de nível mais baixo,BaseContext
. Temos que chamar essaSimpleQueue
para criar uma fila. Por outro lado, não podemos usá-la em dicas de tipo. -
multiprocessing.queues
contém a classeSimpleQueue
que precisamos para dicas de tipo. -
PrimeResult
inclui o número verificado. Mantern
junto com os outros campos do resultado simplifica a exibição mais tarde. -
Isso é um apelido de tipo para uma
SimpleQueue
que a funçãomain
(Exemplo 14) vai usar para enviar os números para os processos que farão a verificação. -
Apelido de tipo para uma segunda
SimpleQueue
que vai coletar os resultados emmain
. Os valores na fila serão tuplas contendo o número a ser testado e uma tuplaResult
. -
Isso é similar a sequential.py.
-
worker
recebe uma fila com os números a serem verificados, e outra para colocar os resultados. -
Nesse código, usei o número
0
como uma pílula venenosa: um sinal para que o processo encerre. Sen
não é0
, continue com o loop.[15] -
Invoca a verificação de número primo e coloca o
PrimeResult
na fila. -
Devolve um
PrimeResult(0, False, 0.0)
, para informar ao loop principal que esse processo terminou seu trabalho. -
procs
é o número de processos que executarão a verificação de números primos em paralelo. -
Coloca na fila
jobs
os números a serem verificados. -
Cria um processo filho para cada
worker
. Cada um desses processos executará o loop dentro de sua própria instância da funçãoworker
, até encontrar um0
na filajobs
. -
Inicia cada processo filho.
-
Coloca um
0
na fila de cada processo, para encerrá-los.
A
função worker
no Exemplo 13 segue um modelo comum em programação concorrente:
percorrer indefinidamente um loop, pegando itens em um fila e processando cada um deles com uma função que realiza o trabalho real.
O loop termina quando a fila produz um valor sentinela.
Nesse modelo, a sentinela que encerra o processo é muitas vezes chamada de "pílula venenosa.
None
é bastante usado como valor sentinela, mas pode não ser adequado se existir a possibilidade dele aparecer entre os dados.
Chamar object()
é uma maneira comum de obter um valor único para usar como sentinela.
Entretanto, isso não funciona entre processos, pois os objetos Python precisam ser serializados para comunicação entre processos.
Quando você pickle.dump
e pickle.load
uma instância de object
, a instância recuperada em pickle.load
é diferentes da original: elas não serão iguais se comparadas.
Uma boa alternativa a None
é o objeto embutido Ellipsis
(também conhecido como …
),
que sobrevive à serialização sem perder sua identidade.[16]
A biblioteca padrão do Python usa muitos valores diferentes (EN) como sentinelas. A PEP 661—Sentinel Values (EN) propõe um tipo sentinela padrão. Em março de 2023, é apenas um rascunho.
Agora vamos estudar a função main
de procs.py no Exemplo 14.
main
link:code/19-concurrency/primes/procs.py[role=include]
-
Se nenhum argumento é dado na linha de comando, define o número de processos como o número de núcleos na CPU; caso contrário, cria quantos processos forem passados no primeiro argumento.
-
jobs
eresults
são as filas descritas no Exemplo 13. -
Inicia
proc
processos para consumirjobs
e informarresults
. -
Mostra quantos números foram verificados e o tempo total decorrido.
-
Os argumentos são o número de
procs
e a fila para armazenar os resultados. -
Percorre o loop até que todos os processos terminem.
-
Obtém um
PrimeResult
. Chamar.get()
em uma fila deixa o processamento bloqueado até que haja um item na fila. Também é possível fazer isso de forma não-bloqueante ou estabelecer um timeout. Veja os detalhes na documentação deSimpleQueue.get
. -
Se
n
é zero, então um processo terminou; incrementa o contadorprocs_done
. -
Senão, incrementa o contador
checked
(para acompanhar os números verificados) e mostra os resultados.
Os resultados não vão retornar na mesma ordem em que as tarefas foram submetidas. Por isso for necessário incluir n
em cada tupla PrimeResult
.
De outra forma eu não teria como saber que resultado corresponde a cada número.
Se o processo principal terminar antes que todos os subprocessos finalizem,
podem surgir relatórios de rastreamento (tracebacks) confusos, com referências a exceções de FileNotFoundError
causados por uma trava interna em multiprocessing
.
Depurar código concorrente é sempre difícil, e depurar código baseado no multiprocessing
é ainda mais difícil devido a toda a complexidade por trás da fachada emulando threads.
Felizmente, o ProcessPoolExecutor
que veremos no [futures_ch] é mais fácil de usar e mais robusto.
Note
|
Agradeço ao leitor Michael Albert, que notou que o código que publiquei durante o pré-lançamento tinha uma "condição de corrida" (race condition) no Exemplo 14. Uma condição de corrida (ou de concorrência) é um bug que pode ou não aparecer, dependendo da ordem das ações realizadas pelas unidades de execução concorrentes. Se "A" acontecer antes de "B", tudo segue normal; mas se "B" acontecer antes, surge um erro. Essa é a corrida. Se você estiver curiosa, esse diff mostra o bug e sua correção:
example-code-2e/commit/2c123057—mas note que depois eu refatorei o exemplo para delegar partes de |
Você poderia tentar rodar procs.py, passando argumentos que modifiquem o número de processos filho. Por exemplo, este comando…
$ python3 procs.py 2
…vai iniciar dois subprocessos, produzindo os resultados quase duas vezes mais rápido que sequential.py—se a sua máquina tiver uma CPU com pelo menos dois núcleos e não estiver ocupada rodando outros programas.
Rodei procs.py 12 vezes, usando de 1 a 20 subprocessos, totalizando 240 execuções. Então calculei a mediana do tempo para todas as execuções com o mesmo número de subprocessos, e desenhei a Figura 2.
Neste laptop de 6 núcleos, o menor tempo mediano ocorreu com 6 processos:10.39s—marcado pela linha pontilhada na Figura 2. Seria de se esperar que o tempo de execução aumentasse após 6 processos, devido à disputa pela CPU, e ele atingiu um máximo local de 12.51s, com 10 processes. Eu não esperava e não sei explicar porque o desempenho melhorou com 11 processos e permaneceu praticamente igual com 13 a 20 processos, com tempos medianos apenas ligeiramente maiores que o menor tempo mediano com 6 processos.
Também escrevi threads.py, uma versão de procs.py usando threading
em vez de multiprocessing
. O código é muito similar quando convertemos exemplo simples entre as duas APIs.[17] Devido à GIL e à natureza de processamento intensivo de is_prime
, a versão com threads é mais lenta que a versão sequencial do Exemplo 12, e fica mais lenta conforme aumenta o número de threads, por causa da disputa pela CPU e o custo da mudança de contexto. Para passar de uma thread para outra, o SO precisa salvar os registradores da CPU e atualizar o contador de programas e o ponteiro do stack, disparando efeitos colaterais custosos,
como invalidar os caches da CPU e talvez até trocar páginas de memória.
[18]
Os dois próximos capítulos tratam de mais temas ligados à programação concorrente em Python, usando a biblioteca de alto nível concurrent.futures para gerenciar threads e processos ([futures_ch]) e a biblioteca asyncio para programação assíncrona ([async_ch]).
As demais seções nesse capítulo procuram responder à questão:
Dadas as limitações discutidas até aqui, como é possível que o Python seja tão bem-sucedido em um mundo de CPUs com múltiplos núcleos?
Considere a seguinte passagem, do artigo muito citado "The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software" (O Almoço Grátis Acabou: Uma Virada Fundamental do Software em Direção à Concorrência) (EN) de Herb Sutter:
Os mais importantes fabricantes e arquiteturas de processadores, da Intel e da AMD até a Sparc e o PowerPC, esgotaram o potencial da maioria das abordagens tradicionais de aumento do desempenho das CPUs. Ao invés de elevar a frequência do clock [dos processadores] e a taxa de transferência das instruções encadeadas a níveis cada vez maiores, eles estão se voltando em massa para o hyper-threading (hiperprocessamento) e para arquiteturas multi-núcleo. Março de 2005. [Disponível online].
O que Sutter chama de "almoço grátis" era a tendência do software ficar mais rápido sem qualquer esforço adicional por parte dos desenvolvedores, porque as CPUs estavam executando código sequencial cada vez mais rápido, ano após ano. Desde 2004 isso não é mais verdade: a frequência dos clocks das CPUs e as otimizações de execução atingiram um platô, e agora qualquer melhoria significativa no desempenho precisa vir do aproveitamento de múltiplos núcleos ou do hyperthreading, avanços que só beneficiam código escrito para execução concorrente.
A história do Python começa no início dos anos 1990, quando as CPUs ainda estavam ficando exponencialmente mais rápidas na execução de código sequencial. Naquele tempo não se falava de CPUs com múltiplos núcleos, exceto para supercomputadores. Assim, a decisão de ter uma GIL era óbvia. A GIL torna o interpretador rodando em um único núcleo mais rápido, e simplifica sua implementação.[19] A GIL também torna mais fácil escrever extensões simples com a API Python/C.
Note
|
Escrevi "extensões simples" porque uma extensão não é obrigada a lidar com a GIL. Uma função escrita em C ou Fortran pode ser centenas de vezes mais rápida que sua equivalente em Python.[20] Assim, a complexidade adicional de liberar a GIL para tirar proveito de CPUs multi-núcleo pode, em muitos casos, não ser necessária. Então podemos agradecer à GIL por muitas das extensões disponíveis em Python—e isso é certamente uma das razões fundamentais da popularidade da linguagem hoje. |
Apesar da GIL, o Python está cada vez mais popular entre aplicações que exigem execução concorrente ou paralela, graças a bibliotecas e arquiteturas de software que contornam as limitações do CPython.
Agora vamos discutir como o Python é usado em administração de sistemas, ciência de dados, e desenvolvimento de aplicações para servidores no mundo do processamento distribuído e dos multi-núcleos de 2023.
O Python é largamente utilizado para gerenciar grandes frotas de servidores, roteadores, balanceadores de carga e armazenamento conectado à rede (network-attached storage ou NAS). Ele é também a opção preferencial para redes definidas por software (SND, software-defined networking) e hacking ético. Os maiores provedores de serviços na nuvem suportam Python através de bibliotecas e tutoriais de sua própria autoria ou da autoria de suas grande comunidades de usuários da linguagem.
Nesse campo, scripts Python automatizam tarefas de configuração, emitindo comandos a serem executados pelas máquinas remotas, então raramente há operações limitadas pela CPU.
Threads ou corrotinas são bastante adequadas para tais atividades.
Em particular, o pacote concurrent.futures
, que veremos no [futures_ch], pode ser usado para realizar as mesmas operações em muitas máquinas remotas ao mesmo tempo, sem grande complexidade.
Além da biblioteca padrão, há muito projetos populares baseados em Python para gerenciar clusters (agrupamentos) de servidores: ferramentas como o Ansible (EN) e o Salt (EN), bem como bibliotecas como a Fabric (EN).
Há também um número crescente de bibliotecas para administração de sistemas que suportam corrotinas e asyncio
.
Em 2016, a equipe de Engenharia de Produção (EN) do Facebook relatou:
"Estamos cada vez mais confiantes no AsyncIO, introduzido no Python 3.4,
e vendo ganhos de desempenho imensos conforme migramos as bases de código do Python 2."
A ciência de dados—incluindo a inteligência artificial—e a computação científica estão muito bem servidas pelo Python.
Aplicações nesses campos são de processamento intensivo, mas os usuários de Python se beneficiam de um vasto ecossistema de bibliotecas de computação numérica, escritas em C, C++, Fortran, Cython, etc.—muitas das quais capazes de aproveitar os benefícios de máquinas multi-núcleo, GPUs, e/ou computação paralela distribuída em clusters heterogêneos.
Em 2021, o ecossistema de ciência de dados de Python já incluía algumas ferramentas impressionantes:
- Project Jupyter
-
Duas interfaces para navegadores—Jupyter Notebook e JupyterLab—que permitem aos usuários rodar e documentar código analítico, potencialmente sendo executado através da rede em máquinas remotas. Ambas são aplicações híbridas Python/Javascript, suportando kernels de processamento escritos em diferentes linguagens, todos integrados via ZeroMQ—uma biblioteca de comunicação por mensagens assíncrona para aplicações distribuídas. O nome Jupyter, inclusive remete a Julia, Python, e R, as três primeiras linguagens suportadas pelo Notebook. O rico ecossistema construído sobre as ferramentas Jupyter incluí o Bokeh, uma poderosa biblioteca de visualização iterativa que permite aos usuários navegarem e interagirem com grandes conjuntos de dados ou um fluxo de dados continuamente atualizado, graças ao desempenho dos navegadores modernos e seus interpretadores JavaScript.
- TensorFlow e PyTorch
-
Estes são os principais frameworks de aprendizagem profunda (deep learning), de acordo com o relatório de Janeiro de 2021 da O’Reilly’s (EN) medido pela utilização em 2020. Os dois projetos são escritos em C++, e conseguem se beneficiar de múltiplos núcleos, GPUs e clusters. Eles também suportam outras linguagens, mas o Python é seu maior foco e é usado pela maioria de seus usuários. O TensorFlow foi criado e é usado internamente pelo Google; O Pythorch pelo Facebook.
- Dask
-
Uma biblioteca de computação paralela que consegue delegar para processos locais ou um cluster de máquinas, "testado em alguns dos maiores supercomputadores do mundo"—como seu site (EN) afirma. O Dask oferece APIs que emulam muito bem o NumPy, o pandas, e o scikit-learn—hoje as mais populares bibliotecas em ciência de dados e aprendizagem de máquina. O Dask pode ser usado a partir do JupyterLab ou do Jupyter Notebook, e usa o Bokeh não apenas para visualização de dados mas também para um quadro interativo mostrando o fluxo de dados e o processamento entre processos/máquinas quase em tempo real. O Dask é tão impressionante que recomento assistir um vídeo tal como esse, 15-minute demo, onde Matthew Rocklin—um mantenedor do projeto—mostra o Dask mastigando dados em 64 núcleos distribuídos por 8 máquinas EC2 na AWS.
Estes são apenas alguns exemplos para ilustrar como a comunidade de ciência de dados está criando soluções que extraem o melhor do Python e superam as limitações do runtime do CPython.
O Python é largamente utilizado em aplicações Web e em APIs de apoio a aplicações para computação móvel no servidor. Como o Google, o YouTube, o Dropbox, o Instagram, o Quora, e o Reddit—entre outros—conseguiram desenvolver aplicações de servidor em Python que atendem centenas de milhões de usuários 24X7? Novamente a resposta vai bem além do que o Python fornece "de fábrica". Antes de discutir as ferramentas necessárias para usar o Python larga escala, preciso citar uma advertência da Technology Radar da Thoughtworks:
Inveja de alto desempenho/inveja de escala da web
Vemos muitas equipes se metendo em apuros por escolher ferramentas, frameworks ou arquiteturas complexas, porque eles "talvez precisem de escalabilidade". Empresas como o Twitter e a Netflix precisam aguentar cargas extremas, então precisam dessas arquiteturas, mas elas também tem equipes de desenvolvimento extremamente habilitadas, capazes de lidar com a complexidade. A maioria das situações não exige essas façanhas de engenharia; as equipes devem manter sua inveja da escalabilidade na web sob controle, e preferir soluções simples que ainda assim fazem o que precisa ser feito.[21]
Na escala da web, a chave é uma arquitetura que permita escalabilidade horizontal. Neste cenário, todos os sistemas são sistemas distribuídos, e possivelmente nenhuma linguagem de programação será a única alternativa ideal para todas as partes da solução.
Sistemas distribuídos são um campo da pesquisa acadêmica, mas felizmente alguns profissionais da área escreveram livros acessíveis, baseados em pesquisas sólidas e experiência prática. Um deles é Martin Kleppmann, o autor de Designing Data-Intensive Applications (Projetando Aplicações de Uso Intensivo de Dados) (O’Reilly).
Observe a Figura 3, o primeiro de muitos diagramas de arquitetura no livro de Kleppmann. Aqui há alguns componentes que vi em muitos ambientes Python onde trabalhei ou que conheci pessoalmente:
-
Caches de aplicação:[22] memcached, Redis, Varnish
-
bancos de dados relacionais: PostgreSQL, MySQL
-
Bancos de documentos: Apache CouchDB, MongoDB
-
Full-text indexes (índices de texto integral): Elasticsearch, Apache Solr
-
Enfileiradores de mensagens: RabbitMQ, Redis
Há outros produtos de código aberto extremamente robustos em cada uma dessas categorias. Os grandes fornecedores de serviços na nuvem também oferecem suas próprias alternativas proprietárias
O diagrama de Kleppmann é genérico e independente da linguagem—como seu livro. Para aplicações de servidor em Python, dois componentes específicos são comumente utilizados:
-
Um servidor de aplicação, para distribuir a carga entre várias instâncias da aplicação Python. O servidor de aplicação apareceria perto do topo na Figura 3, processando as requisições dos clientes antes delas chegaram ao código da aplicação.
-
Uma fila de tarefas construída em torno da fila de mensagens no lado direito da Figura 3, oferecendo uma API de alto nível e mais fácil de usar, para distribuir tarefas para processos rodando em outras máquinas.
As duas próximas seções exploram esses componentes, recomendados pelas boas práticas de implementações de aplicações Python de servidor.
O WSGI— Web Server Gateway Interface (Interface de Gateway de Servidores Web)—é a API padrão para uma aplicação ou um framework Python receber requisições de um servidor HTTP e enviar para ele as respostas.[24] Servidores de aplicação WSGI gerenciam um ou mais processos rodando a sua aplicação, maximizando o uso das CPUs disponíveis.
A Figura 4 ilustra uma instalação WSGI típica.
Tip
|
Os servidores de aplicação mais conhecidos em projeto web com Python são:
Para usuários do servidor HTTP Apache, mod_wsgi é a melhor opção.
Ele é tão antigo com a própria WSGI, mas tem manutenção ativa, e agora pode ser iniciado via linha de comando com o mod_wsgi-express
, que o torna mais fácil de configurar e mais apropriado para uso com containers Docker.
O uWSGI e o Gunicorn são as escolhas mais populares entre os projetos recentes que conheço. Ambos são frequentemente combinados com o servidor HTTP NGINX. uWSGI oferece muita funcionalidade adicional, incluindo um cache de aplicação, uma fila de tarefas, tarefas periódicas estilo cron, e muitas outras. Por outro lado, o uWSGI é muito mais difícil de configurar corretamente que o Gunicorn.[26]
Lançado em 2018, o NGINX Unit é um novo produto dos desenvolvedores do conhecido servidor HTTP e proxy reverso NGINX.
O mod_wsgi e o Gunicorn só suportam apps web Python, enquanto o uWSGI e o NGINX Unit funcionam também com outras linguagens. Para saber mais, consulte a documentação de cada um deles.
O ponto principal: todos esses servidores de aplicação podem, potencialmente, utilizar todos os núcleos de CPU no servidor, criando múltiplos processos Python para executar apps web tradicionais escritas no bom e velho código sequencial em Django, Flask, Pyramid, etc.
Isso explica porque tem sido possível ganhar a vida como desenvolvedor Python sem nunca ter estudado os módulos threading
, multiprocessing
, ou asyncio
:
o servidor de aplicação lida de forma transparente com a concorrência.
(Interface Assíncrona de Ponto de Entrada de Servidor)
Note
|
A WSGI é uma API síncrona. Ela não suporta corrotinas com |
Agora vamos examinar outra forma de evitar a GIL para obter um melhor desempenho em aplicações Python de servidor.
Quando o servidor de aplicação entrega uma requisição a um dos processos Python rodando seu código, sua aplicação precisa responder rápido: você quer que o processo esteja disponível para processar a requisição seguinte assim que possível. Entretanto, algumas requisições exigem ações que podem demorar—por exemplo, enviar um email ou gerar um PDF. As filas de tarefas distribuídas foram projetadas para resolver este problema.
A Celery e a RQ são as mais conhecidas filas de tarefas Open Source com uma API para o Python. Provedores de serviços na nuvem também oferecem suas filas de tarefas proprietárias.
Esses produtos encapsulam filas de mensagens e oferecem uma API de alto nível para delegar tarefas a processos executores, possivelmente rodando em máquinas diferentes.
Note
|
No contexto de filas de tarefas, as palavras produtor e consumidor são usado no lugar da terminologia tradicional de cliente/servidor. Por exemplo, para gerar documentos, um processador de views do Django produz requisições de serviço, que são colocadas em uma fila para serem consumidas por um ou mais processos renderizadores de PDFs. |
Citando diretamente o FAQ do Celery, eis alguns casos de uso:
Executar algo em segundo plano. Por exemplo, para encerrar uma requisição web o mais rápido possível, e então atualizar a página do usuário de forma incremental. Isso dá ao usuário a impressão de um bom desempenho e de "vivacidade", ainda que o trabalho real possa na verdade demorar um pouco mais.
Executar algo após a requisição web ter terminado.
Se assegurar que algo seja feito, através de uma execução assíncrona e usando tentativas repetidas.
Agendar tarefas periódicas.
Além de resolver esses problemas imediatos, as filas de tarefas suportam escalabilidade horizontal. Produtores e consumidores são desacoplados: um produtor não precisa chamar um consumidor, ele coloca uma requisição em uma fila. Consumidores não precisam saber nada sobre os produtores (mas a requisição pode incluir informações sobre o produtor, se uma confirmação for necessária). Pode-se adicionar mais unidades de execução para consumir tarefas a medida que a demanda cresce. Por isso o Celery e o RQ são chamados de filas de tarefas distribuídas.
Lembre-se que nosso simples procs.py (Exemplo 13) usava duas filas: uma para requisições de tarefas, outra para coletar resultados. A arquitetura distribuída do Celery e do RQ usa um esquema similar. Ambos suportam o uso do banco de dados NoSQL Redis para armazenar as filas de mensagens e resultados. O Celery também suporta outras filas de mensagens, como o RabbitMQ ou o Amazon SQS, bem como outros bancos de dados para armazenamento de resultados.
Isso encerra nossa introdução à concorrência em Python.
Os dois próximos capítulos continuam nesse tema, se concentrando nos pacotes concurrent.futures
e asyncio
packages da biblioteca padrão.
Após um pouco de teoria, esse capítulo apresentou scripts da animação giratória, implementados em cada um dos três modelos de programação de concorrência nativos do Python:
-
Threads, com o pacote
threading
-
Processo, com
multiprocessing
-
Corrotinas assíncronas com
asyncio
Então exploramos o impacto real da GIL com um experimento:
mudar os exemplos de animação para computar se um inteiro grande era primo e observar o comportamento resultante.
Isso demonstrou graficamente que funções de uso intensivo da CPU devem ser evitadas em asyncio
, pois elas bloqueiam o loop de eventos.
A versão com threads do experimento funcionou—apesar da GIL—porque o Python periodicamente interrompe as threads, e o exemplo usou apenas duas threads:
uma fazendo um trabalho de computação intensiva, a outra controlando a animação apenas 10 vezes por segundo.
A variante com multiprocessing
contornou a GIL, iniciando um novo processo só para a animação, enquanto o processo principal calculava se o número era primo.
O exemplo seguinte, computando diversos números primos, destacou a diferença entre multiprocessing
e threading
,
provando que apenas processos permitem ao Python se beneficiar de CPUs com múltiplo núcleos.
A GIL do Python torna as threads piores que o código sequencial para processamento pesado.
A GIL domina as discussões sobre computação concorrente e paralela em Python, mas não devemos superestimar seu impacto. Este foi o tema da Python no mundo multi-núcleo.. Por exemplo, a GIL não afeta muitos dos casos de uso de Python em administração de sistemas. Por outro lado, as comunidades de ciência de dados e de desenvolvimento para servidores evitaram os problemas com a GIL usando soluções robustas, criadas sob medida para suas necessidades específicas. As últimas duas seções mencionaram os dois elementos comuns que sustentam o uso de Python em aplicações de servidor escaláveis: servidores de aplicação WSGI e filas de tarefas distribuídas.
Este capítulo tem uma extensa lista de referências, então a dividi em subseções.
A biblioteca concurrent.futures, tratada no [futures_ch], usa threads, processos, travas e filas debaixo dos panos, mas você não vai ver as instâncias individuais desses elementos;
eles são encapsulados e gerenciados por abstrações de um nível mais alto: ThreadPoolExecutor
ou ProcessPoolExecutor
.
Para aprender mais sobre a prática da programação concorrente com aqueles objetos de baixo nível,
"An Intro to Threading in Python" (Uma Introdução [à Programação com] Threads no Python) de Jim Anderson é uma boa primeira leitura.
Doug Hellmann tem um capítulo chamado "Concurrency with Processes, Threads, and Coroutines" (Concorrência com Processos, Threads, e Corrotinas)
em seus site e livro,
The Python 3 Standard Library by Example
(Addison-Wesley).
Effective Python, 2nd ed. (Addison-Wesley), de Brett Slatkin,
Python Essential Reference, 4th ed. (Addison-Wesley), de David Beazley, e Python in a Nutshell, 3rd ed. (O’Reilly) de Martelli et al são outras referências gerais de Python com uma cobertura significativa de threading
e multiprocessing
.
A vasta documentação oficial de multiprocessing
inclui conselhos úteis em sua seção
"Programming guidelines" (Diretrizes de programação) (EN).
Jesse Noller e Richard Oudkerk contribuíram para o pacote multiprocessing
, introduzido na PEP 371—Addition of the multiprocessing package to the standard library (EN). A documentação oficial do pacote é um arquivo de 93 KB .rst—são cerca de 63 páginas—tornando-o um dos capítulos mais longos da biblioteca padrão do Python.
Em High Performance Python, 2nd ed., (O’Reilly), os autores Micha Gorelick e Ian Ozsvald incluem um capítulo sobre multiprocessing
com um exemplo sobre verificação de números primos usando uma estratégia diferente do nosso exemplo procs.py. Para cada número, eles dividem a faixa de fatores possíveis-de 2 a sqrt(n)
—em subfaixas, e fazem cada unidade de execução iterar sobre uma das subfaixas.
Sua abordagem de dividir para conquistar é típica de aplicações de computação científica, onde os conjuntos de dados são enormes, e as estações de trabalho (ou clusters) tem mais núcleos de CPU que usuários.
Em um sistema servidor, processando requisições de muitos usuários, é mais simples e mais eficiente deixar cada processo realizar uma tarefa computacional do início ao fim—reduzindo a sobrecarga de comunicação e coordenação entre processos.
Além de multiprocessing
, Gorelick e Ozsvald apresentam muitas outras formas de desenvolver e implantar aplicações de ciência de dados de alto desempenho, aproveitando múltiplos núcleos de CPU, GPUs, clusters, analisadores e compiladores como CYthon e Numba. Seu capítulo final, "Lessons from the Field," (Lições da Vida Real) é uma valiosa coleção de estudos de caso curtos, contribuição de outros praticantes de computação de alto desempenho em Python.
O Advanced Python Development, de Matthew Wilkes (Apress),
é um dos raros livros a incluir pequenos exemplos para explicar conceitos,
mostrando ao mesmo tempo como desenvolver uma aplicação realista pronta para implantação em produção:
um agregador de dados, similar aos sistemas de monitoramento DevOps ou aos coletores de dados para sensores distribuídos IoT.
Dois capítulos no Advanced Python Development tratam de programação concorrente com threading
e asyncio
.
O Parallel Programming with Python (Packt, 2014), de Jan Palach, explica os principais conceitos por trás da concorrência e do paralelismo, abarcando a biblioteca padrão do Python bem como o Celery.
"The Truth About Threads" (A Verdade Sobre as Threads) é o título do capítulo 2 de Using Asyncio in Python, de Caleb Hattingh (O’Reilly).[27] O capítulo trata dos benefícios e das desvantagens das threads—com citações convincentes de várias fontes abalizadas—deixando claro que os desafios fundamentais das threads não tem relação com o Python ou a GIL. Citando literalmente a página 14 de Using Asyncio in Python:
Esses temas se repetem com frequência:
Programação com threads torna o código difícil de analisar.
Programação com threads é um modelo ineficiente para concorrência em larga escala (milhares de tarefas concorrentes).
Se você quiser aprender do jeito difícil como é complicado raciocinar sobre threads e travas—sem colocar seu emprego em risco—tente resolver os problemas no livro de Allen Downey The Little Book of Semaphores (Green Tea Press). O livro inclui exercícios muito difíceis e até sem solução conhecida, mas mesmo os fáceis são desafiadores.
Se você ficou curioso sobre a GIL, lembre-se que não temos qualquer controle sobre ela a partir do código em Python, então a referência canônica é a documentação da C-API:
Thread State and the Global Interpreter Lock (EN) (O Estado das Threads e a Trava Global do Interpretador).
A resposta no FAQ Python Library and Extension (A Biblioteca e as Extensões do Python):
"Can’t we get rid of the Global Interpreter Lock?" (Não podemos remover o Bloqueio Global do interpretador?).
Também vale a pena ler os posts de Guido van Rossum e Jesse Noller (contribuidor do pacote multiprocessing
), respectivamente:
"It isn’t Easy to Remove the GIL" (Não é Fácil Remover a GIL) e
"Python Threads and the Global Interpreter Lock" (As Threads do Python e a Trava Global do Interpretador).
CPython Internals, de Anthony Shaw (Real Python) explica a implementação do interpretador CPython 3 no nível da programação em C. O capítulo mais longo do livro é "Parallelism and Concurrency" (Paralelismo e Concorrência): um mergulho profundo no suporte nativo do Python a threads e processos, incluindo o gerenciamento da GIL por extensões usando a API C/Python.
Por fim, David Beazley apresentou uma exploração detalhada em "Understanding the Python GIL" (Entendendo a GIL do Python).[28] No slide 54 da apresentação, Beazley relata um aumento no tempo de processamento de uma benchmark específica com o novo algoritmo da GIL, introduzido no Python 3.2. O problema não tem importância com cargas de trabalho reais, de acordo com um comentário de Antoine Pitrou—que implementou o novo algoritmo da GIL—no relatório de bug submetido por Beazley: Python issue #7946.
O Python Fluente se concentra nos recursos fundamentais da linguagem e nas partes centrais da biblioteca padrão. Full Stack Python é um ótimo complemento para esse livro: é sobre o ecossistema do Python, com seções chamadas "Development Environments (Ambientes de Desenvolvimento)," "Data (Dados)," "Web Development (Desenvolvimento Web)," e "DevOps," entre outros.
Já mencionei dois livros que abordam a concorrência usando a biblioteca padrão do Python e também incluem conteúdo significativo sobre bibliotecas de terceiros e ferramentas:
High Performance Python, 2nd ed. e Parallel Programming with Python. O Distributed Computing with Python de Francesco Pierfederici (Packt) cobre a biblioteca padrão e também provedores de infraestrutura de nuvem e clusters HPC (High-Performance Computing, computação de alto desempenho).
O "Python, Performance, and GPUs" (EN) de Matthew Rocklin é uma atualização do status do uso de aceleradores GPU com Python, publicado em junho de 2019.
"O Instagram hoje representa a maior instalação do mundo do framework web Django, que é escrito inteiramente em Python." Essa é a linha de abertura do post "Web Service Efficiency at Instagram with Python" (EN), escrito por Min Ni—um engenheiro de software no Instagram. O post descreve as métricas e ferramentas usadas pelo Instagram para otimizar a eficiência de sua base de código Python, bem como para detectar e diagnosticar regressões de desempenho a cada uma das "30 a 50 vezes diárias" que o back-end é atualizado.
Architecture Patterns with Python: Enabling Test-Driven Development, Domain-Driven Design, and Event-Driven Microservices, de Harry Percival e Bob Gregory (O’Reilly) apresenta modelos de arquitetura para aplicações de servidor em Python. Os autores disponibilizaram o livro gratuitamente online em cosmicpython.com (EN).
Duas bibliotecas elegantes e fáceis de usar para tarefas de paralelização de processos são a lelo de João S. O. Bueno e a python-parallelize de Nat Pryce.
O pacote lelo define um decorador @parallel
que você pode aplicar a qualquer função para torná-la magicamente não-bloqueante:
quando você chama uma função decorada, sua execução é iniciada em outro processo.
O pacote python-parallelize de Nat Pryce fornece um gerador parallelize
, que distribui a execução de um loop for
por múltiplas CPUs.
Ambos os pacotes são baseados na biblioteca multiprocessing.
Eric Snow, um dos desenvolvedores oficiais do Python, mantém um wiki chamado Multicore Python, com observações sobre os esforços dele e de outros para melhorar o suporte do Python a execução em paralelo. Snow é o autor da PEP 554—Multiple Interpreters in the Stdlib. Se aprovada e implementada, a PEP 554 assenta as bases para melhorias futuras, que podem um dia permitir que o Python use múltiplos núcleos sem as sobrecargas do multiprocessing. Um dos grandes empecilhos é a iteração complexa entre múltiplos subinterpretadores ativos e extensões que assumem a existência de um único interpretador.
Mark Shannon—também um mantenedor do Python—criou uma tabela útil comparando os modelos de concorrência em Python, referida em uma discussão sobre subinterpretadores entre ele, Eric Snow e outros desenvolvedores na lista de discussão python-dev. Na tabela de Shannon, a coluna "Ideal CSP" se refere ao modelo teórico de notação _Communicating Sequential Processes (processos sequenciais comunicantes) (EN), proposto por Tony Hoare em 1978. Go também permite objetos compartilhados, violando uma das restrições essenciais do CSP: as unidades de execução devem se comunicar somente através de mensagens enviadas através de canais.
O Stackless Python (também conhecido como Stackless) é um fork do CPython que implementa microthreads, que são threads leves no nível da aplicação—ao contrário das threads do SO. O jogo online multijogador massivo EVE Online foi desenvolvido com Stackless, e os engenheiros da desenvolvedora de jogos CCP foram mantenedores do Stackless por algum tempo. Alguns recursos do Stackless foram reimplementados no interpretador Pypy e no pacote greenlet, a tecnologia central da biblioteca de programação em rede gevent, que por sua vez é a fundação do servidor de aplicação Gunicorn.
O modelo de atores (actor model) de programação concorrente está no centro das linguagens altamente escaláveis Erlang e Elixir, e é também o modelo do framework Akka para Scala e Java. Se você quiser experimentar o modelo de atores em Python, veja as bibliotecas Thespian e Pykka.
Minhas recomendações restantes fazem pouca ou nenhuma menção ao Python, mas de toda forma são relevantes para leitores interessados no tema do capítulo.
RabbitMQ in Action, de Alvaro Videla and Jason J. W. Williams (Manning), é uma introdução muito bem escrita ao RabbitMQ e ao padrão AMQP (Advanced Message Queuing Protocol, Protocolo Avançado de Enfileiramento de Mensagens), com exemplos em Python, PHP, e Ruby. Independente do resto de seu stack tecnológico, e mesmo se você planeja usar Celery com RabbitMQ debaixo dos panos, recomendo esse livro por sua abordagem dos conceitos, da motivação e dos modelos das filas de mensagem distribuídas, bem como a operação e configuração do RabbitMQ em larga escala.
Aprendi muito lendo Seven Concurrency Models in Seven Weeks, de Paul Butcher (Pragmatic Bookshelf), que traz o eloquente subtítulo When Threads Unravel.[29] O capítulo 1 do livro apresenta os conceitos centrais e os desafios da programação com threads e travas em Java.[30] Os outros seis capítulos do livro são dedicados ao que o autor considera as melhores alternativas para programação concorrente e paralela, e como funcionam com diferentes linguagens, ferramentas e bibliotecas. Os exemplos usam Java, Clojure, Elixir, e C (no capítulo sobre programação paralela com o framework OpenCL). O modelo CSP é exemplificado com código Clojure, apesar da linguagem Go merecer os créditos pela popularização daquela abordagem. Elixir é a linguagem dos exemplos ilustrando o modelo de atores. Um capítulo bonus alternativo (disponível online gratuitamente) sobre atores usa Scala e o framework Akka. A menos que você já saiba Scala, Elixir é uma linguagem mais acessível para aprender e experimentar o modelo de atores e plataforma de sistemas distribuídos Erlang/OTP.
Unmesh Joshi, da Thoughtworks contribuiu com várias páginas documentando os "Modelos de Sistemas Distribuídos" no blog de Martin Fowler. A página de abertura é uma ótima introdução ao assunto, com links para modelos individuais. Joshi está acrescentando modelos gradualmente, mas o que já está publicado espelha anos de experiência adquirida a duras penas em sistema de missão crítica.
O Designing Data-Intensive Applications, de Martin Kleppmann (O’Reilly), é um dos raros livros escritos por um profissional com vasta experiência na área e conhecimento acadêmico avançado. O autor trabalhou com infraestrutura de dados em larga escala no LinkedIn e em duas startups, antes de se tornar um pesquisador de sistemas distribuídos na Universidade de Cambridge. Cada capítulo do livro termina com uma extensa lista de referências, incluindo resultados de pesquisas recentes. O livro também inclui vários diagramas esclarecedores e lindos mapas conceituais.
Tive a sorte de estar na audiência do fantástico workshop de Francesco Cesarini sobre a arquitetura de sistemas distribuídos confiáveis, na OSCON 2016: "Designing and architecting for scalability with Erlang/OTP" (Projetando e estruturando para a escalabilidade com Erlang/OTP) (video na O’Reilly Learning Platform). Apesar do título, aos 9:35 no video, Cesarini explica:
Muito pouco do que vou dizer será específico de Erlang […]. Resta o fato de que o Erlang remove muitas dificuldades acidentais no desenvolvimento de sistemas resilientes que nunca falham, além serem escalonáveis. Então será mais fácil se vocês usarem Erlang ou uma linguagem rodando na máquina virtual Erlang.
Aquele workshop foi baseado nos últimos quatro capítulos do Designing for Scalability with Erlang/OTP de Francesco Cesarini e Steve Vinoski (O’Reilly).
Desenvolver sistemas distribuídos é desafiador e empolgante, mas cuidado com a inveja da escalabilidade na web. O princípio KISS (KISS é a sigla de Keep It Simple, Stupid: "Mantenha Isso Simples, Idiota") continua sendo uma recomendação firme de engenharia.
Veja também o artigo "Scalability! But at what COST?", de Frank McSherry, Michael Isard, e Derek G. Murray. Os autores identificaram sistemas paralelos de processamento de grafos apresentados em simpósios acadêmicos que precisavam de centenas de núcleos para superar "uma implementação competente com uma única thread." Eles também encontraram sistemas que "tem desempenho pior que uma thread em todas as configurações reportadas."
Essas descobertas me lembram uma piada hacker clássica:
Meu script Perl é mais rápido que seu cluster Hadoop.
Para gerenciar a complexidade, precisamos de restrições
Aprendi a programar em uma calculadora TI-58. Sua "linguagem" era similar ao assembler. Naquele nível, todas as "variáveis" eram globais, e não havia o conforto dos comandos estruturados de controle de fluxo. Existiam saltos condicionais: instruções que transferiam a execução diretamente para uma localização arbitrária—à frente ou atrás do local atual—dependendo do valor de um registrador ou de uma flag na CPU.
É possível fazer basicamente qualquer coisa em assembler, e esse é o desafio: há muito poucas restrições para evitar que você cometa erros, e para ajudar mantenedores a entender o código quando mudanças são necessárias.
A segunda linguagem que aprendi foi o BASIC desestruturado que vinha nos computadores de 8 bits—nada comparável ao Visual Basic, que surgiu muito mais tarde.
Existiam os comandos FOR
, GOSUB
e RETURN
, mas ainda nenhum conceito de variáveis locais.
O GOSUB
não permitia passagem de parâmetros:
era apenas um GOTO
mais chique, que inseria um número de linha de retorno em uma pilha, daí o RETURN
tinha um local para onde pular de volta.
Subrrotinas podiam ler dados globais, e escrever sobre eles também. Era preciso improvisar outras formas de controle de fluxo, com combinações de IF
e GOTO
—que, lembremos, permita pular para qualquer linha do programa.
Após alguns anos programando com saltos e variáveis globais, lembro da batalha para reestruturar meu cérebro para a "programação estruturada", quando aprendi Pascal. Agora precisava usar comandos de controle de fluxo em torno de blocos de código que tinham um único ponto de entrada. Não podia mais saltar para qualquer instrução que desejasse. Variáveis globais eram inevitáveis em BASIC, mas agora se tornaram tabu. Eu precisava repensar o fluxo de dados e passar argumentos para funções explicitamente.
Meu próximo desafio foi aprender programação orientada a objetos. No fundo, programação orientada a objetos é programação estruturada com mais restrições e polimorfismo. O ocultamento de informações força uma nova perspectiva sobre onde os dados moram. Me lembro de mais de uma vez ficar frustrado por ter que refatorar meu código, para que um método que estava escrevendo pudesse obter informações que estavam encapsuladas em um objeto que aquele método não conseguia acessar.
Linguagens de programação funcionais acrescentam outras restrições, mas a imutabilidade é a mais difícil de engolir, após décadas de programação imperativa e orientada a objetos. Após nos acostumarmos a tais restrições, as vemos como bençãos. Elas fazem com que pensar sobre o código se torne muito mais simples.
A falta de restrições é o maior problema com o modelo de threads—e—travas de programação concorrente. Ao resumir o capítulo 1 de Seven Concurrency Models in Seven Weeks, Paul Butcher escreveu:
A maior fraqueza da abordagem, entretanto, é que programação com threads—e—travas é difícil. Pode ser fácil para um projetista de linguagens acrescentá-las a uma linguagem, mas elas nos dão, a nós pobres programadores, muito pouca ajuda.
Alguns exemplos de comportamento sem restrições naquele modelo:
-
Threads podem compartilhar estruturas de dados mutáveis arbitrárias.
-
O agendador pode interromper uma thread em quase qualquer ponto, incluindo no meio de uma operação simples, como
a += 1
. Muito poucas operações são atômicas no nível das expressões do código-fonte. -
Travas são, em geral, recomendações. Esse é um termo técnico, dizendo que você precisa lembrar de obter explicitamente uma trava antes de atualizar uma estrutura de dados compartilhada. Se você esquecer de obter a trava, nada impede seu código de bagunçar os dados enquanto outra thread, que obedientemente detém a trava, está atualizando os mesmos dados.
Em comparação, considere algumas restrições impostas pelo modelo de atores, no qual a unidade de execução é chamada de um actor ("ator"):[31]
-
Um ator pode manter um estado interno, mas não pode compartilhar esse estado com outros atores.
-
Atores só podem se comunicar enviando e recebendo mensagens.
-
Mensagens só contém cópias de dados, e não referências para dados mutáveis.
-
Um ator só processa uma mensagem de cada vez. Não há execução concorrente dentro de um único ator.
Claro, é possível adotar uma forma de programação ao estilo de ator para qualquer linguagem, seguindo essas regras. Você também pode usar idiomas de programação orientada a objetos em C, e mesmo modelos de programação estruturada em assembler. Mas fazer isso requer muita concordância e disciplina da parte de qualquer um que mexa no código.
Gerenciar travas é desnecessário no modelo de atores, como implementado em Erlang e Elixir, onde todos os tipos de dados são imutáveis.
Threads-e-travas não vão desaparecer. Eu só não acho que lidar com esse tipo de entidade básica seja um bom uso de meu tempo quando escrevo aplicações—e não módulos do kernel, drivers de hardware, ou bancos de dados.
Sempre me reservo o direito de mudar de opinião. Mas neste momento, estou convencido que o modelo de atores é o modelo de programação concorrente mais sensato que existe. CSP (Communicating Sequential Processes) também é sensato, mas sua implementação em Go deixa de fora algumas restrições. A ideia em CSP é que corrotinas (ou goroutines em Go) trocam dados e se sincronizam usando filas (chamadas channels, "canais", em Go). Mas Go também permite compartilhamento de memória e travas. Vi um livro sobre Go defende o uso de memória compartilhada e travas em vez de canais—em nome do desempenho. É difícil abandonar velhos hábitos.
sys.getswitchinterval()
para obter o intervalo; ele pode ser modificado com sys.setswitchinterval(s)
.
zlib
e bz2
são mencionados nominalmente em uma mensagem de Antoine Pitrou na python-dev (EN). Pitrou contribuiu para a lógica da divisão de tempo da GIL no Python 3.2.
"\|/-"
para simplificar os exemplos do livro.
asyncio.Semaphore
na [using_as_completed_sec] (no [async_ch]).
0
é uma sentinela conveniente. None
também é comumente usado para essa finalidade, mas usar 0
simplifica a dica de tipo para PrimeResult
e a implementação de worker
.
threading
e concurrent.futures
foram fortemente influenciadas pela biblioteca padrão do Java.