Do WSGI ao ASGI - Parte 1

O Django 3 veio para sacudir as estruturas! Parece que foi ontem, mas a versão já está aí desde 2019. Entre todas as suas adições, a de maior destaque certamente é a adoção de capacidades async através do ASGI (Asynchronous Server Gateway Interface).

Se você, assim como eu, não vê motivos para largar o WSGI (Web Server Gateway Interface), e ainda tem dificuldades para compreender o que é de fato esse novo Gateway Interface, vem comigo!

Mas antes de mergulhar na prática, essa é uma boa oportunidade para falarmos sobre async, threads, concorrência e paralelismo.

CPU, cores, processos e threads

O CPU é a cabeça por trás de todo o processamento. Ele funciona em ciclos que correspondem ao tempo necessário para a execução de uma operação. Um CPU pode ter múltiplos cores, e cada core é capaz de executar múltiplos processos.

No caso dos processos Python, cada um possui um interpretador Python, memory space, e o famigerado GIL (mais a seguir).

Quando executamos o processo Python, ele:

  • Pode executar múltiplos subprocessos.
  • A partir da main thread, pode iniciar múltiplas threads.

"Diagrama mostrando a diferença entre multithreading e multiprocessing"
Multithreading vs. Multiprocessing

Uma thread é uma unidade de execução dentro de um processo. Cada thread utiliza o espaço de memória do mesmo, compartilhando-o com as demais.

Concorrência e paralelismo

A motivação por trás do uso de threads e subprocessos é permitir que diferentes tarefas aconteçam ao mesmo tempo. E "ao mesmo tempo" pode significar que a intenção seja de um resultado simultâneo, mas na prática, não necessariamente ocorrendo no mesmo instante.

Complicado? Para fins didáticos vamos utilizar o exemplo proposto pelo FinTechExplained:

Eu estou esperando 10 amigos para almoçar, e eu tenho 3 horas para cozinhar o suficiente para 10 pessoas.

Os passos para um prato são:

  • Começo lavando os vegetais (~5 minutos);
  • Corto os vegetais (~13 minutos);
  • Cozinho os mesmos (~30 minutos);
  • Sirvo (~2 minutos).

Fluxograma mostrando os passos iniciando pela lavagem e terminando em servir
O fluxo de forma linear

Imagine que temos apenas uma boca no fogão, e que só conseguimos cozinhar um prato por vez. Se pensarmos de forma linear, um prato leva aproximadamente 50 minutos para ficar pronto. Se multiplicarmos pelo número de amigos, precisaremos de 8 horas cozinhando para atender a demanda.

Concorrência

Para reduzir o tempo total, uma possibilidade é adquirirmos outro fogão. Continuamos a cortar os vegetais de forma sequencial, mas o passo de cozimento pode acontecer de uma maneira na qual eu possa ter as duas bocas funcionando, e checar periodicamente o estado de cozimento de cada prato.

"Com duas bocas, consigo ter concorrência no cozimento"
Com duas bocas, consigo ter concorrência no cozimento

Diogo M. Martins define concorrência como:

Quando duas ou mais tarefas podem começar a ser executadas e terminar em espaços de tempo que se sobrepõem, não significando que elas precisam estar em execução necessariamente no mesmo instante.

Logo, ao invés de 60 minutos (2 pratos x 30 minutos de cozimento) podemos alcançar um tempo menor.

Paralelismo

Com dois cozinheiros, cada um trabalhando de sua casa, conseguimos dividir a carga, e portanto, em 4 horas seremos capazes de atender a demanda.

"Com dois cozinheiros, consigo paralelizar o trabalho"
Com dois cozinheiros, consigo paralelizar o trabalho

E talvez essa seja a forma mais simplista de definir paralelismo: Quando duas ou mais tarefas são executadas literalmente ao mesmo tempo.

Bom... se a motivação por trás das threads e subprocessos é permitir que tarefas aconteçam ao mesmo tempo, podemos utilizar qualquer um deles para alcançar paralelismo, certo?

"Diagrama com a diferença de execução entre concorrência e paralelismo"
Uma imagem pode valer mais que mil palavras (stackoverflow.com)

Não necessariamente.

GIL (Global Interpreter Lock)

O Python Global Interpreter Lock (ou GIL) é uma "trava" que permite que apenas uma thread tenha controle sobre o interpretador. Ou seja, apenas uma thread executa por vez, mesmo em uma arquitetura multi-thread com mais de um core.

Infame? No mínimo. Mas há uma boa motivação para essa trava existir. De forma resumida, ela está relacionada com a forma como a linguagem gerencia memória, e previne problemas de memory leak e deadlocks, ao mesmo tempo que oferece números interessantes de performance.

O artigo do RealPython, "What Is the Python Global Interpreter Lock (GIL)?", é uma excelente referência para você saber tudo a respeito.

Para o bem ou para o mal, na prática você não consegue atingir paralelismo utilizando threads em Python (e note, essa é uma realidade específica da linguagem). Ainda assim, fazer multithreading te dará o resultado concorrente que você deseja.

"Fluxograma de como a GIL funciona"
GIL e o gerenciamento de threads

E como conseguimos de fato paralelismo então? Utilizando processos!

Com a biblioteca de multiprocessing, ao invés de iniciarmos uma thread, iniciamos um novo processo Python que se encarregará de executar o que for requisitado. E citando o início desse artigo:

No caso dos processos Python, cada um possui um interpretador Python, memory space, e o famigerado GIL.

Logo, não sofremos a limitação da trava uma vez que estamos falando de processos à parte, gerenciados pelo Sistema Operacional.

Multithreading e Multiprocessing

Para ficar um pouco mais claro, vamos a um exemplo prático. Verificaremos o tamanho de alguns sites.

A forma mais recomendada de dar os primeiros passos com threads em Python é através da biblioteca concurrent.futures:

# thread.py

import concurrent.futures
import urllib.request

URLS = ['http://www.foxnews.com/',
        'http://www.cnn.com/',
        'http://www.bbc.co.uk/',]

def load_url(url, timeout):
    with urllib.request.urlopen(url, timeout=timeout) as conn:
        return conn.read()

def main():
    with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
        future_to_url = {executor.submit(load_url, url, 60): url for url in URLS}

        for future in concurrent.futures.as_completed(future_to_url):
            url = future_to_url[future]
            data = future.result()
            print('%r tem %d bytes' % (url, len(data)))


if __name__ == "__main__":
    main()

Caso você queira saber mais sobre a sintaxe, vale a pena conferir a documentação oficial do Python.

A execução deve apresentar um resultado parecido com o abaixo:

'http://www.bbc.co.uk/' tem 361696 bytes
'http://www.cnn.com/' tem 1143256 bytes
'http://www.foxnews.com/' tem 334772 bytes

O ThreadPoolExecutor estende do tipo Executor. Possuímos também o filho ProcessPoolExecutor, que tem uma interface semelhante, mas utiliza o módulo multiprocessing por baixo dos panos:

# process.py

import concurrent.futures
import urllib.request

URLS = ['http://www.foxnews.com/',
        'http://www.cnn.com/',
        'http://www.bbc.co.uk/',]

def load_url(url, timeout):
    with urllib.request.urlopen(url, timeout=timeout) as conn:
        return conn.read()

def main():
    with concurrent.futures.ProcessPoolExecutor(max_workers=3) as executor:
        future_to_url = {executor.submit(load_url, url, 60): url for url in URLS}

        for future in concurrent.futures.as_completed(future_to_url):
            url = future_to_url[future]
            data = future.result()
            print('%r tem %d bytes' % (url, len(data)))


if __name__ == "__main__":
    main()

Para maiores detalhes sobre a implementação, confira a documentação do ProcessPoolExecutor.

Com auxílio da ferramenta de linha de comando time, podemos calcular quanto tempo cada script leva para executar as tarefas concorrentemente:

$ time python thread.py
'http://www.bbc.co.uk/' tem 360652 bytes
'http://www.foxnews.com/' tem 336778 bytes
'http://www.cnn.com/' tem 1143341 bytes
python thread.py  0.15s user 0.06s system 38% cpu 0.555 total

$ time python process.py
'http://www.bbc.co.uk/' tem 360652 bytes
'http://www.cnn.com/' tem 1143341 bytes
'http://www.foxnews.com/' tem 336779 bytes
python process.py  0.41s user 0.12s system 74% cpu 0.715 total

E surpreendentemente vemos que o exemplo com threads (0.15s) leva menos tempo que o com subprocessos (0.41s).

I/O-bound e CPU-bound

Pela ideia de que threads em Python não são paralelas, podemos chegar erroneamente à conclusão de que multiprocessing é a solução definitiva.

Depende do tipo de problema que estamos tentando resolver. Há duas categorias distintas que influenciam na decisão:

  • Problemas CPU-bound: Onde o progresso do processo é limitado pela velocidade da CPU .
  • Problemas I/O-bound: O progresso é limitado pela velocidade do subsistema de entrada/saída.

Um exemplo do primeiro é um conjunto de operações matemáticas complexas, que dependem exclusivamente do uso da CPU. O código da seção anterior é um exemplo de I/O-bound.

Threads e I/O

O Sistema Operacional é responsável pelo gerenciamento de uma thread. Ele a interrompe a qualquer momento, salva o seu estado, e executa qualquer outra em seu lugar, podendo resumi-la ao fim do processo.

Larry Hastings, no "Python's Infamous GIL", descreve como o gerenciamento da GIL funciona do ponto de vista da linguagem:

The way Python threads work with the GIL is with a simple counter. With every 100 byte codes executed the GIL is supposed to be released by the thread currently executing in order to give other threads a chance to execute code.

Quando acessamos um banco de dados em outro servidor, um serviço web, ou um arquivo em um sistema de arquivos, estamos realizando uma operação I/O-bound. Qualquer ação que demande comunicação com o hardware (como os sockets), envolve comunicação com o kernel da máquina, e essa operação é mais lenta que as operações de uma CPU.

"Diagrama de tempo comparando I/O waiting e CPU processing com Threads"
I/O e CPU em um contexto I/O-Bound com Threads (realpython.com)

Como resultado, a maioria das operações I/O-bound ficará em um estado de "espera" até o momento em que ela receba um resultado. E como afirma Cody Piersall, nessa incrivelmente simples resposta no Stackoverflow:

The GIL won't hurt you here.

For I/O bound tasks (like downloading webpages), the GIL is not a problem. Python releases the GIL when I/O is happening, which means all the threads will be able execute the requests in parallel.

Em outras palavras, a thread que necessita pegar dados de uma fonte externa irá adquirir a GIL. Subseqüentemente o lock é liberado e é adquirido por outra thread que inicia outro código I/O-bound (por exemplo). Quando a GIL é adquirida novamente pela primeira thread, possivelmente ela já tenha recebido os dados.

"Fluxograma com threads e os passos de acquire GIL, waiting for GIL, e releasing GIL"
Exemplo onde a GIL é aquirida e liberada em diferentes threads (medium.com/fintechexplained)

Some isso ao fato de que criar novos processos é normalmente mais caro do que threads, e temos uma resposta satisfatória aos números apresentados no experimento anterior.

Portanto, se:

  • CPU-bound: Use multiprocessing;
  • I/O-bound: Use threads ou asyncio.

Ah! Claro! asyncio!

E voltamos ao asynchronous, afinal esse é um post sobre ASGI :)

Segundo Miguel Grinberg, no talk "Asynchronous Python for the Complete Beginner", async é:

A style of concurrent programming in which tasks release the CPU during waiting periods, so that other tasks can use it.

É importante salientar que:

  • Async (async IO, asynchoronous, ou asynchronous IO) e asyncio não são exatamente a mesma coisa;
  • O asynchronous IO, de forma geral, é um estilo de programação concorrente;
  • Já o asyncio é uma das implementações Python desse estilo (há outras implementações, como o uvloop).

Continuamos dentro dos domínios da concorrência, mas ainda assim, apresentando uma técnica completamente diferente.

Event loop

De forma simplista, imagine que há um objeto Python chamado de event loop, e esse camarada é quem controla como e quando cada task irá rodar. Ele é consciente de cada task, e sabe em qual estado cada uma está.

Ilustração mostrando de forma subjetiva como o event loop funciona
O event loop é um pouco abstrato, e não consegui pensar em nenhum diagrama para explicá-lo (fadeevab.com)

Por exemplo, o estado "pronto" indica que a tarefa tem trabalho a fazer e está pronta para rodar. O "esperando" indica que a tarefa está esperando que alguma coisa externa termine, como as I/O-bounds operations que discutimos anteriormente.

Para deixar as coisas simples, vamos imaginar que o event loop mantenha uma lista para tarefas "prontas para executar", e outra para "esperando". Ele pega uma das tasks prontas para executar e bota para rodar. E aqui vai um detalhe importante: A tarefa terá total controle da execução até o momento que ela, cooperativamente, devolve o controle para o event loop.

Quando a tarefa devolve o controle para o event loop, ele a coloca na determinada fila dependendo do seu estado. Ele (o event loop) percorre a lista de tarefas "esperando" para checar se o I/O de alguma delas já acabou, e portanto, se o seu estado agora é "pronta para executar". Essa tarefa é então movida para a lista de prontos para executar, e o event loop pega a próxima tarefa dessa mesma lista.

"Diagrama de tempo comparando I/O waiting e CPU processing com asyncio"
I/O e CPU em um contexto I/O-Bound com asyncio (realpython.com)

E o processo se repete.

asyncio x multithreading

O async e threads podem até parecer ter algumas semelhanças, mas há muitas diferenças. Para começar, as "tarefas" no contexto do asyncio são chamadas de coroutines. Recorremos ao Stackoverflow para deixar as coisas mais claras:

With threads, the operating system switches running threads preemptively according to its scheduler, which is an algorithm in the operating system kernel. With coroutines, the programmer and programming language determine when to switch coroutines; in other words, tasks are cooperatively multitasked by pausing and resuming functions at set points, typically (but not necessarily) within a single thread.

Duas coisas importantes precisam ser observadas com essa afirmação:

  1. Tarefas nunca são interrompidas no meio de uma operação, portanto, é mais fácil e seguro compartilhar recursos com asyncio em comparação com threads.
  2. É isso mesmo que você leu: Tipicamente (mas não necessariamente) dentro de uma única thread.

Outro ponto importante é que assim como threads, corrotinas são mais leves que subprocessos. Mas aposto que você ainda está chocado com o single-thread, então vamos voltar a ele.

O exemplo da enxadrista

O asyncio é (por padrão) single-threaded e single-process. Como ele consegue ser uma alternativa tão interessante quanto os seus primos?

"Protagonista de Gambit's Queen jogando xadresz"
Achou que não ia ter referência ao Gambito? (lifestyleasia.com)

Voltamos a citar o RealPython, trazendo o exemplo da enxadrista:

Chess master Judit Polgár hosts a chess exhibition in which she plays multiple amateur players. She has two ways of conducting the exhibition: synchronously and asynchronously.

Assumptions

  • 24 opponents
  • Judit makes each chess move in 5 seconds
  • Opponents each take 55 seconds to make a move
  • Games average 30 pair-moves (60 moves total)

Synchronous version: Judit plays one game at a time, never two at the same time, until the game is complete. Each game takes (55 + 5) * 30 == 1800 seconds, or 30 minutes. The entire exhibition takes 24 * 30 == 720 minutes, or 12 hours.

Asynchronous version: Judit moves from table to table, making one move at each table. She leaves the table and lets the opponent make their next move during the wait time. One move on all 24 games takes Judit 24 * 5 == 120 seconds, or 2 minutes. The entire exhibition is now cut down to 120 * 30 == 3600 seconds, or just 1 hour.

Temos apenas uma Judit, que pode fazer um movimento por vez por ela mesma. Ela jogar cada jogo de forma síncrona limita ela também ao tempo que o outro enxadrista tem para respondê-la. Mas de forma assíncrona, ela consegue lidar com múltiplas tarefas acontecendo concorrentemente, em turnos que permitem que ao tempo que um enxadrista está pensando em uma resposta, ela se move para o próximo, e retorna quando o primeiro estiver pronto.

Imagine que Judit é o event loop, e que as jogadas com os 24 oponentes são tarefas acontecendo concorrentemente.

async/await

Mas como?

O Python apresenta algumas palavras reservadas que permitem informar ao event loop o estado da tarefa sendo executada. Você provavelmente já ouviu falar das principais: async e await.

Com a primeira, indicamos ao Python que uma função trata-se de uma corrotina (ou um generator assíncrono). Já com o await, estamos dando o controle de volta ao event loop, e sinalizando que podemos esperar pelo resultado de determinada operação.

Ou seja, com o código abaixo:

async def g():
    r = await f()
    return r

Queremos dizer:

Suspenda a execução de g(),
até que o que quer que a gente esteja esperando em f()
seja retornado.

Nesse meio tempo, vá executar alguma outra coisa...

E note que bibliotecas para I/O precisam suportar essa forma de escrita, caso contrário não estaremos de fato fazendo uso das propriedades do async.

Na prática

Voltamos ao mesmo exemplo usado com threads e multiprocessing, mas agora reescrito para utilizar a biblioteca asyncio:

# async.py

import asyncio
import httpx
import itertools

URLS = ['http://www.foxnews.com/',
        'http://www.cnn.com/',
        'http://www.bbc.co.uk/', ]


async def measure_url(client, url):
    resp = await client.get(url)
    length = len(resp.text)
    return (url, length)


async def main():
    async with httpx.AsyncClient() as client:
        results = await asyncio.gather(
            *map(measure_url, itertools.repeat(client), URLS)
        )

    for (url, length) in results:
        print(f"{url} tem {length} bytes")


if __name__ == "__main__":
    asyncio.run(main())

Note o uso das palavras reservadas async e await, e do inicializador do event loop. Além, claro, do uso de bibliotecas não bloqueantes como o caso da httpx.

E como fica o comparativo com threads e processos? Mais uma vez rodando com time, temos:

$ time python async.py
http://www.foxnews.com/ tem 339756 bytes
http://www.cnn.com/ tem 1145150 bytes
http://www.bbc.co.uk/ tem 317556 bytes
python async.py  0.22s user 0.07s system 13% cpu 2.151 total

Nada mal. Quase metade do tempo usando multiprocessing, e um pouco mais que usando threads. Isso não quer dizer que asyncio é necessariamento mais lento, apenas que o contexto desse experimento favoreceu o exemplo com threads.

O propósito do comparativo com time nesse artigo é apenas ilustrar que há diferença entre as opções possíveis. Mas se você não está satisfeito com o resultado, o Stackoverflow pode ajudar novamente:

  • CPU Bound => Multi Processing
  • I/O Bound, Fast I/O, Limited Number of Connections => Multi Threading
  • I/O Bound, Slow I/O, Many connections => Asyncio

Vai depender do contexto do problema sendo resolvido.

Considerações finais

Eu lembro de estar em mais de uma entrevista de emprego, e ser perguntado sobre como fazer CPU-bound e IO-bound em Python.

Agora sabemos o básico sobre os principais conceitos relacionados à concorrência em Python. Temos uma perspectiva mais detalhada sobre a GIL, e não entramos no mérito de debater se ela é boa ou ruim. Entender a natureza das threads na linguagem é mais importante, e é crucial para decidirmos da melhor forma como solucionar problemas concorrentes.

E optar pelo async/ASGI faz parte disso também. Portanto, agora que temos todo o contexto de concorrência e paralelismo, no próximo post vamos revisitar o funcionamento do WSGI, compará-lo com o seu primo mais novo, e compreender a motivação por trás da alternativa.

Até a próxima.

Referências