Image credit: X-05.com
How to Build a Message Queue Using Two UNIX Signals
In modern software design, message queues are foundational for decoupling producers and consumers. Yet constraints can push us to explore minimal IPC primitives creatively. This article presents a thoughtful blueprint for a toy message queue implemented with only two UNIX signals. It’s a useful thought exercise for understanding signal semantics, inter-process communication, and the trade-offs you’ll face when you limit yourself to a tiny interface. The discussion draws on established signal concepts and practical cautions documented in the Linux signal literature.
Foundations: signals, payloads, and constraints
Unix signals are a lightweight, asynchronous mechanism for notifying a process that something happened. The two canonical user-defined signals, SIGUSR1 and SIGUSR2, are designed for custom purposes and are not guaranteed to be delivered repeatedly if the receiver is overwhelmed or blocked. In practice, signals are not queued in the same way as messages in a queue; multiple occurrences of the same signal may be collapsed if the handler isn’t able to process them in time. For robust data transfer, you often need a protocol layered on top of signals, or you should consider alternatives such as POSIX message queues or signalfd for determinism. See the Linux manual on signal semantics for details about user-defined signals and delivery guarantees: signal(7) and related resources.
A two-signal protocol: concepts and design choices
The core idea is to encode bits of data as signals: one signal represents a 0 bit, the other represents a 1 bit. A simple, byte-oriented framing protocol can transmit a length header followed by payload bytes. Because UNIX signals do not guarantee queuing, the receiver must implement a handshake to avoid data loss and to preserve ordering. A pragmatic approach uses an acknowledgement (ACK) after each bit, mapping ACKs to the opposite signal as a simple, deterministic handshake. This design emphasizes clarity and educational value over production-grade reliability.
Key considerations include:
- Order: Each bit is delivered in sequence, but if signals arrive faster than the receiver can handle, some bits may be lost unless an explicit ACK is used.
- Reliability: Standard signals are not queued; frequent handshakes and a bounded frame size help, but the scheme remains fragile under heavy load or busy receivers.
- Payload: Since signals themselves carry no intrinsic payload, data must be reconstructed bit-by-bit by the receiver, using the sender’s PID (obtained via siginfo) to direct acknowledgments.
Architecture overview: sender, receiver, and the handshake
The sender process transmits a message by sending a header that encodes the length, followed by the payload bytes. Each bit is sent as a signal: SIGUSR1 for 0, SIGUSR2 for 1. After sending a bit, the sender waits for an ACK signal from the receiver (the ACK is delivered on the opposite signal, forming a simple two-signal handshake). The receiver’s signal handler decodes bits, assembles bytes, and enqueues complete messages in a local in-memory ring buffer. When a byte is fully reconstructed, the receiver sends the ACK to the sender and continues with the next bit.
This architecture relies on the sender and receiver sharing a simple protocol and having a reliable mechanism to identify the sender process (via the siginfo_t structure). The approach highlights the importance of a well-defined state machine when the primitive set is intentionally small. For a deeper look at signal semantics, consult the standard references on signal handling, including the man pages mentioned earlier.
Implementation sketch: a practical, readable starting point
The following is a concise, didactic C-style outline. It’s not production-ready code, but it shows how you might structure a toy queue with two signals. It emphasizes the roles of bit framing, ACKs, and per-byte assembly. Use it as a foundation for a learning project rather than a deployable IPC mechanism.
// Pseudo-C sketch for sending bits as signals
// NOTE: Educational only; not production-ready.
#include <signal.h>
#include <unistd.h>
#include <stdint.h>
void send_bit(pid_t dest, int bit) {
int sig = bit ? SIGUSR2 : SIGUSR1;
kill(dest, sig);
// await ack (handshake on the opposite signal)
// pause() or a custom flag set by an ACK handler could be used here
}
void send_message(pid_t dest, const uint8_t *data, size_t len) {
// 32-bit length header (big-endian)
uint32_t len_be = htonl((uint32_t)len);
for (int i = 0; i < 4; ++i) {
uint8_t b = (len_be >> (24 - i * 8)) & 0xFF;
for (int bit = 0; bit < 8; ++bit) {
send_bit(dest, (b >> (7 - bit)) & 1);
}
}
// payload
for (size_t i = 0; i < len; ++i) {
for (int bit = 0; bit < 8; ++bit) {
send_bit(dest, (data[i] >> (7 - bit)) & 1);
}
}
}
// Receiver side (outline)
volatile sig_atomic_t bit_count = 0;
volatile uint8_t current_byte = 0;
void bit_handler(int sig, siginfo_t *info, void *ucontext) {
int bit = (sig == SIGUSR2) ? 1 : 0; // 0 for SIGUSR1, 1 for SIGUSR2
current_byte = (current_byte << 1) | bit;
bit_count++;
if (bit_count == 8) {
// enqueue current_byte into a simple ring buffer
bit_count = 0;
current_byte = 0;
// send ACK on the opposite signal to the sender
int ack_sig = (sig == SIGUSR2) ? SIGUSR1 : SIGUSR2;
kill(info->si_pid, ack_sig);
}
}
The snippet uses the sender’s PID supplied by the signal info to direct acknowledgments. It demonstrates the essential flow: decode bits, assemble bytes, and acknowledge receipt before continuing. While instructive, this approach omits many robustness features required in a real system.
Limitations and practical guidance
Two-signals queues teach conceptual IPC under constraints, but they are not a substitute for established mechanisms. Key limitations include:
- Data may be lost if signals collide or the receiver cannot keep up, since signals are not inherently queued.
- Latency scales with process scheduling; timing is inherently nondeterministic on general-purpose systems.
- Signal-based protocols are susceptible to race conditions and edge-case issues during process death or signal masking.
When building real systems, prefer purpose-built IPC primitives such as POSIX message queues, shared memory with synchronization, or signalfd for more deterministic event handling. The exercise remains valuable for understanding the limits of signaling and the importance of explicit handshakes when data integrity matters.
Takeaways and next steps
Using two UNIX signals to implement a message queue is a disciplined exercise in protocol design, not a recommended production approach. It clarifies how signals operate, highlights the necessity of clear framing and ACKs, and reveals why production systems favor richer IPC primitives. If you pursue this path further, focus on explicit state machines, bounded buffers, and careful failure handling to avoid silent data loss. At the same time, recognize when to retire the experiment in favor of robust alternatives.