O problema com as abordagens usuais da programação assíncrona é que são propostas do tipo "tudo ou nada". Ou você reescreve todo o código, de forma que nada nele bloqueie, ou está só perdendo tempo.[1]
RabbitMQ in Action
Este capítulo trata de três grandes tópicos interligados:
-
As instruções
async def,await,async with, easync for; -
Objetos que suportam tais instruções 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; -
asyncioe outras bibliotecas assíncronas.
Este capítulo parte das ideias de iteráveis e geradores ([ch_generators], em particular a [classic_coroutines_sec]), gerenciadores de contexto ([ch_with_match]), e conceitos gerais de programação concorrente ([ch_concurrency_models]).
Vamos estudar clientes HTTP concorrentes similares aos vistos no [ch_executors], 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 laço 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 FastAPI. A seguir estudaremos outros objetos
suportados pelas palavras-chave async/await: funções geradoras assíncronas,
compreensões assíncronas, e expressões geradoras assíncronas. Para enfatizar
que estes recursos não estarão limitados ao asyncio, veremos
um exemplo reescrito para usar o Curio—o inovador e influente 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 Para mais profundidade sobre |
Quando escrevi a
primeira edição de Python Fluente, a biblioteca asyncio era provisória e as
palavras-chave async/await não existiam. Por isso atualizei todos os exemplos
deste capítulo. 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 que aprendi na prática, por isso eu a considero leitura essencial para qualquer um trabalhando com programação assíncrona. Elas podem ajudar você a evitar muitos problemas—no Python ou mesmo 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.
Na [classic_coroutines_sec], vimos que Python oferece três tipos de corrotinas desde a versão 3.5:
- Corrotina nativa
-
Uma função corrotina definida com
async def. Uma corrotina nativa pode acionar outra corrotina nativa, usando a instruçãoawait, semelhante ao funcionamento deyield fromem corrotinas clássicas. A instruçãoasync defsempre define uma corrotina nativa, mesmo se a instruçãoawaitnão aparecer em seu corpo. A instruçãoawaitsó pode ser usada dentro de uma corrotina nativa.[3] - 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 usandoyieldem 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 peloasyncio. - Corrotinas baseadas em geradores
-
Uma função geradora decorada com
@types.coroutine—introduzido no Python 3.5. Esse decorador torna o gerador compatível com a nova instruçãoawait.
Neste capítulo vamos nos concentrar nas corrotinas nativas, bem como nos geradores assíncronos:
- Geradora assíncrona
-
Uma função geradora definida com
async defque usayieldem seu corpo. Ela devolve um objeto gerador assíncrono que implementa__anext__, um método corrotina para obter o próximo item.
|
Warning
|
@asyncio.coroutine não tem futuro[4]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 de Python e o sufixo .DEV—por exemplo, AWAIT.DEV.
O blogdom.py: procura domínios para um blog sobre Python é um script usando asyncio que verifica vários domínios de forma concorrente.
Essa é a 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.devObserve que os domínios aparecem fora de ordem. Se você rodar o script, os verá
sendo exibidos um após o outro, em 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 está em uso, e pode estar disponível para
adquirir.[5]
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 fossem 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 blogdom.py: procura domínios para um blog sobre Python 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.
-
probedevolve uma tupla com o nome do domínio e um valor booleano;Truesignifica 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 laço 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. -
mainprecisa ser uma corrotina, para podermos usarawaitaqui. -
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
probecom 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 [ch_executors], [flags_threadpool_futures_ex]. -
Nesse ponto, sabemos que a corrotina terminou, pois é assim que
as_completedfunciona. Portanto, a expressãoawaitnão vai bloquear, mas precisamos dela para obter o resultado decoro. Secorogerou uma exceção não tratada, ela será gerada novamente aqui. -
asyncio.runinicia o laço de eventos e retorna apenas quando o laço terminar. Esse é um modelo comum para scripts usandoasyncio: implementarmaincomo uma corrotina e acioná-la comasyncio.rundentro do bloco
if name == 'main':
|
Tip
|
A função |
Há muitos conceitos novos para entender no asyncio,
mas a lógica básica do blogdom.py: procura domínios para um blog sobre Python é fácil de compreender se você usar o truque sugerido pelo próprio Guido van Rossum:
finja que as palavras-chave async e await não estão ali.
Fazendo isso, você vai perceber que a lógica de uma corrotina pode ser lida como uma função sequencial.
Por exemplo, imagine que o corpo desta corrotina…
async def probe(domain: str) -> tuple[str, bool]:
laço = 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 do laço de eventos:
def probe(domain: str) -> tuple[str, bool]: # no async
laço = 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 somente 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 laço de eventos,
não para a corrotina probe(‘if.dev’), que está suspensa.
Enquanto isso, o laço de eventos pode ativar outros objetos corrotina pendentes,
tal como probe('or.dev').
Quando o laço de eventos recebe a resposta da consulta getaddrinfo('if.dev',
None), aquele objeto corrotina específico prossegue sua execução, e devolve o
controle para 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" (iterable). A palavra-chave await funciona com "esperáveis" (awaitable).
|
Note
|
Os tradutores da documentação do Python em português traduziram awaitable como "aguardável". Adotei "esperável" por ser mais simples. E também porque nem todo esperável é agradável ;-) |
Como usuário do asyncio, estes são os esperáveis que você verá diariamente:
-
Um objeto corrotina nativo, que você obtém invocando uma função corrotina nativa
-
Uma tarefa
asyncio.Task, criada ao invocarasyncio.create_task(…)passando um objeto corrotina nativo.
Entretanto, o código do usuário final nem sempre precisa acionar uma Task com await.
Usamos asyncio.create_task(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
([spinner_async_start_ex] do [ch_concurrency_models]).
Criar a tarefa é o suficiente para agendar o acionamento da corrotina pelo laço de eventos.
|
Warning
|
Mesmo que você não precise cancelar a tarefa ou esperar pelo resultado,
é 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 estes 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 devolve 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» que a expressão |
Agora vamos estudar a versão asyncio do script para baixar figuras da Web.
O script flags_asyncio.py baixa um conjunto fixo de 20 bandeiras de https://fluentpython.com. Já mencionamos este script na [ex_web_downloads_sec], mas agora vamos examiná-lo em detalhes, aplicando os conceitos que acabamos de ver.
No Python 3.10, o asyncio só suporta TCP e UDP,
e não temos pacotes de cliente ou servidor HTTP assíncronos na biblioteca padrão.
Estou usando o HTTPX nos exemplos de clientes HTTP.
Vamos explorar o flags_asyncio.py a partir do final do módulo, isto é, olhando primeiro as funções que iniciam as ações no flags_asyncio.py: funções de inicialização.
|
Warning
|
Para deixar o código mais fácil de ler, flags_asyncio.py não faz tratamento de erros.
Nesta introdução a Os exemplos flags* aqui e no [ch_executors] 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]-
Esta tem que ser uma função comum—não uma corrotina—para podermos passá-la para função
maindo módulo flags.py ([flags_module_ex] do [ch_executors]) no passo⑦. -
Executa o laço de eventos, acionando o objeto corrotina
supervisor(cc_list)até que ele retorne. Esta função ficará bloqueada enquanto o laço de eventos roda. O resultado desupervisor(cc_list)será devolvido porasyncio_run. -
Operações assíncronas de cliente HTTP no
httpxsão métodos deAsyncClient, que também é um gerenciador de contexto assíncrono, para uso comasync with. Trata-se de um gerenciador de contexto com métodos corrotina para inicialização e encerramento (detalhes logo mais na Gerenciadores de contexto assíncronos). -
Cria uma lista de objetos corrotina, invocando a corrotina
download_oneuma 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 submetidos. -
supervisordevolve o tamanho da lista vinda deasyncio.gather. -
Invocamos
maindoflags.py([flags_module_ex] do [ch_executors])
Agora vamos revisar a parte superior de flags_asyncio.py (flags_asyncio.py: imports e funções de download). Reorganizei as corrotinas para podermos lê-las na ordem em que são iniciadas pelo laço de eventos.
link:../code/20-executors/getflags/flags_asyncio.py[role=include]-
httpxprecisa ser instalado; não vem com a biblioteca padrão. -
Reutiliza código de flags.py ([flags_module_ex] do [ch_executors]).
-
download_oneprecisa ser uma corrotina nativa, para acionarget_flagcomawait. Quando recebe a resposta, exibe o código de país bandeira, e salva a imagem. -
get_flagprecisa receber oAsyncClientpara fazer a requisição. -
O método
getde uma instância dehttpx.AsyncClientdevolve um objetoClientResponse. -
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 laço de eventos do
asyncio.
|
Note
|
Idealmente, a invocação de A Usando |
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, como AsyncClient e ClientResponse—como veremos na Gerenciadores de contexto assíncronos.
A diferença fundamental entre os exemplos de corrotinas clássicas vistas na [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 Em um programa assíncrono, uma função do usuário inicia o laço de eventos, agendando uma corrotina inicial com asyncio.run. Cada corrotina do usuário aciona a seguinte com uma expressão await, formando um canal que permite a comunicação direta entre uma biblioteca como a HTTPX e o laço de eventos do framework assíncrono..
asyncio.run. Cada corrotina do usuário aciona a seguinte com uma expressão await, formando um canal que permite a comunicação direta entre uma biblioteca como a HTTPX e o laço de eventos do framework assíncrono.Debaixo dos panos, o laço de eventos do asyncio faz as chamadas a .send que
acionam as nossas corrotinas, e nossas corrotinas acionam outras corrotinas com await,
inclusive corrotinas da biblioteca. Como já mencionado, a maior parte da
implementação de await vem de yield from, que internamente invoca .send
para acionar as corrotinas.
O canal await termina em um esperável de baixo nível da biblioteca HTTPX,
que devolve um gerador que o laço de eventos pode acionar em resposta a eventos
tais como E/S de rede ou timers. 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, podemos ter
múltiplos canais de await ao mesmo tempo, permitindo acionar
operações de E/S concorrentes em um único laço de eventos, em uma única
thread.
No flags_asyncio.py: imports e funções de download, note que eu reutilizei a função get_flag de
flags.py ([flags_module_ex] do [ch_executors]). 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 acionada com await ou asyncio.create_task. Assim o controle é
devolvido ao laço de eventos, enquanto não chega a resposta da operação de envio
ou recebimento de dados. Se for inviável reescrever a função bloqueante como
uma corrotina, devemos executá-la em outra thread ou processo, como
veremos na Delegando tarefas a executores.
Por isso escolhi a epígrafe desse capítulo, que inclui o conselho: "Ou você reescreve todo o código, de forma que nada nele bloqueie ou 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] do [ch_executors]).
O código no flags_asyncio.py: imports e funções de download aciona get_flag com await,
então download_one precisa também ser uma corrotina.
Para cada requisição, supervisor cria um objeto corrotina download_one,
e eles são todos acionados pela corrotina asyncio.gather.
Vamos agora estudar a instrução async with, que apareceu em supervisor
(flags_asyncio.py: funções de inicialização) e get_flag (flags_asyncio.py: imports e funções de download).
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 Código exemplo da documentação do driver PostgreSQL asyncpg, 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 de
gerenciador de contexto: a transação precisa ser iniciada, dados são modificados
com connection.execute, e então temos que fazer um rollback (reversão) ou um
commit (confirmação), 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. Entretanto, a implementação da instrução with
clássica não suporta corrotinas na implementação dos métodos __enter__ ou
__exit__.
Por isso a
PEP 492—Coroutines with async and await syntax
(Corrotinas com a sintaxe async e await) introduziu a instrução async with,
que funciona com gerenciadores de contexto assíncronos: objetos
que implementam os métodos __aenter__ e __aexit__ como corrotinas.
Usando async with, o Código exemplo da documentação do driver PostgreSQL asyncpg pode ser escrito como
esse outro exemplo 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__ aciona um dos 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 muitas transações concorrentemente.
|
Tip
|
Caleb Hattingh sobre o asyncpg
Outro detalhe impressionante 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 banco de conexões para conexões internas ao próprio Postgres. Isto significa que não precisamos de ferramentas adicionais (por exemplo o pgbouncer), como explicado na «documentação» do asyncpg. |
Voltando ao flags_asyncio.py, a classe AsyncClient do httpx é um
gerenciador de contexto assíncrono, para poder acionar 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 a 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 Running flags2_asyncio.py 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
|
Seja 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 |
Agora vejamos como o flags2_asyncio.py é implementado.
No
flags_asyncio.py: imports e funções de download, passamos várias corrotinas para asyncio.gather, que
devolve uma lista com os resultados das corrotinas na ordem em que foram
submetidas. Isto significa que asyncio.gather só pode retornar quando todos os
esperáveis terminarem. Entretanto, para atualizar a barra de progresso,
precisamos receber cada resultado assim que ele está pronto.
Felizmente existe uma equivalente assíncrona da função geradora as_completed
que usamos no exemplo de banco de threads com a barra de progresso,
([flags2_threadpool_full] do [ch_executors]).
O flags2_asyncio.py: parte superior (inicial) do script; o resto do código está no [flags2_asyncio_rest] mostra o início do script flags2_asyncio.py, onde as
corrotinas get_flag e download_one são definidas. O flags2_asyncio.py: continuação de [flags2_asyncio_top]
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: ela requer o parâmetroclient. -
Segunda e terceira diferenças:
.geté um método deAsyncClient, e é uma corrotina, então precisamos acioná-la comawait. -
Usa o
semaphorecomo um gerenciador de contexto assíncrono, assim o programa todo não é bloqueado; apenas essa corrotina é suspensa quando o contador do semáforo é zero. Veremos mais sobre isso na caixa Semáforos no Python, Limitando as requisições com um semáforo. -
A lógica de tratamento de erro é idêntica à de
download_one, do [flags2_basic_http_ex] do [ch_executors]. -
Salvar a imagem é uma operação de E/S. Para não bloquear o laço de eventos, roda
save_flagem 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"—pois
ler/escrever arquivos é «milhares de vezes mais demorado»
que ler/escrever na RAM. Se você estiver usando
«armazenamento conectado à rede», acessar "o disco"
significa fazer E/S na rede local.
Desde o Python 3.9, a corrotina asyncio.to_thread facilitou delegar operações
de arquivo para um banco 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 antes, vamos terminar
nosso estudo do código do cliente HTTP.
Clientes de rede como os que estamos estudando devem ser throttled (limitados no desempenho) para não martelarem 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. O mesmo semáforo é usado por várias corrotinas, com um número máximo configurável. Assim podemos limitar o número de corrotinas concorrentes ativas usando o semáforo. A caixa Semáforos no Python tem mais informações.
No flags2_threadpool.py ([flags2_threadpool_full] do [ch_executors]), a
limitação de desempenho (throttling) foi feita na função download_many
instanciando o ThreadPoolExecutor passando um número máximo de threads no
argumento max_workers. Em flags2_asyncio.py, um asyncio.Semaphore é criado
pela função supervisor (mostrada no flags2_asyncio.py: continuação de [flags2_asyncio_top]) e passado como o
argumento semaphore para download_one no flags2_asyncio.py: parte superior (inicial) do script; o resto do código está no [flags2_asyncio_rest].
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—como as travas e
as barreiras—podem ser construídas a partir de semáforos. Há três classes
Semaphore na biblioteca padrão de Python: uma em threading, outra em
multiprocessing, e uma terceira em asyncio. Essas classes têm interfaces
parecidas, mas suas implementações são bem diferentes. Aqui apresento a versão
de asyncio.
Um asyncio.Semaphore tem um contador interno que é decrementado toda vez que
acionamos o método corrotina .acquire() com await.
O contador é incrementado
quando invocamos 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)Fazer await em .acquire() não bloqueia quando o contador
interno é maior que zero.
Mas o contador está zerado, .acquire() suspende
a corrotina que chamou await até que alguma outra corrotina chame
.release() no mesmo Semaphore, incrementando assim o contador.
Em vez de usar estes métodos diretamente,
é mais seguro usar o semaphore como um gerenciador de contexto assíncrono,
como fiz na função download_one em flags2_asyncio.py: parte superior (inicial) do script; o resto do código está no [flags2_asyncio_rest]:
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__ invoca .release().
Este bloco async with garante que no máximo concur_req
instâncias de corrotinas get_flags estarão ativas em qualquer 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, impedindo mais operações
.release() que .acquire().[7]
Agora vamos olhar o resto do script no flags2_asyncio.py: continuação de [flags2_asyncio_top].
link:../code/20-executors/getflags/flags2_asyncio.py[role=include]-
supervisorrecebe os mesmos argumentos que a funçãodownload_many, mas não pode ser invocada diretamente demain, pois é uma corrotina e não uma função comum comodownload_many. -
Cria um
asyncio.Semaphoreque não vai permitir mais queconcur_reqcorrotinas ativas entre aquelas usando este semáforo. O valor deconcur_reqé calculado pela funçãomainde flags2_common.py, baseado nas opções de linha de comando e nas constantes definidas 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_completeddiretamente no laçoforabaixo porque posso precisar envolvê-la com o iteradortqdmpara a barra de progresso, dependendo da opção de verbosidade na linha de comando. -
Envolve o iterador
as_completedcom a função geradoratqdm, para mostrar o progresso. -
Declara e inicializa
errorcomNone; esta variável será usada para salvar uma exceção além do blocotry/except, se alguma for levantada. -
Itera pelos objetos corrotina que terminaram a execução; este laço é similar ao de
download_manyno [flags2_threadpool_full] do [ch_executors]. -
Aciona a corrotina com
awaitpara obter seu resultado. Isto não bloqueia porqueas_completedsó produz corrotinas que já terminaram. -
Esta atribuição é necessária porque o escopo da variável
excé limitado a esta cláusulaexcept, mas preciso preservar o valor para uso posterior. -
Mesmo que acima.
-
Se houve um erro, muda o
status. -
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_manyinstancia o objeto corrotinasupervisore o passa para o laço de eventos comasyncio.run, coletando o contador quesupervisordevolve quando o laço de eventos termina.
No flags2_asyncio.py: continuação de [flags2_asyncio_top], não pudemos usar o mapeamento de futures para os
códigos de país que vimos em [flags2_threadpool_full] do [ch_executors],
porque os esperáveis devolvidos por asyncio.as_completed não são
necessariamente os mesmos esperáveis que passamos na invocação de
as_completed. Internamente, a lógica do asyncio pode embrulhar os esperáveis
que fornecemos por outros esperáveis que afinal produzirão 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 |
Isto encerra nossa discussão da funcionalidade de um cliente Web
com tratamento de erros e barra de progresso, implementado com asyncio.
O próximo exemplo demonstra um modelo simples de execução de uma tarefa assíncrona após outra usando corrotinas.
Pessoas com experiência em JavaScript sabem que rodar uma função assíncrona após
outra acabou gerando o padrão de funções aninhadas conhecido como
pyramid of doom (pirâmide da perdição). A palavra-chave
await desfaz a maldição. Por isso await agora é parte de Python e de
JavaScript.
Vamos a um exemplo.
Suponha que você queira salvar cada bandeira com o nome do país e o código, em vez de apenas o código. Agora você precisa fazer duas requisições HTTP por bandeira: uma para pegar a imagem da bandeira propriamente dita, a outra para pegar 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
preservando os dados (código e nome do país) em variáveis locais, prontas para
serem usadas na hora de salvar o arquivo. Se você precisasse fazer o mesmo em um
script assíncrono com callbacks, precisaria de funções aninhadas, de forma que o
código e o nome do país estivessem disponíveis em clausuras até o momento de
salvar o arquivo, pois cada callback roda em um escopo local diferente. A
palavra-chave await evita este aninhamento, permitindo que
você acione as requisições assíncronas uma após a outra, compartilhando o escopo
local da corrotina que aciona 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 código de baixo nível sem suporte a corrotinas. De qualquer forma, a discussão no StackOverflow, What is the use case for future.add_done_callback()? (Qual o caso de uso para future.add_done_callback()?) explica por que 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 no nível da aplicação. |
A terceira variante do script asyncio de download de bandeiras traz algumas mudanças:
get_country-
Esta nova corrotina baixa o arquivo metadata.json daquele código de país, e extrai dele o nome do país.
download_one-
Esta corrotina agora usa
awaitpara delegar paraget_flage 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 (flags3_asyncio.py: corrotina get_country).
Note que é muito parecido com o get_flag do flags2_asyncio.py: parte superior (inicial) do script; o resto do código está no [flags2_asyncio_rest].
get_countrylink:../code/20-executors/getflags/flags3_asyncio.py[role=include]-
Esta corrotina devolve uma string com o nome do país—se tudo correr bem.
-
metadatavai receber umdictPython construído a partir do conteúdo JSON da resposta. -
Devolve o nome do país.
Agora vamos ver o download_one modificado do flags3_asyncio.py: corrotina download_one, que tem apenas algumas linhas diferentes da corrotina de mesmo nome do flags2_asyncio.py: parte superior (inicial) do script; o resto do código está no [flags2_asyncio_rest].
download_onelink:../code/20-executors/getflags/flags3_asyncio.py[role=include]-
Retém o
semaphorepara acionarget_flag… -
…e novamente para acionar
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 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 reter semáforos e travas
pelo menor tempo possível.
Eu poderia ter agendado as funções 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 é inútil rodar get_country. Mas há casos
em que 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: use await para
acionar corrotinas e outros esperáveis, como instâncias de asyncio.Task.
Mas algumas APIs são complexas, misturam corrotinas e funções comuns
de forma aparentemente arbitrária, como a classe StreamWriter que usaremos no tcp_mojifinder.py: continuação de [ex_tcp_mojifinder_main].
O flags3_asyncio.py: corrotina download_one 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 todo tipo de 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 usar thread principal para ler e escrever no armazenamento bloqueia o laço de eventos.
Na corrotina download_one de flags2_asyncio.py: parte superior (inicial) do script; o resto do código está no [flags2_asyncio_rest], usei a seguinte linha
para salvar a imagem baixada:
await asyncio.to_thread(save_flag, image, f'{cc}.gif')Como mencionei 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 Linhas para usar no lugar de await asyncio.to_thread.
await asyncio.to_threadlink:../code/20-executors/getflags/flags2_asyncio_executor.py[role=include]-
Obtém uma referência para o laço de eventos.
-
O primeiro argumento é o executor a ser utilizado; passar
Noneseleciona o default, umThreadPoolExecutorque está sempre disponível no laço de eventos doasyncio. -
Você pode passar argumentos posicionais para a função a ser executada, mas se você precisar passar argumentos nomeados, use
functool.partial, como descrito na «documentação derun_in_executor».
A função mais nova asyncio.to_thread é mais fácil de usar e mais flexível, já que também aceita argumentos nomeados.
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
blogdom.py: procura domínios para um blog sobre Python é implementada invocando 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 é encapsular em corrotinas quaisquer
chamadas bloqueantes que sejam detalhes de implementação, e usar
run_in_executor dentro das corrotinas para executar as chamadas bloqueantes.
Assim é possível apresentar uma interface consistente de corrotinas a serem
acionadas com await, escondendo as threads que precisam ser usadas por razões
pragmáticas.
Por exemplo, o driver assíncrono do MongoDB para Python, chamado
Motor, tem uma API compatível com async/await que na
verdade é uma fachada, escondendo um banco de threads que conversa com o
MongoDB. O criador do Motor, A. Jesse Jiryu Davis, explica suas razões em
Response to "Asynchronous Python and Databases"
(Resposta a "Python Assíncrono e os Bancos de Dados"). Spoiler: Jiryu Davis
descobriu que um banco de threads tem melhor desempenho no caso de uso
específico de um driver de banco de dados—desmascarando o mito de que abordagens
assíncronas são sempre mais eficientes 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 ocupar
intensivamente a CPU. Assim 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.
O revisor Caleb Hattingh (autor de
Using Asyncio in Python)
me passou o seguinte aviso sobre executores e o asyncio.
|
Warning
|
O aviso de Caleb sobre run_in_executors
Usar |
Agora saímos de scripts clientes 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.
Estes servidores permitem que os usuários pesquisem caracteres Unicode buscando
palavras que ocorrem em seus nomes fornecidos pelo módulo unicodedata que discutimos na
[unicodedata_sec]. A Janela de navegador mostrando os resultados da busca por "mountain" no serviço web_mojifinder.py. 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
o box opcional a seguir contém apenas uma breve explicação sobre ele.
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 incluídos no Python 3.10, 'DIGIT' será mapeado para
868 caracteres que têm esta 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.
Veja na Explorando o atributo entries e o método search de InvertedIndex no console de Python uma demonstração usando os caracteres indexados para as palavras 'CAT' e 'FACE'.[9]
O método InvertedIndex.search quebra a consulta em palavras separadas, e devolve a interseção dos registros para cada palavra.
É por isso que buscar por "face" encontra 171 resultados, "cat" encontra 14, mas "cat face" apenas 10.
Esta é 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 o FastAPI: um dos frameworks ASGI para desenvolvimento Web em Python, mencionado na [wsgi_app_server_sec]. A Janela de navegador mostrando os resultados da busca por "mountain" no serviço web_mojifinder.py. é uma captura de tela da interface de usuário. É uma aplicação 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.
O FastAPI foi projetado para implementar o lado servidor de SPAs e aplicativos
de celular, que consistem principalmente de pontos de acesso de APIs Web, devolvendo
respostas JSON em vez de HTML renderizado no servidor. O FastAPI se vale de
decoradores, dicas de tipo e introspecção de código para eliminar muito
código repetitivo das APIs Web, e também gera uma
documentação no padrão OpenAPI do Swagger.
A Documentação OpenAPI do ponto de acesso /search, gerada automaticamente. mostra a página /docs do
web_mojifinder.py, gerada automaticamente.
O web_mojifinder.py: código-fonte completo é o código de web_mojifinder.py, mas é só
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 da tabela com os
resultados. Se tiver interesse em ler JavaScript puro sem uso de frameworks,
confira 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 web_mojifinder.py: código-fonte completo com uvicorn em modo de desenvolvimento:
$ uvicorn web_mojifinder:app --reloados 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] -
Esta linha define a app ASGI. Ela poderia ser apenas
app = FastAPI(). Os parâmetros são metadados para a documentação da API, gerada automaticamente. -
Um schema pydantic para uma resposta JSON, com campos
charename.[12] -
Cria o
indexe carrega o formulário HTML estático, anexando ambos aoapp.statepara uso posterior. -
Roda
initquando esse módulo é carregado pelo servidor ASGI. -
Rota para o ponto de acesso
/search;response_modelusa aquele modeloCharNamedo pydantic para descrever o formato da resposta. -
O 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. Comoqnão tem default, a FastAPI devolverá um status 422 (Unprocessable Entity, Entidade Não-Processável) seqnão estiver presente na string da consulta. -
Devolver um iterável de
dictscompatível com o schemaresponse_modelpermite ao FastAPI criar uma resposta JSON de acordo com oresponse_modelno 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 web_mojifinder.py: código-fonte completo não faz chamada direta ao asyncio.
O FastAPI é construído sobre o toolkit ASGI Starlette, que por sua vez usa o asyncio.
Note também que o corpo de search não usa await, async with, ou async for,
então poderia ser uma função comum.
Defini search como uma 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 importante do FastAPI—e dos frameworks ASGI em geral—
suportarem corrotinas que podem se valer de bibliotecas assíncronas para E/S de rede.
|
Tip
|
As funções A melhor prática é ter um proxy/balanceador de carga na frente do ASGI, servindo todos os recursos estáticos, e também usar uma CDN (Rede de Entrega de Conteúdo) quando possível. Um proxy/balanceador de carga deste tipo é o Traefik, descrito como um edge router (roteador de ponta), que "recebe requisições em nome de seu sistema e descobre quais componentes são responsáveis por lidar com elas." O site do FastAPI apresenta «ferramentas de geração de projeto» que organizam o código para usar o Trafik e outros sofwares auxiliares. |
Os entusiastas da tipagem estática podem ter notado que não coloquei 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 Response Model - Return Type da 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 do banco de dados ou algum outro modelo, e então usar oresponse_modelpara realizar a validação de campos e a serialização.
Por exemplo, em search, devolvi um gerador de itens dict e não uma lista
de objetos CharName, mas isso basta 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 analisar outro servidor que usa sockets TCP e a biblioteca padrão do Python para responder consultas via Telnet no terminal.
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. A Sessão de telnet com o servidor tcp_mojifinder.py: consultando "fire." mostra a interface
de usuário em modo texto.
Este programa é duas vezes mais longo que o web_mojifinder.py (descontando o HTML e o JavaScript daquele exemplo).
Por isso dividi a apresentação em três partes:
tcp_mojifinder.py: um servidor TCP simples; continua no [tcp_mojifinder_top], tcp_mojifinder.py: continuação de [ex_tcp_mojifinder_main], e tcp_mojifinder.py: corrotina search.
O topo do arquivo tcp_mojifinder.py, com as instruções import, está no tcp_mojifinder.py: continuação de [ex_tcp_mojifinder_main].
Mas vou começar descrevendo a corrotina supervisor e a função main que controlam o programa.
link:../code/21-async/mojifinder/tcp_mojifinder.py[role=include]-
Este
awaitdevolve uma instância deasyncio.Server, um servidor TCP baseado em sockets. Por padrão,start_servercria e inicia o servidor, então ele está pronto para receber conexões. -
O primeiro argumento para
start_serveréclient_connected_cb, uma função de callback para ser executada quando a conexão com um novo cliente se inicia. Ela pode ser uma função ou uma corrotina, mas precisa aceitar exatamente dois argumentos: umasyncio.StreamReadere umasyncio.StreamWriter. Porém, minha corrotinafindertambém precisa receber umindex, então useifunctools.partialpara vincular aquele parâmetro e obter um invocável que recebe o leitor (StreamReader) e o escritor (StreamWriter). Adaptar funções do usuário a APIs de callback é o caso de uso mais comum defunctools.partial. -
hosteportsã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 propriedadesocketsda classeServerem maio de 2021. Veja «Issue #5535 no typeshed».[13] -
Exibe o endereço e a porta do primeiro socket do servidor.
-
Apesar de
start_serverjá ter iniciado o servidor como uma tarefa concorrente, preciso usar oawaitno métodoserve_forever, para que meusupervisorseja suspenso aqui. Sem essa linha, osupervisorretornaria imediatamente, encerrando o laço iniciado comasyncio.run(supervisor(…)), e fechando o programa. A documentação deServer.serve_foreverdiz: "Este método pode ser chamado se o servidor já estiver aceitando conexões." -
Constrói o índice invertido.[14]
-
Inicia o laço de eventos rodando
supervisor. -
Captura
KeyboardInterruptpara evitar um traceback ruidoso quando encerramos o servidor teclando 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 no tcp_mojifinder.py: isso é o lado servidor da sessão mostrada na [tcp_mojifinder_demo].
$ 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 aparecer, notei um intervalo de 0,6s na minha máquina, enquanto o índice era construído. -
Saída de
supervisor. -
Primeira volta do laço
whilena funçãofinderdo tcp_mojifinder.py: continuação de [ex_tcp_mojifinder_main]. A pilha TCP/IP atribuiu a porta 58192 a meu cliente Telnet. Se você conectar diversos clientes ao servidor, verá suas diferentes portas aparecerem na saída. -
Segunda iteração do laço
whileemfinder. -
Teclei CTRL-C no terminal do cliente; o laço
whileemfindertermina. -
A corrotina
finderexibe esta mensagem e encerra. Enquanto isso o servidor continua rodando, pronto para receber outros clientes. -
Teclei CTRL-C no terminal do servidor;
server.serve_foreveré cancelado, encerrandosupervisore o laço de eventos. -
Saída de
main.
Após main construir o índice e iniciar o laço de eventos, a corrotina
supervisor rapidamente exibe a mensagem Serving on…,
e fica suspensa na última linha:
await server.serve_forever()Neste ponto o controle flui para o laço de eventos do asyncio e lá permanece,
voltando ocasionalmente para a corrotina finder, que devolve o controle de
volta para o laço de eventos sempre que precisa esperar a rede para enviar ou
receber dados.
Enquanto o laço de eventos estiver ativo, uma nova instância da corrotina
finder será iniciada para cada cliente que se conecte ao servidor. Desta
forma, milhares de clientes podem ser atendidos concorrentemente por este
servidor simples. Isto segue até que ocorra um KeyboardInterrupt no servidor
ou que seu processo seja encerrado 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 resultados deInvertedIndex.searchem uma interface de usuário baseada em texto, como a linha de comando ou uma sessão Telnet. -
Para passar
finderparaasyncio.start_server, a envolvi comfunctools.partial, porque o servidor espera uma corrotina ou função que receba apenas os argumentosreaderewriter. -
Obtém o endereço do cliente remoto ao qual o socket está conectado.
-
Este laço controla um diálogo que persiste até um caractere de controle ser recebido do cliente.
-
O método
StreamWriter.writenão é uma corrotina, é uma função comum. Esta linha envia o prompt?>. -
StreamWriter.drainesvazia o buffer dewriter; ela é uma corrotina, então precisa ser acionada comawait. -
StreamWriter.readlineé uma corrotina que devolvebytes. -
Se nenhum byte foi recebido, o cliente fechou a conexão, então sai do loop.
-
Decodifica os
bytesparastr, usando a codificação UTF-8 como default. -
Pode ocorrer um
UnicodeDecodeErrorquando 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 laço se um caractere de controle ou null foi recebido.
-
searchrealiza a busca; o código será apresentado a seguir. -
Registra a resposta no console do servidor.
-
Fecha o
StreamWriter. -
Espera até
StreamWriterfechar. Isso é recomendado na documentação do método.close(). -
Registra o final dessa sessão do cliente no console do servidor.
O último trecho deste código é a corrotina search, tcp_mojifinder.py: corrotina search.
searchlink:../code/21-async/mojifinder/tcp_mojifinder.py[role=include]-
searchprecisa ser uma corrotina, pois escreve em umStreamWritere precisa acionar o método corrotina.drain(). -
Consulta o índice invertido.
-
Esta expressão geradora produzirá strings de bytes codificadas em UTF-8 com o ponto de código Unicode, o caractere, seu nome e uma sequência
CRLF(Return+Line Feed), isto é,b’U+0039\t9\tDIGIT NINE\r\n'. -
Envia
lines. Surpreendentemente,writer.writelinesnão é uma corrotina. -
Mas
writer.drain()é uma corrotina. Não esqueça doawait! -
Constrói e 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
|
Note 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 de streams» de alto nível do asyncio,
que fornece um servidor pronto para usar,
então só precisamos codar uma função de processamento,
que pode ser um callback simples ou uma corrotina. Há também uma
«API de Transportes e Protocolos»
de baixo nível, inspirada nas abstrações de transporte e protocolo do framework Twisted.
Veja a documentação do asyncio para mais informações, incluindo os
«servidores echo e clientes TCP e UDP»
implementados com a API de baixo nível.
Nosso próximo tópico é a instrução 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__ e __aexit__, devolvendo
esperáveis—normalmente na forma de objetos corrotina.
De forma análoga, async for funciona com iteráveis assíncronos: objetos que
implementam __aiter__. Entretanto, __aiter__ precisa ser um método
normal—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 vimos na
[iterable_not_self_iterator_sec].
A «documentação» do driver assíncrono de PostgreSQL aiopg
traz um exemplo que ilustra o uso de async for para iterar sobre as linhas de
resultados devolvidas pelo objeto cursor, definido no driver do 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,)]Neste exemplo, a consulta vai devolver só uma 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ó. Por isso
é importante que async for row in cur: não bloqueie o laço 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 laço de
eventos a cada chamada a __anext__, e continuar mais tarde, quando mais
linhas chegarem do PostgreSQL.
Você pode
implementar um iterador assíncrono escrevendo uma classe com __anext__ e
__aiter__, mas há um jeito mais fácil: escreva uma função declarada com
async def que use yield em seu corpo. Isto é semelhante à forma como funções
geradoras simplificam o implementar o padrão do Iterador clássico.
Vamos estudar um exemplo simples usando async for e implementando um gerador
assíncrono. No blogdom.py: procura domínios para um blog sobre Python 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) 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 de 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 laço 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. Então execute:
$ python -m asyncioVocê verá o console iniciar, mais ou menos assim:
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
>>>Note 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 a instrução
import asyncio é exibida para deixar isso evidente.
Vamos agora importar domainlib.py e brincar com suas duas corrotinas: probe e multi_probe (Experimentando com domainlib.py após executar python3 -m asyncio).
python3 -m asyncio>>> await asyncio.sleep(3, 'Bom dia!') # (1)
'Bom dia!'
>>> from domainlib import *
>>> await probe('python.org') # (2)
Result(domain='python.org', found=True) # (3)
>>> names = 'python.org rust-lang.org golang.org xyz.invalid'.split() # (4)
>>> async for result in multi_probe(names): # (5)
... print(*result, sep='\t')
...
golang.org True # (6)
xyz.invalid False
python.org True
rust-lang.org True
>>>-
Experimente um simples
awaitpara ver o console assíncrono em ação. Dica:asyncio.sleep()pode receber um segundo argumento opcional que será devolvido através doawait. -
Acione a corrotina
probe. -
A versão de
probeemdomainlibdevolve umaNamedTuplechamadaResult. -
Faça uma 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 significa "este domínio não existe."[16] -
Itera com
async forsobre o gerador assíncronomulti_probepara exibir 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 Experimentando com domainlib.py após executar python3 -m asyncio mostra que multi_probe é um gerador assíncrono, pois é compatível com async for. Vamos executar mais alguns experimentos, continuando com o Mais experimentos, continuação do [domainlib_demo_repl].
>>> 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-
Invocar uma corrotina nativa devolve um objeto corrotina.
-
Invocar um gerador assíncrono devolve um objeto
async_generator. -
Não podemos usar um laço
forcomum para percorrer geradores assíncronos, porque eles implementam__aiter__em vez de__iter__.
Geradores assíncronos são acionados pelas palavras-chave async for,
que pode ser uma instrução de laço (como visto em Experimentando com domainlib.py após executar python3 -m asyncio),
mas também podem aparecer em compreensões assíncronas, que veremos mais tarde.
Aqui está o módulo domainlib.py, com o gerador assíncrono multi_probe:
link:../code/21-async/domains/asyncio/domainlib.py[role=include]-
NamedTupletorna o resultado deprobemais 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.
-
probeagora recebe um argumento opcionalloop, para evitar chamadas repetidas aget_running_looptoda vez que esta corrotina é acionada no laço emmulti_probe. -
Uma função geradora assíncrona produz um objeto gerador assíncrono, que pode ser anotado como
AsyncIterator[TipoDoItem]. -
Constrói uma lista de objetos corrotina
probe, cada um com umdomaindiferente. -
Isto não é
async forporqueasyncio.as_completedé um gerador clássico. -
Aciona o objeto corrotina para obter o resultado.
-
Produz um
result. Esta linha faz com quemulti_probeseja um gerador assíncrono.
|
Note
|
O corpo do laço for coro in asyncio.as_completed(coros):
yield await coroPython 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. |
Uma vez que temos o 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 de 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.netGraças à domainlib, o código de domaincheck.py é bem direto:
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).
-
Formata um cabeçalho para a saída tabular.
-
Itera de forma assíncrona sobre
multi_probe(domains). -
Define
indentcomo zero ou dois tabs, para colocar o resultado na coluna apropriada. -
Roda a corrotina
maincom o argumento de linha de comando passado.
Geradores têm uma outra utilidade, não relacionado à iteração: eles 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 precisar, considere usar o decorador
@asynccontextmanager, incluído no módulo contextlib no
Python 3.7. É análogo 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 usando @asynccontextmanager e loop.run_in_executor é o código de Caleb—com uma única mudança e o acréscimo das explicações.
@asynccontextmanager e loop.run_in_executorfrom contextlib import asynccontextmanager
@asynccontextmanager
async def web_page(url): # (1)
laço = 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 precisa ser um gerador assíncrono.
-
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 laço de eventos. -
Todas as linhas antes dessa expressão
yieldvão se tornar o método corrotina__aenter__do gerenciador de contexto assíncrono criado pelo decorador. O valor dedataserá vinculado à variáveldataapós a cláusulaasno comandoasync withabaixo. -
As linhas após o
yieldse tornarão o método corrotina__aexit__. Aqui outra chamada bloqueante é delegada para um executor de threads. -
Usa
web_pagecomasync with.
Isso é muito similar ao decorador sequencial @contextmanager.
Por favor, consulte a [using_cm_decorator_sec] para mais 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 comparando-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
yieldem seu corpo—é isso que o torna um gerador. Uma corrotina nativa nunca contém umyield. -
Uma corrotina nativa pode devolver (
return) algum valor diferente deNone, mas um gerador assíncrono só pode usar instruçõesreturnvazias. -
Corrotinas nativas são esperáveis: elas podem ser acionadas por expressões
awaitou passadas para uma das muitas funções doasyncioque aceitam argumentos esperáveis, comocreate_taskougather. Em contrapartida, geradores assíncronos não são esperáveis. Eles são iteráveis assíncronos, acionados porasync forou por compreensões assíncronas.
Hora de falar sobre as tais compreensões assíncronas.
A PEP 530—Asynchronous Comprehensions
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 domainlib.py: funções para sondar domínios,
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 xyz.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 fordefine 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 pela instrução
async for, que por sua vez só pode aparecer dentro do corpo de umaasync defou no console assíncrono mágico que usei nesse exemplo.
Resumindo: uma expressão geradora assíncrona pode ser definida em qualquer ponto do seu programa, mas só pode ser acionada dentro de uma corrotina nativa ou de uma função geradora assíncrona.
Agora veremos as demais construções sintáticas propostas na PEP 530.
Diferente das expressões geradoras assíncronas, as compreensões assíncronas só podem ser definidas e usadas dentro de corrotinas nativas ou de funções geradoras assíncronas. Isso faz sentido porque as compreensões são ávidas (eager): elas são executadas imediatamente para construir uma lista. Em contraste, as expressões geradoras (assíncronas ou não), são preguiçosas (lazy). Elas criam um objeto gerador que só será executado quando um laço percorrer o gerador.
Yury Selivanov—autor da PEP 530—justificou a necessidade de compreensões assíncronas com três trechos curtos de código, reproduzidos a seguir.
Podemos 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 xyz.invalid'.split()
>>> names = sorted(names)
>>> coros = [probe(name) for name in names]
>>> await asyncio.gather(*coros)
[Result(domain='golang.org', found=True),
Result(domain='xyz.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='xyz.invalid', found=False),
Result(domain='python.org', found=True),
Result(domain='rust-lang.org', found=True)]
>>>Usei sorted para ordenar a lista de nomes e mostrar que os resultados chegam
na ordem em que foram submetidos, nos dois casos.
A PEP 530 também permite o uso de async for e await
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, 'xyz.invalid': False,
'rust-lang.org': True}Podemos usar a palavra-chave await na expressão antes das cláusulas for ou
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 adicionais ao redor da expressão await devido à
precedência mais alta do operador . (ponto) de __getattr__.
Relembrando, todas essas compreensões só podem aparecer no corpo de uma async def ou no console assíncrono encantado.
Agora vamos discutir uma característica muito importante das instruções e expressões async e dos objetos que eles criam:
Estas construções são muito usadas com o asyncio mas, na verdade,
são independentes da biblioteca.
Os elementos da linguagem async/await de Python
não estão presos a nenhum laço de eventos ou biblioteca específicos.[17] Graças à API extensível fornecida por métodos especiais, qualquer pessoa
suficientemente motivada pode escrever seu ambiente de runtime e um framework
assíncrono para acionar corrotinas nativas, geradores assíncronos, etc.
Foi o que fez David Beazley em seu projeto Curio.
Beazley estava interessado em repensar como estes recursos da linguagem poderiam
ser usados em um framework desenvolvido do zero, sem carregar uma bagagem do
passado. Lembre-se de que o asyncio foi lançado no Python 3.4, quando não existiam
as instruções async, e em vez de await usávamos yield from.
Portanto, a API original do asyncio não podia oferecer 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.
WARNING
O Curio é uma prova de conceito, e David Beazley não está mais evoluindo o projeto. Sua influência mais marcante está no framework Trio, que continua evoluindo e tem mais suporte de bibliotecas.
Se você estiver usando Python 3.12 ou superior, precisará instalar um fork atualizado publicado no PyPI como curio-compat.
O blogdom.py: [blogdom_ex], agora usando o Curio mostra o script blogdom.py (blogdom.py: procura domínios para um blog sobre Python) reescrito para usar o Curio.
link:../code/21-async/domains/curio/blogdom.py[role=include]-
probenão precisa obter o laço de eventos, porque… -
…
getaddrinfoé uma função decurio.socket, não um método de um objetoloop—como noasyncio. -
Um
TaskGroupé um conceito central no Curio, para monitorar e controlar várias corrotinas, garantindo que elas todas sejam acionadas e encerradas, sem deixar alguma para trás. -
TaskGroup.spawné como você inicia uma corrotina, gerenciada por uma instância específica deTaskGroup. A corrotina é embrulhada em umaTask. -
Iterar com
async forsobre umTaskGroupproduz instâncias deTaskà medida que cada uma termina. Isto corresponde à linha em blogdom.py: procura domínios para um blog sobre Python que usa
for … in as_completed(…): -
O Curio foi pioneiro no uso dessa maneira simples de iniciar um programa assíncrono em Python.
Para ilustrar este último ponto: lendo os exemplos de código de asyncio na
primeira edição do Python Fluente, verá linhas como estas repetidas várias
vezes:
laço = 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 repetitivos 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"
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)Objetos TaskGroup suportam
«concorrência estruturada»:
uma disciplina de programação concorrente que organiza todas a atividades de um
grupo de tarefas assíncronas em um bloco de código com apenas um ponto de
entrada e uma saída. Isto é análogo à programação estruturada,
que introduziu instruções de bloco para limitar os pontos de
entrada e saída de condicionais, laços e sub-rotinas, e eliminou
a instrução GOTO que permitia desviar a execução direto
para qualquer outra linha do código.
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 finalizadas ou canceladas e
qualquer exceção foi levantada.
|
Note
|
A concorrência estruturada está sendo adotada pelo |
Outra inovação importante 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. Sim,
o Curio pode ser executado em uma thread ao lado do
asyncio em outra thread, no mesmo processo, comunicando-se através de
UniversalQueue e de UniversalEvent. A API destas classes "universais" é
a mesma dentro e fora de corrotinas, mas em uma corrotina é preciso
acionar os métodos 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 revertendo rapidamente para IPv4 quando necessário.
O design do Curio foi muito influente.
o framework Trio, iniciado por Nathaniel J. Smith,
foi muito inspirado nele.
O Curio pode também ter estimulado os contribuidores de Python a melhorar a usabilidade da API do 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 métodos de loop,
ou precisavam do laço de eventos como um argumento.
Em versões mais recentes de Python, acesso direto ao laço não é mais tão necessário e,
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 devolvido pela instrução
return no corpo da corrotina nativa. Isto é mais simples que as anotações
de corrotinas clássicas, discutidas na [generic_classic_coroutine_types_sec].
Neste capítulo vimos vários exemplos de corrotinas nativas anotadas,
incluindo a probe do blogdom.py: [blogdom_ex], agora usando o Curio:
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 como argumento, 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 Python ≥ 3.9, use os equivalentes definidos em collections.abc.
Quero destacar três aspectos destes tipos genéricos.
Primeiro: eles são todos covariantes no primeiro parâmetro de tipo, que é o tipo dos itens produzidos a partir destes 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 no penúltimo parâmetro.
Aquele é o tipo do argumento passado ao método de baixo nível
.send(), que o laço de eventos invoca para acionar geradores assíncronos e
corrotinas. Desta forma, é um tipo de "entrada", então
vale a regra #2 da variância:
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 contravariante.
Terceiro: AsyncGenerator não tem tipo de retorno, ao contrário de typing.Generator,
usado para anotar corrotinas clássicas, apesar do nome que sugere outra coisa,
como vimos na [generic_classic_coroutine_types_sec].
Devolver um valor levantando StopIteration(value) foi a gambiarra que permitiu
a geradores funcionarem como corrotinas clássicas suportando yield from, como
vimos na [classic_coroutines_sec]. Não há tal sobreposição entre os objetos
assíncronos: objetos AsyncGenerator produzem itens mas não devolvem
um resultado final, e são completamente
separados de objetos corrotina que devolvem um resultado, mas não usam yield.
Por fim, vamos discutir rapidamente as vantagens e desafios da programação assíncrona.
As seções finais deste capítulo discutem ideias de alto nível sobre programação assíncrona, independente da linguagem ou da biblioteca usadas.
Vamos começar explicando por que a programação assíncrona é útil, seguido por um mito popular e como lidar com ele.
Ryan Dahl, o inventor do Node.js, introduz a filosofia do projeto dizendo "Estamos fazendo E/S de forma totalmente errada."[18] 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 Latência em computadores modernos para ler dados em diferentes dispositivos. A terceira coluna mostra os tempos proporcionais em uma escala fácil de entender para nós, humanos vagarosos..
| Dispositivo | Ciclos de CPU | Escala proporcional "humana" |
|---|---|---|
cache L1 |
3 |
3 segundos |
cache L2 |
14 |
14 segundos |
RAM |
250 |
250 segundos |
HD local |
41.000.000 |
1,3 anos |
rede |
240.000.000 |
7,6 anos |
Para entender a Latência em computadores modernos para ler dados em diferentes dispositivos. A terceira coluna mostra os tempos proporcionais em uma escala fácil de entender para nós, humanos vagarosos., tenha em mente que as CPUs modernas, com seus clocks em frequências na casa dos GHz, rodam bilhões de ciclos por segundo. Suponha que uma CPU rode exatamente 1 bilhão de ciclos por segundo. Tal CPU pode realizar mais de 333 milhões de leituras do cache L1 em 1 segundo, ou 4 (quatro!) leituras da rede no mesmo segundo. A terceira coluna da Latência em computadores modernos para ler dados em diferentes dispositivos. A terceira coluna mostra os tempos proporcionais em uma escala fácil de entender para nós, humanos vagarosos. coloca os números em perspectiva, multiplicando a segunda coluna por um fator constante. Então, em um universo alternativo, se uma leitura da RAM demorasse 250 segundos, uma leitura da rede demoraria 7,6 anos! A diferença quantitativa é tão grande que se torna uma diferença qualitativa importante: esperar 250 segundos é muito diferente de esperar 7,6 anos!
A Latência em computadores modernos para ler dados em diferentes dispositivos. A terceira coluna mostra os tempos proporcionais em uma escala fácil de entender para nós, humanos vagarosos. explica por que uma abordagem disciplinada da programação assíncrona pode levar a servidores de alto desempenho. O desafio é alcançar esta 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 I/O bound systems (sistemas limitados por E/S), ou seja, sistemas onde o gargalo é a entrada e saída de dados, e não 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 CPU e na memória. Enquanto esperam, cedem o controle para o laço de eventos, que pode então acionar outras tarefas pendentes. Mas, inevitavelmente, qualquer sistema não-trivial terá partes limitadas pela CPU. Até mesmo sistemas triviais revelam isso, sob stress. Na caixa Ponto de vista ao final deste capítulo escrevi sobre dois programas assíncronos sofrendo com funções limitadas pela CPU que atrasavam o laço 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, precisa ter testes automatizados 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. A solução poderá exigir mudanças drásticas.
Aqui estão algumas opções para quando você identifica gargalos de uso da CPU:
-
Delegar a tarefa para um banco de processos Python.
-
Delegar a tarefa para uma 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 pode tolerar a perda de desempenho e deixar como está—mas registre essa decisão, para ficar mais fácil revertê-la 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 de deixar como está entra na conta de «dívida tecnológica».
Programação concorrente é um tópico fascinante, e eu gostaria de escrever mais. 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, ou você está só perdendo tempo.
RabbitMQ in Action
Escolhi esta epígrafe para este
capítulo por duas razões. Em um nível mais alto, ela nos lembra de evitar o
bloqueio do laço de eventos, delegando tarefas lentas para outra unidade de
processamento, desde uma thread ou processo local, 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 de repente pode complicar o seu trabalho.
Após os exemplos simples com o spinner no [ch_concurrency_models], 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ável. No código-fonte de flags_asyncio.py encontramos o primeiro
exemplo de um gerenciador de contexto assíncrono: httpx.AsyncClient.
As variantes mais avançadas do programa de download de bandeiras apresentaram
duas funções poderosas: o gerador asyncio.as_completed e a corrotina
loop.run_in_executor para delegar tarefas para threads ou processos.
Também vimos o conceito e a aplicação de um semáforo,
para limitar o número de downloads concorrentes—como se espera de um
cliente HTTP bem comportado.
A programação 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 protocolo TCP e o asyncio.
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 de 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 de Python não
estão presos ao pacote asyncio. O Curio também demonstra o conceito de
concorrência estruturada, que poderá ter um grande impacto muitas
linguagens de programação, trazendo mais clareza para o código concorrente.
Por fim, a Como a programação assíncrona funciona e como não funciona apresentou o principal atrativo da programação assíncrona: não perder tempo esperando por E/S. Também vimos que não existem sistemas limitados só por E/S, e como lidar com as inevitáveis partes do seu programa assíncrono que utilizam intensivamente a CPU.
A palestra
de David Beazley na abertura da PyOhio 2016,
Fear and Awaiting in Async
(Medo e espera em [programação] assíncrona) é uma introdução incrível
com "código ao vivo" demonstrando os
recursos da linguagem viabilizados 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,
implementada mais tarde naquele mesmo ano, no Python 3.6.
Fora isso, todo o resto da palestra de Beazley é atemporal, pois ele revela
como os objetos assíncronos vistos neste capítulo funcionam, sem ajuda de
qualquer framework—com uma simples função run que invoca .send(None) para
acionar corrotinas. Apenas no final Beazley mostra o
Curio, que ele havia começado a desenvolver naquele ano,
como uma prova de conceito, para ver o quão longe seria possível levar a programação
assíncrona usando apenas corrotinas, sem callbacks ou futures.
Como vimos, dá para ir muito longe—como demonstra o Curio e o desenvolvimento
posterior do Trio por Nathaniel J. Smith. A
documentação do Curio contém «links» para outras
palestras de Beazley sobre o assunto.
Além de criar o Trio,
Nathaniel J. Smith escreveu dois artigos muito profundos, que eu recomendo:
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),
comparando os designs do Curio e do asyncio, e
Notes on structured concurrency, or: go statement considered harmful
(Notas sobre concorrência estruturada, ou: a instrução go considerada nociva),
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 asyncio e Trio?) no StackOverflow.
Para aprender mais sobre o pacote asyncio, já mencionei os melhores recursos
que conheço no início do capítulo: a
«documentação oficial», após a
«profunda reorganização» 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 de 30 minutos, muito acessível, à 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 Total),
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 de Python),
apresentada por Michael Kennedy, onde aprendi sobre a biblioteca
unsync,
que fornece 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 das líderes mundiais das PyLadies—apresentou a excelente Advanced asyncio: Solving Real-world Production Problems (Asyncio Avançado: Resolvendo Problemas de Produção do Mundo Real), a partir de sua experiência usando Python como engenheira no Spotify.
Em 2020, Łukasz Langa gravou uma ótima série de vídeos sobre o asyncio, começando com
Learn Python’s AsyncIO #1—The Async Ecosystem
(Aprenda o AsyncIO de Python—O Ecossistema Async).
Langa também fez um vídeo muito bacana,
AsyncIO + Music,
para a PyCon 2020, que mostra o asyncio aplicado a um domínio orientado a eventos muito concreto,
e também explica esta aplicação do início ao fim.
Outra área dominada por programação orientada a eventos são os sistemas embarcados.
Por isso Damien George adicionou o suporte a async/await em seu interpretador
MicroPython para microcontroladores.
Na PyCon Australia 2018, Matt Trentini demonstrou a biblioteca
uasyncio,
um subconjunto de 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 Python async frameworks—Beyond developer tribalism (Frameworks assíncronos de Python—para além do tribalismo dos desenvolvedores), 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 entre funções comuns e
funções assíncronas—que chamamos de 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 têm a mesma cor. Gosto disso no Go.
Mas também acho que Nathaniel J. Smith tem razão quando escreveu
Go statement considered harmful
(Instrução go considerada nociva).
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 laço de
eventos embutido do asyncio." Os benchmarks (números de desempenho)
apresentados no «post» de Selivanov anunciando a
biblioteca, em 2016, eram muito impressionantes: "ela é pelo menos
2x mais rápida que o nodejs e gevent, bem como qualquer outro framework
assíncrono de Python. O desempenho do asyncio com o uvloop é próximo ao
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 por múltiplas corrotinas acionadas por um laço de eventos, tudo na mesma thread.[19] -
Que o código Python use a biblioteca httptools além do próprio uvloop.
Selivanov explica que escreveu httptools após testar o desempenho do 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 caso, a análise de cabeçalhos HTTP é intensiva em CPU, e tinha uma implementação lenta, em Python, na biblioteca aiohttp quando Selivanov realizou os testes em 2016. Sempre que uma função escrita em Python estava processando os cabeçalhos, o laço 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 que usa intensivamente a CPU, os ganhos de desempenho de um laço de eventos mais rápido eram perdidos.
Morte lenta
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 bibliotecas 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, de alto desempenho,
precursora do próprio asyncio.
Python era usado para construir uma fachada para a interface Web, integrando funcionalidades fornecidas por bibliotecas 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.[20] Com o passar do tempo, os desenvolvedores notaram que o desempenho do sistema estava piorando, e o time não conseguia localizar os principais gargalos.
O que estava acontecendo: cada nova funcionalidade introduzia mais código intensivo em CPU, atrasando o laço de eventos do Twisted. O papel de Python como uma linguagem de integração entre processos externos implicava em muita interpretação de dados, serialização, desserialização, e 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.
A solução seria repensar a arquitetura do sistema, reescrever muito código, usar uma fila de tarefas, e talvez criar microsserviços ou bibliotecas customizadas, escritas em linguagens mais eficientes no processamento concorrente intensivo em CPU (eu sugeri Go para esta finalidade). Os gestores não quiseram 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 falou que é prioritário decidir quais ferramentas serão usadas para executar tarefas intensivas em CPU sem atrapalhar o laço de eventos, logo no início de qualquer projeto envolvendo programação assíncrona. Esta 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. Isto é explicado na Experimentando com o console assíncrono de Python.
true.dev está disponível por US$ 360,00 ao ano no momento em que escrevo esta nota. Também vi que for.dev está registrado, mas seu DNS não está configurado.
as_completed, bem como a relação próxima entre futures e corrotinas no asyncio.
pathlib nos exemplos 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 laço de eventos que é inseparável do ambiente de runtime, isto é, um navegador, o Node.js ou o Deno.




