Build a task ventilator with PUSH/PULL and DEALER/ROUTER
For Rust developers who know Tokio and want to try ZeroMQ patterns without the C library
You will build a small job pipeline over TCP on localhost: a ventilator hands numbered tasks to two workers over PUSH/PULL, the workers send their answers back over DEALER sockets to a ROUTER collector and the whole thing runs and shuts down cleanly from one cargo run.
Before you start
You need Rust with Cargo. rzmq comes from crates.io:
rzmq = "0"Set up the project#
Start a new binary crate.
Add rzmq and Tokio. rzmq runs on the Tokio runtime, so you need both. Your [dependencies] section should look like this:
[]
= "0"
= { = "1", = ["full"] }This tutorial uses the default features, which cover everything below. Every socket in it talks TCP on 127.0.0.1, so the same code would work with the pieces split across processes or machines. Here they all live in one process as Tokio tasks so you can run it in one go.
Send tasks from PUSH to PULL#
Every rzmq program starts with a Context. You create sockets from it and call term() on it at the end to close them all. A PUSH socket sends messages out to whoever is connected and a PULL socket receives them. The ventilator is the stable end, so it binds. The worker connects to it.
Replace src/main.rs with this:
use ;
const TASKS: &str = "tcp://127.0.0.1:5557";
async
A message is a Msg. Msg::from_vec takes ownership of a Vec<u8> and msg.data() hands the bytes back as an Option<&[u8]>. rzmq doesn't care what the bytes are, so this program just sends the task number as text.
Run it:
All three sends finish before the worker reads anything. PUSH queues messages for the connected peer and the PULL side holds them until you call recv, so the two ends don't have to move in lockstep.
Add a second worker#
PUSH spreads messages round-robin across its connected peers, so adding workers is the way you scale this pattern. Each worker now gets its own PULL socket and runs in its own Tokio task.
PUSH can only share work among peers that have finished connecting. If you start sending right after the connect calls, the first worker to finish its handshake can take every task before the second one shows up.
To avoid that, the ventilator watches its own socket events. monitor_default() returns a channel of SocketEvents. A HandshakeSucceeded event arrives once per peer that has finished connecting. The program waits for one per worker before it sends anything. Open the monitor before calling bind so no events are missed.
Replace src/main.rs:
use SocketEvent;
use ;
const TASKS: &str = "tcp://127.0.0.1:5557";
const WORKERS: usize = 2;
const TASK_COUNT: u64 = 6;
async
PUSH deals messages round-robin across connected peers, so six tasks split three and three between two workers. Each worker takes its three and returns. main waits for both workers and only then calls ctx.term(), which closes the sockets once the work is done.
Run it:
Your port numbers will be different. The order of the lines and which worker gets the odd tasks can also change from run to run, but each worker gets three.
Send results back over DEALER and ROUTER#
The workers need somewhere to send their answers. A ROUTER socket fits well for the collector. Every message it receives starts with an extra frame naming the peer it came from. When it sends, it reads the first frame to pick which peer gets the message. Each worker connects to it with a DEALER socket, which is the asynchronous partner for ROUTER and can send messages made of several frames.
Each worker gives its DEALER a name with the ROUTING_ID option before connecting. That name is what shows up as the first frame on the ROUTER side. A worker sends its result as two frames, the task number and its square, using send_multipart. The collector gets three frames from recv_multipart (name, task and square) and replies ok by sending the name frame back with the reply behind it. The worker waits for that reply before pulling its next task.
The worker also sleeps for 20 * n milliseconds to fake some work, so bigger tasks take longer. As in the previous step, each worker handles its share of the tasks and returns.
Replace src/main.rs with the full program:
use ;
use ;
use BTreeMap;
use Duration;
const TASKS: &str = "tcp://127.0.0.1:5557";
const RESULTS: &str = "tcp://127.0.0.1:5558";
const WORKERS: usize = 2;
const TASK_COUNT: u64 = 8;
async
async
async
set_option takes an option ID from rzmq::socket and any value that implements rzmq's ToBytes trait. That includes String, &str, i32, bool and byte slices.
Run it:
As before, the two worker names can swap places between runs. The collector always sees eight results that add up to 204, split four and four.
Shut down cleanly#
Every task in this program knows when its work is done, so shutdown is a matter of waiting in the right order:
let per_worker = collecting.await.expect?;
for in &per_worker
for handle in workers
ctx.term.await?;The collector returns after it has seen all eight results. Each worker returns after its share of tasks and the reply to its last result. main waits for all of them and then calls Context::term(), which closes every socket made from the context and waits for their background tasks to finish. Any error along the way is a real error, so it is passed up with ? instead of being ignored.
For a longer-running service that doesn't know its workload up front, you would add a stop signal instead, for example a tokio::sync::watch channel checked with tokio::select! next to recv. Dropping a pending recv future in rzmq doesn't lose the message; it stays queued for the next call. That makes it safe to race recv against a shutdown signal in select!.
Where to go next#
The rest of the socket types follow the same shape: create from the context, set options, bind or connect, then send and receive. PUB/SUB takes a SUBSCRIBE option with a topic prefix. The SNDHWM and RCVHWM options cap how many messages queue up per peer.
On Linux, rzmq has an optional io_uring backend for TCP. You turn it on with a feature flag and then opt in per socket with the IO_URING_SESSION_ENABLED option:
[]
= { = "0", = ["io-uring"] }The feature is Linux-only, so this tutorial leaves it out.
The usage guide covers socket options, security (PLAIN, CURVE and Noise_XX), monitoring and io_uring setup in the rzmq usage guide on GitHub.
The full API reference is on docs.rs.
