Python Concurrency Tutorial

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

https://www.youtube.com/watch?v=18B1pznaU1o

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 e parallel

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

>

>

A arquitectura von Neumann
>>

O seu código estará sempre a executar tarefas relacionadas com cada um destes componentes. Algumas instruções são “computacionais” ( x + 1), outras são de memória (x = 1), e outras são E/S (fp = open('data.csv')).

Para entender corretamente os detalhes da concorrência em Python, é importante ter em mente os diferentes “tempos de acesso” desses componentes. Acessar a CPU é muito mais rápido do que acessar a RAM, muito menos as I/O. Há um estudo comparando a latência do computador com tempos relativos humanos: se um ciclo de CPU representa 1 segundo do tempo humano, um pedido de rede de São Francisco para Hong Kong leva 11 anos.

Latência à escala humana (fonte)

A diferença radical no tempo com estas “instruções” será fundamental para discutir os threads e o Python GIL.

O Papel do Sistema Operacional

Se eu estivesse construindo um curso universitário sobre Concorrência, eu teria uma palestra inteira apenas cobrindo a História dos Sistemas Operacionais. É a única maneira de entender como os mecanismos de concorrência evoluíram.

Os primeiros sistemas operacionais focados no consumidor (pense no MS-DOS) não suportavam multitarefa. No máximo, apenas um programa seria executado em um determinado momento. Eles eram seguidos por sistemas “multitarefa cooperativos” (Windows 3.0). Este tipo de multitarefa parecia mais um “ciclo de eventos” (pense no asíncio do Python) do que um programa multi-tarefa moderno. Em um sistema operacional multitarefa cooperativo, a aplicação é responsável por “liberar” a CPU quando ela não é mais necessária e deixar outras aplicações rodarem. Isto obviamente não funcionou, pois você não pode confiar em cada aplicativo (ou mesmo em seus desenvolvedores para ser perspicaz o suficiente para fazer a chamada certa na hora de lançar a CPU). Os Sistemas Operacionais modernos (Windows 95, Linux, etc) mudaram seu design multitarefa para “Preemptive Multitasking”, no qual o Sistema Operacional é o encarregado de decidir quais aplicativos recebem CPU, com que frequência e por quanto tempo.

O Sistema Operacional então, é o único que decide quais processos devem ser executados, e por quanto tempo. Isto é o que permite que o aplicativo ainda sinta como se fosse “multitarefa”, mesmo que tenha apenas 1 CPU. O Scheduler do Sistema Operacional está alternando Processos para frente e para trás muito rapidamente.

No diagrama seguinte, 3 processos (P1, P2, P3) estão rodando simultaneamente, compartilhando a mesma CPU (a linha azul é o tempo de CPU). P1 consegue executar por algum tempo antes de ser rapidamente trocado para P2, e depois P3, e assim por diante.

3 processos estão rodando “simultaneamente”

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

Representação visual de um Processo OS

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 simples
>

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:

Um programa multithreaded

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

Treads compartilham dados de processo

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:

>

Latência à escala humana (fonte)

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.

Deixe uma resposta

O seu endereço de email não será publicado.