C.W.K.
Stream
Lesson 02 of 04 · published

One Worker, Opened Once

~10 min · lifespan, persistent-worker, event-loop

Level 0Silent
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Open the output stream once, at startup. Hold it for the life of the process. Every request enqueues; none opens its own."

Born With the App, Not With a Request

The audio worker is created in the application's lifespan — the block that runs once at startup and once at shutdown. On start it opens a single persistent output stream and holds it. On shutdown it drains its queue and closes the stream cleanly. In between, for the entire life of the process, that one worker owns the device. Requests don't open streams; they enqueue audio onto the worker, and playback is serialized through it.

Why Not Open a Stream Per Request

Opening the device per request seems simpler until two requests overlap. Now two streams contend for one output, playback races, and the "who has the speaker" question has no stable answer. A single long-lived worker turns concurrency into a queue: requests line up, the worker plays them in order, and there's never a moment where two owners fight over the device. The persistence is the point — the stream outlives every individual request.

Keep Heavy Work Off the Event Loop

There's a subtlety that bites async services: the worker's audio path uses blocking, CPU-heavy tools — decoding, probing, muxing with ffmpeg. Run those directly on the event loop and they freeze every other request while they churn. Bellows runs ffmpeg and ffprobe off the loop, in an executor, so the async server stays responsive. And because startup and shutdown are explicit, an interrupted job can be healed when the process comes back up, rather than left dangling.

Code

Lifespan owns the worker; requests just enqueue·python
from contextlib import asynccontextmanager
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app: FastAPI):
    worker = AudioWorker()           # ONE persistent output stream
    await worker.start()             # opens the device once, holds it open
    app.state.audio = worker
    try:
        yield                        # ... the whole life of the process ...
    finally:
        await worker.stop()          # drain the queue, close the stream cleanly

app = FastAPI(lifespan=lifespan)

# Requests never open a stream -- they enqueue on the ONE worker:
async def play(app, audio):
    await app.state.audio.enqueue(audio)   # serialized; no per-request race

# Heavy ffmpeg/ffprobe runs OFF the event loop so the server stays responsive.

External links

Exercise

Find a resource in a system you know that's opened per request but is slow to open or contended — a database connection, a file handle, an external device, a model loaded into memory. Rewrite it as an open-once-at-startup, held-for-life, shared-through-a-queue resource. Name one blocking operation on its path that should run off the event loop, and what stalls if it doesn't.
Hint
Two questions expose the fix: 'is this expensive to open?' (then open it once) and 'does it block?' (then get it off the loop). Per-request open plus on-loop blocking is the combination that quietly kills throughput.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.