0%

A Taste of Async

What This Taste Leaves Out

This chapter promised a taste, and here is where it ends. You can now read an async def handler and find each place it hands control back: every await. You have run a with asyncio.run, on the server, and written the scoreboard’s viewer handler as a coroutine that awaits next_snapshot(), send and close. And you have read the shape of a websockets.asyncio server. This lesson names what a real course in asyncio would add, so that you know where your knowledge stops, and then reads one more framework’s version of the handler you wrote.

Tasks and cancellation

In Lesson 3’s Run, three viewers were served at once on one thread. The asyncio server did that by running each connection’s handler as a task: a coroutine that the keeps alive on its own and runs side by side with the others, switching at every await. You never started a task yourself. next_snapshot() came ready-made, and the server started a task for each handler.

Real async programs start tasks of their own all the time: a handler that waits for the next snapshot and watches its connection at the same moment, a server that runs a scorekeeper and a clock side by side, a client that talks to three servers at once. Starting a task, waiting for several to finish (an asyncio.TaskGroup is the standard tool), and stopping them cleanly, by cancelling a task and letting it tidy up when it is told to stop, are skills with rules of their own. They are named here and not taught. They are the heart of the Async Python course, which needs only Python in Practice.

Which of these is a topic for the Async Python course rather than one this course taught?

Blocking calls inside a loop

Lesson 3 kept time.sleep and Event.wait out of the handler, because a call that blocks holds the loop’s only thread. Those two are easy to spot. Others hide better: a recv on a plain , reading a large file, a long calculation, or any library that waits on a network or a disk without await. Inside a coroutine, each one stops every other viewer until it returns.

There are two usual cures. Use a library built for the loop, the way websockets.asyncio replaces the sync server you used before. Or hand the blocking call to a thread, so the loop can go on while it waits; asyncio has a function for that, asyncio.to_thread. Knowing which calls block, and choosing between those cures, is again Async Python’s subject.

Which line, inside an async def handler, stops every other viewer while it runs?

Testing async code

When you pressed Submit in Lesson 3, the check stepped your coroutine by hand, with stand-ins that handed control back at labeled points and no at all. That shows exactly where a handler awaits, and it is enough for a handler with three awaits. Real projects test coroutines on a real loop: with a plugin for pytest that runs async def test functions, or with asyncio.run inside an ordinary test, and they test timeouts and as well. Async Python is the place for that too.

FastAPI, one more time

In with Python, Chapter 11 turned your handler into path operations, and you read decorators such as @app.get(...) there. FastAPI has endpoints as well, and they are coroutines like the one you wrote. Here is a small one, with an in-process test, for reading only:

from fastapi import FastAPI, WebSocket
from fastapi.testclient import TestClient

app = FastAPI()


@app.websocket("/watch")
async def watch(websocket: WebSocket):
    await websocket.accept()
    await websocket.send_json({"type": "snapshot", "version": 0, "scores": {"Owls": 0}})
    await websocket.close(code=1000, reason="the quiz is over")


with TestClient(app).websocket_connect("/watch") as ws:
    print(ws.receive_json())
    ws.receive_json()

Read it beside your handler. @app.websocket("/watch") registers the coroutine for WebSocket on /watch, the way @app.get registered a function for GET requests. The annotation in websocket: WebSocket, also for reading only, is how FastAPI knows to pass the connection in. await websocket.accept() is the call that makes the server answer the upgrade with 101 Switching Protocols; until then, the request could still be refused, as your Chapter 2 code refused it. send_json encodes the and sends it as a text message, and close sends the close with your code and reason. The test uses the TestClient you know: websocket_connect performs the handshake in the same process, with no and no network.

This lesson’s examples run in your browser, where FastAPI is not available, so this code does not run here. The site’s server sandbox does have FastAPI and its test client, and there the test received the snapshot, {'type': 'snapshot', 'version': 0, 'scores': {'Owls': 0}}, and the second receive_json() raised WebSocketDisconnect with code 1000 and the reason the quiz is over.

A FastAPI WebSocket endpoint:

@app.websocket("/watch")
async def watch(websocket: WebSocket):
    await websocket.accept()
    await websocket.send_json({"type": "snapshot", "version": 0, "scores": {"Owls": 0}})
    await websocket.close(code=1000, reason="the quiz is over")

What does await websocket.accept() correspond to in your Chapter 2 code?

That is the edge of the taste. The next chapter turns these edges into routes you can choose from.