8000 GitHub - Kludex/zuvloop: A libuv event loop for asyncio, written in Zig. · GitHub
[go: up one dir, main page]

Skip to content
KludexPublic

About

A libuv event loop for asyncio, written in Zig.

Resources

Contributing

Security policy

Stars

99 stars

Watchers

2 watching

Forks

Repository files navigation

zuvloop

A fast, drop-in asyncio event loop, powered by libuv and written in Zig ⚡


Documentation: https://zuvloop.marcelotryle.com

Source Code: https://github.com/Kludex/zuvloop


zuvloop is a replacement for the built-in asyncio event loop.

Your code stays the same. The loop underneath gets faster. 🚀

The key features are:

  • Fast: Scheduling, timers, sockets, and DNS run in native code, driven by libuv - the same engine behind Node.js. Over 20x faster than asyncio at thread-safe scheduling. Compare it with uvloop and rloop in the benchmarks below.
  • Drop-in: One line to switch. Everything is standard asyncio — same Task objects, same protocols, same APIs.
  • Fully typed: Ships type hints for everything and passes strict mypy. Your editor will love it. ✨
  • Observable: Built-in OpenTelemetry instrumentation — slow-callback spans, loop metrics, an unhandled-exception counter. Zero cost until you turn it on.
  • Modern: Built for Python 3.14, including the new asyncio introspection tools (python -m asyncio ps, call graphs, and friends).

Performance

zuvloop vs rloop vs uvloop vs asyncio benchmarks

Throughput relative to stock asyncio (higher is better), measured with the suite in benchmarks/ on an M3 Max, macOS 26.6.2, CPython 3.14.6, and libuv 1.51.0. The comparison uses uvloop 0.22.1 and rloop 0.5.0. Each result is the median of seven interleaved in-process runs or five interleaved HTTP runs. The chart shows selected benchmarks, with a separate scale for each panel. Labels show absolute throughput. See the recorded results and commands.

These measurements come from a shared machine with background load. Check the recorded run-to-run variation before treating small differences as meaningful.

Benchmark asyncio uvloop rloop zuvloop
call_soon 1.93M/s 3.56M/s 6.30M/s 6.05M/s
call_soon with arguments 1.59M/s 2.36M/s 4.41M/s 4.71M/s
call_soon_threadsafe 407.7k/s 4.14M/s 11.37M/s 9.21M/s
timer schedule + cancel 1.09M/s 1.49M/s 5.05M/s 8.79M/s
completed timer rounds 67.4k/s 75.0k/s 3.39M/s 3.10M/s
prebuilt due timer batch 1.35M/s 2.68M/s 9.58M/s 5.59M/s
ready chain with 250 idle connections 54.3k/s 59.5k/s 3.05M/s 2.12M/s
bulk stream 5.1 GiB/s 5.9 GiB/s 5.9 GiB/s 6.1 GiB/s
echo round trips, 1 KiB 38.6k/s 47.4k/s 46.1k/s 49.6k/s
uvicorn, plaintext 47.6k req/s 65.6k req/s 50.4k req/s 70.1k req/s
uvicorn, 10 KiB body 43.7k req/s 63.2k req/s 49.6k req/s 68.5k req/s
aiohttp server 46.2k req/s 55.6k req/s 48.6k req/s 57.1k req/s
aiohttp client 11.2k req/s 13.2k req/s 12.7k req/s 12.9k req/s
getaddrinfo, numeric host 22.7k/s 1.40M/s 1.01M/s 1.63M/s

With rloop, the aiohttp server benchmark logs TypeError callback errors when the load generator disconnects at its deadline. The throughput above includes this behavior.

The timer rows measure different work. Timer schedule + cancel isolates heap bookkeeping and handle cleanup without firing callbacks. Completed timer rounds chain one zero-delay timer per event loop turn. The prebuilt due timer batch measures heap draining and callback dispatch, with allocation and deallocation outside the timed section.

Curious how? The architecture docs explain the design: argument storage inside handles (no tuple per callback), a native timer heap behind a single uv_timer_t, per-turn vectored write batching, zero-copy reads, and a getaddrinfo fast path for address literals.

Requirements

  • Python 3.14+
  • Standard and free-threaded CPython builds
  • Linux, macOS or Windows
  • Prebuilt wheels for Linux x86-64/AArch64, macOS x86-64/arm64 and Windows AMD64/ARM64

Installation

$ pip install zuvloop

Source distributions install a pinned Zig 0.16 toolchain in their isolated build environment. Direct native development commands require Zig 0.16 on PATH.

Example

Write normal asyncio code, run it with zuvloop:

import asyncio

import zuvloop


async def main() -> None:
    reader, writer = await asyncio.open_connection("example.com", 80)
    writer.write(b"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")
    await writer.drain()
    print(await reader.read(64))
    writer.close()
    await writer.wait_closed()


zuvloop.run(main())

Prefer to keep asyncio.run()? Hand it the loop factory:

asyncio.run(main(), loop_factory=zuvloop.new_event_loop)

That's it. That's the migration. 🎉

Observability

zuvloop emits plain OpenTelemetry. Its small runtime surface is opentelemetry-api plus typing-extensions for the shipped type declarations — no SDK and nothing vendor-specific. Providers can be configured before the loop starts or from inside it — logfire.configure() in main() works: zuvloop checks at each run_forever() entry and re-checks on its sampling interval (loop.metrics_interval, 10 seconds by default) while the loop runs. Until a provider is installed the instruments are no-ops and slow-callback timing stays off.

Anything that speaks OpenTelemetry can collect it. For example, with Logfire:

import logfire
import zuvloop

logfire.configure()  # installs the OTel providers


async def main() -> None: ...


zuvloop.run(main())

That's all — there is no zuvloop-specific setup. Spans and counters are emitted as events happen, and the loop gauges are sampled automatically while the loop runs (published only once a real provider is installed; without one the snapshot is dropped).

You get:

  • zuvloop.slow_callback spans include elapsed callback time measured by uv_hrtime() in native code. The call graph is captured after the callback returns, so its last await identifies the next suspension point, not necessarily the work that was slow. thread.id identifies the loop thread. Set loop.slow_callback_cpu_time_enabled = True to add cpu_time in seconds when the platform clock is available. The default is False, avoiding a thread CPU clock read on every monitored callback. A low CPU/wall ratio can reflect synchronous waiting or scheduling delays; it does not distinguish them. GC CPU time is included in callback CPU time.
  • zuvloop.unhandled_exceptions counter, with the exception class in error.type. The exception itself goes to the standard asyncio logger, not to a span.
  • Counters, a callback-duration histogram, and live loop gauges (loop_count, events, idle_time_ns, ready, timers, watchers, ...).

And because zuvloop schedules real asyncio.Task objects, the Python 3.14 introspection tools work unchanged:

$ python -m asyncio ps <pid>
$ python -m asyncio pstree <pid>

Compatibility

Every pull request is held behind three stable aggregate gates. The first covers the in-repository suite on Linux and macOS, the portable suite on Windows, plus musl runtime tests, cross-compilation, ReleaseSafe builds and documentation. The second runs CPython conformance and pinned upstream suites from aiohttp, uvicorn, AnyIO, websockets, aioquic, Tornado and HTTPX2 with zuvloop swapped underneath. The third runs the native sanitizer build and a 500-cycle resource-ownership soak.

The compatibility run also tests CPython 3.14.0, the newest 3.14 patch, and standard and free-threaded 3.15 builds, then exercises gRPC AsyncIO, asyncpg, Psycopg and redis-py against real local services. The immutable commits and exact commands in .github/workflows/compatibility.yml are the source of truth.

scripts/conformance.py runs CPython's EventLoopTestsMixin, SubprocessTestsMixin and BaseSockTestsMixin against zuvloop, downloading the source of whichever interpreter is running so the suite always matches it. Each test runs in its own process, so a hang is reported rather than stopping the run. Three of the four skips are white-box tests of CPython's own internals - two patch asyncio.base_events.socket, one counts calls to BaseEventLoop._run_once - which no loop outside the standard library can satisfy.

The aiohttp compatibility run disables its optional blockbuster plugin because that plugin exempts stdlib asyncio calls by source filename and therefore reports equivalent os.stat and os.sendfile calls from any third-party loop. Its remaining strict expected failure is the loop.time() difference below. One concurrent WebSocket-close test is skipped because it assumes selector-loop ready/I/O ordering and fails intermittently on uvloop too; every other aiohttp test remains enforced.

(For reference: uvloop cannot complete the aiohttp suite — it fails fifteen tests and then hangs.)

There is one intentional difference: patching loop.time() does not move the scheduler. zuvloop keeps its timer heap in native code and reads the clock directly, so monkeypatching time() — a trick some test suites use to fast-forward timeouts — changes what loop.time() returns and nothing else. A loop that needs a controllable clock should schedule against one explicitly.

One more deliberate divergence: handles returned by call_soon implement the asyncio.Handle interface but are not instances of it: the base class is 56 bytes of storage such a handle never writes, measured at 2% of call_soon, which is the object the loop allocates more often than any other. call_later and call_at do return real asyncio.TimerHandle instances, so they order and compare by deadline, and call_soon_threadsafe returns a real asyncio.Handle, because 3.14 requires cancelling one from another thread to block until a callback that has already started finishes.

Development

$ uv venv --python 3.14
$ uv pip install -e . --group dev
$ uv run pytest
$ uv run mypy
$ uv run ruff check
$ uv run ruff format --check
$ ./scripts/check-zig  # requires ZLint 0.9.1 on PATH
$ uv run --group bench python benchmarks/run.py

The extension is rebuilt by the hatch-ziglang build hook on every install. To rebuild in place:

$ python scripts/build.py

vendor/libuv is an unmodified upstream release tarball; see vendor/README.md. Update it with ./vendor/update-libuv.sh <version> <sha256>.

License

This project is licensed under the terms of the MIT license.

About

A libuv event loop for asyncio, written in Zig.

Resources

Contributing

Security policy

Stars

99 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages

0