maxisbey/zuvloop

A libuv event loop for asyncio, written in Zig.

โ˜… 0Forks 0GitHub โ†—Compare

README

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. Up to 6x faster than asyncio and faster than uvloop on every benchmark 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, unhandled-exception spans, loop metrics. 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 uvloop vs asyncio benchmarks

Throughput relative to stock asyncio (higher is better), measured with the suite in benchmarks/ on an M3 Max, macOS 26, CPython 3.14. The labels show the absolute numbers.

Benchmark asyncio uvloop zuvloop
call_soon 2.69M/s 4.69M/s 5.91M/s
call_soon with arguments 2.43M/s 3.87M/s 6.47M/s
timer schedule + cancel 1.58M/s 2.62M/s 9.55M/s
bulk stream 8.4 GiB/s 8.5 GiB/s 10.2 GiB/s
echo round trips, 1 KiB 39.0k/s 56.8k/s 58.5k/s
uvicorn, plaintext 55.3k req/s 71.9k req/s 75.9k req/s
uvicorn, 10 KiB body 52.6k req/s 68.7k req/s 73.6k req/s
aiohttp server 49.0k req/s 59.8k req/s 60.6k req/s
aiohttp client 13.2k req/s 16.2k req/s 16.9k req/s
getaddrinfo, numeric host 28.5k/s 1.57M/s 1.90M/s

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+
  • Linux or macOS

Installation

$ pip install zuvloop

To build from source you also need Zig 0.16.

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. The only runtime dependency is opentelemetry-api โ€” not the SDK, nothing vendor-specific. Configure providers before starting the loop; zuvloop checks for them at each run_forever() entry. Until then 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 (only when a real provider is installed, so an uninstrumented program never pays for sampling).

You get:

  • zuvloop.slow_callback spans โ€” with real start/end timestamps measured by uv_hrtime() in native code, and the awaiting call graph attached (via asyncio.format_call_graph()), so you see why a callback was running, not just its repr.
  • zuvloop.unhandled_exception spans โ€” with the exception recorded.
  • 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

zuvloop is checked against CPython's own conformance suite and against the test suites of the projects that exercise an event loop hardest โ€” run unmodified, with the loop swapped underneath:

Suite Result
CPython test_asyncio 88 passed, 4 skipped, none failing
uvicorn 1257 passed, no failures
aiohttp 4473 passed, 36 failed โ€” 33 of which also fail on stock asyncio

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.

Of aiohttp's three remaining failures, two are blockbuster reporting a blocking os.stat that the standard library makes on the same path, and the third is the loop.time() difference below.

(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.

Also not implemented: sendfile() and sock_sendfile() raise NotImplementedError. Handles returned by call_soon and call_later implement the asyncio.Handle and asyncio.TimerHandle interfaces but are not instances of those classes; call_soon_threadsafe does return 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 --group bench python benchmarks/run.py

The extension is rebuilt by hatch_build.py 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>.

License

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

Contributors

Kludexzzstoatzzdependabot[bot]

Issues