9.14. Sockets com asyncio

Uma chamada recv() bloqueante congela todo o script até chegar um byte. Uma chamada accept() bloqueante serve apenas um cliente de cada vez. Ambas são exatamente o tipo de situação de «espera em I/O» que o asyncio existe para gerir. O capítulo asyncio aborda o ciclo de eventos, as corrotinas e os primitivos de sincronização; esta página aborda as partes específicas de rede.

O módulo asyncio expõe as funcionalidades de rede através de um pequeno número de auxiliares que recebem e devolvem streams – objetos de alto nível que encapsulam um socket e oferecem versões com await-able de leitura e escrita. O socket subjacente ainda está lá; a aplicação simplesmente não o utiliza diretamente.

9.14.1. Um cliente com asyncio

asyncio.open_connection() é o equivalente asyncio de socket.socket.connect(). Abre uma ligação TCP e devolve dois objetos stream: um reader e um writer

import asyncio

async def client():
    reader, writer = await asyncio.open_connection("192.168.1.20", 9000)

    writer.write(b"hello\n")
    await writer.drain()                   # wait until bytes have been sent

    reply = await reader.readline()
    print("reply:", reply)

    writer.close()
    await writer.wait_closed()

asyncio.run(client())

Três aspetos a notar:

  • A configuração da ligação é um único await em vez de uma chamada bloqueante. Enquanto o handshake está em curso, o ciclo de eventos está livre para executar outras corrotinas.

  • write() coloca bytes num buffer de saída; drain() é o await que cede ao ciclo até que esses bytes tenham sido efetivamente enviados pela rede.

  • readline() lê bytes até chegar uma nova linha. A classe stream inclui também read() (ler até N bytes) e readexactly() (ler exatamente N bytes), que resolvem o problema dos limites de mensagem do TCP sem escrever os ciclos de enquadramento manualmente.

9.14.2. Um servidor com asyncio

asyncio.start_server() é o equivalente asyncio da sequência bind/listen/accept. Recebe um callback que será executado uma vez por cada ligação recebida, com o mesmo par reader/writer que o lado cliente utiliza:

import asyncio

async def handle(reader, writer):
    addr = writer.get_extra_info("peername")
    print("connection from", addr)

    while True:
        data = await reader.read(1024)
        if not data:
            break
        writer.write(data)                 # echo back
        await writer.drain()

    writer.close()
    await writer.wait_closed()

async def main():
    server = await asyncio.start_server(handle, "0.0.0.0", 9000)
    print("listening on", server.sockets[0].getsockname())
    async with server:
        await server.serve_forever()

asyncio.run(main())

Cada ligação aceite torna-se a sua própria tarefa a executar handle. O ciclo de eventos distribui entre elas naturalmente – um cliente lento não pode bloquear os outros, pois enquanto aguarda em await reader.read(...) o ciclo está livre para progredir em todas as outras ligações. Adicionar dez clientes simultâneos não requer dez threads; o mesmo ciclo de eventos com uma única thread conduz todos eles.

Esta é a razão prática pela qual as aplicações de rede de câmara escritas para asyncio escalam muito melhor do que o código bloqueante equivalente: o exemplo de servidor em Sockets TCP era um-cliente-de-cada-vez; este é muitos-clientes-ao-mesmo-tempo sem esforço adicional.

9.14.3. Trabalho simultâneo a par da rede

A grande vantagem é misturar as operações de rede com o restante trabalho da câmara no mesmo ciclo. A câmara pode capturar um fotograma, executar processamento de imagem, e servir um protocolo de rede, tudo intercalado:

import asyncio

async def capture_loop():
    while True:
        img = await camera.snapshot()
        # process img ...
        await asyncio.sleep_ms(100)

async def handle(reader, writer):
    ...

async def main():
    server = await asyncio.start_server(handle, "0.0.0.0", 9000)
    await asyncio.gather(
        server.serve_forever(),
        capture_loop(),
    )

asyncio.run(main())

asyncio.gather() executa as duas corrotinas no mesmo ciclo de eventos. Enquanto a câmara está em espera em sleep_ms() entre fotogramas, o servidor pode processar tráfego de rede. Enquanto o servidor aguarda pelo próximo byte, a câmara pode capturar. Ambos progridem numa única thread MicroPython.

9.14.4. UDP com asyncio

O módulo asyncio não oferece as mesmas streams de alto nível para UDP – os datagramas não se enquadram na forma de leitura/escrita de uma stream. A abordagem prática na câmara é colocar o trabalho UDP na sua própria corrotina, colocar o socket em modo não bloqueante, e ceder ao ciclo de eventos entre tentativas de leitura:

import asyncio
import socket

async def udp_listener(port):
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.setblocking(False)
    s.bind(("0.0.0.0", port))

    while True:
        try:
            data, src = s.recvfrom(1024)
        except OSError:
            await asyncio.sleep_ms(10)
            continue
        print("got", data, "from", src)

O socket é colocado em modo não bloqueante com s.setblocking(False), pelo que recvfrom() levanta OSError imediatamente quando não há datagramas à espera, em vez de bloquear todo o ciclo de eventos. O await asyncio.sleep_ms(10) no ramo vazio devolve o controlo ao ciclo de eventos até à próxima sondagem.

O envio segue a mesma forma: sendto() num socket não bloqueante ou tem sucesso imediatamente ou levanta uma exceção. Não existe sendallto – os datagramas UDP são atómicos, pelo que cada envio é um datagrama completo ou nenhum. Se o buffer de envio estiver cheio, a ação correta para UDP é normalmente descartar o datagrama e deixar o seguinte sair na próxima iteração do ciclo:

async def udp_telemetry(target_addr, period_ms):
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.setblocking(False)

    while True:
        payload = collect_telemetry()
        try:
            s.sendto(payload, target_addr)
        except OSError:
            pass                # buffer full -- skip this one

        await asyncio.sleep_ms(period_ms)

O ramo de falha é raro na prática. O UDP não tem controlo de fluxo, pelo que sendto() quase sempre tem sucesso na primeira tentativa; o except existe principalmente para que uma breve falha de rede não cause a falha da corrotina.

A secção Asyncio aborda os padrões mais amplos para misturar I/O bloqueante num programa asyncio; os mesmos padrões aplicam-se diretamente a um socket UDP.

9.14.5. Timeouts e cancelamento

Envolver uma chamada de rede em asyncio.wait_for() define um prazo para ela:

try:
    reply = await asyncio.wait_for(reader.readline(), timeout=2.0)
except asyncio.TimeoutError:
    print("server is slow")

Uma corrotina que está a demorar demasiado pode também ser cancel()-ada de outro local. Ambos os mecanismos são abordados em detalhe no capítulo de coordenação; aplicam-se sem alterações às streams devolvidas por asyncio.open_connection() e asyncio.start_server().

Para a referência completa de Stream (a classe por trás dos readers e writers, mais os auxiliares que esta página utilizou), consulte asyncio — escalonador de I/O assíncrono.