Monocoque
A Rust-native ZeroMQ-compatible messaging runtime, io_uring by default with an optional tokio backend
Monocoque is a ZeroMQ-compatible messaging library written in Rust. It implements ZMTP 3.1 from scratch over a small runtime facade: io_uring by default (via compio), with an optional tokio backend for portability. Either way it interoperates with any existing libzmq peer while staying entirely within Rust's memory model.
The name comes from Formula 1 engineering, where the monocoque chassis achieves structural strength through form rather than bolt-on reinforcement. Same idea here: performance through correct architecture, not unsafe shortcuts.
Features
- All 11 ZeroMQ socket types: REQ, REP, DEALER, ROUTER, PUB, SUB, XPUB, XSUB, PUSH, PULL, PAIR
- PLAIN and CURVE (CurveZMQ/X25519) authentication, ZAP support
- TCP and IPC (Unix domain socket) transports
- Automatic reconnection with exponential backoff on all socket types
- ZMTP 3.1 heartbeating (PING/PONG) wired into all send/recv loops
- Socket monitoring via channel-based lifecycle events
- Explicit batching API for maximum throughput, plus
recv_batch()to drain a burst of messages in one.await - Allocation-free receive via
recv_into/try_recv_into: reuse one buffer across a hot recv loop instead of allocating aVecper message - Vectored (
writev) sends for large frames: the body skips the userspace copy - PUB fan-out coalesces queued broadcasts into one vectored write per subscriber
- PUSH/PULL worker pools via
PushFanOut(round-robin ventilator) andPullFanIn(fair-queued sink) - Zero-copy message passing via
Bytesrefcounting
Performance
Benchmarked against rust-zmq (FFI bindings to libzmq). Separate OS threads for sender and receiver, real loopback TCP, Intel Core i7-1355U (12 threads), Linux 6.17, release build. Both runtime backends run the identical suite; the rust-zmq control was stable across both runs, so the difference between them is a real measurement.
PUSH/PULL throughput with write coalescing (with_write_coalescing(true)):
| Message size | compio | tokio | rust-zmq |
|---|---|---|---|
| 64 B | 9.2 M msg/s | 13.6 M msg/s | 1.33 M msg/s |
| 256 B | 5.6 M msg/s | 9.8 M msg/s | 1.09 M msg/s |
| 1 KB | 2.4 M msg/s | 5.3 M msg/s | 656 K msg/s |
| 4 KB | 841 K msg/s | 1.74 M msg/s | 328 K msg/s |
| 16 KB | 268 K msg/s | 473 K msg/s | 117 K msg/s |
Both backends beat libzmq by a wide margin once coalescing batches the writes (~7× on compio, ~10× on tokio at 64 B). On these single-flow loopback microbenchmarks the tokio/epoll backend is the faster of the two: a one-connection ping-pong does not exercise io_uring's strengths (batched submission, registered buffers, many concurrent connections) and just pays its per-op submission overhead. compio (io_uring) is the default and is where the wins land for real network I/O and high connection counts. Measure on your own workload.
Default (eager) mode sends each message immediately and suits latency-sensitive
work. For large frames eager mode automatically uses a vectored write
(writev) so the body is never copied into the send buffer; the threshold
(vectored_write_threshold, default 32 KB) is tunable per workload. IPC (Unix
domain sockets) is ~2.1× (compio) to ~3× (tokio) faster than TCP loopback for
same-host throughput.
PUB/SUB leads libzmq on both axes: single-subscriber fan-out runs ~3.1× (compio) to ~3.3× (tokio) faster, and topic filtering at 10% match is a near tie. See docs/performance.md for the full breakdown including latency numbers, per-backend tables, the vectored-write crossover measurements, PUB/SUB pattern results, and tuning guidance.
Quick Start
[]
= { = "0.1", = ["zmq"] }
# Drives the default io_uring backend and provides the #[compio::main] macro.
# To run on tokio instead, see "Runtime backends" below.
= { = "0.10", = ["runtime", "macros"] }
use ;
// Connect a DEALER
let mut dealer = connect.await?;
dealer.send.await?;
let reply = dealer.recv.await?;
// Bind a ROUTER
let mut router = bind.await?;
let msg = router.recv.await?; // msg[0] is the routing identity
// PUB/SUB
let mut publisher = bind.await?;
publisher.send.await?;
let mut subscriber = connect.await?;
subscriber.subscribe.await?;
let msg = subscriber.recv.await?;
For high throughput, enable write coalescing or use the explicit batch API.
By default each send() issues one kernel write per message. Write coalescing batches
those writes into a 64 KB buffer and flushes them in a single syscall, which is where
the large throughput gains in the table above come from. Because messages may sit in
userspace until flush() is called, coalescing is opt-in: you decide exactly when the
data goes out. See docs/performance.md for the full explanation
and tuning guide.
// Write coalescing: opt-in, requires flush() after each burst (PUSH/PULL)
let mut push = connect_with_options.await?;
for msg in &batch
push.flush.await?; // flush bytes that did not fill the 64 KB threshold
// Explicit batch API: encode N messages then one write (DEALER/ROUTER)
for msg in &batch
dealer.flush.await?;
Runtime backends
Monocoque runs on io_uring through compio by default, but the socket stack is
written against a small runtime facade, so it can drive the same code on tokio
instead. Pick one backend at compile time:
# Default: native io_uring via compio
= { = "0.1", = ["zmq"] }
# Or run on tokio
= { = "0.1", = false, = ["runtime-tokio", "zmq"] }
The two backends are mutually exclusive. The protocol layer, frame codec and
buffer model are identical on both: only the connect/spawn/timer primitives
differ. The tokio backend follows compio's thread-per-core model, so run it on a
current-thread runtime inside a LocalSet.
let rt = new_current_thread.enable_all.build?;
let local = new;
local.block_on?;
If you would rather not name a runtime in your own code, monocoque::rt::LocalRuntime
is a backend-agnostic entry point: it builds the right single-threaded runtime for
whichever feature is enabled, so the same source runs on either.
let rt = new?;
rt.block_on?;
The runtime_backends example is the same program run both ways:
Safety
unsafe is confined to a handful of small, well-contained spots, each marked with #![allow(unsafe_code)]:
monocoque-core/src/alloc.rs- the arena allocator that provides pinned, io_uring-safe buffers.monocoque-core/src/rt.rs- the tokio stream adapter, which reads into an owned buffer's spare capacity and declares the read length.monocoque-core/src/tcp.rs- TCP socket tuning (nodelay, keepalive) through the raw socket handle.monocoque-zmtp/src/inproc_stream.rs- the in-process stream adapter that fills an owned buffer.
Everything else is safe Rust.
Memory invariants:
- Buffers are never reused while referenced (tracked via
Bytesrefcounts) SlabMut->Bytesis a one-way transition; no mutation after freeze- PUB fanout is refcount-based (
Bytes::clone()), never copies payloads
Development
# The same suite runs on the tokio backend
Interop testing against libzmq: see docs/INTEROP_TESTING.md.
Roadmap
Core features are complete. Possible future work:
- io_uring fixed buffers (
IORING_OP_READ_FIXED) - removes the last kernel-boundary copy per read; ~5-15% latency improvement at an already low baseline. (Large writes already use vectoredwritev.) - Prefix trie for topic matching - the publisher-side prefilter and per-subscriber matching use a linear prefix scan, which is fast for the handful of distinct prefixes a PUB typically holds; a trie would only help when a single PUB accumulates 100+ distinct subscription prefixes or deep hierarchies
- Per-subscriber concurrent writes - PUB fan-out throughput now exceeds libzmq and is sharded across worker threads (each write has a fault-isolation timeout), but writes within a worker are sequential, so one slow subscriber can still delay the others on its worker
Long term: high-performance RPC, additional transports (QUIC, shared memory), custom protocol framework.
License
MIT - see LICENSE.
Built with: compio, bytes, flume, smallvec