Skip to content

Receive channel notifications with asyncio.Protocol instead of streams - #268

Draft
cboulay wants to merge 2 commits into
devfrom
cboulay/protocol-hot-path
Draft

Receive channel notifications with asyncio.Protocol instead of streams#268
cboulay wants to merge 2 commits into
devfrom
cboulay/protocol-hot-path

Conversation

@cboulay

@cboulay cboulay commented Sep 4, 2026

Copy link
Copy Markdown
Member

Draft — not for merge yet. Opening this so @griffinmilsap can look it over before it goes anywhere.

What and why

Profiling a two-process SHM hop showed the time is not where I expected. Serialization is ~14us of a ~97us hop; the dominant stage is 50us between the publisher finishing broadcast() and the subscriber's channel waking up. That turned out to be the streams API, not the transport and not ezmsg.

asyncio.open_connection delivers bytes by resolving a future and scheduling the reading task, costing a full event-loop iteration per message before any work happens. An asyncio.Protocol has data_received() called synchronously from the transport's read callback, so the notification is cached and subscribers notified right there.

Measured

Two-process SHM hop, 8 KB AxisArray, medians over 2400 messages, interleaved A/B on this base:

stage streams Protocol
pub exit → rx wakeup 50.6 us 15.6 us 3.2x
end-to-end 97.0 us 69.0 us −29%
throughput 17,337 msg/s 18,697 msg/s +8%

Protocol won every round. Corroborating detail: latency is flat from 16 B to 512 KB payloads, which is what pointed at fixed scheduling cost rather than serialization in the first place.

uvloop

Written to public asyncio API only — no reader._buffer, no transport internals — so uvloop stays a drop-in on POSIX. Verified end-to-end under uvloop with no code changes: all messages delivered, clean, ~50 us end-to-end. So this gets stock asyncio most of the way, and uvloop later would roughly halve the original baseline.

Design

frameproto.FramedProtocol runs a connection in two phases:

  • handshake — sequential, driven by await read_exactly(). One task wakeup per read, which is fine since it happens once.
  • dispatchstart_dispatch() flips to frames_available() being called inline from data_received.

SHM resize is the one path needing an await, which a read callback can't do. On a generation change the protocol pauses reading and parks the frame; a task then re-dispatches it against the new segment, or drops it and releases backpressure if the attach fails. Covered by test_shm_grow and a rewritten reattach unit test.

Things worth a reviewer's attention

  • Behaviour change: a frame-dispatch exception is now logged with traceback and the transport closed, rather than propagating out of the channel task and potentially surfacing through wait_closed() during teardown. Happy to make it propagate if that's preferred.
  • Now atomic: because dispatch never awaits, delivery is atomic w.r.t. other tasks on the loop. The task-based version wasn't. This is a behavioural difference even though it looks like a pure win.
  • Test rewrite: test_channel_preserves_cached_message_during_shm_reattach drove _publisher_connection with a StreamReader, which no longer exists. Rewritten against a real ChannelProtocol with a fake transport, and strengthened — it now also asserts the segment swapped, the parked frame was re-dispatched, the client was notified, and the buffer fully consumed.

Deliberately not included

The publisher's ack receive was built and passed the full suite, but measured consistently ~12% worse end-to-end across 5 interleaved rounds. Localized by stage: broadcast() itself and the subscriber side were unchanged; the entire cost was bytes leaving the publisher later. Working hypothesis is that inline ack handling delays the transport's write scheduling, where the ack task previously competed on equal footing in the ready queue — unverified, flagging it as a hypothesis. Reverted rather than shipped.

The graph server connections stay on streams: control plane, not per-message.

Testing

Full suite green on this base: 424 passed, 1 skipped. Lint clean.

Measurements are single-machine (Darwin, arm64, Python 3.13). Worth replicating on Linux before anyone leans on the numbers.

🤖 Generated with Claude Code

The channel's connection to its publisher used the high-level streams API,
which delivers bytes by resolving a future and scheduling the reading task.
That costs a full event-loop iteration before any work happens, on every
message.

An asyncio.Protocol has data_received() called synchronously from the
transport's read callback, so a notification is cached and its subscribers
notified in that callback instead. Measured on a two-process SHM hop
(8 KB AxisArray, medians over 2400 messages, interleaved A/B):

    stage                    streams    Protocol
    pub exit -> rx wakeup     50.6us      15.6us     3.2x
    end-to-end                97.0us      69.0us      -29%
    throughput             17,337/s    18,697/s       +8%

frameproto.FramedProtocol runs a connection in two phases: a handshake
driven by `await read_exactly()`, then start_dispatch() flips to
frames_available() being called inline. Only public asyncio API is used, so
uvloop remains a drop-in on POSIX -- verified end-to-end under uvloop, where
the same code reaches ~50us.

SHM resize is the one path that needs an await, which a read callback cannot
do. On a generation change the protocol pauses reading and parks the frame,
then a task re-dispatches it against the new segment, or drops it and
releases its backpressure if the attach fails.

Two behaviour notes:

- A frame-dispatch exception is now logged with its traceback and the
  transport closed, rather than propagating out of the channel task and
  potentially surfacing through wait_closed() during teardown.
- Because dispatch never awaits, delivery is now atomic with respect to
  other tasks on the loop, which the task-based version was not.

Deliberately not converted: the publisher's ack receive. It was built and
passed the suite, but measured consistently ~12% worse end-to-end across 5
interleaved rounds -- the cost localized to bytes leaving the publisher
later, with broadcast() itself and the subscriber side unchanged. The graph
server connections stay on streams as well; they are control plane, not
per-message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Channel.wait_closed() now waits on the protocol's connection_lost, which the
task-based version did not -- it cancelled a task and returned regardless of
transport state. That introduced a way to hang shutdown.

A graceful transport.close() does not schedule connection_lost while the
transport still has queued writes; it waits for them to flush. If the peer
has stopped reading -- which the publisher does during teardown, when it
cancels the task that consumes acks -- those bytes never drain and
wait_closed() blocks forever.

Demonstrated directly: with the write buffer stuffed, close() leaves
wait_closed() pending indefinitely while abort() completes immediately.
Teardown paths now abort, which discards queued output and fires
connection_lost without waiting for the peer. Dropping pending acks is safe:
the publisher frees the whole channel's backpressure when it sees the
disconnect.

Adds a regression test that fills the transport's write buffer against a peer
that never reads, then asserts teardown completes. It fails with
TimeoutError if abort() is swapped back to close().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant