O problema com as abordagens usuais da programação assíncrona é que elas são propostas do tipo "tudo ou nada". Ou você reescreve todo o código, de forma que nada nele bloqueie [o processamento] ou você está só perdendo tempo.
Alvaro Videla e Jason J. W. Williams, RabbitMQ in Action (RabbitMQ em Ação)Videla & Williams, RabbitMQ in Action (RabbitMQ em Ação) (Manning), Capítulo 4, "Solving Problems with Rabbit: coding and patterns (Resolvendo Problemas com Rabbit: programação e modelos)," p. 61.
Este capítulo trata de três grandes tópicos intimamente interligados:
-
Os elementos de linguagem
async def
,await
,async with
, easync for
do Python; -
Objetos que suportam tais elementos através de métodos especiais como
__await__
,__aiter__
etc., tais como corrotinas nativas e variantes assíncronas de gerenciadores de contexto, iteráveis, geradores e compreensões; -
asyncio e outras bibliotecas assíncronas.
Este capítulo parte das ideias de iteráveis e geradores ([iterables2generators], em particular da [classic_coroutines_sec]), gerenciadores de contexto (no [with_match_ch]), e conceitos gerais de programação concorrente (no [concurrency_models_ch]).
Vamos estudar clientes HTTP concorrentes similares aos vistos no [futures_ch], reescritos com corrotinas nativas e gerenciadores de contexto assíncronos, usando a mesma biblioteca HTTPX de antes, mas agora através de sua API assíncrona. Veremos também como evitar o bloqueio do loop de eventos, delegando operações lentas para um executor de threads ou processos.
Após os exemplos de clientes HTTP, teremos duas aplicações simples de servidor,
uma delas usando o framework cada vez mais popular FastAPI.
A seguir tratamos de outros artefatos da linguagem viabilizados pelas palavras-chave async/await
:
funções geradoras assíncronas, compreensões assíncronas, e expressões geradoras assíncronas. Para realçar o fato daqueles recursos da linguagem não estarem limitados ao asyncio, veremos um exemplo reescrito para usar a Curio—o elegante e inovador framework inventado por David Beazley.
Finalizando o capítulo, escrevi uma pequena seção sobre vantagens e armadilhas da programação assíncrona.
Há um longo caminho à nossa frente. Teremos espaço apenas para exemplos básicos, mas eles vão ilustrar as características mais importantes de cada ideia.
Tip
|
A documentação do asyncio melhorou muito após Yury Selivanov[1] reorganizá-la, dando maior destaque às funções úteis para desenvolvedores de aplicações. A maior parte da API de asyncio consiste em funções e classes voltadas para criadores de pacotes como frameworks web e drivers de bancos de dados, ou seja, são necessários para criar bibliotecas assíncronas, mas não aplicações. Para mais profundidade sobre asyncio, recomendo Using Asyncio in Python ("Usando Asyncio em Python") de Caleb Hattingh (O’Reilly). Política de transparência: Caleb é um dos revisores técnicos deste livro. |
Quando escrevi a primeira edição de Python Fluente, a biblioteca asyncio era provisória e as palavras-chave async/await
não existiam.
Assim, todos os exemplos desse capítulo precisaram ser atualizados.
Também criei novos exemplos: scripts de sondagem de domínios, um serviço web com FastAPI, e experimentos com o novo modo assíncrono do console do Python.
Novas seções tratam de recursos da linguagem inexistentes naquele momento, como corrotinas nativas, async with
, async for
, e os objetos que suportam essas instruções.
As ideias na Como a programação assíncrona funciona e como não funciona refletem lições importantes tiradas da experiência prática, e a considero uma leitura essencial para qualquer um trabalhando com programação assíncrona. Elas podem ajudar você a evitar muitos problemas—seja no Python, seja no Node.js.
Por fim, removi vários parágrafos sobre asyncio.Futures
, que agora considero parte das APIs de baixo nível do asyncio.
No início da [classic_coroutines_sec], vimos que, desde o Python 3.5, a linguagem oferece três tipos de corrotinas:
- Corrotina nativa
-
Uma função corrotina definida com
async def
. Você pode delegar de uma corrotina nativa para outra corrotina nativa, usando a palavra-chaveawait
, de forma similar àquela como as corrotinas clássicas usamyield from
. O comandoasync def
sempre define uma corrotina nativa, mesmo se a palavra-chaveawait
não seja usada em seu corpo. A palavra-chaveawait
não pode ser usada fora de uma corrotina nativa.[2] - Corrotina clássica
-
Uma função geradora que consome dados enviados a ela via chamadas a
my_coro.send(data)
, e que lê aqueles dados usandoyield
em uma expressão. Corrotinas clássicas podem delegar para outras corrotinas clássicas usandoyield from
. Corrotinas clássicas não podem ser controladas porawait
, e não são mais suportadas pelo asyncio. - Corrotinas baseadas em geradoras
-
Uma função geradora decorada com
@types.coroutine
—introduzido no Python 3.5. Esse decorador torna a geradora compatível com a nova palavra-chaveawait
.
Nesse capítulo vamos nos concentrar nas corrotinas nativas, bem como nas geradoras assíncronas:
- Geradora assíncrona
-
Uma função geradora definida com
async def
que usayield
em seu corpo. Ela devolve um objeto gerador assíncrono que oferece um__anext__
, um método corrotina para obter o próximo item.
Warning
|
@asyncio.coroutine Não Tem Futuro[3]
O decorador |
Imagine que você esteja prestes a lançar um novo blog sobre Python, e planeje registrar um domínio usando uma palavra-chave do Python e o sufixo .DEV—por exemplo, AWAIT.DEV. O Exemplo 1 é um script usando asyncio que verifica vários domínios de forma concorrente. Essa é saída produzida pelo script:
$ python3 blogdom.py
with.dev
+ elif.dev
+ def.dev
from.dev
else.dev
or.dev
if.dev
del.dev
+ as.dev
none.dev
pass.dev
true.dev
+ in.dev
+ for.dev
+ is.dev
+ and.dev
+ try.dev
+ not.dev
Observe que os domínios aparecem fora de ordem.
Se você rodar o script, os verá sendo exibidos um após o outro, a intervalos variados.
O sinal de +
indica que sua máquina foi capaz de resolver o domínio via DNS.
Caso contrário, o domínio não foi resolvido e pode estar disponível.[4]
No blogdom.py, a sondagem de DNS é feita por objetos corrotinas nativas. Como as operações assíncronas são intercaladas, o tempo necessário para verificar 18 domínios é bem menor que se eles fosse verificados sequencialmente. Na verdade, o tempo total é quase o igual ao da resposta mais lenta, em vez da soma dos tempos de todas as respostas do DNS.
O Exemplo 1 mostra o código dp blogdom.py.
link:code/21-async/domains/asyncio/blogdom.py[role=include]
-
Estabelece o comprimento máximo da palavra-chave para domínios, pois quanto menor, melhor.
-
probe
devolve uma tupla com o nome do domínio e um valor booleano;True
significa que o domínio foi resolvido. Incluir o nome do domínio aqui facilita a exibição dos resultados. -
Obtém uma referência para o loop de eventos do
asyncio
, para usá-la a seguir. -
O método corrotina
loop.getaddrinfo(…)
devolve uma tupla de parâmetros com cinco partes para conectar ao endereço dado usando um socket. Neste exemplo não precisamos do resultado. Se conseguirmos um resultado, o domínio foi resolvido; caso contrário, não. -
main
tem que ser uma corrotina, para podemros usarawait
aqui. -
Gerador para produzir palavras-chave com tamanho até
MAX_KEYWORD_LEN
. -
Gerador para produzir nome de domínio com o sufixo
.dev
. -
Cria uma lista de objetos corrotina, invocando a corrotina
probe
com cada argumentodomain
. -
asyncio.as_completed
é um gerador que produz corrotinas que devolvem os resultados das corrotinas passadas a ele. Ele as produz na ordem em que elas terminam seu processamento, não na ordem em que foram submetidas. É similar aofutures.as_completed
, que vimos no [futures_ch], [flags_threadpool_futures_ex]. -
Nesse ponto, sabemos que a corrotina terminou, pois é assim que
as_completed
funciona. Portanto, a expressãoawait
não vai bloquear, mas precisamos dela para obter o resultado decoro
. Secoro
gerou uma exceção não tratada, ela será gerada novamente aqui. -
asyncio.run
inicia o loop de eventos e retorna apenas quando o loop terminar. Esse é um modelo comum para scripts usandoasyncio
: implementarmain
como uma corrotina e controlá-la comasyncio.run
dentro do blocoif name == 'main':
.
Tip
|
A função |
Há muitos conceitos novos para entender no asyncio, mas a lógica básica do Exemplo 1 é fácil de compreender se você usar o truque sugerido pelo próprio Guido van Rossum:
cerre os olhos e finja que as palavras-chave async
e await
não estão ali.
Fazendo isso, você vai perceber que as corrotinas podem ser lidas como as boas e velhas funções sequenciais.
Por exemplo, imagine que o corpo dessa corrotina…
async def probe(domain: str) -> tuple[str, bool]:
loop = asyncio.get_running_loop()
try:
await loop.getaddrinfo(domain, None)
except socket.gaierror:
return (domain, False)
return (domain, True)
…funciona como a função abaixo, exceto que, magicamente, ela nunca bloqueia a execução:
def probe(domain: str) -> tuple[str, bool]: # no async
loop = asyncio.get_running_loop()
try:
loop.getaddrinfo(domain, None) # no await
except socket.gaierror:
return (domain, False)
return (domain, True)
Usar a sintaxe await loop.getaddrinfo(…)
evita o bloqueio, porque await
suspende o objeto corrotina atual.
Por exemplo, durante a execução da corrotina probe('if.dev')
,
um novo objeto corrotina é criado por getaddrinfo('if.dev', None)
.
Aplicar await
sobre ele inicia a consulta de baixo nível addrinfo
e devolve o controle para o loop de eventos, não para a corrotina probe(‘if.dev’)
, que está suspensa.
O loop de eventos pode então ativar outros objetos corrotina pendentes, tal como probe('or.dev')
.
Quando o loop de eventos recebe uma resposta para a consulta getaddrinfo('if.dev', None)
,
aquele objeto corrotina específico prossegue sua execução, e devolve o controle pra o probe('if.dev')
—que estava suspenso no await
—e pode agora tratar alguma possível exceção e devolver a tupla com o resultado.
Até aqui, vimos asyncio.as_completed
e await
sendo aplicados apenas a corrotinas.
Mas eles podem lidar com qualquer objeto "esperável". Esse conceito será explicado a seguir.
A palavra-chave for
funciona com iteráveis.
A palavra-chave await
funciona com esperáveis (awaitable).
Como um usuário final do asyncio, esses são os esperáveis que você verá diariamente:
-
Um objeto corrotina nativa, que você obtém chamando uma função corrotina nativa
-
Uma
asyncio.Task
, que você normalmente obtém passando um objeto corrotina paraasyncio.create_task()
Entretanto, o código do usuário final nem sempre precisa await
por uma Task
.
Usamos asyncio.create_task(one_coro())
para agendar one_coro
para execução concorrente, sem esperar que retorne.
Foi o que fizemos com a corrotina spinner
em spinner_async.py (no [spinner_async_start_ex]).
Criar a tarefa é o suficiente para agendar a execução da corrotina.
Warning
|
Mesmo que você não precise cancelar a tarefa ou esperar por ela,
é necessário preservar o objeto |
Por outro lado, usamos await other_coro()
para executar other_coro
agora mesmo
e esperar que ela termine, porque precisamos do resultado para prosseguir.
Em spinner_async.py, a corrotina supervisor
usava res = await slow()
para executar slow
e aguardar seu resultado..
Ao implementar bibliotecas assíncronas ou contribuir para o próprio asyncio, você pode também encontrar esse esperáveis de baixo nível:
-
Um objeto com um método
__await__
que devolve um iterador; por exemplo, uma instância deasyncio.Future
(asyncio.Task
é uma subclasse deasyncio.Future
) -
Objetos escritos em outras linguagens usando a API Python/C, com uma função
tp_as_async.am_await
, que devolvem um iterador (similar ao método__await__
)
As bases de código existentes podem também conter um tipo adicional de esperável: objetos corrotina baseados em geradores, que estão no processo de serem descontinuados.
Note
|
A PEP 492 afirma (EN) que a expressão |
Agora vamos estudar a versão asyncio de um script que baixa um conjunto fixo de imagens de bandeiras.
O script flags_asyncio.py baixa um conjunto fixo de 20 bandeiras de fluentpython.com. Nós já o mencionamos na [ex_web_downloads_sec], mas agora vamos examiná-lo em detalhes, aplicando os conceitos que acabamos de ver.
A partir do Python 3.10, o asyncio só suporta TCP e UDP diretamente, e não há pacotes de cliente ou servidor HTTP assíncronos na bilbioteca padrão. Estou usando o HTTPX em todos os exemplos de cliente HTTP.
Vamos explorar o flags_asyncio.py de baixo para cima, isto é, olhando primeiro as função que configuram a ação no Exemplo 2.
Warning
|
Para deixar o código mais fácil de ler, flags_asyncio.py não tem qualquer tratamento de erro.
Nessa introdução a Os exemplos de flags_.py aqui e no [futures_ch] compartilham código e dados, então os coloquei juntos no diretório example-code-2e/20-executors/getflags. |
link:code/20-executors/getflags/flags_asyncio.py[role=include]
-
Essa precisa ser uma função comum—não uma corrotina—para poder ser passada para e chamada pela função
main
do módulo flags.py ([flags_module_ex]). -
Executa o loop de eventos, monitorando o objeto corrotina
supervisor(cc_list)
até que ele retorne. Isso vai bloquear enquanto o loop de eventos roda. O resultado dessa linha é o que quer quesupervisor
devolver. -
Operação de cliente HTTP assíncronas no
httpx
são métodos deAsyncClient
, que também é um gerenciador de contexto assíncrono: um gerenciador de contexto com métodos assíncronos de configuração e destruição (veremos mais sobre isso na Gerenciadores de contexto assíncronos). -
Cria uma lista de objetos corrotina, chamando a corrotina
download_one
uma vez para cada bandeira a ser obtida. -
Espera pela corrotina
asyncio.gather
, que aceita um ou mais argumentos esperáveis e aguarda até que todos terminem, devolvendo uma lista de resultados para os esperáveis fornecidos, na ordem em que foram enviados. -
supervisor
devolve o tamanho da lista vinda deasyncio.gather
.
Agora vamos revisar a parte superior de flags_asyncio.py (Exemplo 3). Reorganizei as corrotinas para podermos lê-las na ordem em que são iniciadas pelo loop de eventos.
link:code/20-executors/getflags/flags_asyncio.py[role=include]
-
httpx
precisa ser importado—não faz parte da biblioteca padrão -
Reutiliza código de flags.py ([flags_module_ex]).
-
download_one
tem que ser uma corrotina nativa, para poderawait
porget_flag
—que executa a requisição HTTP. Ela então mostra o código de país bandeira baixada, e salva a imagem. -
get_flag
precisa receber oAsyncClient
para fazer a requisição. -
O método
get
de uma instância dehttpx.AsyncClient
devolve um objetoClientResponse
, que também é um gerenciador assíncrono de contexto. -
Operações de E/S de rede são implementadas como métodos corrotina, então eles são controlados de forma assíncrona pelo loop de eventos do
asyncio
.
Note
|
Seria melhor, em termos de desempenho, que a chamada a A Usando asyncio.as_completed e uma thread vai mostrar como delegar |
O seu código delega para as corrotinas do httpx
explicitamente, usando await
, ou implicitamente, usando os métodos especiais dos gerenciadores de contexto assíncronos, tais como AsyncClient
e ClientResponse
—como veremos na Gerenciadores de contexto assíncronos.
A diferença fundamental entre os exemplos de corrotinas clássicas vistas nas [classic_coroutines_sec] e flags_asyncio.py é que não há chamadas a .send()
ou expressões yield
visíveis nesse último.
O seu código fica entre a biblioteca asyncio e as bibliotecas assíncronas que você estiver usando, como por exemplo a HTTPX. Isso está ilustrado na Figura 1.
asyncio.run
. Cada corrotina do usuário aciona a seguinte com uma expressão await
, formando um canal que permite a comunicação entre uma biblioteca como a HTTPX e o loop de eventos.Debaixo dos panos, o loop de eventos do asyncio
faz as chamadas a .send
que acionam as nossas corrotinas, e nossas corrotinas await
por outras corrotinas, incluindo corrotinas da biblioteca.
Como já mencionado, a maior parte da implementação de await
vem de yield from
, que também usa chamadas a .send
para acionar corrotinas.
O canal await
acaba por chegar a um esperável de baixo nível, que devolve um gerador que o loop de eventos pode acionar em resposta a eventos tais com cronômetros ou E/S de rede.
Os esperáveis e geradores no final desses canais await
estão implementados nas profundezas das bibliotecas, não são parte de suas APIs e podem ser extensões Python/C.
Usando funções como asyncio.gather
e asyncio.create_task
,
é possível iniciar múltiplos canais await
concorrentes, permitindo a execução concorrente de múltiplas operações de E/S acionadas por um único loop de eventos, em uma única thread.
Observe que, no Exemplo 3, não pude reutilizar a função get_flag
de
flags.py ([flags_module_ex]).
Tive que reescrevê-la como uma corrotina para usar a API assíncrona do HTTPX.
Para obter o melhor desempenho do asyncio, precisamos substituir todas as funções que fazem E/S por uma versão assíncrona, que seja ativada com await
ou asyncio.create_task
. Dessa forma o controle é devolvido ao loop de eventos enquanto a função aguarda pela operação de entrada ou saída. Se você não puder reescrever a função bloqueante como uma corrotina, deveria executá-la em uma thread ou um processo separados, como veremos na Delegando tarefas a executores.
Essa é a razão da escolha da epígrafe desse capítulo, que incluí o seguinte conselho: "[Ou] você reescreve todo o código, de forma que nada nele bloqueie [o processamento] ou você está só perdendo tempo.""
Pela mesma razão, também não pude reutilizar a função download_one
de flags_threadpool.py
([flags_threadpool_ex]).
O código no Exemplo 3 aciona get_flag
com await
,
então download_one
precisa também ser uma corrotina.
Para cada requisição, um objeto corrotina download_one
é criado em supervisor
, e eles são todos acionados pela corrotina asyncio.gather
.
Na [context_managers_sec], vimos como um objeto pode ser usado para executar código antes e depois do corpo de um bloco with
, se sua classe oferecer os métodos __enter__
e __exit__
.
Agora, considere o Exemplo 4, que usa o driver PostgreSQL asyncpg compatível com o asyncio (documentação do asyncpg sobre transações).
tr = connection.transaction()
await tr.start()
try:
await connection.execute("INSERT INTO mytable VALUES (1, 2, 3)")
except:
await tr.rollback()
raise
else:
await tr.commit()
Uma transação de banco de dados se presta naturalmente a protocolo do gerenciador de contexto:
a transação precisa ser iniciada, dados são modificados com connection.execute
, e então um roolback (reversão) ou um commit (confirmação) precisam acontecer, dependendo do resultado das mudanças.
Em um driver assíncrono como o asyncpg, a configuração e a execução precisam acontecer em corrotinas, para que outras operações possam ocorrer de forma concorrente.
Entretando, a implementação do comando with
clássico não suporta corrotinas na implementação dos métodos __enter__
ou __exit__
.
Por essa razão a PEP 492—Coroutines with async and await syntax (Corrotinas com async e await) (EN) introduziu o comando async with
, que funciona com gerenciadores de contexto assíncronos:
objetos implementando os métodos __aenter__
e __aexit__
como corrotinas.
Com async with
, o Exemplo 4 pode ser escrito como esse outro trecho da documentação do asyncpg:
async with connection.transaction():
await connection.execute("INSERT INTO mytable VALUES (1, 2, 3)")
Na
classe asyncpg.Transaction
,
o método corrotina __aenter__
executa await self.start()
, e
a corrotina __aexit__
espera pelos métodos corrotina privados rollback
ou commit
,
dependendo da ocorrência ou não de uma exceção.
Usar corrotinas para implementar Transaction
como um gerenciador de contexto assíncrono permite ao asyncpg controlar, de forma concorrente, muitas transações simultâneas.
Tip
|
Caleb Hattingh sobre o asyncpg
Outro detalhe fantástico sobre o asyncpg é que ele também contorna a falta de suporte à alta-concorrência do PostgreSQL (que usa um processo servidor por conexão) implementando um pool de conexões para conexões internas ao próprio Postgres. Isso significa que você não precisa de ferramentas adicionais (por exemplo o pgbouncer), como explicado na documentação (EN) do asyncpg.[6] |
Voltando ao flags_asyncio.py, a classe AsyncClient
do httpx
é um gerenciador de contexto assíncrono, então pode usar esperáveis em seus métodos corrotina especiais __aenter__
e __aexit__
.
Note
|
A Geradores assíncronos como gerenciadores de contexto mostra como usar a |
Agora vamos melhorar o exemplo asyncio de download de bandeiras com uma barra de progresso, que nos levará a explorar um pouco mais da API do asyncio.
Vamos recordar a [flags2_sec], na qual o conjunto de exemplos flags2
compartilhava a mesma interface de linha de comando, e todos mostravam uma barra de progresso enquanto os downloads aconteciam. Eles também incluíam tratamento de erros.
Tip
|
Encorajo você a brincar com os exemplos |
Por exemplo, o Exemplo 5 mostra uma tentativa de obter 100 bandeiras (-al 100
) do servidor ERROR
,
usando 100 conexões concorrentes (-m 100
).
Os 48 erros no resultado são ou HTTP 418 ou erros de tempo de espera excedido (time-out)—o [mau]comportamento esperado do slow_server.py.
$ python3 flags2_asyncio.py -s ERROR -al 100 -m 100
ERROR site: http://localhost:8002/flags
Searching for 100 flags: from AD to LK
100 concurrent connections will be used.
100%|█████████████████████████████████████████| 100/100 [00:03<00:00, 30.48it/s]
--------------------
52 flags downloaded.
48 errors.
Elapsed time: 3.31s
Warning
|
Aja de forma responsável ao testar clientes concorrentes
Mesmo que o tempo total de download não seja muito diferente entre os clientes HTTP na versão com threads e na versão asyncio HTTP , o asyncio é capaz de enviar requisições mais rápido, então aumenta a probabilidade do servidor suspeitar de um ataque DoS. Para exercitar esses clientes concorrentes em sua capacidade máxima, por favor use servidores HTTP locais em seus testes, como explicado no [setting_up_servers_box]. |
Agora vejamos como o flags2_asyncio.py é implementado.
No Exemplo 3, passamos várias corrotinas para asyncio.gather
, que devolve uma lista com os resultados das corrotinas na ordem em que foram submetidas.
Isso significa que asyncio.gather
só pode retornar quando todos os esperáveis terminarem.
Entretanto, para atualizar uma barra de progresso, precisamos receber cada um dos resultados assim que eles estejam prontos.
Felizmente existe um equivalente asyncio
da função geradora as_completed
que usamos no exemplo de pool de threads com a barra de progresso, ([flags2_threadpool_full]).
O Exemplo 6 mostra o início do script flags2_asyncio.py, onde as corrotinas get_flag
e download_one
são definidas. O Exemplo 7 lista o restante do código-fonte, com supervisor
e download_many
.
O script é maior que flags_asyncio.py por causa do tratamento de erros.
link:code/20-executors/getflags/flags2_asyncio.py[role=include]
-
get_flag
é muito similar à versão sequencial no [flags2_basic_http_ex]. Primeira diferença: ele exige o parâmetroclient
. -
Segunda e terceira diferenças:
.get
é um método deAsyncClient
, e é uma corrotina, então precisamosawait
por ela. -
Usa o
semaphore
como um gerenciador de contexto assíncrono, assim o programa como um todo não é bloqueado; apenas essa corrotina é suspensa quando o contador do semáforo é zero. Veja mais sobre isso em Semáforos no Python. -
A lógica de tratamento de erro é idêntica à de
download_one
, do [flags2_basic_http_ex]. -
Salvar a imagem é uma operação de E/S. Para não bloquear o loop de eventos, roda
save_flag
em uma thread.
No asyncio, toda a comunicação de rede é feita com corrotinas, mas não E/S de arquivos. Entretanto, E/S de arquivos também é "bloqueante"—no sentido que ler/escrever arquivos é milhares de vezes mais demorado que ler/escrever na RAM. Se você estiver usando armazenamento conectado à rede, isso pode até envolver E/S de rede internamente.
Desde o Python 3.9, a corrotina asyncio.to_thread
facilitou delegar operações de arquivo para um pool de threads fornecido pelo asyncio.
Se você precisa suportar Python 3.7 ou 3.8,
a Delegando tarefas a executores mostra como fazer isso, adicionando algumas linhas ao seu programa.
Mas primeiro, vamos terminar nosso estudo do código do cliente HTTP.
Clientes de rede como os que estamos estudando devem ser limitados ("throttled") (isto é, desacelerados) para que não martelem o servidor com um número excessivo de requisições concorrentes.
Um semáforo é uma estrutura primitiva de sincronização, mais flexível que uma trava. Um semáforo pode ser mantido por múltiplas corrotinas, com um número máximo configurável. Isso o torna ideial para limitar o número de corrotinas concorrentes ativas. O Semáforos no Python tem mais informações.
No flags2_threadpool.py ([flags2_threadpool_full]),
a limitação era obtida instanciando o ThreadPoolExecutor
com o argumento obrigatório max_workers
fixado em concur_req
na função download_many
.
Em flags2_asyncio.py, um asyncio.Semaphore
é criado pela função supervisor
(mostrada no Exemplo 7)
e passado como o argumento semaphore
para download_one
no Exemplo 6.
O cientista da computação Edsger W. Dijkstra inventou o semáforo no início dos anos 1960.
É uma ideia simples, mas tão flexível que a maioria dos outros objetos de sincronização—tais como as travas e as barreiras—podem ser construídas a partir de semáforos.
Há três classes Semaphore
na biblioteca padrão do Python:
uma em threading
, outra em multiprocessing
, e uma terceira em asyncio
.
Essas classes são parecidas, mas têm implementações bem diferentes.
Aqui vamos descrever a versão de asyncio
.
Um asyncio.Semaphore
tem um contador interno que é decrementado toda vez que
usamos await
no método corrotina .acquire()
,
e incrementado quando chamamos o método .release()
—que não é uma corrotina porque nunca bloqueia. O valor inicial do contador é definido quando o Semaphore
é instanciado:
semaphore = asyncio.Semaphore(concur_req)
Invocar await
em .acquire()
não causa qualquer atraso quando o contador interno
é maior que zero.
Se o contador for 0, entretanto,
.acquire()
suspende a a corrotina que chamou await
até que alguma outra corrotina chame
.release()
no mesmo Semaphore
, incrementando assim o contador.
Em vez de usar esses métodos diretamente,
é mais seguro usar o semaphore
como um gerenciador de contexto assíncrono,
como fiz na função download_one
em Exemplo 6:
async with semaphore:
image = await get_flag(client, base_url, cc)
O método corrotina Semaphore.__aenter__
espera por .acquire()
(usando await
internamente),
e seu método corrotina __aexit__
chama .release()
.
Este async with
garante que não mais que concur_req
instâncias de corrotinas get_flags
estarão ativas a qualquer dado momento.
Cada uma das classes Semaphore
na biblioteca padrão tem uma subclasse BoundedSemaphore
, que impõe uma restrição adicional: o contador interno não pode nunca ficar maior que o valor inicial, quando ocorrerem mais operações .release()
que .acquire()
.[7]
Agora vamos olhar o resto do script em Exemplo 7.
link:code/20-executors/getflags/flags2_asyncio.py[role=include]
-
supervisor
recebe os mesmos argumentos que a funçãodownload_many
, mas ele não pode ser invocado diretamente demain
, pois é uma corrotina e não uma função simples comodownload_many
. -
Cria um
asyncio.Semaphore
que não vai permitir mais queconcur_req
corrotinas ativas entre aquelas usando este semáforo. O valor deconcur_req
é calculado pela funçãomain
de flags2_common.py, baseado nas opções de linha de comando e nas constantes estabelecidas em cada exemplo. -
Cria uma lista de objetos corrotina, um para cada chamada à corrotina
download_one
. -
Obtém um iterador que vai devolver objetos corrotina quando eles terminarem sua execução. Não coloquei essa chamada a
as_completed
diretamente no loopfor
abaixo porque posso precisar envolvê-la com o iteradortqdm
para a barra de progresso, dependendo da opção do usuário para verbosidade. -
Envolve o iterador
as_completed
com a função geradoratqdm
, para mostrar o progresso. -
Declara e inicializa
error
comNone
; essa variável será usada para manter uma exceção além do blocotry/except
, se alguma for levantada. -
Itera pelos objetos corrotina que terminaram a execução; esse loop é similar ao de
download_many
em [flags2_threadpool_full]. -
await
pela corrotina para obter seu resultado. Isso não bloqueia porqueas_completed
só produz corrotinas que já terminaram. -
Essa atribuição é necessária porque o escopo da variável
exc
é limitado a essa cláusulaexcept
, mas preciso preservar o valor para uso posterior. -
Mesmo que acima.
-
Se houve um erro, muda o
status
. -
Se em modo verboso, extrai a URL da exceção que foi levantada…
-
…e extrai o nome do arquivo para mostrar o código do país em seguida.
-
download_many
instancia o objeto corrotinasupervisor
e o passa para o loop de eventos comasyncio.run
, coletando o contador quesupervisor
devolve quando o loop de eventos termina.
No Exemplo 7, não podíamos usar o mapeamento de futures
para os códigos de país que vimos em [flags2_threadpool_full], porque os esperáveis devolvidos por asyncio.as_completed
são os mesmos esperáveis que passamos na chamada a as_completed
. Internamente, o mecanismo do asyncio pode substituir os esperáveis que fornecemos por outros que irão, no fim, produzir os mesmos resultados.[8]
Tip
|
Já que não podia usar os esperáveis como chaves para recuperar os códigos de país de um |
Isso encerra nossa discussão da funcionalidade de um exemplo usando asyncio similar ao flags2_threadpool.py que vimos antes.
O próximo exemplo demonstra um modelo simples de execução de uma tarefa assíncrona após outra usando corrotinas.
Isso merece nossa atenção porque qualquer um com experiência prévia em Javascript sabe que rodar um função assíncrona após outra foi a razão para o padrão de codificação aninhado conhecido como
pyramid of doom (pirâmide da perdição) (EN).
A palavra-chave await
desfaz a maldição.
Por isso await
agora é parte do Python e do Javascript.
Suponha que você queira salvar cada bandeira com o nome e o código do país, em vez de apenas o código. Agora você precisa fazer duas requisições HTTP por bandeira: uma para obter a imagem da bandeira propriamente dita, a outra para obter o arquivo metadata.json, no mesmo diretório da imagem—é nesse arquivo que o nome do país está registrado.
Coordenar múltiplas requisições na mesma tarefa é fácil no script com threads: basta fazer uma requisição depois a outra, bloqueando a thread duas vezes, e mantendo os dois dados (código e nome do país) em variáveis locais, prontas para serem usadas quando os arquivos forem salvo.
Se você precisasse fazer o mesmo em um script assíncrono com callbacks, você precisaria de funções aninhadas, de forma que o código e o nome do país estivessem disponíveis até o momento em que fosse possível salvar o arquivo, pois cada callback roda em um escopo local diferente.
A palavra-chave await
fornece um saída para esse problema, permitindo que você acione as requisições assíncronas uma após a outra, compartilhando o escopo local da corrotina que dirige as ações.
Tip
|
Se você está trabalhando com programação de aplicações assíncronas no Python moderno e recorre a uma grande quantidade de callbacks, provavelmente está aplicando modelos antigos, que não fazem mais sentido no Python atual. Isso é justificável se você estiver escrevendo uma biblioteca que se conecta a código legado ou a código de baixo nível, que não suportem corrotinas. De qualquer forma, o Q&A do StackOverflow, "What is the use case for future.add_done_callback()?" (Qual o caso de uso para future.add_done_callback()?) (EN) explica porque callbacks são necessários em código de baixo nível, mas não são muito úteis hoje em dia em código Python a nível de aplicação. |
A terceira variante do script asyncio
de download de bandeiras traz algumas mudanças:
get_country
-
Essa nova corrotina baixa o arquivo metadata.json daquele código de país, e extrai dele o nome do país.
download_one
-
Essa corrotina agora usa
await
para delegar paraget_flag
e para a nova corrotinaget_country
, usando o resultado dessa última para compor o nome do arquivo a ser salvo.
Vamos começar com o código de get_country
(Exemplo 8).
Observe que ele muito similar ao get_flag
do Exemplo 6.
get_country
link:code/20-executors/getflags/flags3_asyncio.py[role=include]
-
Essa corrotina devolve uma string com o nome do país—se tudo correr bem.
-
metadata
vai receber umdict
Python construído a partir do conteúdo JSON da resposta. -
Devolve o nome do país.
Agora vamos ver o download_one
modificado do Exemplo 9, que tem apenas algumas linhas diferentes da corrotina de mesmo nome do Exemplo 6.
download_one
link:code/20-executors/getflags/flags3_asyncio.py[role=include]
-
Segura o
semaphore
paraawait
porget_flag
… -
…e novamente por
get_country
. -
Usa o nome do país para criar um nome de arquivo. Como usuário da linha de comando, não gosto de ver espaços em nomes de arquivo.
Muito melhor que callbacks aninhados!
Coloquei as chamadas a get_flag
e get_country
em blocos with
separados, controlados pelo semaphore
porque é uma boa prática manter semáforos e travas pelo menor tempo possível.
Eu poderia ter agendado ambos os scripts, get_flag
e get_country
, em paralelo, usando asyncio.gather
, mas se get_flag
levantar uma exceção não haverá imagem para salvar, então seria inútil rodar get_country
. Mas há casos onde faz sentido usar asyncio.gather
para acessar várias APIs simultaneamente, em vez de esperar por uma resposta antes de fazer a próxima requisição
Em flags3_asyncio.py, a sintaxe await
aparece seis vezes, e async with
três vezes.
Espero que você esteja pegando o jeito da programação assíncrona em Python.
Um desafio é saber quando você precisa usar await
e quando você não pode usá-la.
A resposta, em princípio, é fácil: você await
por corrotinas e outros esperáveis, tais como instâncias de asyncio.Task
.
Mas algumas APIs são complexas, misturam corrotinas e funções normais de maneiras aparentemente arbitrárias, como a classe StreamWriter
que usaremos no Exemplo 14.
O Exemplo 9 encerra o grupo de exemplos flags. Vamos agora discutir o uso de executores de threads ou processos na programação assíncrona.
Uma vantagem importante do Node.js sobre o Python para programação assíncrona é a biblioteca padrão do Node.js, que inclui APIs assíncronas para toda a E/S—não apenas para E/S de rede. No Python, se você não for cuidadosa, a E/S de arquivos pode degradar seriamente o desempenho de aplicações assíncronas, pois ler e escrever no armazenamento desde a thread principal bloqueia o loop de eventos.
No corrotina download_one
de Exemplo 6, usei a seguinte linha para salvar a imagem baixada para o disco:
await asyncio.to_thread(save_flag, image, f'{cc}.gif')
Como mencionado antes, o asyncio.to_thread
foi acrescentado no Python 3.9.
Se você precisa suportar 3.7 ou 3.8,
substitua aquela linha pelas linhas em Exemplo 10.
await asyncio.to_thread
link:code/20-executors/getflags/flags2_asyncio_executor.py[role=include]
-
Obtém uma referência para o loop de eventos.
-
O primeiro argumento é o executor a ser utilizado; passar
None
seleciona o default,ThreadPoolExecutor
, que está sempre disponível no loop de eventos doasyncio
. -
Você pode passar argumentos posicionais para a função a ser executada, mas se você precisar passar argumentos de palavra-chave, vai precisar recorrer a
functool.partial
, como descrito na documentação derun_in_executor
.
A função mais recente asyncio.to_thread
é mais fácil de usar e mais flexível, já que também aceita argumentos de palavra-chave.
A própria implementação de asyncio
usa run_in_executor
debaixo dos panos em alguns pontos.
Por exemplo, a corrotina loop.getaddrinfo(…)
, que vimos no Exemplo 1 é implementada chamando a função getaddrinfo
do módulo socket
—uma função bloqueante que pode levar alguns segundos para retornar, pois depende de resolução de DNS.
Um padrão comum em APIs assíncronas é encobrir chamadas bloqueantes que sejam detalhes de implementação nas corrotinas usando run_in_executor
internamente.
Dessa forma, é possível apresentar uma interface consistente de corrotinas a serem acionadas com await
e esconder as threads que precisam ser usadas por razões pragmáticas.
O driver assíncrono para o MongoDB Motor tem uma API compatível com async/await
que na verdade é uma fachada, encobrindo um núcleo de threads que conversa com o servidor de banco de dados.
A. Jesse Jiryu Davis, o principal desenvolvedor do Motor, explica suas razões em
“Response to ‘Asynchronous Python and Databases’” (“_Resposta a ‘O Python Assíncrono e os Bancos de Dados’”).
Spoiler: Davis descobriu que um pool de threads tem melhor desempenho no caso de uso específico de um driver de banco de dados—apesar do mito que abordagens assíncronas são sempre mais rápidas que threads para E/S de rede.
A principal razão para passar um Executor
explícito para loop.run_in_executor
é utilizar um ProcessPoolExecutor
, se a função a ser executada for de uso intensivo da CPU. Dessa forma ela rodará em um processo Python diferente, evitando a disputa pela GIL. Por seu alto custo de inicialização, seria melhor iniciar o ProcessPoolExecutor
no supervisor
, e passá-lo para as corrotinas que precisem utilizá-lo.
Caleb Hattingh—O autor de Using Asyncio in Python (O' Reilly)—é um dos revisores técnicos desse livro, e sugeriu que eu acrescentasse o seguinte aviso sobre executores e o asyncio.
Warning
|
O aviso de Caleb sobre run_in_executors
Usar |
Agora saímos de scripts cliente para escrever servidores com o asyncio
.
O exemplo clássico de um servidor TCP de brinquedo é um
servidor eco. Vamos escrever brinquedos um pouco mais interessantes: utilitários de servidor para busca de caracteres Unicode, primeiro usando HTTP com a FastAPI, depois usando TCP puro apenas com asyncio
.
Esse servidores permitem que os usuários façam consultas sobre caracteres Unicode baseadas em palavras em seus nomes padrão no módulo unicodedata
que discutimos na [unicodedata_sec].
A Figura 2 mostra uma sessão com o web_mojifinder.py, o primeiro servidor que escreveremos.
A lógica de busca no Unicode nesses exemplos é a classe InvertedIndex
no módulo charindex.py no repositório de código do Python Fluente. Não há nada concorrente naquele pequeno módulo, então vou dar apenas um explicação breve sobre ele, no box opcional a seguir. Você pode pular para a implementação do servidor HTTP na Um serviço web com FastAPI.
Um índice invertido normalmente mapeia palavras a
documentos onde elas ocorrem.
Nos exemplos mojifinder, cada "documento" é o nome de um caractere Unicode.
A classe charindex.InvertedIndex
indexa cada palavra que aparece no nome de
cada caractere no banco de dados Unicode, e cria um índice invertido em um defaultdict
.
Por exemplo, para indexar o caractere U+0037—DIGIT SEVEN—o construtor
de InvertedIndex
anexa o caractere '7'
aos registros sob as chaves 'DIGIT'
e 'SEVEN'
.
Após indexar os dados do Unicode 13.0.0 incluídos no Python 3.10, 'DIGIT'
será mapeado para
868 caracteres que tem essa palavra em seus nomes;
e 'SEVEN'
para 143, incluindo U+1F556—CLOCK FACE SEVEN OCLOCK e
U+2790—DINGBAT NEGATIVE CIRCLED SANS-SERIF DIGIT SEVEN.
O método InvertedIndex.search
quebra a consulta em palavras separadas, e devolve a intersecção dos registros para cada palavra.
É por isso que buscar por "face" encontra 171 resultados, "cat" encontra 14, mas "cat face" apenas 10.
Essa é a bela ideia por trás dos índices invertidos: uma pedra fundamental da recuperação de informação—a teoria por trás dos mecanismos de busca. Veja o artigo "Listas Invertidas" na Wikipedia para saber mais.
Escrevi o próximo exemplo—web_mojifinder.py—usando a FastAPI: uma dos frameworks ASGI de desenvolvimento Web do Python, mencionada na [asgi_note]. A Figura 2 é uma captura de tela da interface de usuário. É uma aplicação muito simples, de uma página só (SPA, Single Page Application): após o download inicial do HTML, a interface é atualizada via Javascript no cliente, em comunicação com o servidor.
A FastAPI foi projetada para implementar o lado servidor de SPAs and apps móveis,
que consistem principalmente de pontos de acesso de APIs web, devolvendo respostas JSON em vez de HTML renderizado no servidor. A FastAPI se vale de decoradores, dicas de tipo e introspecção de código para eliminar muito do código repetitivo das APIs web, e também publica automaticamente uma documentação no padrão OpenAPI—a.k.a. Swagger—para a API que criamos.
A Figura 4 mostra a página /docs
para o web_mojifinder.py, gerada automaticamente.
O Exemplo 11 é o código do web_mojifinder.py, mas aquele é apenas o código do lado servidor. Quando você acessa a URL raiz /
, o servidor envia o arquivo form.html, que contém 81 linhas de código, incluindo 54 linhas de Javascript para comunicação com o servidor e preenchimento de uma tabela com os resultados. Se você estiver interessado em ler Javascript puro sem uso de frameworks, vá olhar o 21-async/mojifinder/static/form.html no
repositório de código do Python Fluente.
Para rodar o web_mojifinder.py, você precisa instalar dois pacotes e suas dependências: FastAPI e uvicorn.[10]
Este é o comando para executar o Exemplo 11 com uvicorn em modo de desenvolvimento:
$ uvicorn web_mojifinder:app --reload
os parâmetros são:
web_mojifinder:app
-
O nome do pacote, dois pontos, e o nome da aplicação ASGI definida nele—
app
é o nome usado por convenção. --reload
-
Faz o uvicorn monitorar mudanças no código-fonte da aplicação, e recarregá-la automaticamente. Útil apenas durante o desenvolvimento.
Vamos agora olhar o código-fonte do web_mojifinder.py.
link:code/21-async/mojifinder/web_mojifinder.py[role=include]
-
Não relacionado ao tema desse capítulo, mas digno de nota: o uso elegante do operador
/
sobrecarregado porpathlib
.[11] -
Essa linha define a app ASGI. Ela poderia ser tão simples como
app = FastAPI()
. Os parâmetros mostrados são metadata para a documentação auto-gerada. -
Um schema pydantic para uma resposta JSON, com campos
char
ename
.[12] -
Cria o
index
e carrega o formulário HTML estático, anexando ambos aoapp.state
para uso posterior. -
Roda
init
quando esse módulo é carregado pelo servidor ASGI. -
Rota para o ponto de acesso
/search
;response_model
usa aquele modeloCharName
do pydantic para descrever o formato da resposta. -
A FastAPI assume que qualquer parâmetro que apareça na assinatura da função ou da corrotina e que não esteja no caminho da rota será passado na string de consulta HTTP, isto é,
/search?q=cat
. Comoq
não tem default, a FastAPI devolverá um status 422 (Unprocessable Entity, Entidade Não-Processável) seq
não estiver presente na string da consulta. -
Devolver um iterável de
dicts
compatível com o schemaresponse_model
permite ao FastAPI criar uma resposta JSON de acordo com oresponse_model
no decorador@app.get
, -
Funções regulares (isto é, não-assíncronas) também podem ser usadas para produzir respostas.
-
Este módulo não tem uma função principal. É carregado e acionado pelo servidor ASGI—neste exemplo, o uvicorn.
O Exemplo 11 não tem qualquer chamada direta ao asyncio
.
O FastAPI é construído sobre o tollkit ASGI Starlette, que por sua vez usa o asyncio
.
Observe também que o corpo de search
não usa await
, async with
, ou async for
,
e assim poderia ser uma função normal.
Defini search
como um corrotina apenas para mostrar que o FastAPI sabe como lidar com elas.
Em uma aplicação real, a maioria dos pontos de acesso serão consultas a bancos de dados ou acessos a outros servidores remotos, então é uma vantagem crítica do FastAPI—e de frameworks ASGI em geral—
suportarem corrotinas que podem se valer de bibliotecas assíncronas para E/S de rede.
Tip
|
As funções |
Os entusiastas pela tipagem podem ter notado que não há dicas de tipo para os resultados devolvidos por search
e form
.
Em vez disto, o FastAPI aceita o argumento nomeado response_model=
nos decoradores de rota.
A página "Modelo de Resposta" (EN) na documentação do FastAPI explica:
O modelo de resposta é declarado neste parâmetro em vez de como uma anotação de tipo de resultado devolvido por uma função, porque a função de rota pode não devolver aquele modelo de resposta mas sim um
dict
, um objeto banco de dados ou algum outro modelo, e então usar oresponse_model
para realizar a limitação de campo e a serialização.
Por exemplo, em search
, eu devolvi um gerador de itens dict
e não uma lista de objetos CharName
,
mas isso é o suficiente para o FastAPI e o pydantic validarem meus dados e construírem a resposta JSON apropriada, compatível com response_model=list[CharName]
.
Agora vamos nos concentrar no script tcp_mojifinder.py, que responde às consultas, na Figura 5.
O programa tcp_mojifinder.py usa TCP puro para se comunicar com um cliente como o Telnet ou o Netcat, então pude escrevê-lo usando asyncio
sem dependências externas—e sem reinventar o HTTP. O Figura 5 mostra a interface de texto do usuário.
Este programa é duas vezes mais longo que o web_mojifinder.py, então dividi sua apresentação em três partes:
Exemplo 12, Exemplo 14, e Exemplo 15.
O início de tcp_mojifinder.py—incluindo os comandos import
—está no Exemplo 14,mas vou começar descrevendo a corrotina supervisor
e a função main
que controla o programa.
link:code/21-async/mojifinder/tcp_mojifinder.py[role=include]
-
Este
await
rapidamente recebe um instância deasyncio.Server
, um servidor TCP baseado em sockets. Por default,start_server
cria e inicia o servidor, então ele está pronto para receber conexões. -
O primeiro argumento para
start_server
éclient_connected_cb
, um callback para ser executado quando a conexão com um novo cliente se inicia. O callback pode ser uma função ou uma corrotina, mas precisa aceitar exatamente dois argumentos: umasyncio.StreamReader
e umasyncio.StreamWriter
. Entretanto, minha corrotinafinder
também precisa receber umindex
, então useifunctools.partial
para vincular aquele parâmetro e obter um invocável que receber o leitor (asyncio.StreamReader
) e o escritor (asyncio.StreamWriter
). Adaptar funções do usuário a APIs de callback é o caso de uso mais comum defunctools.partial
. -
host
eport
são o segundo e o terceiro argumentos destart_server
. Veja a assinatura completa na documentação doasyncio
. -
Este
cast
é necessário porque o typeshed tem uma dica de tipo desatualizada para a propriedadesockets
da classeServer
—isso em maio de 2021. Veja Issue #5535 no typeshed.[13] -
Mostra o endereço e a porta do primeiro socket do servidor.
-
Apesar de
start_server
já ter iniciado o servidor como uma tarefa concorrente, preciso usar oawait
no métodoserver_forever
, para que meusupervisor
seja suspenso aqui. Sem essa linha, osupervisor
retornaria imediatamente, encerrando o loop iniciado comasyncio.run(supervisor(…))
, e fechando o programa. A documentação deServer.serve_forever
diz: "Este método pode ser chamado se o servidor já estiver aceitando conexões." -
Constrói o índice invertido.[14]
-
Inicia o loop de eventos rodando
supervisor
. -
Captura
KeyboardInterrupt
para evitar o traceback dispersivo quando encerro o servidor com Ctrl-C, no terminal onde ele está rodando.
Pode ser mais fácil entender como o controle flui em tcp_mojifinder.py estudando a saída que ele gera no console do servidor, listada em Exemplo 13.
$ python3 tcp_mojifinder.py
Building index. # (1)
Serving on ('127.0.0.1', 2323). Hit Ctrl-C to stop. # (2)
From ('127.0.0.1', 58192): 'cat face' # (3)
To ('127.0.0.1', 58192): 10 results.
From ('127.0.0.1', 58192): 'fire' # (4)
To ('127.0.0.1', 58192): 11 results.
From ('127.0.0.1', 58192): '\x00' # (5)
Close ('127.0.0.1', 58192). # (6)
^C # (7)
Server shut down. # (8)
$
-
Saída de
main
. Antes da próxima linha surgir, vi um intervalo de 0,6s na minha máquina, enquanto o índice era construído. -
Saída de
supervisor
. -
Primeira iteração de um loop
while
emfinder
. A pilha TCP/IP atribuiu a porta 58192 a meu cliente Telnet. Se você conectar diversos clientes ao servidor, verá suas várias portas aparecerem na saída. -
Segunda iteração do loop
while
emfinder
. -
Eu apertei Ctrl-C no terminal cliente; o loop
while
emfinder
termina. -
A corrotina
finder
mostra essa mensagem e então encerra. Enquanto isso o servidor continua rodando, pronto para receber outro cliente. -
Aperto Ctrl-C no terminal do servidor;
server.serve_forever
é cancelado, encerrandosupervisor
e o loop de eventos. -
Saída de
main
.
Após main
construir o índice e iniciar o loop de eventos, supervisor
rapidamente mostra a mensagem Serving on…
, e é suspenso na linha await server.serve_forever()
. Nesse ponto o controle flui para dentro do loop de eventos e lá permanece, voltando ocasionalmente para a corrotina finder
, que devolve o controle de volta para o loop de eventos sempre que precisa esperar que a rede envie ou receba dados.
Enquanto o loop de eventos estiver ativo, uma nova instância da corrotina finder
será iniciada para cada cliente que se conecte ao servidor. Dessa forma, múltiplos clientes podem ser atendidos de forma concorrente por esse servidor simples. Isso segue até que ocorra um KeyboardInterrupt
no servidor ou que seu processo seja eliminado pelo SO.
Agora vamos ver o início de tcp_mojifinder.py, com a corrotina finder
.
link:code/21-async/mojifinder/tcp_mojifinder.py[role=include]
-
format_results
é útil para mostrar os resultado deInvertedIndex.search
em uma interface de usuário baseada em texto, como a linha de comando ou uma sessão Telnet. -
Para passar
finder
paraasyncio.start_server
, a envolvi comfunctools.partial
, porque o servidor espera uma corrotina ou função que receba apenas os argumentosreader
ewriter
. -
Obtém o endereço do cliente remoto ao qual o socket está conectado.
-
Esse loop controla um diálogo que persiste até um caractere de controle ser recebido do cliente.
-
O método
StreamWriter.write
não é uma corrotina, é apenas um função normal; essa linha envia o prompt?>
. -
O
StreamWriter.drain
esvazia o buffer dewriter
; ela é uma corrotina, então precisa ser acionada comawait
. -
StreamWriter.readline
é um corrotina que devolvebytes
. -
Se nenhum byte foi recebido, o cliente fechou a conexão, então sai do loop.
-
Decodifica os
bytes
parastr
, usando a codificação UTF-8 como default. -
Pode ocorrer um
UnicodeDecodeError
quando o usuário digita Ctrl-C e o cliente Telnet envia caracteres de controle; se isso acontecer, substitui a consulta pelo caractere null, para simplificar. -
Registra a consulta no console do servidor.
-
Sai do loop se um caractere de controle ou null foi recebido.
-
search
realiza a busca efetiva; o código será apresentado a seguir. -
Registra a resposta no console do servidor.
-
Fecha o
StreamWriter
. -
Espera até
StreamWriter
fechar. Isso é recomendado na documentação do método.close()
. -
Registra o final dessa sessão do cliente no console do servidor.
O último pedaço desse exemplo é a corrotina search
, listada no Exemplo 15.
search
link:code/21-async/mojifinder/tcp_mojifinder.py[role=include]
-
search
tem que ser uma corrotina, pois escreve em umStreamWriter
e precisa usar seu método corrotina.drain()
. -
Consulta o índice invertido.
-
Essa expressão geradora vai produzir strings de bytes codificadas em UTF-8 com o ponto de código Unicode, o caractere efetivo, seu nome e uma sequência
CRLF
(Return+Line Feed), isto é,b’U+0039\t9\tDIGIT NINE\r\n'
. -
Envia a
lines
. Surpreendentemente,writer.writelines
não é uma corrotina. -
Mas
writer.drain()
é uma corrotina. Não esqueça doawait
! -
Cria depois envia uma linha de status.
Observe que toda a E/S de rede em tcp_mojifinder.py é feita em bytes
; precisamos decodificar os bytes
recebidos da rede, e codificar strings antes de enviá-las. No Python 3, a codificação default é UTF-8, e foi o que usei implicitamente em todas as chamadas a encode
e decode
nesse exemplo.
Warning
|
Veja que alguns dos métodos de E/S são corrotinas, e precisam ser acionados com |
O código de tcp_mojifinder.py se vale da API Streams de alto nível do asyncio
, que fornece um servidor pronto para ser usado, de forma que basta implemetar uma função de processamento, que pode ser um callback simples ou uma corrotina. Há também uma API de Transportes e Protocolos (EN) de baixo nível, inspirada pelas abstrações transporte e protocolo do framework Twisted. Veja a documentação do asyncio
para maiores informações, incluindo os servidores echo e clientes TCP e UDP implementados com aquela API de nível mais baixo.
Nosso próximo tópico é async for
e os objetos que a fazem funcionar.
Na Gerenciadores de contexto assíncronos vimos como async with
funciona com objetos que implementam os métodos __aenter__
and __aexit__
, devolvendo esperáveis—normalmente na forma de objetos corrotina.
Se forma similar, async for
funciona com iteráveis assíncronos: objetos que implementam __aiter__
. Entretanto, __aiter__
precisa ser um método regular—não um método corrotina—e precisa devolver um iterador assíncrono.
Um iterador assíncrono fornece um método corrotina __anext__
que devolve um esperável—muitas vezes um objeto corrotina. Também se espera que eles implementem __aiter__
, que normalmente devolve self
. Isso espelha a importante distinção entre iteráveis e iteradores que discutimos na [iterable_not_self_iterator_sec].
A documentação (EN) do driver assíncrono de PostgreSQL aiopg traz um exemplo que ilustra o uso de async for
para iterar sobre as linhas de cursor de banco de dados.
async def go():
pool = await aiopg.create_pool(dsn)
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("SELECT 1")
ret = []
async for row in cur:
ret.append(row)
assert ret == [(1,)]
Nesse exemplo, a consulta vai devolver uma única linha, mas em um cenário realista é possível receber milhares de linhas na resposta a um SELECT
.
Para respostas grandes, o cursor não será carregado com todas as linhas de uma vez só.
Assim é importante que async for row in cur:
não bloqueie o loop de eventos enquanto o cursor pode estar esperando por linhas adicionais.
Ao implementar o cursor como um iterador assíncrono, aiopg pode devolver o controle para o loop de eventos a cada chamada a __anext__
, e continuar mais tarde, quando mais linhas cheguem do PostgreSQL.
Você pode implementar um iterador assíncrono escrevendo uma classe com __anext__
e __aiter__
, mas há um jeito mais simples: escreve uma função declarada com async def
que use yield
em seu corpo.
Isso é paralelo à forma como funções geradoras simplificam o modelo clássico do Iterador.
Vamos estudar um exemplo simples usando async for
e implementando um gerador assíncrono.
No Exemplo 1 vimos blogdom.py, um script que sondava nomes de domínio.
Suponha agora que encontramos outros usos para a corrotina probe
definida ali, e decidimos colocá-la em um novo módulo—domainlib.py—junto com um novo gerador assíncrono multi_probe
, que recebe uma lista de nomes de domínio e produz resultados conforme eles são sondados.
Vamos ver a implementação de domainlib.py logo, mas primeiro examinaremos como ele é usado com o novo console assíncrono do Python.
Desde o Python 3.8, é possível rodar o interpretador com a opção de linha de comando -m asyncio
, para obter um "async REPL": um console de Python que importa asyncio
, fornece um loop de eventos ativo, e aceita await
, async for
, e async with
no prompt principal—que em qualquer outro contexto são erros de sintaxe quando usados fora de corrotinas nativas.[15]
Para experimentar com o domainlib.py, vá ao diretório 21-async/domains/asyncio/ na sua cópia local do repositório de código do Python Fluente. Aí rode::
$ python -m asyncio
Você verá o console iniciar, de forma similar a isso:
asyncio REPL 3.9.1 (v3.9.1:1e5d33e9b9, Dec 7 2020, 12:10:52)
[Clang 6.0 (clang-600.0.57)] on darwin
Use "await" directly instead of "asyncio.run()".
Type "help", "copyright", "credits" or "license" for more information.
>>> import asyncio
>>>
Veja como o cabeçalho diz que você pode usar await
em vez de asyncio.run()
—para acionar corrotinas e outros esperáveis.
E mais: eu não digitei import asyncio
.
O módulo asyncio
é automaticamente importado e aquela linha torna esse fato claro para o usuário.
Vamos agora importar domainlib.py e brincar com suas duas corrotinas: probe
and multi_probe
(Exemplo 16).
python3 -m asyncio
>>> await asyncio.sleep(3, 'Rise and shine!') # (1)
'Rise and shine!'
>>> from domainlib import *
>>> await probe('python.org') # (2)
Result(domain='python.org', found=True) # (3)
>>> names = 'python.org rust-lang.org golang.org no-lang.invalid'.split() # (4)
>>> async for result in multi_probe(names): # (5)
... print(*result, sep='\t')
...
golang.org True # (6)
no-lang.invalid False
python.org True
rust-lang.org True
>>>
-
Tente um simples
await
para ver o console assíncrono em ação. Dica:asyncio.sleep()
pode receber um segundo argumento opcional que é devolvido quando você usaawait
com ele. -
Acione a corrotina
probe
. -
A versão
domainlib
deprobe
devolve uma tupla nomeadaResult
. -
Faça um lista de domínios. O domínio de nível superior
.invalid
é reservado para testes. Consultas ao DNS por tais domínios sempre recebem uma resposta NXDOMAIN dos servidores DNS, que quer dizer "aquele domínio não existe."[16] -
Itera com
async for
sobre o gerador assíncronomulti_probe
para mostrar os resultados. -
Note que os resultados não estão na ordem em que os domínios foram enviados a
multiprobe
. Eles aparecem quando cada resposta do DNS chega.
O Exemplo 16 mostra que multi_probe
é um gerador assíncrono, pois ele é compatível com async for
. Vamos executar mais alguns experimentos, continuando com o Exemplo 17.
>>> probe('python.org') # (1)
<coroutine object probe at 0x10e313740>
>>> multi_probe(names) # (2)
<async_generator object multi_probe at 0x10e246b80>
>>> for r in multi_probe(names): # (3)
... print(r)
...
Traceback (most recent call last):
...
TypeError: 'async_generator' object is not iterable
-
Chamar uma corrotina nativa devolve um objeto corrotina.
-
Chamar um gerador assíncrono devolve um objeto
async_generator
. -
Não podemos usar um loop
for
regular com geradores assíncronos, porque eles implementam__aiter__
em vez de__iter__
.
Geradores assíncronos são acionados por async for
, que pode ser um comando bloqueante (como visto em Exemplo 16), e também podem aparecer em compreensões assíncronas, que veremos mais tarde.
Vamos agora estudar o código do domainlib.py, com o gerador assíncrono multi_probe
(Exemplo 18).
link:code/21-async/domains/asyncio/domainlib.py[role=include]
-
A
NamedTuple
torna o resultado deprobe
mais fácil de ler e depurar. -
Este apelido de tipo serve para evitar que a linha seguinte fique grande demais em uma listagem impressa em um livro.
-
probe
agora recebe um argumento opcionalloop
, para evitar chamadas repetidas aget_running_loop
quando essa corrotina é acionada pormulti_probe
. -
Uma função geradora assíncrona produz um objeto gerador assíncrono, que pode ser anotado como
AsyncIterator[SomeType]
. -
Constrói uma lista de objetos corrotina
probe
, cada um com umdomain
diferente. -
Isso não é
async for
porqueasyncio.as_completed
é um gerador clássico. -
Espera pelo objeto corrotina para obter o resultado.
-
Produz
result
. Esta linha faz com quemulti_probe
seja um gerador assíncrono.
Note
|
O loop for coro in asyncio.as_completed(coros):
yield await coro O Python interpreta isso como Achei que poderia ser confuso usar esse atalho no primeiro exemplo de gerador assíncrono no livro, então dividi em duas linhas. |
Dado domainlib.py, podemos demonstrar o uso do gerador assíncrono multi_probe
em domaincheck.py: um script que recebe um sufixo de domínio e busca por domínios criados a partir de palavras-chave curtas do Python.
Aqui está uma amostra da saída de domaincheck.py:
$ ./domaincheck.py net
FOUND NOT FOUND
===== =========
in.net
del.net
true.net
for.net
is.net
none.net
try.net
from.net
and.net
or.net
else.net
with.net
if.net
as.net
elif.net
pass.net
not.net
def.net
Graças à domainlib, o código de domaincheck.py é bastante direto, como se vê no Exemplo 19.
link:code/21-async/domains/asyncio/domaincheck.py[role=include]
-
Gera palavras-chave de tamanho até
4
. -
Gera nomes de domínio com o sufixo recebido como TLD (Top Level Domain, "Domínio de Topo").
-
Formata um cabeçalho para a saída tabular.
-
Itera de forma assíncrona sobre
multi_probe(domains)
. -
Define
indent
como zero ou dois tabs, para colocar o resultado na coluna correta. -
Roda a corrotina
main
com o argumento de linha de comando passado.
Geradores tem um uso adicional, não relacionado à iteração: ele podem ser usados como gerenciadores de contexto. Isso também se aplica aos geradores assíncronos.
Escrever nossos próprios gerenciadores de contexto assíncronos não é uma tarefa de programação frequente, mas se você precisar escrever um, considere usar o decorador @asynccontextmanager
(EN), incluído no módulo contextlib
no Python 3.7.
Ele é muito similar ao decorador @contextmanager
que estudamos na [using_cm_decorator_sec].
Um exemplo interessante da combinação de @asynccontextmanager
com loop.run_in_executor
aparece no livro de Caleb Hattingh,
Using Asyncio in Python. O Exemplo 20 é o código de Caleb—com uma única mudança e o acréscimo das explicações.
@asynccontextmanager
e loop.run_in_executor
from contextlib import asynccontextmanager
@asynccontextmanager
async def web_page(url): # (1)
loop = asyncio.get_running_loop() # (2)
data = await loop.run_in_executor( # (3)
None, download_webpage, url)
yield data # (4)
await loop.run_in_executor(None, update_stats, url) # (5)
async with web_page('google.com') as data: # (6)
process(data)
-
A função decorada tem que ser um gerador assíncrono.
-
Uma pequena atualização no código de Caleb: usar o
get_running_loop
, mais leve, no lugar deget_event_loop
. -
Suponha que
download_webpage
é uma função bloqueante que usa a biblioteca requests; vamos rodá-la em uma thread separada, para evitar o bloqueio do loop de eventos. -
Todas as linhas antes dessa expressão
yield
vão se tornar o método corrotina__aenter__
do gerenciador de contexto assíncrono criado pelo decorador. O valor dedata
será vinculado à variáveldata
após a cláusulaas
no comandoasync with
abaixo. -
As linhas após o
yield
se tornarão o método corrotina__aexit__
. Aqui outra chamada bloqueante é delegada para um executor de threads. -
Usa
web_page
comasync with
.
Isso é muito similar ao decorador sequencial @contextmanager
.
Por favor, consulte a [using_cm_decorator_sec] para maiores detalhes, inclusive o tratamento de erro na linha do yield
.
Para outro exemplo usando @asynccontextmanager
, veja a
documentação do contextlib
.
Por fim, vamos terminar nossa jornada pelas funções geradoras assíncronas comparado-as com as corrotinas nativas.
Aqui estão algumas semelhanças e diferenças fundamentais entre uma corrotina nativa e uma função geradora assíncrona:
-
Ambas são declaradas com
async def
. -
Um gerador assíncrono sempre tem uma expressão
yield
em seu corpo—é isso que o torna um gerador. Uma corrotina nativa nunca contém umyield
. -
Uma corrotina nativa pode
return
(devolver) algum valor diferente deNone
. Um gerador assíncrono só pode usar comandosreturn
vazios. -
Corrotinas nativas são esperáveis: elas podem ser acionadas por expressões
await
ou passadas para alguma das muitas funções doasyncio
que aceitam argumentos esperáveis, tal comocreate_task
. Geradores assíncronos não são esperáveis. Eles são iteráveis assíncronos, acionados porasync for
ou por compreensões assíncronas.
É hora então de falar sobre as compreensões assíncronas.
A PEP 530—Asynchronous Comprehensions (EN) introduziu o uso de async for
e await
na sintaxe de compreensões e expressões geradoras, a partir do Python 3.6.
A única sintaxe definida na PEP 530 que pode aparecer fora do corpo
de uma async def
é uma expressão geradora assíncrona.
Dado o gerador assíncrono multi_probe
do Exemplo 18,
poderíamos escrever outro gerador assíncrono que devolvesse apenas os nomes de domínios encontrados.
Aqui está uma forma de fazer isso—novamente usando o console assíncrono iniciado com -m asyncio
:
>>> from domainlib import multi_probe
>>> names = 'python.org rust-lang.org golang.org no-lang.invalid'.split()
>>> gen_found = (name async for name, found in multi_probe(names) if found) # (1)
>>> gen_found
<async_generator object <genexpr> at 0x10a8f9700> # (2)
>>> async for name in gen_found: # (3)
... print(name)
...
golang.org
python.org
rust-lang.org
-
O uso de
async for
torna isso uma expressão geradora assíncrona. Ela pode ser definida em qualquer lugar de um módulo Python. -
A expressão geradora assíncrona cria um objeto
async_generator
—exatamente o mesmo tipo de objeto devolvido por uma função geradora assíncrona comomulti_probe
. -
O objeto gerador assíncrono é acionado pelo comando
async for
, que por sua vez só pode aparecer dentro do corpo de umaasync def
ou no console assíncrono mágico que eu usei nesse exemplo.
Resumindo: uma expressão geradora assíncrona pode ser definida em qualquer ponto do seu programa, mas só pode ser consumida dentro de uma corrotina nativa ou de uma função geradora assíncrona.
As demais construções sintáticas introduzidos pela PEP 530 só podem ser definidos e usados dentro de corrotinas nativas ou de funções geradoras assíncronas.
Yury Selivanov—autor da PEP 530—justifica a necessidade de compreensões assíncronas com três trechos curtos de código, reproduzidos a seguir.
Podemos todos concordar que deveria ser possível reescrever esse código:
result = []
async for i in aiter():
if i % 2:
result.append(i)
assim:
result = [i async for i in aiter() if i % 2]
Além disso, dada uma corrotina nativa fun
, deveria ser possível escrever isso:
result = [await fun() for fun in funcs]
Tip
|
Usar |
Voltemos ao console assíncrono mágico:
>>> names = 'python.org rust-lang.org golang.org no-lang.invalid'.split()
>>> names = sorted(names)
>>> coros = [probe(name) for name in names]
>>> await asyncio.gather(*coros)
[Result(domain='golang.org', found=True),
Result(domain='no-lang.invalid', found=False),
Result(domain='python.org', found=True),
Result(domain='rust-lang.org', found=True)]
>>> [await probe(name) for name in names]
[Result(domain='golang.org', found=True),
Result(domain='no-lang.invalid', found=False),
Result(domain='python.org', found=True),
Result(domain='rust-lang.org', found=True)]
>>>
Observe que eu ordenei a lista de nomes, para mostrar que os resultados chegam na ordem em que foram enviados, nos dois casos.
A PEP 530 permite o uso de async for
e await
em compreensões de lista, bem como em compreensões de dict
e de set
. Por exemplo, aqui está uma compreensão de dict
para armazenar os resultados de multi_probe
no console assíncrono:
>>> {name: found async for name, found in multi_probe(names)}
{'golang.org': True, 'python.org': True, 'no-lang.invalid': False,
'rust-lang.org': True}
Podemos usar a palavra-chave await
na expressão antes de cláusulas for
ou de async for
, e também na expressão após a cláusula if
.
Aqui está uma compreensão de set
no console assíncrono, coletando apenas os domínios encontrados.
>>> {name for name in names if (await probe(name)).found}
{'rust-lang.org', 'python.org', 'golang.org'}
Precisei colocar parênteses extras ao redor da expressão await
devido à precedência mais alta do operador .
(ponto) de __getattr__
.
Repetindo, todas essas compreensões só podem aparecer no corpo de uma async def
ou no console assíncrono encantado.
Agora vamos discutir um recurso muito importante dos comandos async
, das expressões async
, e dos objetos que eles criam.
Esses artefatos são muitas vezes usados com o asyncio mas, na verdade, eles são independentes da biblioteca.
Os elementos da linguagem async/await
do Python não estão presos a nenhum loop de eventos ou biblioteca específicos.[17]
Graças à API extensível fornecida por métodos especiais, qualquer um suficientemente motivado pode escrever seu ambiente de runtime e suo framework assíncronos para acionar corrotinas nativas, geradores assíncronos, etc.
Foi isso que David Beazley fez em seu projeto Curio.
Ele estava interessado em repensar como esses recursos da linguagem poderiam ser usados em um framework desenvolvido do zero. Lembre-se que o asyncio
foi lançado no Python 3.4, e usava yield from
em vez de await
, então sua API não conseguia aproveitar gerenciadores de contexto assíncronos, iteradores assíncronos e tudo o mais que as palavras-chave async/await
tornaram possível. O resultado é que o Curio tem uma API mais elegante e uma implementação mais simples quando comparado ao asyncio
.
O Exemplo 21 mostra o script blogdom.py (Exemplo 1) reescrito para usar o Curio.
link:code/21-async/domains/curio/blogdom.py[role=include]
-
probe
não precisa obter o loop de eventos, porque… -
…
getaddrinfo
é uma função nível superior decurio.socket
, não um método de um objetoloop
—como ele é noasyncio
. -
Um
TaskGroup
é um conceito central no Curio, para monitorar e controlar várias corrotinas, e para garantir que elas todas sejam executadas e terminadas corretamente. -
TaskGroup.spawn
é como você inicia uma corrotina, gerenciada por uma instância específica deTaskGroup
. A corrotina é envolvida em umaTask
. -
Iterar com
async for
sobre umTaskGroup
produz instâncias deTask
a medida que cada uma termina. Isso corresponde à linha em Exemplo 1 que usafor … as_completed(…):
. -
O Curio foi pioneiro no uso dessa maneira sensata de iniciar um programa assíncrono em Python.
Para expandir esse último ponto: se você olhar nos exemplo de código de asyncio
na primeira edição do Python Fluente, verá linhas como as seguintes, repetidas várias vezes:
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()
Um TaskGroup
do Curio é um gerenciador de contexto assíncrono que substitui várias APIs e padrões de codificação ad hoc do asyncio
.
Acabamos de ver como iterar sobre um TaskGroup
torna a função asyncio.as_completed(…)
desnecessária.
Outro exemplo: em vez da função especial gather
, este trecho da
documentação de "Task Groups" (EN)
coleta os resultados de todas as tarefas no grupo:
async with TaskGroup(wait=all) as g:
await g.spawn(coro1)
await g.spawn(coro2)
await g.spawn(coro3)
print('Results:', g.results)
Grupos de tarefas (task groups) suportam concorrência estruturada:
uma forma de programação concorrente que restringe todas a atividade de um grupo de tarefas assíncronas a um único ponto de entrada e saída.
Isso é análogo à programaçào estruturada, que eliminou o comando GOTO
e introduziu os comandos de bloco para limitar os pontos de entrada e saída de loops e sub-rotinas.
Quando usado como um gerenciador de contexto assíncrono, um TaskGroup
garante que na saída do bloco, todas as tarefas criadas dentro dele estão ou finalizadas ou canceladas e qualquer exceção foi levantada.
Note
|
A concorrência estruturada vai provavelmente ser adotada pelo |
Outro importante recurso do Curio é um suporte melhor para programar com corrotinas e threads na mesma base de código—uma necessidade de qualquer programa assíncrono não-trivial.
Iniciar uma thread com await spawn_thread(func, …)
devolve um objeto AsyncThread
com uma interface de Task
. As threads podem chamar corrotinas, graças à função especial AWAIT(coro)
—escrita inteiramente com maiúsculas porque await
agora é uma palavra-chave.
O Curio também oferece uma UniversalQueue
que pode ser usada para coordenar o trabalho entre threads, corrotinas Curio e corrotinas asyncio
.
Isso mesmo, o Curio tem recursos que permitem que ele rode em uma thread junto com asyncio
em outra thread, no mesmo processo, se comunicando através da UniversalQueue
e de UniversalEvent
.
A API para essas classes "universais" é a mesma dentro e fora de corrotinas, mas em uma corrotina é preciso preceder as chamadas com await
.
Em outubro de 2021, quando estou escrevendo esse capítulo, a HTTPX é a primeira biblioteca HTTP cliente compatível com o Curio, mas não sei de nenhuma biblioteca assíncrona de banco de dados que o suporte nesse momento. No repositório do Curio há um conjunto impressionante de exemplos de programação para rede, incluindo um que utiliza WebSocket, e outro implementando o algoritmo concorrente RFC 8305—Happy Eyeballs, para conexão com pontos de acesso IPv6 com rápido recuo para IPv4 se necessário.
O design do Curio tem tido grande influência.
o framework Trio, iniciada por Nathaniel J. Smith,
foi muito inspirada pelo Curio.
O Curio pode também ter alertado os contribuidores do Python a melhorarem a usabilidade da API asyncio
.
Por exemplo, em suas primeiras versões, os usuários do asyncio
muitas vezes eram obrigados a obter e ficar passando um objeto loop
, porque algumas funções essenciais eram ou métodos de loop
ou exigiam um argumento loop
.
Em versões mais recentes do Python, acesso direto ao loop não é mais tão necessário e, de fato, várias funções que aceitavam um loop
opcional estão agora descontinuando aquele argumento.
Anotações de tipo para tipos assíncronos é o nosso próximo tópico.
O tipo devolvido por uma corrotina nativa é o tipo do objeto que você obtém quando usa await
naquela corrotina, que é o tipo do objeto que aparece nos comandos return
no corpo da função corrotina nativa.[18]
Nesse capítulo mostro muitos exemplos de corrotinas nativas anotadas, incluindo a probe
do Exemplo 21:
async def probe(domain: str) -> tuple[str, bool]:
try:
await socket.getaddrinfo(domain, None)
except socket.gaierror:
return (domain, False)
return (domain, True)
Se você precisar anotar um parâmetro que recebe um objeto corrotina, então o tipo genérico é:
class typing.Coroutine(Awaitable[V_co], Generic[T_co, T_contra, V_co]):
...
Aquele tipo e os tipos seguintes foram introduzidos no Python 3.5 e 3.6 para anotar objetos assíncronos:
class typing.AsyncContextManager(Generic[T_co]):
...
class typing.AsyncIterable(Generic[T_co]):
...
class typing.AsyncIterator(AsyncIterable[T_co]):
...
class typing.AsyncGenerator(AsyncIterator[T_co], Generic[T_co, T_contra]):
...
class typing.Awaitable(Generic[T_co]):
...
Com o Python ≥ 3.9, use os equivalentes deles em collections.abc
.
Quero destacar três aspectos desses tipos genéricos.
Primeiro: eles são todos covariantes do primeiro parâmetro de tipo, que é o tipo dos itens produzidos a partir desses objetos. Lembre-se da regra #1 da [variance_rules_sec]:
Se um parâmetro de tipo formal define um tipo para um dado que sai do objeto, ele pode ser covariante.
Segundo: AsyncGenerator
e Coroutine
são contra-variantes do segundo ao último parâmetros. Aquele é o tipo do argumento do método de baixo nível .send()
, que o loop de eventos chama para acionar geradores assíncronos e corrotinas. Dessa forma, é um tipo de "entrada".
Assim, pode ser contra-variante, pelo Regra de Variância #2
Se um parâmetro de tipo formal define um tipo para um dado que entra no objeto após sua construção inicial, ele pode ser contra-variante.
Terceiro: AsyncGenerator
não tem tipo de devolução, ao contrário de typing.Generator
,
que vimos na [generic_classic_coroutine_types_sec].
Devolver um valor levantando StopIteration(value)
era uma das gambiarras que permitia a geradores operarem como corrotinas e suportar yield from
, como vimos na [classic_coroutines_sec].
Não há tal sobreposição entre os objetos assíncronos:
objetos AsyncGenerator
não devolvem valores e são completamente separados de objetos corrotina, que são anotados com typing.Coroutine
.
Por fim, vamos discutir rapidamente as vantagens e desafios da programaçào assíncrona.
As seções finais deste capítulo discutem as ideias de alto nível em torno da programação assíncrona, independente da linguagem ou da biblioteca usadas.
Vamos começar explicando a razão número 1 pela qual a programação assíncrona é atrativa, seguido por um mito popular e como lidar com ele.
Ryan Dahl, o inventor do Node.js, introduz a filosofia por trás de seu projeto dizendo "Estamos fazendo E/S de forma totalmente errada."[19] (EN). Ele define uma função bloqueante como uma função que faz E/S de arquivo ou rede, e argumenta que elas não podem ser tratadas da mesma forma que tratamos funções não-bloqueantes. Para explicar a razão disso, ele apresenta os números na segunda coluna da Tabela 1.
Dispositivo | Ciclos de CPU | Escala proporcional "humana" |
---|---|---|
L1 cache |
3 |
3 segundos |
L2 cache |
14 |
14 segundos |
RAM |
250 |
250 segundos |
disk |
41.000.000 |
1,3 anos |
network |
240.000.000 |
7,6 anos |
Para a Tabela 1 fazer sentido, tenha em mente que as CPUs modernas, com relógios funcionando em frequências na casa dos GHz, rodam bilhões de ciclos por segundo. Vamos dizer que uma CPU rode exatamente 1 bilhão de ciclos por segundo. Aquela CPU pode realizar mais de 333 milhões de leituras do cache L1 em 1 segundo, ou 4 (quatro!) leituras da rede no mesmo tempo. A terceira coluna da Tabela 1 coloca os números em perspectiva, multiplicando a segunda coluna por um fator constante. Então, em um universo alternativo, se uma leitura do cache L1 demorasse 3 segundos, uma leitura da rede demoraria 7,6 anos!
A Tabela 1 explica porque uma abordagem disciplinada da programação assíncrona pode levar a servidores de alto desempenho. O desafio é conseguir essa disciplina. O primeiro passo é reconhecer que um sistema limitado apenas por E/S é uma fantasia.
Um meme exaustivamente repetido é que programação assíncrona é boa para "sistemas limitados por E/S"—I/O bound systems, ou seja, sistemas onde o gargalo é E/S, e não processamento de dados na CPU. Aprendi da forma mais difícil que não existem "sistemas limitados por E/S." Você pode ter funções limitadas por E/S. Talvez a maioria das funções no seu sistema sejam limitadas por E/S; isto é, elas passam mais tempo esperando por E/S do que realizando operações na memória. Enquanto esperam, cedem o controle para o loop de eventos, que pode então acionar outras tarefas pendentes. Mas, inevitavelmente, qualquer sistema não-trivial terá partes limitadas pela CPU. Mesmo sistemas triviais revelam isso, sob stress. No Ponto de vista, conto a história de dois programas assíncronos sofrendo com funções limitadas pela CPU freando loop de eventos, com severos impactos no desempenho do sistema como um todo.
Dado que qualquer sistema não-trivial terá funções limitadas pela CPU, lidar com elas é a chave do sucesso na programação assíncrona.
Se você está usando Python em larga escala, deve ter testes automatizados projetados especificamente para detectar regressões de desempenho assim que elas acontecem. Isso é de importância crítica com código assíncrono, mas é relevante também para código Python baseado em threads—por causa da GIL. Se você esperar até a lentidão começar a incomodar a equipe de desenvolvimento, será tarde demais. O conserto provavelmente vai exigir algumas mudanças drásticas.
Aqui estão algumas opções para quando você identifica gargalos de uso da CPU:
-
Delegar a tarefa para um pool de processos Python.
-
Delegar a tarefa para um fila de tarefas externa.
-
Reescrever o código relevante em Cython, C, Rust ou alguma outra linguagem que compile para código de máquina e faça interface com a API Python/C, de preferência liberando a GIL.
-
Decidir que você pode tolerar a perda de desempenho e deixar como está—mas registre essa decisão, para torná-la mais fácil de reverter no futuro.
A fila de tarefas externa deveria ser escolhida e integrada o mais rápido possível, no início do projeto, para que ninguém na equipe hesite em usá-la quando necessário.
A opção deixar como está entra na categoria de dívida tecnológica.
Programação concorrente é um tópico fascinante, e eu gostaria de escrever muito mais sobre isso. Mas não é o foco principal deste livro, e este já é um dos capítulos mais longos, então vamos encerrar por aqui.
O problema com as abordagens usuais da programação assíncrona é que elas são propostas do tipo "tudo ou nada". Ou você reescreve todo o código, de forma que nada nele bloqueie [o processamento] ou você está só perdendo tempo.
RabbitMQ in Action
Escolhi essa epígrafe para esse capítulo por duas razões.
Em um nível mais alto, ela nos lembra de evitar o bloqueio do loop de eventos, delegando tarefas lentas para uma unidade de processamento diferente, desde uma simples thread indo até uma fila de tarefas distribuída.
Em um nível mais baixo, ela também é um aviso: no momento em que você escreve seu primeiro async def
, seu programa vai inevitavelmente ver surgir mais e mais async def
, await
, async with
, e async for
.
E o uso de bibliotecas não-assíncronas subitamente se tornará um desafio.
Após os exemplos simples com o spinner no [concurrency_models_ch], aqui nosso maior foco foi a programação assíncrona com corrotinas nativas, começando com o exemplo de sondagem de DNS blogdom.py, seguido pelo conceito de esperáveis. Lendo o código-fonte de flags_asyncio.py, descobrimos o primeiro exemplo de um gerenciador de contexto assíncrono.
As variantes mais avançadas do programa de download de bandeiras introduziram duas funções poderosas: o gerador asyncio.as_completed
generator e a corrotina loop.run_in_executor
. Nós também vimos o conceito e a aplicação de um semáforo, para limitar o número de downloads concorrentes—como esperado de clientes HTTP bem comportados.
A programaçãp assíncrona para servidores foi apresentada com os exemplos mojifinder:
um serviço web usando a FastAPI e o tcp_mojifinder.py—este último utilizando apenas o asyncio
e o protocolo TCP..
A seguir, iteração assíncrona e iteráveis assíncronos foram o principal tópico, com seções sobre async for
, o console assíncrono do Python, geradores assíncronos expressões geradoras assíncronas e compreensões assíncronas.
O último exemplo do capítulo foi o blogdom.py reescrito com o framework Curio, demonstrando como os recursos de programação assíncrona do Python não estão presos ao pacote asyncio
.
O Curio também demonstra o conceito de concorrência estruturada, que pode vir a ter um grande impacto em toda a indústria de tecnologia, trazendo mais clareza para o código concorrente.
Por fim, as seções sob o título Como a programação assíncrona funciona e como não funciona discutiram o principal atrativo da programação assíncrona, a falácia dos "sistemas limitados por E/S" e como lidar com as inevitáveis partes de uso intensivo de CPU em seu programa.
A palestra de abertura da PyOhio 2016, de David Beazley,
"Fear and Awaiting in Async" (EN) é uma fantástica introdução, com "código ao vivo", ao potencial dos recursos da linguagem tornados possíveis pela contribuição de Yury Selivanov ao Python 3.5, as palavras-chave async/await
.
Em certo momento, Beazley reclama que await
não pode ser usada em compreensões de lista, mas isso foi resolvido por Selivanov na PEP 530—Asynchronous Comprehensions (EN), implementada mais tarde naquele mesmo ano, no Python 3.6.
Fora isso, todo o resto da palestra de Beazley é atemporal, pois ele demonstra como os objetos assíncronos vistos nesse capítulo funcionam, sem ajuda de qualquer framework—com uma simples função run
usando .send(None)
para acionar corrotinas.
Apenas no final Beazley mostra o Curio, que ele havia começado a programar naquele ano, como um experimento, para ver o quão longe era possível levar a programação assíncrona sem se basear em callbacks ou futures, usando apenas corrotinas.
Como se viu, dá para ir muito longe—como demonstra a evolução do Curio e a criação posterior do Trio por Nathaniel J. Smith.
A documentação do Curio’s contém links para outras palestras de Beazley sobre o assunto.
Além criar o Trio, Nathaniel J. Smith escreveu dois post de blog muito profundos, que gostaria de recomendar: "Some thoughts on asynchronous API design in a post-async/await world" (Algumas reflexões sobre o design de APIs assíncronas em um mundo pós-async/await) (EN), comparando os designs do Curio e do asyncio, e "Notes on structured concurrency, or: Go statement considered harmful" (Notas sobre concorrência estruturada, ou: o comando Go considerado nocivo) (EN), sobre concorrência estruturada. Smith também deu uma longa e informativa resposta à questão: "What is the core difference between asyncio and trio?" (Qual é a principal diferença entre o asyncio e o trio?) (EN) no StackOverflow.
Para aprender mais sobre o pacote asyncio, já mencionei os melhores recursos escritos que conheço no início do capítulo: a documentação oficial, após a fantástica reorganização (EN) iniciada por Yury Selivanov em 2018, e o livro de Caleb Hattingh, Using Asyncio in Python (O’Reilly).
Na documentação oficial, não deixe de ler "Desenvolvendo com asyncio", que documenta o modo de depuração do asyncio e também discute erros e armadilhas comuns, e como evitá-los.
Para uma introdução muito acessível, de 30 minutos, à programação assíncrona em geral e também ao asyncio,
assista a palestra
"Asynchronous Python for the Complete Beginner" (Python Assíncrono para o Iniciante Completo) (EN), de Miguel Grinberg,
apresentada na PyCon 2017.
Outra ótima introdução é
"Demystifying Python’s Async and Await Keywords" (Desmistificando as Palavras-Chave Async e Await do Python) (EN),
apresentada por Michael Kennedy, onde entre outras coisas aprendi sobre a bilblioteca
unsync, que oferece um decorador para delegar a execução de corrotinas, funções dedicadas a E/S e funções de uso intensivo de CPU para asyncio
, threading
, ou multiprocessing
, conforme a necessidade.
Na EuroPython 2019, Lynn Root—uma líder global da PyLadies—apresentou a excelente "Advanced asyncio: Solving Real-world Production Problems" (Asyncio Avançado: Resolvendo Problemas de Produção do Mundo Real) (EN), baseada na sua experiência usando Python como um engenheira do Spotify.
Em 2020, Łukasz Langa gravou um grande série de vídeos sobre o asyncio, começando com "Learn Python’s AsyncIO #1—The Async Ecosystem" (Aprenda o AsyncIO do Python—O Ecossistema Async) (EN). Langa também fez um vídeo muito bacana, "AsyncIO + Music" (EN), para a PyCon 2020, que não apenas mostra o asyncio aplicado a um domínio orientado a eventos muito concreto, como também explica essa aplicação do início ao fim.
Outra área dominada por programaçao orientada a eventos são os sistemas embarcados.
Por isso Damien George adicionou o suporte a async/await
em seu interpretador MicroPython (EN) para microcontroladores
Na PyCon Australia 2018, Matt Trentini demonstrou a biblioteca
uasyncio (EN),
um subconjunto do asyncio que é parte da biblioteca padrão do MicroPython.
Para uma visão de mais alto nível sobre a programação assíncrona em Python, leia o post de blog "Python async frameworks—Beyond developer tribalism" (Frameworks assíncronos do Python—Para além do tribalismo dos desenvolvedores) (EN), de Tom Christie.
Por fim, recomendo "What Color Is Your Function?" (Qual a Cor da Sua Função?) de Bob Nystrom, discutindo os modelos de execução incompatíveis de funções normais versus funções assíncronas—também conhecidas como corrotinas—em Javascript, Python, C# e outras linguagens. Alerta de spoiler: A conclusão de Nystrom é que a linguagem que acertou nessa área foi Go, onde todas as funções tem a mesma cor. Eu gosto disso no Go. Mas também acho que Nathaniel J. Smith tem razão quando escreveu "Go statement considered harmful" (Comando Go considerado nocivo) (EN). Nada é perfeito, e programação concorrente é sempre difícil.
Como uma função lerda quase estragou as benchmarks do uvloop
Em 2016, Yury Selivanov lançou o uvloop, "um substituto rápido e direto para o loop de eventos embutido do asyncio event loop." Os números de referência (benchmarks) apresentados no post de blog de Selivanov anunciando a biblioteca, em 2016, eram muito impressionantes. Ele escreveu: "ela é pelo menos 2x mais rápida que o nodejs e gevent, bem como qualquer outro framework assíncrona do Python. O desempenho do asyncio com o uvloop é próximo àquele de programas em Go."
Entretanto, o post revela que a uvloop é capaz de competir com o desempenho do Go sob duas condições:
-
Que o Go seja configurado para usar uma única thread. Isso faz o runtime do Go se comportar de forma similar ao asyncio: a concorrência é alcançada através de múltiplas corrotinas acionadas por um loop de eventos, todos na mesma thread.[20]
-
Que o código Python 3.5 use a biblioteca httptools além do próprio uvloop.
Selivanov explica que escreveu httptools após testar o desempenho da uvloop com a aiohttp—uma das primeiras bibliotecas HTTP completas construídas sobre o asyncio
:
Entretanto, o gargalo de desempenho no aiohttp estava em seu parser de HTTP, que era tão lento que pouco importava a velocidade da biblioteca de E/S subjacente. Para tornar as coisas mais interessantes, criamos uma biblioteca para Python usar a http-parser (a biblioteca em C do parser do Node.js, originalmente desenvolvida para o NGINX). A biblioteca é chamada httptools, e está disponível no Github e no PyPI.
Agora reflita sobre isso: os testes de desempenho HTTP de Selivanov consistiam de um simples servidor eco escrito em diferentes linguagens e usando diferentes bibliotecas, testados pela ferramenta de benchmarking wrk. A maioria dos desenvolvedores consideraria um simples servidor eco um "sistema limitado por E/S", certo? Mas no fim, a análise de cabeçalhos HTTP é vinculada à CPU, e tinha uma implementação Python lenta na aiohttp quando Selivanov realizou os testes, em 2016. Sempre que uma função escrita em Python estava processando os cabeçalhos, o loop de eventos era bloqueado. O impacto foi tão significativo que Selivanov se deu ao trabalho extra de escrever o httptools. Sem a otimização do código de uso intensivo de CPU, os ganhos de desempenho de um loop de eventos mais rápido eram perdidos.
Morte por mil cortes
Em vez de um simples servidor eco, imagine um sistema Python complexo e em evolução, com milhares de linhas de código assíncrono, e conectado a muitas bibliotexas externas. Anos atrás me pediram para ajudar a diagnosticar problemas de desempenho em um sistema assim. Ele era escrito em Python 2.7, com o framework Twisted—uma biblioteca sólida e, de muitas maneiras, uma precursora do próprio asyncio.
Python era usado para construir uma fachada para a interface Web, integrando funcionalidades fornecidas por biliotecas pré-existentes e ferramentas de linha de comando escritas em outras linguagens—mas não projetadas para execução concorrente.
O projeto era ambicioso: já estava em desenvolvimento há mais de um ano, mas ainda não estava em produção.[21] Com o tempo, os desenvolvedores notaram que o desempenho do sistema como um todo estava diminuindo, e estavam tendo muita dificuldade em localizar os gargalos.
O que estava acontecendo: a cada nova funcionalidade, mais código intensivo em CPU desacelerava o loop de eventos do Twisted. O papel do Python como um linguagem de "cola" significava que havia muita interpretação de dados, serialização, desserialização, e muitas conversões entre formatos. Não havia um gargalo único: o problema estava espalhado por incontáveis pequenas funções criadas ao longo de meses de desenvolvimento. O conserto exigiria repensar a arquitetura do sistema, reescrever muito código, provavelmente usar um fila de tarefas, e talvez usar microsserviços ou bibliotecas personalizadas, escritas em linguagens mais adequadas a processamento concorrente intensivo em CPU. Os apoiadores internos não estavam preparados para fazer aquele investimento adicional, e o projeto foi cancelado semanas depois deste diagnóstico.
Quando contei essa história para Glyph Lefkowitz—fundador do projeto Twisted—ele me disse que uma de suas prioridades, no início de qualquer projeto envolvendo programação assíncrona, é decidir que ferramentas serão usadas para executar de tarefas intensivas em CPU sem atrapalhar o loop de eventos. Essa conversa com Glyph foi a inspiração para a Evitando as armadilhas do uso da CPU.
-m asyncio
, pode então usar await
diretamente no prompt >>>
para controlar uma corrotina nativa. Isso é explicado na Experimentando com o console assíncrono do Python.
true.dev
está disponível por US$ 360,00 ao ano no momento em que escrevi isso. Também notei que for.dev
está registrado, mas seu DNS não está configurado.
as_completed
, bem como sobre a relação próxima entre futures e corrotinas no asyncio.
pathlib
nos exemplo de código.
loop.run_with_executor()
na corrotina supervisor
. Dessa forma o servidor estaria pronto para receber requisições imediatamente, enquanto o índice é construído. Isso é verdade, mas como consultar o índice é a única coisa que esse servidor faz, isso não seria uma grande vantagem nesse exemplo.
async/await
são atrelados ao loop de eventos que é inseparável do ambiente de runtime, isto é, um navegador, o Node.js ou o Deno.