12.14. Sockets with asyncio¶
A blocking recv() call freezes the
whole script until a byte arrives. A blocking
accept() call serves only one
client at a time. Both of these are exactly the kind of
“wait on I/O” situation asyncio exists to handle.
The asyncio chapter
covers the event loop, coroutines, and the synchronisation
primitives; this page covers the network-specific
pieces.
The asyncio module exposes networking through a small
number of helpers that take and return streams –
high-level objects that wrap a socket and offer
await-able versions of read and write. The
underlying socket is still there; the application just
does not touch it directly.
12.14.1. A client with asyncio¶
asyncio.open_connection() is the asyncio
counterpart to socket.socket.connect(). It opens
a TCP connection and returns two stream objects: a
reader and a 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())
Three things to note:
The connection setup is one
awaitinstead of a blocking call. While the handshake is in flight, the event loop is free to run other coroutines.write()puts bytes into an outbound buffer;drain()is theawaitthat yields to the loop until those bytes have actually been sent over the network.readline()reads bytes until a newline arrives. The stream class includesread()(read up to N bytes) andreadexactly()(read exactly N bytes) as well, which solve TCP’s message-boundary problem without writing the framing loops by hand.
12.14.2. A server with asyncio¶
asyncio.start_server() is the asyncio counterpart
to the bind/listen/accept dance. It takes a callback
that will be run once per incoming connection, with the
same reader/writer pair the client side uses:
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())
Every accepted connection becomes its own task running
handle. The event loop dispatches between them
naturally – one slow client cannot block the others,
because while it is waiting on await reader.read(...)
the loop is free to make progress on every other
connection. Adding ten concurrent clients does not
require ten threads; the same single-threaded event
loop drives them all.
This is the practical reason camera networking applications written for asyncio scale so much better than the equivalent blocking code: the server picture on TCP sockets was one-client-at-a-time; this one is many-clients-at-once with no extra effort.
12.14.3. Concurrent work alongside networking¶
The big payoff is mixing networking with the rest of the camera’s work in the same loop. The camera can capture a frame, run image processing, and serve a network protocol, all interleaved:
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() runs the two coroutines on the
same event loop. While the camera is sleeping in
sleep_ms() between frames, the server
gets to dispatch network traffic. While the server is
waiting for the next byte, the camera gets to capture.
Both make progress on a single MicroPython thread.
12.14.4. UDP with asyncio¶
The asyncio module does not offer the same high-level streams for UDP – datagrams do not fit the read/write shape of a stream. The practical approach on the camera is to put the UDP work in its own coroutine, switch the socket to non-blocking mode, and yield to the event loop between read attempts:
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)
The socket is set non-blocking with
s.setblocking(False), so
recvfrom() raises
OSError immediately when no datagram is waiting
instead of blocking the whole event loop. The
await asyncio.sleep_ms(10) in the empty branch
hands control back to the event loop until the next
poll.
Sending follows the same shape:
sendto() on a non-blocking socket
either succeeds immediately or raises. There is no
sendallto – UDP datagrams are atomic, so each send
is one whole datagram or none. If the send buffer is
full, the right move for UDP is usually to drop the
datagram and let the next one go out the next time
through the loop:
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)
The failing branch is rare in practice. UDP has no flow
control, so sendto() almost always
succeeds on first try; the except mostly exists so
a brief network hiccup does not crash the coroutine.
The Asyncio section covers the broader patterns for mixing blocking I/O into an asyncio program; the same patterns apply directly to a UDP socket.
12.14.5. Timeouts and cancellation¶
Wrapping a network call in asyncio.wait_for()
puts a deadline on it:
try:
reply = await asyncio.wait_for(reader.readline(), timeout=2.0)
except asyncio.TimeoutError:
print("server is slow")
A coroutine that is taking too long can also be
cancel()-led from elsewhere. Both
mechanisms are covered in detail in the
coordination chapter;
they apply unchanged to streams returned by
asyncio.open_connection() and
asyncio.start_server().
For the full Stream reference (the
class behind the readers and writers, plus the helpers
this page used in passing), see
asyncio — asynchronous I/O scheduler.