Stream messages over io_uring with PUSH/PULL and PUB/SUB
For Rust developers on Linux who move a lot of messages and want rzmq's io_uring backend under them
You will build two streams over TCP on localhost and run both on rzmq's io_uring backend. A PUSH/PULL pipeline carries 10,000 readings to a sink and a PUB socket streams price ticks to two subscribers. Along the way you pick the number of io_uring workers, switch TCP_CORK on and off and ask for zero-copy sends and multishot receives.
Before you start
You need Rust with Cargo. rzmq comes from crates.io:
rzmq = "0"Set up the project#
Start a new binary crate.
The io_uring backend is Linux only. Turn it on with the io-uring feature:
[]
= { = "0", = ["io-uring"] }
= { = "1", = ["full"] }Streams are where io_uring pays off. A PUSH socket feeding a pipeline or a PUB socket feeding subscribers keeps a steady flow of messages going in one direction, so the backend can batch submissions to the kernel instead of making a system call per message.
Turn on io_uring for a PUSH/PULL stream#
Turning on the backend takes two things. initialize_uring_backend runs once before the first Context and sets up the backend's worker threads. Then each socket that should use it sets IO_URING_SESSION_ENABLED to 1i32 before bind or connect. Its TCP connections then run on io_uring. At the end, shutdown_uring_backend stops the workers after the context has terminated.
The first stream is a pipeline: a PUSH socket sends the readings 1 to 10,000 and a PULL sink adds them up. Replace src/main.rs with this:
use IO_URING_SESSION_ENABLED;
use ;
use ;
const READINGS: &str = "tcp://127.0.0.1:5660";
const READING_COUNT: u64 = 10_000;
async
async
The send and receive code is the same as on the default backend. The backend is a property of each socket's connections, so the PUSH/PULL rules don't change: PUSH queues readings for its connected peer and PULL hands them out in the order they were sent.
Run it:
Choose the number of workers#
The backend runs its I/O on UringWorker threads. Each worker has its own ring, its own buffer pools and its own set of file descriptors. A new connection is assigned to a worker round-robin and stays on that worker for its whole life, so the worker count caps how many threads share the connections.
UringConfig::num_workers sets the count. The default comes from rzmq::uring::default_uring_num_workers(), which takes half the available cores rounded up, subtracts 2 and clamps the result to between 1 and 8. That leaves room for the Tokio runtime's own threads.
Set it in the config you pass to initialize_uring_backend:
initialize_uring_backend
?;Each worker registers its own receive buffer ring, so the pinned memory for receive buffers grows with the worker count. The zero-copy send budget works the other way: default_send_buffer_count is a total that is split across the workers. More workers help when many connections are busy at once. A program with one or two streams gets nothing from more workers than it has connections.
The output doesn't change:
sink received 10000 readings, sum 50005000
Stream prices to subscribers with PUB/SUB#
A PUB socket sends every message to all subscribers whose subscriptions match. This stream publishes the ticks 1 to 1,000 to two subscribers, prices and audit, both subscribed to everything.
A subscriber's subscription reaches the publisher a moment after its connection is up and a PUB socket drops messages that no subscriber has asked for yet. So before streaming, the publisher repeats a hello message every 10 ms. Each subscriber answers its first hello over a separate PUSH/PULL channel. Once both have answered, the publisher knows both are listening and starts the real stream. An end message closes it.
The publisher also turns on TCP_CORK, which the next section explains. The helper now takes a cork flag and sets TCP_CORK right after opting the socket into io_uring.
Replace src/main.rs:
use ;
use ;
use ;
use Duration;
const TICKS: &str = "tcp://127.0.0.1:5661";
const READY: &str = "tcp://127.0.0.1:5662";
const TICK_COUNT: u64 = 1_000;
const SUBSCRIBERS: = ;
async
async
async
async
Run it:
Both subscribers got every tick. main joins them in the order they were spawned, so the lines always come out in this order.
Switch TCP_CORK on and off#
TCP_CORK takes 1i32 or 0i32 and turns TCP corking on or off for an io_uring socket. With corking on, the kernel holds small writes back and fills TCP segments before sending them, which batches a stream of small messages into fewer packets. The publisher above sends a thousand short ticks back to back, which is the case corking is for.
Corking is the wrong choice when every message has to go out the moment it is sent, such as a request that someone is waiting on. The subscribers and the ready channel in this program only send one short answer each, so they keep it off.
To see the setting change, pass false for the publisher:
let publisher = uring_socket.await?;publisher TCP_CORK = 0
prices: 1000 ticks, sum 500500
audit: 1000 ticks, sum 500500
Corking changes how bytes are grouped into TCP segments. It never changes which messages arrive or their order, so the ticks and sums are the same either way. Put true back before moving on.
Ask for zero-copy sends and multishot receives#
Three more options tune how an io_uring socket moves data. Each one is a request that the backend configuration has to support:
IO_URING_SNDZEROCOPYasks for zero-copy sends. It takes effect whenUringConfig::default_send_zerocopyistrue, which also sets up the send buffer pool.IO_URING_ZC_SEND_THRESHOLDis the smallest payload in bytes that goes out as a zero-copy send. Smaller payloads use a normal copy-based send. The default is16384and it only matters whenIO_URING_SNDZEROCOPYis on.IO_URING_RCVMULTISHOTasks for multishot receives, where one receive request keeps delivering data as it arrives. It depends onUringConfig::default_recv_multishot, which istruein the default config.
Import the new options:
use
;Turn on the zero-copy pool in the backend config:
initialize_uring_backend
?;Set zero-copy on the publisher and multishot on each subscriber's SUB socket, before bind and connect:
let publisher = uring_socket.await?;
publisher.set_option.await?;
publisher.set_option.await?;
publisher.bind.await?; let ticks = uring_socket.await?;
ticks.set_option.await?;
ticks.set_option.await?;
ticks.connect.await?;A tick is a few bytes, well under the 16384-byte threshold, so ticks still go out as copy-based sends. Zero-copy pays off for large payloads such as snapshots or file chunks, where copying the data would cost more than setting up the zero-copy send. The output is the same as before:
publisher TCP_CORK = 1
prices: 1000 ticks, sum 500500
audit: 1000 ticks, sum 500500
Shut down cleanly#
Every program here ends the same way:
ctx.term.await?;
shutdown_uring_backend.await?;The streams end on their own: the sink stops after the last reading and each subscriber returns on end. ctx.term() then closes every socket made from the context and waits for their background tasks. shutdown_uring_backend() runs last and joins the backend's worker threads. initialize_uring_backend runs once per process, so it belongs at the top of main before the first context.
Where to go next#
The rzmq usage guide on GitHub covers every UringConfig field, the rest of the socket options and outbound write batching with SNDBATCH_COUNT and SNDBATCH_BYTES. The PUB/SUB tutorial covers topic filtering and UNSUBSCRIBE. The PUSH/PULL tutorial covers high-water marks and send timeouts.
The full API reference, including the rzmq::uring module and UringConfig, is on docs.rs.
