Este é um resumo do meu tutorial PyCon 2020. Você pode encontrar o vídeo original e o código fonte aqui: https://github.com/santiagobasulto/pycon-concurrency-tutorial-2020
Este é um guia rápido/tutorial sobre como escrever eficazmente programas concorrentes usando Python. A simultaneidade em Python pode ser confusa. Existem vários módulos (threading
, _thread
, multiprocessing
, subprocess
). Há também o muito odiado GIL, mas apenas para o CPython (PyPy e Jython não têm um GIL). As coleções não são seguras, exceto por alguns detalhes de implementação com CPython.
O objetivo deste tutorial é fornecer recomendações de como abordar cada caso de uso de código concorrente (ou paralelo).
Você notará que, ao contrário de alguns outros tutoriais mais tradicionais de Python “somente multithreading”, eu começo com um resumo tanto da Arquitetura de Computadores quanto dos conceitos de Sistemas Operacionais. Entender estes conceitos básicos é fundamental para construir corretamente programas simultâneos. Por favor, pule estas seções se você já está familiarizado com estes tópicos.
Este tutorial está dividido nas seguintes seções:
- Concurrency vs Parallelism
- Arquitetura de Computador
- O papel do Sistema Operacional
- Temas com Python
- Sincronização de Tópicos
- Multiprocessamento
- Bibliotecas de alto nível:
concurrent.futures
eparallel
Concurrency vs Parallelism
Parallelism é quando várias tarefas estão em execução ao mesmo tempo. É o objetivo final dos programas simultâneos. Concorrência é menos que paralelismo, significa que estamos iniciando várias tarefas e fazendo malabarismos no mesmo período de tempo. Entretanto, em qualquer momento em particular, estamos fazendo apenas uma de cada vez.
Aplicado ao cozimento, estes seriam exemplos de concurrência e paralelismo.
A moeda tem apenas uma pessoa na cozinha:
- Comece a cortar as cebolas
- Comece a aquecer a panela
- Conclua a cortar as cebolas
Neste caso, é claro que não podemos fazer várias coisas na mesma hora. Podemos começar todas elas, mas temos de saltar para trás e para a frente para as controlar.
Paralelismo tem várias pessoas na cozinha:
- Pessoa 1 está a cortar cebolas
- Pessoa 2 está a cortar pimentos vermelhos
- Pessoa 3 espera que a panela aqueça
Neste caso há várias tarefas a serem feitas ao mesmo tempo.
Arquitetura do computador
Todos os computadores modernos podem ser simplificados usando a arquitetura von Neumann, que tem 3 componentes principais: Computação (CPU), Memória (RAM), E/S (discos rígidos, redes, saída de vídeo).
O Processo
Um Processo é a abstração que o Sistema Operacional usa para executar seu código. Ele envolve seu código neste processo, e atribui memória e outros recursos compartilhados. A abstração do processo permite ao sistema operacional “distinguir” entre os programas em execução, de modo que eles criam conflitos entre si. Por exemplo, o Processo 1 não pode acessar a memória reservada pelo Processo 2. Também é importante em termos de segurança dos usuários. Se o Usuário 1 iniciar um processo, esse processo será capaz de ler os arquivos acessíveis por esse usuário.
O diagrama seguinte contém uma representação visual de um Processo. O processo contém o código que deve executar, a RAM alocada e todos os dados criados pelo programa (variáveis, arquivos abertos, etc).
Agora podemos ver o mesmo “programa” (o mesmo pedaço de código) rodando em múltiplos processos ao mesmo tempo.
Agora podemos passar para a segunda fase: como é realmente conseguida a simultaneidade (ou mesmo o paralelismo)? Da sua perspectiva como desenvolvedor, existem dois mecanismos que você pode empregar para alcançar “simultaneidade”: multithreading ou multiprocessamento.
Multithreading
Threads são fluxos de execução simultâneos dentro do mesmo processo. O processo inicia vários threads de execução, que “evoluem” independentemente com base no que têm de fazer.
Por exemplo, digamos que temos um programa que deve descarregar dados de três websites, e depois combiná-los para um relatório final. Cada website demora cerca de dois segundos para responder com os dados. Um único programa com threads seria algo como:
d1 = get_website_1() # 2 secs
d2 = get_website_2() # 2 secs
d3 = get_website_3() # 2 secscombine(d1, d2, d3) # very fast
O tempo total de execução será de >6 segundos:
>
Um programa com threads múltiplos começará a descarregar os dados dos três websites ao mesmo tempo, e esperar até que todos eles estejam prontos para fazer o relatório final.
t1 = Thread(get_website_1)
t2 = Thread(get_website_2)
t3 = Thread(get_website_3)wait_for_threads()d1 = t1.result()
d2 = t2.result()
d3 = t3.result()combine(d1, d2, d3)
A execução agora estará próxima a ~2 segundos, já que os três websites responderão aproximadamente ao mesmo tempo:
Em Python, os threads são criados com a classe Thread
, a partir do módulo threading
, e você pode ver vários exemplos no primeiro caderno do reporte do meu tutorial: 1. Thread Basics.ipynb.
Threads e Dados Compartilhados
Threads são explicitamente criados pelo seu programa, assim eles vivem dentro do mesmo processo. Isso significa que eles compartilham todos os mesmos dados do processo. Eles podem ler e modificar variáveis locais, e acessar a mesma memória ou os mesmos recursos (arquivos, soquetes de rede, etc).
Isso pode causar alguns problemas, especialmente a mutação inesperada de dados compartilhados e condições de corrida. Você pode ver um exemplo de uma condição de corrida no notebook 2. Thread Data & Race Conditions.ipynb. No mesmo notebook, introduzimos alguns mecanismos para “sincronizar” tópicos para evitar Condições de Corrida, mas esses mesmos mecanismos podem potencialmente introduzir o problema de um Deadlock. Há um exemplo de um Deadlock no bloco de notas: 3. Deadlocks.ipynb.
Produtores, Consumidores e Filas de Espera
Problemas de sincronização podem ser resolvidos apenas mudando o paradigma.
A melhor forma de evitar problemas de sincronização é evitar totalmente a sincronização.
Isto é conseguido utilizando o padrão Produtor-Consumidor. Os threads do Produtor criam “tarefas pendentes” para executar e depois colocam-nas numa fila partilhada. Os threads do consumidor irão ler as tarefas da fila e fazer o trabalho real.
Em Python, a classe Queue
do módulo queue
é uma fila segura de threads que é utilizada para estes fins.
Existe um exemplo de um modelo Produtor-Consumidor no bloco de notas
4. Modelo Produtor-Consumidor.ipynb.
>
O Python Global Interpreter Lock
O GIL é uma das características mais odiadas do Python. Na verdade não é assim tão mau, mas tem uma reputação terrível.
O GIL é uma fechadura “global” que é colocada pelo intérprete Python que garante que apenas 1 thread pode acessar a CPU em um determinado momento. Isto parece estúpido: por que você vai se preocupar com código multithreaded se apenas uma thread será capaz de acessar a CPU em um dado momento de qualquer forma?
Aqui é onde é importante entender que seu código não é só sobre o uso da CPU. Seu código fará diferentes tarefas, como ler arquivos, obter informações da rede, etc. Lembra-se dos nossos tempos de acesso de latência? Aqui estão novamente:
Esta é a chave para combater a limitação da GIL. No momento em que uma thread precisa ler algo de um arquivo, ela pode liberar a CPU e deixar outras threads rodarem. Isto é porque a maioria dos programas são o que chamamos de I/O-Bound. Isto é, eles usam muitos recursos de I/O (rede, sistema de arquivos, etc).
Você pode ver uma “prova” de uma tarefa limitada de I/O no notebook: 5. O Python GIL.ipynb.
Multiprocessamento
Alguns outros programas são inevitavelmente vinculados à CPU, tais como programas que precisam fazer muitos cálculos. Às vezes, você quer acelerar as coisas. Se você usar multithreading para tarefas ligadas à CPU, você vai perceber que seus programas não são mais rápidos por causa da GIL (eles podem realmente ficar ainda mais lentos).
Enter multiprocessamento, o segundo mecanismo que temos para escrever programas concorrentes. Ao invés de ter múltiplos threads, nós realmente criaremos múltiplos processos infantis que irão cuidar de executar diferentes tarefas simultaneamente.
Há um exemplo de um programa baseado em multiprocessamento no notebook 6. Multiprocessing.ipynb
concurrent.futures
Para finalizar o meu tutorial, gostaria de salientar que existe um módulo de nível superior que faz parte da Python Standard Library que deve ser utilizado quando possível: concorrente.futures.
Provê abstrações de alto nível para criar pools de threads e processos, e é maduro o suficiente para ser considerado, sempre que possível, a alternativa padrão para escrever código concorrente.
É isso mesmo! Avise-me se você tiver alguma dúvida. Ficarei feliz em respondê-las.