You don't need Kafka: Building a message queue with Unix signals

Have you ever wondered if we could replace any message broker with a very simple one using only two UNIX signals? I did, and I want to share my journey of how I achieved it.

If you're interested in learning about UNIX signals, binary operations the easy way, how a message broker works under the hood, and a bit of Ruby, this post is for you. And if you came here just because of the clickbait title, I apologize and invite you to keep reading. It'll be fun, I promise.

A few days ago, I saw some discussion on the internet about how we could send messages between processes. Many people think of sockets, which are the most common way to send messages, even allowing communication across different machines and networks. Some don't even realize that pipes are another way to send messages between processes: Note the word "send". Yes, anonymous pipes are a form of IPC (Inter-process communication).

Other forms of IPC in UNIX include: A UNIX signal is a standardized message sent to a program to trigger specific behavior, such as quitting or error handling. There are many signals we can send to a process, including:

Okay, we know that signals are a primitive form of IPC. UNIX-like systems provide a syscall called kill that sends signals to processes. Historically, this syscall was created solely to terminate processes. But over time, they needed to accommodate other types of signals, so they reused the same syscall for different purposes.

For instance, let's create a simple Ruby script sleeper.rb which sleeps for 60 seconds, nothing more:

In another window, we can send the SIGTERM signal to the process 55402 via syscall kill:

In the script session:

In Ruby, we can also trap a signal using the trap method in Ruby:

Which in turn, after sending the signal, will gracefully:

Hold on. That's exactly why I'm here. To prove points by doing useless stuff, like when I simulated OOP in Bash a couple of years ago (it was fun though). To understand how we can "hack" UNIX signals and send messages between processes, let's first talk a bit about binary operations.

Yes, those "zeros" and "ones" you were scared of when you saw them for the first time. But they don't bite (LOL), I promise.

If we model a message as a sequence of characters, we could say that at a high-level, messages are simply strings. But in memory, they are stored as bytes. We know that bytes are made of bits.

In computer terms, what's a bit? It's simply an abstraction representing only two states:

That's it. For instance, using ASCII, we know that the letter "h" has the following codes:

We know that some signals such as SIGTERM, SIGINT, and SIGCONT can be trapped, but intercepting them would harm their original purpose. But thankfully, UNIX provides two user-defined signals that are perfect for our hacking experiment.

First things first, let's trap those signals in the code:

After sending some kill -SIGUSR1 56172 and kill -SIGUSR2 56172, we can see that the process prints the following content:

Signals don't carry data. But the example we have is perfect for changing to bits, uh?

We're simply encoding the letter as a binary representation and sending it via signals

Again, we're encoding it as a binary and sending it via signals.

On the other side, the receiver should be capable of decoding the message and converting it back to the letter "h":

So, how do we decode 01101000 (the letter "h" in ASCII)? Let's break it down into a few steps:

So, our sum becomes, from MSB to LSB: 104 is exactly the decimal representation of the letter "h" in ASCII.

Basically, we'll accumulate bits and whenever we form a complete byte, we'll decode it to its ASCII representation. The very basic implementation of our accumulate_bit(bit) method would look like as follows:

Pay attention to this code. It's very important and builds the foundation for the next steps. If you didn't get it, go back and read it again. Try it yourself in the terminal or using your preferred programming language.

Now, how to convert the decimal 104 to the ASCII character representation?

Luckily, Ruby provides a method called chr which does the job:

We could do the same job for the rest of the word "hello", for instance.

According to the ASCII table, it should be the following:

Let's check if Ruby knows that:

We can even "decode" the word to the decimal representation in ASCII:

Let's see our receiver in action! Start the receiver in one terminal:

Great! Now the receiver is listening for signals.

In another terminal, let's manually send signals to form the letter "h" (which is 01101000 in binary, remember?):

And in the receiver terminal, we should see:

The Result: Sending a Message using Only Two UNIX Signals

How amazing is that? We just sent the letter "h" using only two UNIX signals!

But wait. Manually sending 8 signals for each character? That's tedious and error-prone.

What if we wanted to send the word "hello"? That's 5 characters × 8 bits = 40 signals to send manually. No way.

The sender's job is the opposite of the receiver: it should encode a message (string) into bits and send them as signals to the receiver process.

Let's think about what we need:

The tricky part here is the step 3: how do we extract individual bits from a byte?

To extract the bit at position i, we can use the following formula:

For the letter "h" (01101000 in binary, 104 in decimal):

And so on for positions 4, 5, 6, and 7.

This gives us: 0, 0, 0, 1, 0, 1, 1, 0 — exactly the bits we need from LSB to MSB!

Pay close attention to this technique. It's a fundamental operation in low-level programming.

So now time to build the sender.rb which is pretty simple:

For each byte (8-bit structure) we extract the bit performing the right shift + AND operations.

The result is the extracted bit.

Processes sending messages with only two signals! How wonderful is that?

A Message Broker using UNIX Signals

Now, sending the hello message is super easy. The sender is already able to send not only a letter but any message using signals:

Just change the receiver implementation a little bit:

However, if we send the message again, the receiver will print everything in the same line.

It's obvious: the receiver doesn't know where the sender finished the message, so it's impossible to know where we should stop one message and print the next one on a new line with \n.

We should then determine how the sender indicates the end of the message. How about being it all zeroes (0000 0000)?

Hence, when the receiver gets a NULL terminator, it will print a line feed \n.

Let's change the sender.rb first:

Amazing, right? We just built an entire communication system between two processes using one of the most primitive methods available: UNIX signals.

The sky's the limit now! Why not build a full-fledged message broker using this crazy technique?

A Modest Message Broker using UNIX Signals

We'll break down the development into three components:

Note that we're using a module called SignalCodec which will be explained soon.

Basically this module contains all core components to encode/decode signals and perform bitwise operations.

So, what are we waiting for? Let's build it!