Skip to content

Latest commit

 

History

History
1508 lines (1173 loc) · 113 KB

cap19.adoc

File metadata and controls

1508 lines (1173 loc) · 113 KB

Modelos de concorrência em Python

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]

— Rob Pike
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."

Novidades nesse capítulo

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.

A visão geral

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.

Um pouco de jargão

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-chave yield ou await, 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 pacotes multiprocessing e asyncio implementam suas próprias classes de fila. Os pacotes queue e asyncio também incluem filas não FIFO: LifoQueue e PriorityQueue.

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.

Processos, threads, e a infame GIL do Python

Veja como os conceitos que acabamos de tratar se aplicam ao Python, em dez pontos:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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ódulos zlib and bz2, também liberam a GIL.[7]

  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.

  8. 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]

  9. 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.

  10. 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 ou concurrent.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.

Um "Olá mundo" concorrente

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.

Caracteres animados com threads

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.

Captura de tela do console mostrando a saída dos dois exemplos.
Figura 1. Os scripts spinner_thread.py e spinner_async.py produzem um resultado similar: o repr do objeto spinner e o texto "Answer: 42". Na captura de tela, spinner_async.py ainda está rodando, e a mensagem animada "/ thinking!" é apresentada; aquela linha será substituída por "Answer: 42" após 3 segundos.

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.

Exemplo 1. spinner_thread.py: as funções spin e slow
link:code/19-concurrency/spinner_thread.py[role=include]
  1. Essa função vai rodar em uma thread separada. O argumento done é uma instância de threading.Event, um objeto simples para sincronizar threads.

  2. Isso é um loop infinito, porque itertools.cycle produz um caractere por vez, circulando pela string para sempre.

  3. 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').

  4. O método Event.wait(timeout=None) retorna True quando o evento é acionado por outra thread; se o timeout passou, ele retorna False. 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.

  5. Sai do loop infinito.

  6. Sobrescreve a linha de status com espaços para limpá-la e move o cursor de volta para o início.

  7. slow() será chamada pela thread principal. Imagine que isso é uma chamada de API lenta, através da rede. Chamar sleep bloqueia a thread principal, mas a GIL é liberada e a thread da animação pode continuar.

Tip

O primeiro detalhe importante deste exemplo é que time.sleep() bloqueia a thread que a chama, mas libera a GIL, permitindo que outras threads Python rodem.

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.

Exemplo 2. spinner_thread.py: as funções supervisor e main
link:code/19-concurrency/spinner_thread.py[role=include]
  1. supervisor irá retornar o resultado de slow.

  2. A instância de threading.Event é a chave para coordenar as atividades das threads main e spinner, como explicado abaixo.

  3. Para criar uma nova Thread, forneça uma função como argumento palavra-chave target, e argumentos posicionais para a target como uma tupla passada via args.

  4. Mostra o objeto spinner. A saída é <Thread(Thread-1, initial)>, onde initial é o estado da thread—significando aqui que ela ainda não foi iniciada.

  5. Inicia a thread spinner.

  6. Chama slow, que bloqueia a thread principal. Enquanto isso, a thread secundária está rodando a animação.

  7. Muda a flag de Event para True; isso vai encerrar o loop for dentro da função spin.

  8. Espera até que a thread spinner termine.

  9. Roda a função supervisor. Escrevi main e supervisor como funções separadas para deixar esse exemplo mais parecido com a versão asyncio 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.

Animação com processos

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).

Exemplo 3. spinner_proc.py: apenas as partes modificadas são mostradas; todo o resto é idêntico a spinner_thread.py
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
  1. A API básica de multiprocessing imita a API de threading, mas as dicas de tipo e o Mypy mostram essa diferença: multiprocessing.Event é uma função (e não uma classe como threading.Event) que retorna uma instância de synchronize.Event…​

  2. …​nos obrigando a importar multiprocessing.synchronize…​

  3. …​para escrever essa dica de tipo.

  4. O uso básico da classe Process é similar ao da classe Thread.

  5. O objeto spinner aparece como <Process name='Process-1' parent=14868 initial>`, onde 14868 é 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 multiprocessing.shared_memory (memória compartilhada para acesso direto entre processos) na biblioteca padrão, mas ele não suporta instâncias de classes definidas pelo usuário. Além bytes nus, o pacote permite que processos compartilhem uma ShareableList, uma sequência mutável que pode manter um número fixo de itens dos tipos int, float, bool, e None, bem como str e bytes, até o limite de 10 MB por item. Veja a documentação de ShareableList para mais detalhes.

Agora vamos ver como o mesmo comportamento pode ser obtido com corrotinas em vez de threads ou processos.

Animação com corrotinas

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.

Exemplo 4. spinner_async.py: a função main e a corrotina supervisor
link:code/19-concurrency/spinner_async.py[role=include]
  1. main é a única função regular definida nesse programa—as outras são corrotinas.

  2. 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é que supervisor retorne. O valor de retorno de supervisor será o valor de retorno de asyncio.run.

  3. Corrotinas nativas são definidas com async def.

  4. asyncio.create_task agenda a execução futura de spin, retornando imediatamente uma instância de asyncio.Task.

  5. O repr do objeto spinner se parece com <Task pending name='Task-2' coro=<spin() running at /path/to/spinner_async.py:11>>.

  6. A palavra-chave await chama slow, bloqueando supervisor até que slow retorne. O valor de retorno de slow será atribuído a result.

  7. O método Task.cancel lança uma exceção CancelledError 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é que coro retorne. O valor de retorno da chamada a run() é o que quer que coro 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é que coro retorne. O valor da expressão await será é o que quer que coro retorne.

Note

Lembre-se: invocar uma corrotina como coro() retorna imediatamente um objeto corrotina, mas não executa o corpo da função coro. Acionar o corpo de corrotinas é a função do loop de eventos.

Vamos estudar agora as corrotinas spin e slow no Exemplo 5.

Exemplo 5. spinner_async.py: as corrotinas spin e slow
link:code/19-concurrency/spinner_async.py[role=include]
  1. Não precisamos do argumento Event, que era usado para sinalizar que slow havia terminado de rodar no spinner_thread.py (Exemplo 1).

  2. Use await asyncio.sleep(.1) em vez de time.sleep(.1), para pausar sem bloquear outras corrotinas. Veja o experimento após o exemplo.

  3. asyncio.CancelledError é lançada quando o método cancel é chamado na Task que controla essa corrotina. É hora de sair do loop.

  4. A corrotina slow também usa await asyncio.sleep em vez de time.sleep.

Experimento: Estragando a animação para sublinhar um ponto

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.

Exemplo 6. spinner_async.py: substituindo 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:

  1. O objeto spinner aparece: <Task pending name='Task-2' coro=<spin() running at …/spinner_async.py:12>>.

  2. A animação nunca aparece. O programa trava por 3 segundos.

  3. 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.

Exemplo 7. spinner_async_experiment.py: as corrotinas supervisor e slow
link:code/19-concurrency/spinner_async_experiment.py[role=include]
  1. A tarefa spinner é criada para, no futuro, controlar a execução de spin.

  2. O display mostra que Task está "pending"(em espera).

  3. A expressão await transfere o controle para a corrotina slow.

  4. 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, e slow retorna.

  5. Logo após slow retornar, a tarefa spinner é cancelada. O fluxo de controle jamais chegou ao corpo da corrotina spin.

O spinner_async_experiment.py ensina uma lição importante, como explicado no box abaixo.

Warning

Nunca use time.sleep(…) em corrotinas asyncio, a menos que você queira pausar o programa inteiro. Se uma corrotina precisa passar algum tempo sem fazer nada, ela deve await asyncio.sleep(DELAY). Isso devolve o controle para o loop de eventos de asyncio, que pode acionar outras corrotinas em espera.

Greenlet e gevent

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.

Supervisores lado a lado

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.

Exemplo 8. spinner_thread.py: a função supervisor com threads
def 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

Para comparar, o Exemplo 9 mostra a corrotina supervisor do Exemplo 4.

Exemplo 9. spinner_async.py: a corrotina assíncrona 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 a threading.Thread.

  • Uma Task aciona um objeto corrotina, e uma Thread 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 para asyncio.create_task(…).

  • Quando asyncio.create_task(…) retorna um objeto Task, ele já esta agendado para rodar, mas uma instância de Thread precisa ser iniciada explicitamente através de uma chamada a seu método start.

  • Na supervisor da versão com threads, slow é uma função comum e é invocada diretamente pela thread principal. Na versão assíncrona da supervisor, slow é uma corrotina guiada por await.

  • Não há API para terminar uma thread externamente; em vez disso, é preciso enviar um sinal—como acionar o done no objeto Event. Para objetos Task, há o método de instância Task.cancel(), que dispara um CancelledError na expressão await na qual o corpo da corrotina está suspensa naquele momento.

  • A corrotina supervisor deve ser iniciada com asyncio.run na função main.

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.

O real impacto da GIL

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.

Exemplo 10. primes.py: uma verificação de números primos fácil de entender, do exemplo em ProcessPool​Executor na documentação do Python
link: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]

Teste Rápido

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:

  1. Em spinner_proc.py, substitua time.sleep(3) com uma chamada a is_prime(n)?

  2. Em spinner_thread.py, substitua time.sleep(3) com uma chamada a is_prime(n)?

  3. Em spinner_async.py, substitua await asyncio.sleep(3) com uma chamada a is_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.

1. Resposta para multiprocessamento

A animação é controlada por um processo filho, então continua girando enquanto o teste de números primos é computado no processo raiz.[14]

2. Resposta para versão com threads

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.

3. Resposta para asyncio

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.

Soneca profunda com sleep(0)

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.

Exemplo 11. spinner_async_nap.py: is_prime agora é uma corrotina
link:code/19-concurrency/primes/spinner_prime_async_nap.py[role=include]
  1. Vai dormir a cada 50.000 iterações (porque o argumento step em range é 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.

Um pool de processos caseiro

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 ProcessPoolExecutor do pacote concurrent.futures, que internamente usa filas.

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.

Exemplo 12. sequential.py: verificação de números primos em um pequeno conjunto de dados
link:code/19-concurrency/primes/sequential.py[role=include]
  1. A função check (na próxima chamada) retorna uma tupla Result com o valor booleano da chamada a is_prime e o tempo decorrido.

  2. check(n) chama is_prime(n) e calcula o tempo decorrido para retornar um Result.

  3. Para cada número na amostra, chamamos check e apresentamos o resultado.

  4. Calcula e mostra o tempo total decorrido.

Solução baseada em processos

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.

Entendendo os tempos decorridos

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 multiprocessing.cpu_count() retorna 12 no MacBook Pro que estou usando para escrever esse capítulo. Ele é na verdade um i7 com uma CPU de 6 núcleos, mas o SO informa 12 CPUs devido ao hyperthreading, uma tecnologia da Intel que executa duas threads por núcleo. Entretanto, hyperthreading funciona melhor quando uma das threads não está trabalhando tão pesado quanto a outra thread no mesmo núcleo—talvez a primeira esteja parada, esperando por dados após uma perda de cache, e a outra está mastigando números. De qualquer forma, não há almoço grátis: este laptop tem o desempenho de uma máquina com 6 CPUs para atividades de processamento intensivo com pouco uso de memória—como essa verificação simples de números primos.

Código para o verificador de números primos com múltiplos núcleos

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

SimpleQueue foi acrescentada a multiprocessing no Python 3.9. Se você estiver usando uma versão anterior do Python, pode substituir SimpleQueue por Queue no Exemplo 13.

Exemplo 13. procs.py: verificação de primos com múltiplos processos; importações, tipos, e funções
link:code/19-concurrency/primes/procs.py[role=include]
  1. Na tentativa de emular threading, multiprocessing fornece multiprocessing.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 essa SimpleQueue para criar uma fila. Por outro lado, não podemos usá-la em dicas de tipo.

  2. multiprocessing.queues contém a classe SimpleQueue que precisamos para dicas de tipo.

  3. PrimeResult inclui o número verificado. Manter n junto com os outros campos do resultado simplifica a exibição mais tarde.

  4. Isso é um apelido de tipo para uma SimpleQueue que a função main (Exemplo 14) vai usar para enviar os números para os processos que farão a verificação.

  5. Apelido de tipo para uma segunda SimpleQueue que vai coletar os resultados em main. Os valores na fila serão tuplas contendo o número a ser testado e uma tupla Result.

  6. Isso é similar a sequential.py.

  7. worker recebe uma fila com os números a serem verificados, e outra para colocar os resultados.

  8. Nesse código, usei o número 0 como uma pílula venenosa: um sinal para que o processo encerre. Se n não é 0, continue com o loop.[15]

  9. Invoca a verificação de número primo e coloca o PrimeResult na fila.

  10. Devolve um PrimeResult(0, False, 0.0), para informar ao loop principal que esse processo terminou seu trabalho.

  11. procs é o número de processos que executarão a verificação de números primos em paralelo.

  12. Coloca na fila jobs os números a serem verificados.

  13. Cria um processo filho para cada worker. Cada um desses processos executará o loop dentro de sua própria instância da função worker, até encontrar um 0 na fila jobs.

  14. Inicia cada processo filho.

  15. Coloca um 0 na fila de cada processo, para encerrá-los.

Loops, sentinelas e pílulas venenosas

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.

Exemplo 14. procs.py: verificação de números primos com múltiplos processos; função main
link:code/19-concurrency/primes/procs.py[role=include]
  1. 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.

  2. jobs e results são as filas descritas no Exemplo 13.

  3. Inicia proc processos para consumir jobs e informar results.

  4. Recupera e exibe os resultados; report está definido em 6.

  5. Mostra quantos números foram verificados e o tempo total decorrido.

  6. Os argumentos são o número de procs e a fila para armazenar os resultados.

  7. Percorre o loop até que todos os processos terminem.

  8. 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 de SimpleQueue.get.

  9. Se n é zero, então um processo terminou; incrementa o contador procs_done.

  10. 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 main para as funções start_jobs e report. Há um arquivo README.md na mesma pasta explicando o problema e a solução.

Experimentando com mais ou menos processos

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.

Mediana dos tempos de execução para cada número de processos
Figura 2. Mediana dos tempos de execução para cada número de subprocessos de 1 a 20. O maior tempo mediano foi 40,81s, com 1 processo. O tempo mediano mais baixo foi 10,39s, com 6 processos, indicado pela linha pontilhada.

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.

Não-solução baseada em threads

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?

Python no mundo multi-núcleo.

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.

Administração de sistemas

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."

Ciência de dados

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.

Desenvolvimento de aplicações server-side para Web/Computação Móvel

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

Arquitetura para um sistema de dados combinando diversos componentes
Figura 3. Uma arquitetura possível para um sistema, combinando diversos componentes.[23]

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.

Servidores de aplicação WSGI

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

Se quiséssemos fundir os dois diagramas, o conteúdo do retângulo tracejado na Figura 4 substituiria o retângulo sólido "Application code"(código da aplicação) no topo da Figura 3.

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.

Diagrama de bloco mostrando o cliente conectado ao servidor HTTP, conectado ao servidor de aplicação, conectado a quatro processos Python.
Figura 4. Clientes se conectam a um servidor HTTP que entrega arquivos estáticos e roteia outras requisições para o servidor de aplicação, que inicia processo filhos para executar o código da aplicação, utilizando múltiplos núcleos de CPU. A API WSGI é a ponte entre o servidor de aplicação e o código da aplicação Python.

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.

ASGI—Asynchronous Server Gateway Interface

(Interface Assíncrona de Ponto de Entrada de Servidor)

Note

A WSGI é uma API síncrona. Ela não suporta corrotinas com async/await—a forma mais eficiente de implementar WebSockets or long pooling de HTTP em Python. A especificação da ASGI é a sucessora da WSGI, projetada para frameworks Python assíncronos para programação web, tais como aiohttp, Sanic, FastAPI, etc., bem como Django e Flask, que estão gradativamente acrescentando funcionalidades assíncronas.

Agora vamos examinar outra forma de evitar a GIL para obter um melhor desempenho em aplicações Python de servidor.

Filas de tarefas distribuídas

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.

Resumo do capítulo

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.

Para saber mais

Este capítulo tem uma extensa lista de referências, então a dividi em subseções.

Concorrência com threads e processos

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.

A GIL

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.

Concorrência além da biblioteca padrão

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.

Concorrência e escalabilidade para além do Python

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.

Ponto de vista

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.


1. Slide 8 of the talk Concurrency Is Not Parallelism.
2. Estudei e trabalhei com o Prof. Imre Simon, que gostava de dizer que há dois grandes pecados na ciência: usar palavras diferentes para significar a mesma coisa e usar uma palavra para significar coisas diferentes. Imre Simon (1942-2009) foi um pioneiro da ciência da computação no Brasil, com contribuições seminais para a Teoria dos Autômatos. Ele fundou o campo da Matemática Tropical e foi também um defensor do software livre, da cultura livre, e da Wikipédia.
3. Essa seção foi sugerida por meu amigo Bruce Eckel—autor de livros sobre Kotlin, Scala, Java, e C++.
4. NT: "FIFO" é a sigla em inglês para "first in, first out".
5. Chame sys.getswitchinterval() para obter o intervalo; ele pode ser modificado com sys.setswitchinterval(s).
6. Uma syscall é uma chamada a partir do código do usuário para uma função do núcleo (kernel) do sistema operacional. E/S, temporizadores e travas são alguns dos serviços do núcleo do SO disponíveis através de syscalls. Para aprender mais sobre esse tópico, leia o artigo "Chamada de sistema" na Wikipedia.
7. Os módulos 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.
8. Fonte: slide 106 do tutorial de Beazley, "Generators: The Final Frontier" (EN).
9. Fonte: início do capítulo "threading — Paralelismo baseado em Thread" (EN).
10. O Unicode tem muitos caracteres úteis para animações simples, como por exemplo os padrões Braille. Usei os caracteres ASCII "\|/-" para simplificar os exemplos do livro.
11. O semáforo é um bloco fundamental que pode ser usado para implementar outros mecanismos de sincronização. O Python fornece diferentes classes de semáforos para uso com threads, processos e corrotinas. Veremos o asyncio.Semaphore na [using_as_completed_sec] (no [async_ch]).
12. Agradeço aos revisores técnicos Caleb Hattingh e Jürgen Gmach, que não me deixaram esquecer de greenlet e gevent.
13. É um MacBook Pro 15” de 2018, com uma CPU Intel Core i7 2.2 GHz de 6 núcleos.
14. Isso é verdade hoje porque você provavelmente está usando um SO moderno, com multitarefa preemptiva. O Windows antes da era NT e o MacOS antes da era OSX não eram "preemptivos", então qualquer processo podia tomar 100% da CPU e paralisar o sistema inteiro. Não estamos inteiramente livres desse tipo de problema hoje, mas confie na minha barba branca: esse tipo de coisa assombrava todos os usuários nos anos 1990, e a única cura era um reset de hardware.
15. Nesse exemplo, 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.
16. Sobreviver à serialização sem perder nossa identidade é um ótimo objetivo de vida.
18. Para saber mais, consulte "Troca de contexto" na Wikipedia.
19. Provavelmente foram essas mesmas razões que levaram o criador do Ruby, Yukihiro Matsumoto, a também usar uma GIL no seu interpretador.
20. Na faculdade, como exercício, tive que implementar o algorítimo de compressão LZW em C. Mas antes escrevi o código em Python, para verificar meu entendimento da especificação. A versão C foi cerca de 900 vezes mais rápida.
21. Fonte: Thoughtworks Technology Advisory Board, Technology Radar—November 2015 (EN).
22. Compare os caches de aplicação—usados diretamente pelo código de sua aplicação—com caches HTTP, que estariam no limite superior da Figura 3, servindo recursos estáticos como imagens e arquivos CSS ou JS. Redes de Fornecimento de Conteúdo (CDNs de Content Delivery Networks) oferecem outro tipo de cache HTTP, instalados em datacenters próximos aos usuários finais de sua aplicação.
23. Diagrama adaptado da Figure 1-1, Designing Data-Intensive Applications de Martin Kleppmann (O’Reilly).
24. Alguns palestrantes soletram a sigla WSGI, enquanto outros a pronunciam como uma palavra rimando com "whisky."
25. uWSGI é escrito com um "u" minúsculo, mas pronunciado como a letra grega "µ," então o nome completo soa como "micro-whisky", mas com um "g" no lugar do "k."
26. Os engenheiros da Bloomberg Peter Sperl and Ben Green escreveram "Configuring uWSGI for Production Deployment" (Configurando o uWSGI para Implantação em Produção) (EN), explicando como muitas das configurações default do uWSGI não são adequadas para cenários comuns de implantação. Sperl apresentou um resumo de suas recomendações na EuroPython 2019. Muito recomendado para usuários de uWSGI.
27. Caleb é um dos revisores técnicos dessa edição de Python Fluente.
28. Agradeço a Lucas Brunialti por me enviar um link para essa palestra.
29. NT: Trocadilho intraduzível com thread no sentido de "fio" ou "linha", algo como "Quando as linhas desfiam."
30. As APIs Python threading e concurrent.futures foram fortemente influenciadas pela biblioteca padrão do Java.
31. A comunidade Erlang usa o termo "processo" para se referir a atores. Em Erlang, cada processo é uma função em seu próprio loop, que então são muito leves, tornando viável ter milhões deles ativos ao mesmo tempo em uma única máquina—nenhuma relação com os pesados processo do SO, dos quais falamos em outros pontos desse capitulo. Então temos aqui exemplos dos dois pecados descritos pelo Prof. Simon: usar palavras diferentes para se referir à mesma coisa, e usar uma palavra para se referir a coisas diferentes.