9.14. Sockets med asyncio

Ett blockerande recv()-anrop fryser hela skriptet tills en byte anländer. Ett blockerande accept()-anrop betjänar bara en klient åt gången. Båda dessa är precis den typ av ”vänta på I/O”-situation som asyncio finns till för att hantera. asyncio-kapitlet täcker händelseloopen, koroutiner och synkroniseringsprimitiverna; den här sidan täcker de nätverksspecifika delarna.

Asyncio-modulen exponerar nätverk genom ett litet antal hjälpfunktioner som tar och returnerar strömmar – högnivåobjekt som omsluter en socket och erbjuder await-bara versioner av läsning och skrivning. Den underliggande socketen finns fortfarande där; applikationen rör den bara inte direkt.

9.14.1. En klient med asyncio

asyncio.open_connection() är asyncio-motsvarigheten till socket.socket.connect(). Den öppnar en TCP-anslutning och returnerar två strömobjekt: en läsare och en skrivare

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

Tre saker att notera:

  • Anslutningens uppsättning är ett enda await istället för ett blockerande anrop. Medan handskakningen pågår är händelseloopen fri att köra andra koroutiner.

  • write() lägger bytes i en utgående buffert; drain() är det await som lämnar över till loopen tills dessa bytes faktiskt har skickats över nätverket.

  • readline() läser bytes tills ett radslut anländer. Strömklassen innehåller även read() (läs upp till N bytes) och readexactly() (läs exakt N bytes), vilka löser TCP:s problem med meddelandegränser utan att man behöver skriva ramningslooparna för hand.

9.14.2. En server med asyncio

asyncio.start_server() är asyncio-motsvarigheten till bind/listen/accept-dansen. Den tar ett återanrop som körs en gång per inkommande anslutning, med samma läsar/skrivar-par som klientsidan använder:

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

Varje accepterad anslutning blir sin egen uppgift som kör handle. Händelseloopen växlar mellan dem på ett naturligt sätt – en långsam klient kan inte blockera de andra, eftersom loopen medan den väntar på await reader.read(...) är fri att göra framsteg på varje annan anslutning. Att lägga till tio samtidiga klienter kräver inte tio trådar; samma enkeltrådade händelseloop driver dem alla.

Detta är den praktiska anledningen till att nätverksapplikationer för kameror skrivna för asyncio skalar så mycket bättre än motsvarande blockerande kod: serverbilden på TCP-socketar var en-klient-åt-gången; den här är många-klienter-samtidigt utan extra ansträngning.

9.14.3. Samtidigt arbete jämsides med nätverk

Den stora vinsten är att blanda nätverk med resten av kamerans arbete i samma loop. Kameran kan ta en bildruta, köra bildbehandling och betjäna ett nätverksprotokoll, allt sammanflätat:

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() kör de två koroutinerna på samma händelseloop. Medan kameran sover i sleep_ms() mellan bildrutorna får servern växla nätverkstrafik. Medan servern väntar på nästa byte får kameran ta en bild. Båda gör framsteg på en enda MicroPython-tråd.

9.14.4. UDP med asyncio

Asyncio-modulen erbjuder inte samma högnivåströmmar för UDP – datagram passar inte in i en ströms läs/skriv-form. Det praktiska tillvägagångssättet på kameran är att lägga UDP-arbetet i en egen koroutin, sätta socketen i icke-blockerande läge och lämna över till händelseloopen mellan läsförsöken:

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)

Socketen sätts i icke-blockerande läge med s.setblocking(False), så att recvfrom() omedelbart kastar OSError när inget datagram väntar istället för att blockera hela händelseloopen. await asyncio.sleep_ms(10) i den tomma grenen lämnar tillbaka kontrollen till händelseloopen fram till nästa pollning.

Sändning följer samma form: sendto() på en icke-blockerande socket antingen lyckas omedelbart eller kastar undantag. Det finns ingen sendallto – UDP-datagram är atomära, så varje sändning är antingen ett helt datagram eller inget. Om sändbufferten är full är det rätta draget för UDP vanligtvis att kasta datagrammet och låta nästa gå ut nästa gång genom loopen:

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)

Den fallerande grenen är sällsynt i praktiken. UDP har ingen flödeskontroll, så sendto() lyckas nästan alltid på första försöket; except finns mest till för att en kort nätverksstörning inte ska krascha koroutinen.

Avsnittet Asyncio täcker de bredare mönstren för att blanda blockerande I/O i ett asyncio-program; samma mönster gäller direkt för en UDP-socket.

9.14.5. Tidsgränser och avbrytning

Att omsluta ett nätverksanrop i asyncio.wait_for() sätter en tidsfrist på det:

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

En koroutin som tar för lång tid kan också cancel()-as från annat håll. Båda mekanismerna beskrivs i detalj i samordningskapitlet; de gäller oförändrade för strömmar som returneras av asyncio.open_connection() och asyncio.start_server().

För den fullständiga referensen för Stream (klassen bakom läsarna och skrivarna, plus hjälpfunktionerna den här sidan använde i förbigående), se asyncio — asynkron I/O-schemaläggare.