Architecture¶
zuvloop is a CPython extension module written in Zig, with a small Python layer on top. What lives where follows one rule: native if it runs per callback or per packet, Python if it runs per connection or per loop.
| Layer | Where | Why |
|---|---|---|
| Ready queue, timer heap, cross-thread inbox | zig/loop.zig |
Once per callback |
| Callback handles | zig/handle.zig |
Arguments stored inline, invoked through vectorcall |
| Stream and pipe reads and writes | zig/transport.zig |
Once per packet |
| Datagram sends and receives | zig/datagram.zig |
Once per datagram |
| Descriptor watchers | zig/poller.zig |
One uv_poll_t per descriptor |
| Name resolution | zig/dns.zig |
On libuv's threadpool, not the executor |
| Process spawning | zig/process.zig |
libuv reaps the child, so no watcher is needed |
| Connection and server setup | zuvloop/_connect.py |
Once per connection |
| Lifecycle, executors, error reporting | zuvloop/_base.py |
Once per loop |
| OpenTelemetry emission | zuvloop/_instrumentation.py |
The only file that imports OTel |
The GIL¶
The GIL is released for the whole uv_run call. Callbacks reacquire it through a
thread state saved once by the loop, rather than PyGILState_Ensure, and a
nested callback within the same batch pays nothing.
Scheduling¶
call_soon(cb, a, b) allocates no tuple. Up to three arguments live inside the
handle and are passed straight to PyObject_Vectorcall.
Timers share one uv_timer_t behind an internal heap rather than taking a libuv
handle each. Cancellation is O(1) and the heap is compacted lazily, which matches
asyncio's scheduler semantics rather than merely approximating them.
A batch runs exactly the callbacks queued when it started. asyncio guarantees
everything already scheduled runs even if one of them calls stop(), so the
batch is never cut short.
Reads¶
Two paths, chosen by the traffic. Below 64 KiB the data is copied out of a shared
buffer into an exactly sized bytes; above it, libuv fills the final object
directly and nothing is copied. The threshold a transport is judged against
follows what the peer has been sending, doubling whenever a read fills the
buffer.
Writes¶
Everything written during one turn goes out as a single vectored uv_try_write.
The flush runs from a prepare handle, which libuv runs before it computes the
poll timeout — from a check handle, a loop with nothing else to do would block
for I/O while still holding data the peer was waiting for.
Nothing is copied: when the socket cannot take it all, the queued request holds a buffer view of the caller's memory.
Name resolution¶
An address literal is answered by inet_pton without entering libc.
getaddrinfo costs around half a microsecond even under AI_NUMERICHOST, where
it has nothing to look up but still builds a chain, takes the resolver's locks
and has to be freed.
Everything the shortcut cannot answer identically falls back to libc: a scoped
address, whose zone only libc can resolve; a legacy form like 127.1 that
inet_pton rejects and getaddrinfo accepts; an unspecified socket type, which
libc answers with one entry per type. Real hostnames resolve on libuv's
threadpool, not the executor.
Sockets¶
Sockets are created, bound and accepted in Python. Those happen once per connection, so the readable implementation is worth more there than the last microsecond — and it is where the ownership rules live.
/// note | Descriptor ownership
Once libuv adopts a descriptor it owns it. The Python socket that was holding it is detached rather than closed, or Python would close a descriptor libuv has already closed and possibly reused. Everything after adoption is written to be infallible, so ownership cannot be split by a failure part-way through. ///