{"slug": "show-hn-monocoque-zmtp-3-1-zeromq-messaging-library-in-pure-rust", "title": "Show HN: Monocoque – ZMTP 3.1 (ZeroMQ) messaging library in pure Rust", "summary": "Monocoque, a Rust-native ZeroMQ-compatible messaging library implementing ZMTP 3.1, has been released on Hacker News. It offers io_uring, tokio, and smol backends, achieving up to 3.7x higher throughput than libzmq in benchmarks. The library supports all 11 ZeroMQ socket types, CURVE encryption, and zero-copy messaging.", "body_md": "A Rust-native ZeroMQ-compatible messaging runtime, io_uring by default with optional tokio and smol backends\n\nMonocoque 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`\n\n), with optional tokio and smol backends for portability. Whichever you pick, it interoperates with any existing libzmq peer while staying entirely within Rust's memory model.\n\nThe 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.\n\n- All 11 ZeroMQ socket types: REQ, REP, DEALER, ROUTER, PUB, SUB, XPUB, XSUB, PUSH, PULL, PAIR\n- PLAIN and CURVE (CurveZMQ/X25519) authentication, ZAP support\n- TCP and IPC (Unix domain socket) transports\n- Automatic reconnection with exponential backoff on all socket types\n- ZMTP 3.1 heartbeating (PING/PONG) wired into all send/recv loops\n- Socket monitoring via channel-based lifecycle events\n- Explicit batching API for maximum throughput, plus\n`recv_batch()`\n\nto drain a burst of messages in one`.await`\n\n- Allocation-free receive via\n`recv_into`\n\n/`try_recv_into`\n\n: reuse one buffer across a hot recv loop instead of allocating a`Vec`\n\nper message - Vectored (\n`writev`\n\n) sends for large frames: the body skips the userspace copy - PUB fan-out coalesces queued broadcasts into one vectored write per subscriber\n- PUSH/PULL worker pools via\n`PushFanOut`\n\n(round-robin ventilator) and`PullFanIn`\n\n(fair-queued sink) - Zero-copy message passing via\n`Bytes`\n\nrefcounting\n\nBenchmarked 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. The three runtime backends run the identical suite, and the figures below were re-measured together on the same machine; the compio column reflects the compio 0.19 runtime (its throughput and latency stepped up noticeably over the earlier runtime). The rust-zmq column uses the same live-connection timer.\n\n**PUSH/PULL throughput with write coalescing** (`with_write_coalescing(true)`\n\n):\n\n| Message size | compio | tokio | smol | rust-zmq |\n|---|---|---|---|---|\n| 64 B | 13.6 M msg/s | 17.1 M msg/s |\n13.2 M msg/s | 4.58 M msg/s |\n| 256 B | 8.2 M msg/s | 12.0 M msg/s |\n8.5 M msg/s | 2.60 M msg/s |\n| 1 KB | 3.5 M msg/s | 4.6 M msg/s |\n3.3 M msg/s | 1.01 M msg/s |\n| 4 KB | 1.19 M msg/s | 1.60 M msg/s |\n1.10 M msg/s | 383 K msg/s |\n| 16 KB | 370 K msg/s | 462 K msg/s |\n331 K msg/s | 130 K msg/s |\n\nAll three backends beat libzmq once coalescing batches the writes: ~3.0x (compio), ~3.7x (tokio), ~2.9x (smol) at 64 B, and ~2-4x across the size range. On these single-flow loopback microbenchmarks the epoll backends (tokio, smol) are the faster: 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.\n\nDefault (eager) mode sends each message immediately, one syscall per `send()`\n\n, and\nis the mode for latency-sensitive work where you want each message on the wire now\nrather than batched. On a bulk one-way firehose libzmq's internal batching leads\nat small sizes; steady-state REQ/REP latency, though, is ~2.6-3.9x lower on every\nmonocoque backend (~9-14 µs vs libzmq's ~36 µs; compio is the lowest at ~9 µs).\nTurn on coalescing for small-message throughput. For **large** frames eager mode\nautomatically uses a vectored write (`writev`\n\n) so the body is never copied into\nthe send buffer; the threshold (`vectored_write_threshold`\n\n, default 32 KB) is\ntunable per workload. IPC (Unix domain sockets) is ~3x faster than TCP loopback\non every backend for same-host throughput.\n\n**PUB/SUB leads libzmq on both axes**: single-subscriber fan-out runs ~3.0x (compio),\n~3.5x (tokio), ~3.2x (smol) faster, and topic filtering at 10% match is a near tie. See\n[docs/performance.md](/vorjdux/monocoque/blob/main/docs/performance.md) for the full breakdown including\nlatency numbers, per-backend tables, the vectored-write crossover measurements,\nPUB/SUB pattern results, and tuning guidance.\n\n```\n[dependencies]\nmonocoque-rs = { version = \"0.3\", features = [\"zmq\"] }\n# Drives the default io_uring backend and provides the #[compio::main] macro.\n# To run on tokio or smol instead, see \"Runtime backends\" below.\ncompio = { version = \"0.19\", features = [\"runtime\", \"macros\"] }\njs\nuse monocoque::zmq::{DealerSocket, RouterSocket};\n\n// Connect a DEALER\nlet mut dealer = DealerSocket::connect(\"tcp://127.0.0.1:5555\").await?;\ndealer.send(vec![b\"Hello\".into()]).await?;\nlet reply = dealer.recv().await?;\n\n// Bind a ROUTER\nlet mut router = RouterSocket::bind(\"tcp://127.0.0.1:5555\").await?;\nlet msg = router.recv().await?;  // msg[0] is the routing identity\njs\n// PUB/SUB\nlet mut publisher = PubSocket::bind(\"tcp://127.0.0.1:5556\").await?;\npublisher.send(vec![b\"events\".into(), b\"payload\".into()]).await?;\n\nlet mut subscriber = SubSocket::connect(\"tcp://127.0.0.1:5556\").await?;\nsubscriber.subscribe(b\"events\").await?;\nlet msg = subscriber.recv().await?;\n```\n\nFor high throughput, enable write coalescing or use the explicit batch API.\n\nBy default each `send()`\n\nissues one kernel write per message. Write coalescing batches\nthose writes into a 64 KB buffer and flushes them in a single syscall, which is where\nthe large throughput gains in the table above come from. Because messages may sit in\nuserspace until `flush()`\n\nis called, coalescing is opt-in: you decide exactly when the\ndata goes out. See [docs/performance.md](/vorjdux/monocoque/blob/main/docs/performance.md) for the full explanation\nand tuning guide.\n\n``` js\n// Write coalescing: opt-in, requires flush() after each burst (PUSH/PULL)\nlet mut push = PushSocket::connect_with_options(\n    \"127.0.0.1:5555\",\n    SocketOptions::default().with_write_coalescing(true),\n).await?;\nfor msg in &batch {\n    push.send(vec![msg.clone()]).await?;\n}\npush.flush().await?;  // flush bytes that did not fill the 64 KB threshold\n\n// Explicit batch API: encode N messages then one write (DEALER/ROUTER)\nfor msg in &batch {\n    dealer.send_buffered(msg.clone())?;\n}\ndealer.flush().await?;\n```\n\nMonocoque runs on `io_uring`\n\nthrough compio by default, but the socket stack is\nwritten against a small runtime facade, so it can drive the same code on tokio\nor smol instead. Pick one backend at compile time:\n\n```\n# Default: native io_uring via compio\nmonocoque-rs = { version = \"0.3\", features = [\"zmq\"] }\n\n# Or run on tokio\nmonocoque-rs = { version = \"0.3\", default-features = false, features = [\"runtime-tokio\", \"zmq\"] }\n\n# Or run on smol\nmonocoque-rs = { version = \"0.3\", default-features = false, features = [\"runtime-smol\", \"zmq\"] }\n```\n\nThe three backends are mutually exclusive. The protocol layer, frame codec and\nbuffer model are identical across all of them: only the connect/spawn/timer\nprimitives differ. The tokio and smol backends follow compio's thread-per-core\nmodel, so run tokio on a current-thread runtime inside a `LocalSet`\n\n(smol uses a\nsingle-threaded `LocalExecutor`\n\n; the backend-agnostic `LocalRuntime`\n\nbelow sets\nup the right one for you).\n\n``` js\nlet rt = tokio::runtime::Builder::new_current_thread().enable_all().build()?;\nlet local = tokio::task::LocalSet::new();\nlocal.block_on(&rt, async {\n    let mut push = PushSocket::connect(\"127.0.0.1:5555\").await?;\n    push.send(vec![b\"hello\".into()]).await?;\n    Ok::<_, std::io::Error>(())\n})?;\n```\n\nIf you would rather not name a runtime in your own code, `monocoque::rt::LocalRuntime`\n\nis a backend-agnostic entry point: it builds the right single-threaded runtime for\nwhichever feature is enabled, so the same source runs on either.\n\n``` js\nlet rt = monocoque::rt::LocalRuntime::new()?;\nrt.block_on(async {\n    let mut push = PushSocket::connect(\"127.0.0.1:5555\").await?;\n    push.send(vec![b\"hello\".into()]).await?;\n    Ok::<_, std::io::Error>(())\n})?;\n```\n\nThe `runtime_backends`\n\nexample is the same program run both ways:\n\n```\ncargo run --example runtime_backends --features zmq                                   # compio\ncargo run --example runtime_backends --no-default-features --features runtime-tokio,zmq  # tokio\ncargo run --example runtime_backends --no-default-features --features runtime-smol,zmq   # smol\n```\n\n`unsafe`\n\nis confined to a handful of small, well-contained spots, each behind a documented contract:\n\n`monocoque-core/src/io.rs`\n\n- the owned-buffer read helpers shared by every backend.`fill_read`\n\nowns the workspace's single`set_buf_init`\n\ncall (declaring how many bytes a read initialized in a buffer's spare capacity), and`take_read_buffer`\n\nhands out read-sized slabs from a reused`BytesMut`\n\n. The socket read paths call`take_read_buffer`\n\nin documented`unsafe`\n\nblocks.`monocoque-core/src/tcp.rs`\n\n(and a few socket-tuning call sites) - TCP socket tuning (nodelay, keepalive) through the raw socket handle.`monocoque-zmtp/src/inproc_stream.rs`\n\n- the in-process stream adapter that fills an owned buffer.\n\nEverything else is safe Rust.\n\nMemory invariants:\n\n- Buffers are never reused while referenced (tracked via\n`Bytes`\n\nrefcounts) - A read slab is frozen to\n`Bytes`\n\nin a one-way transition; no mutation after freeze - The read slab is allocated lazily on the first read, so an idle socket holds none\n- PUB fanout is refcount-based (\n`Bytes::clone()`\n\n), never copies payloads\n\n```\ncargo build --release --workspace\ncargo test --workspace --features zmq\ncargo bench --features zmq       # runs the benchmark suite\n\n# The same tests and benchmarks also run on the tokio and smol backends\ncargo test --workspace --no-default-features --features runtime-tokio,zmq\ncargo bench --no-default-features --features runtime-tokio,zmq\ncargo test --workspace --no-default-features --features runtime-smol,zmq\ncargo bench --no-default-features --features runtime-smol,zmq\n```\n\nInterop testing against libzmq: see [docs/INTEROP_TESTING.md](/vorjdux/monocoque/blob/main/docs/INTEROP_TESTING.md).\n\nCore features are complete. Possible future work:\n\n- io_uring fixed buffers (\n`IORING_OP_READ_FIXED`\n\n) - removes the last kernel-boundary copy per read; ~5-15% latency improvement at an already low baseline. (Large*writes*already use vectored`writev`\n\n.) - 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+\n*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\n*within*a worker are sequential, so one slow subscriber can still delay the others on its worker\n\nLong term: high-performance RPC, additional transports (QUIC, shared memory), custom protocol framework.\n\nMIT - see [LICENSE](/vorjdux/monocoque/blob/main/LICENSE).\n\nBuilt with: `compio`\n\n(default backend), `tokio`\n\nor `smol`\n\n(optional backends), `bytes`\n\n, `flume`\n\n, `smallvec`", "url": "https://wpnews.pro/news/show-hn-monocoque-zmtp-3-1-zeromq-messaging-library-in-pure-rust", "canonical_source": "https://github.com/vorjdux/monocoque", "published_at": "2026-07-17 09:49:34+00:00", "updated_at": "2026-07-17 10:21:45.186937+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Monocoque", "ZeroMQ", "ZMTP 3.1", "Rust", "io_uring", "tokio", "smol", "libzmq"], "alternates": {"html": "https://wpnews.pro/news/show-hn-monocoque-zmtp-3-1-zeromq-messaging-library-in-pure-rust", "markdown": "https://wpnews.pro/news/show-hn-monocoque-zmtp-3-1-zeromq-messaging-library-in-pure-rust.md", "text": "https://wpnews.pro/news/show-hn-monocoque-zmtp-3-1-zeromq-messaging-library-in-pure-rust.txt", "jsonld": "https://wpnews.pro/news/show-hn-monocoque-zmtp-3-1-zeromq-messaging-library-in-pure-rust.jsonld"}}