Distributed Systems - ZeroMQ
Context
I started getting deeper into the distributed systems and thought it’s a good idea to document some tests with ZeroMQ and Python.
I am going to document 3 fundamanetal patterns I learned.
Why ZeroMQ?
ZeroMQ (also written as ØMQ or 0MQ) sits somewhere between raw TCP sockets and full-blown message brokers like RabbitMQ or Kafka. It gives you the performance of working close to the metal while abstracting away the complexity of socket programming. No message broker to install, no daemon to manage – just lightweight, embeddable messaging patterns that work.
What makes ZeroMQ special is its philosophy: it provides building blocks for creating your own messaging architecture rather than imposing a one-size-fits-all solution. Think of it as "sockets that do the right thing" – handling reconnection, buffering, and routing automatically.
Setting Up ZeroMQ in Python
# Initialize a new Python project
uv init zeromq-test
# Create and activate virtual environment
uv venv
source .venv/bin/activate
# Install PyZMQ
uv pip install pyzmq
My project structure organizes the three patterns I tried:
.
├── client_boardcast.py
├── client_pipeline.py
├── client_req_repl.py
├── server_broadcast.py
├── server_pipeline.py
├── server_req_repl.py
├── pyproject.toml
└── README.md
It can be found and tried at https://github.com/vbrinza/zeromq-test
Pattern 1: Request-Reply
The request-reply pattern is synchronous, predictable, and perfect for RPC-style communication where a client needs a response before proceeding.
How It Works
The REQ-REP pattern enforces a strict send-receive cycle. The client sends a request and blocks until it receives a reply. The server receives a request and must send a reply before it can receive the next request. This lockstep dance ensures message ordering and prevents lost requests.
Implementation
Server (server_req_repl.py):
import zmq
context = zmq.Context()
p = "tcp://*:5555"
s = context.socket(zmq.REP)
s.bind(p)
while True:
message = s.recv()
print(message + b"*")
if not b"STOP" in message:
s.send(message + b"*")
else:
break
Client (client_req_repl.py):
import zmq
context = zmq.Context()
p = "tcp://localhost:5555"
s = context.socket(zmq.REQ)
s.connect(p)
s.send(b"Hello world 1")
message = s.recv()
s.send_string("STOP")
print(message)
Running the examples
# Terminal 1: Start the server
uv run server_req_repl.py
# Terminal 2: Run the client
uv run client_req_repl.py
When to Use Request-Reply
This pattern shines when you need guaranteed delivery and response correlation. Perfect for:
Database queries
Authentication services
Synchronous API calls
Command acknowledgments
The trade-off? It's blocking and doesn't scale well for high-throughput scenarios. Each client ties up a server thread while waiting for processing.
Pattern 2: Publish-Subscribe – The Event Broadcaster
The pub-sub pattern is all about one-to-many distribution. Publishers send messages without knowing who's listening, and subscribers receive only the messages they're interested in.
How It Works
Publishers use a PUB socket that fans out messages to all connected SUB sockets. Subscribers can filter messages by subscribing to specific topic prefixes. This creates a loosely coupled architecture where publishers and subscribers can come and go without affecting each other.
Implementation
Publisher (server_broadcast.py):
import zmq, time
context = zmq.Context()
s = context.socket(zmq.PUB)
p = "tcp://*:5556"
s.bind(p)
while True:
time.sleep(5)
s.send_string("TIME" + time.asctime())
Subscriber (client_broadcast.py):
import zmq
context = zmq.Context()
s = context.socket(zmq.SUB)
p = "tcp://localhost:5555"
s.connect(p)
s.setsockopt_string(zmq.SUBSCRIBE, "TIME")
for i in range(5):
time = s.recv()
print(time)
Running the examples
# Terminal 1: Start the server
uv run server_broadcast.py
# Terminal 2: Run the client
uv run client_broadcast.py
When to Use Publish-Subscribe
This pattern is ideal for:
Real-time data feeds (market data, sensor readings)
Event notifications
Log aggregation
Broadcasting configuration changes
The beauty of pub-sub is its scalability – one publisher can efficiently serve thousands of subscribers. The challenge? There's no built-in reliability. If a subscriber is slow or disconnected, messages are lost. This is by design – it keeps publishers fast and non-blocking.
Pattern 3: Pipeline – The Task Distributor
The pipeline pattern (also known as push-pull) creates a unidirectional flow for distributing work among multiple workers. It's perfect for parallel processing and load balancing.
How It Works
A ventilator (PUSH socket) distributes tasks to multiple workers (PULL sockets). Each message goes to exactly one worker, automatically load-balanced in a round-robin fashion. Workers can then push results to a sink for collection.
Implementation
Ventilator (server_pipeline.py):
import zmq, time, pickle, sys, random
context = zmq.Context()
me = str(sys.argv[1])
s = context.socket(zmq.PUSH)
p = "tcp://*:5555"
s.bind(p)
for i in range(100):
workload = random.randint(1, 100)
s.send(pickle.dumps((me,workload)))
Worker (client_pipeline.py):
import zmq, time, pickle, sys
context = zmq.Context()
me = str(sys.argv[1])
p1 = "tcp://localhost:5555"
r = context.socket(zmq.PULL)
r.connect(p1)
while True:
work = pickle.loads(r.recv())
print(work)
Running the Examples
# Terminal 1: Ventilator
uv run server_pipeline.py
# Terminals 2-4: Workers (run multiple instances)
uv run client_pipeline.py
When to Use Pipeline
Pipeline patterns excel at:
Parallel data processing
Map-reduce operations
Work queue distribution
Multi-stage processing pipelines
The automatic load balancing means faster workers naturally process more tasks. No coordinator needed – ZeroMQ handles the distribution transparently.