Have you ever asked yourself what if we could replace any message broker with a very simple one using only two UNIX signals? Well, I’m not surprised if you didn’t. But I did. And I want to share my journey of how I achieved it.
If you want to learn 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:
$ echo 'hello' | base64
aGVsbG8K
Here’s what’s happening:
echo is started with the content “hello”echo is a program that prints the message to STDOUTbase64 processbase64 process encodes its input to Base64 and then puts the result in STDOUTNote the word “send”. Yes, anonymous pipes are a form of IPC (Inter-process communication). Other forms of IPC in UNIX include:
According to Wikipedia:
A UNIX signal is a standardized message sent to a program to trigger specific behaviour, such as quitting or error handling
There are many signals we can send to a process, including:
Ctrl+C in the terminal. It can be trapped, allowing the process to perform cleanup before exiting gracefullyOkay, 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:
puts "Process ID: #{Process.pid}"
puts "Sleeping for 60 seconds..."
sleep 60
After running we see:
Process ID: 55402
Sleeping for 60 seconds...
In another window, we can send the SIGTERM signal to the process 55402 via syscall kill:
$ kill -SIGTERM 55402
And then, in the script session:
[1] 55402 terminated ruby sleeper.rb
In Ruby, we can also trap a signal using the trap method in Ruby:
puts "Process ID: #{Process.pid}"
puts "Sleeping for 60 seconds..."
trap('SIGTERM') do
puts "Received SIGTERM, exiting gracefully..."
exit
end
sleep 60
Which in turn, after sending the signal, will gracefully:
Process ID: 55536
Sleeping for 60 seconds...
Received SIGTERM, exiting gracefully...
After all, we cannot send messages using signals. They are a primitive way of sending standardized messages which will trigger specific behaviours. At most, we can trap some signals, but nothing more.
Okay Leandro, but what’s the purpose of this article then?
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:
0x68 in hexadecimal01101000 in binaryBinary-wise, what if we represented each “0” with a specific signal and each “1” with another? 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:
puts "Process ID: #{Process.pid}"
puts "Sleeping forever. Send signals to this process to see how it responds."
trap('SIGUSR1') do
puts "Received SIGUSR1 signal"
end
trap('SIGUSR2') do
puts "Received SIGUSR2 signal"
end
sleep
Process ID: 56172
Sleeping forever. Send signals to this process to see how it responds.
After sending some kill -SIGUSR1 56172 and kill -SIGUSR2 56172, we can see that the process prints the following content:
Process ID: 56172
Sleeping forever. Send signals to this process to see how it responds.
Received SIGUSR1 signal
Received SIGUSR2 signal
Received SIGUSR2 signal
Received SIGUSR1 signal
Received SIGUSR1 signal
Received SIGUSR2 signal
Signals don’t carry data. But the example we have is perfect for changing to bits, uh?
Received SIGUSR1 signal # 0
Received SIGUSR2 signal # 1
Received SIGUSR2 signal # 1
Received SIGUSR1 signal # 0
Received SIGUSR2 signal # 1
Received SIGUSR1 signal # 0
Received SIGUSR1 signal # 0
Received SIGUSR1 signal # 0
That’s exactly 01101000, the binary representation of the letter “h”. 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.
How cool is that?

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:
(0 << 7) + (1 << 6) + (1 << 5) + (0 << 4) + ... + (0 << 0):0 << 7 = (2 ** 7) * 0 = 128 * 0 = 01 << 6 = (2 ** 6) * 1 = 64 * 1 = 64Similarly to the remaining bits:
1 << 5 = 320 << 4 = 01 << 3 = 80 << 2 = 00 << 1 = 00 << 0 = 0So, our sum becomes, from MSB to LSB:
MSB LSB
0 1 1 0 1 0 0 0
0 + 64 + 32 + 0 + 8 + 0 + 0 + 0 = 104
104 is exactly the decimal representation of the letter “h” in ASCII.
How wonderful is that?
Now let’s convert these operations to Ruby code. We’ll write a simple program receiver.rb that receives signals in order from LSB to MSB (positions 0 to 7) and then converts them back to ASCII characters, printing to STDOUT.
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:
@position = 0 # start with the LSB
@accumulator = 0
def accumulate_bit(bit)
# The left shift operator (<<) is used to
# shift the bits of the number to the left.
#
# This is equivalent of: (2 ** @position) * bit
@accumulator += (bit << @position)
return @accumulator if @position == 7 # stop accumulating after 8 bits (byte)
@position += 1 # move to the next bit position: 0 becomes 1, 1 becomes 2, etc.
end
# Letter "h" in binary is 01101000
# But we'll send from the LSB to the MSB
#
# 0110 1000 (MSB -> LSB) becomes 0001 0110 (LSB -> MSB)
# The order doesn't matter that much, it'll depend on
# the receiver's implementation.
accumulate_bit(0)
accumulate_bit(0)
accumulate_bit(0)
accumulate_bit(1)
accumulate_bit(0)
accumulate_bit(1)
accumulate_bit(1)
accumulate_bit(0)
puts @accumulator # should print 104, which is the ASCII code for "h"
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:
irb> puts 104.chr
=> "h"
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:
e in decimal is 101l in decimal is 108o in decimal is 111Let’s check if Ruby knows that:
104.chr # "h"
101.chr # "e"
108.chr # "l"
111.chr # "o"
We can even “decode” the word to the decimal representation in ASCII:
irb> "hello".bytes
=> [104, 101, 108, 108, 111]
Now, time to finish our receiver implementation to properly print the letter “h”:
@position = 0 # start with the LSB
@accumulator = 0
trap('SIGUSR1') { decode_signal(0) }
trap('SIGUSR2') { decode_signal(1) }
def decode_signal(bit)
accumulate_bit(bit)
return unless @position == 8 # if not yet accumulated a byte, keep accumulating
print "Received byte: #{@accumulator} (#{@accumulator.chr})\n"
@accumulator = 0 # reset the accumulator
@position = 0 # reset position for the next byte
end
def accumulate_bit(bit)
# The left shift operator (<<) is used to
# shift the bits of the number to the left.
#
# This is equivalent of: (2 ** @position) * bit
@accumulator += (bit << @position)
@position += 1 # move to the next bit position: 0 becomes 1, 1 becomes 2, etc.
end
puts "Process ID: #{Process.pid}"
sleep
Read that code and its comments. It’s very important. Do not continue reading until you really get what’s happening here.
SIGUSR1, we accumulate the bit 0SIGUSR2, accumulate then the bit 18, it means we have a byte. At this moment we should print the ASCII representation using the .chr we seen earlier. Then, reset bit position and accumulatorLet’s see our receiver in action! Start the receiver in one terminal:
$ ruby receiver.rb
Process ID: 58219
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?):
# Sending from LSB to MSB: 0, 0, 0, 1, 0, 1, 1, 0
$ kill -SIGUSR1 58219 # 0
$ kill -SIGUSR1 58219 # 0
$ kill -SIGUSR1 58219 # 0
$ kill -SIGUSR2 58219 # 1
$ kill -SIGUSR1 58219 # 0
$ kill -SIGUSR2 58219 # 1
$ kill -SIGUSR2 58219 # 1
$ kill -SIGUSR1 58219 # 0
And in the receiver terminal, we should see:
Received byte: 104 (h)
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.
We need a sender.
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:
SIGUSR1 for bit 0, SIGUSR2 for bit 1The 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:
bit = (byte >> i) & 1
Let me break this down:
byte >> i performs a right shift by i positions& 1 is a bitwise AND operation that extracts only the rightmost bitFor the letter “h” (01101000 in binary, 104 in decimal):
Position 0 (LSB):
(104 >> 0) = 104 / (2 ** 0) = 104 / 1 = 10401101000 » 0 = 0110100001101000 & 00000001 = 0 (one AND zero is zero)Position 1:
(104 >> 1) = 104 / (2 ** 1) = 104 / 2 = 5201101000 » 1 = 0011010000110100 & 00000001 = 0Position 2:
(104 >> 2) = 104 / (2 ** 2) = 104 / 4 = 2601101000 » 2 = 0001101000011010 & 00000001 = 0Position 3:
(104 >> 3) = 104 / (2 ** 3) = 104 / 8 = 1301101000 » 3 = 0000110100001101 & 00000001 = 1 (one AND one equals one)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!
(104 >> 0) & 1 = 104 & 1 = 0(104 >> 1) & 1 = 52 & 1 = 0(104 >> 2) & 1 = 26 & 1 = 0(104 >> 3) & 1 = 13 & 1 = 1(104 >> 4) & 1 = 6 & 1 = 0(104 >> 5) & 1 = 3 & 1 = 1(104 >> 6) & 1 = 1 & 1 = 1(104 >> 7) & 1 = 0 & 1 = 0Pay 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:
receiver_pid = ARGV[0].to_i
message = ARGV[1..-1].join(' ')
def encode_byte(byte)
8.times.map do |i|
# Extract each bit from the byte, starting from the LSB
(byte >> i) & 1
end
end
message.bytes.each do |byte|
encode_byte(byte).each do |bit|
signal = bit == 0 ? 'SIGUSR1' : 'SIGUSR2'
Process.kill(signal, receiver_pid)
sleep 0.001 # Delay to allow the receiver to process the signal
end
end
For each byte (8-bit structure) we extract the bit performing the right shift + AND oprerations. The result is the extracted bit.
In the receiver window:
$ ruby receiver.rb
Process ID: 68968
And in the sender window:
$ ruby sender.rb 68968 h
The receiver will print:
$ ruby receiver.rb
Process ID: 68968
Received byte: 104 (h)
Processes sending messages with only two signals! How wonderful is that?
Now, sending the hello message is super easy. The sender is already able to send not only a letter but any message using signals:
$ ruby sender.rb 68968 hello
# And the receiver:
Received byte: 104 (h)
Received byte: 101 (e)
Received byte: 108 (l)
Received byte: 108 (l)
Received byte: 111 (o)
Just change the receiver implementation a little bit:
def decode_signal(bit)
accumulate_bit(bit)
return unless @position == 8 # if not yet accumulated a byte, keep accumulating
print @accumulator.chr # print the byte as a character
@accumulator = 0 # reset the accumulator
@position = 0 # reset position for the next byte
end
And then:
$ ruby sender.rb 96875 Hello
# In the receiver's terminal
Process ID: 96875
Hello
However, if we send the message again, the receiver will print everything in the same line:
$ ruby sender.rb 96875 Hello
$ ruby sender.rb 96875 Hello
# In the receiver's terminal
Process ID: 96875
HelloHello
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)?
0000 0000)0110 1000 # h
0110 0101 # e
0110 1000 # l
0110 1000 # l
0110 1111 # o
0000 0000 # NULL
Hence, when the receiver gets a NULL terminator, it will print a line feed \n. Let’s change the sender.rb first:
receiver_pid = ARGV[0].to_i
message = ARGV[1..-1].join(' ')
def encode_byte(byte)
8.times.map do |i|
# Extract each bit from the byte, starting from the LSB
(byte >> i) & 1
end
end
message.bytes.each do |byte|
encode_byte(byte).each do |bit|
signal = bit == 0 ? 'SIGUSR1' : 'SIGUSR2'
Process.kill(signal, receiver_pid)
sleep 0.001 # Delay to allow the receiver to process the signal
end
end
# Send NULL terminator (0000 0000)
8.times do
Process.kill('SIGUSR1', receiver_pid)
sleep 0.001 # Delay to allow the receiver to process the signal
end
puts "Message sent to receiver (PID: #{receiver_pid})"
Then, the receiver.rb:
@position = 0 # start with the LSB
@accumulator = 0
trap('SIGUSR1') { decode_signal(0) }
trap('SIGUSR2') { decode_signal(1) }
def decode_signal(bit)
accumulate_bit(bit)
return unless @position == 8 # if not yet accumulated a byte, keep accumulating
if @accumulator.zero? # NULL terminator received
print "\n"
else
print @accumulator.chr # print the byte as a character
end
@accumulator = 0 # reset the accumulator
@position = 0 # reset position for the next byte
end
def accumulate_bit(bit)
# The left shift operator (<<) is used to
# shift the bits of the number to the left.
#
# This is equivalent of: (2 ** @position) * bit
@accumulator += (bit << @position)
@position += 1 # move to the next bit position: 0 becomes 1, 1 becomes 2, etc.
end
puts "Process ID: #{Process.pid}"
sleep
Output:
$ ruby sender.rb 96875 Hello, World!
$ ruby sender.rb 96875 You're welcome
$ ruby sender.rb 96875 How are you?
# Receiver
Process ID: 97176
Hello, World!
You're welcome
How are you?
OMG Leandro! That’s amazing!
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?
We’ll break down the development into three components:

#!/usr/bin/env ruby
require_relative 'signal_codec'
require_relative 'consumer'
class Broker
PID = 'broker.pid'.freeze
def initialize
@codec = SignalCodec.new
@queue = Queue.new
@consumer_index = 0
end
def start
register_broker
trap('SIGUSR1') { process_bit(0) }
trap('SIGUSR2') { process_bit(1) }
puts "Broker PID: #{Process.pid}"
puts "Waiting for messages..."
distribute_messages
sleep # Keep alive
end
private
def process_bit(bit)
@codec.accumulate_bit(bit) do |message|
@queue.push(message) unless message.empty?
end
end
def register_broker
File.write(PID, Process.pid)
at_exit { File.delete(PID) if File.exist?(PID) }
end
def distribute_messages
Thread.new do
loop do
sleep 0.1
next if @queue.empty?
consumers = File.exist?(Consumer::FILE) ? File.readlines(Consumer::FILE).map(&:to_i) : []
next if consumers.empty?
message = @queue.pop(true) rescue next
consumer_pid = consumers[@consumer_index % consumers.size]
@consumer_index += 1
puts "[SEND] #{message} → Consumer #{consumer_pid}"
@codec.send_message(message, consumer_pid)
end
end
end
end
if __FILE__ == $0
broker = Broker.new
broker.start
end
USR1 (bit 0) and USR2 (bit 1)USR1 and USR2 too)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.
Consumer implementation:#!/usr/bin/env ruby
require_relative 'signal_codec'
class Consumer
FILE = 'consumers.txt'.freeze
def initialize
@codec = SignalCodec.new
end
def start
register_consumer
trap('SIGUSR1') { process_bit(0) }
trap('SIGUSR2') { process_bit(1) }
puts "Consumer PID: #{Process.pid}"
puts "Waiting for messages..."
sleep # Keep alive
end
private
def process_bit(bit)
@codec.accumulate_bit(bit) do |message|
puts "[RECEIVE] #{message}"
end
end
def register_consumer
File.open(FILE, 'a') { |f| f.puts Process.pid }
at_exit { deregister_consumer }
end
def deregister_consumer
if File.exist?(FILE)
consumers = File.readlines(FILE).map(&:strip).reject { |pid| pid.to_i == Process.pid }
File.write(FILE, consumers.join("\n"))
end
end
end
if __FILE__ == $0
consumer = Consumer.new
consumer.start
end
Producer implementation, which is pretty straightforward:#!/usr/bin/env ruby
require_relative 'signal_codec'
require_relative 'broker'
unless File.exist?(Broker::PID)
abort "Error: Broker not running (#{Broker::PID} not found)"
end
broker_pid = File.read(Broker::PID).strip.to_i
message = ARGV.join(' ')
if message.empty?
puts "Usage: ruby producer.rb <message>"
exit 1
end
codec = SignalCodec.new
puts "Sending: #{message}"
codec.send_message(message, broker_pid)
puts "Message sent to broker (PID: #{broker_pid})"
So far, this architecture should look familiar. Many broker implementations follow these basic foundations.
Of course, production-ready implementations are far more robust than this one. Here, we’re just poking around with hacking and experimentation
The coolest part is the SignalCodec though:
class SignalCodec
SIGNAL_DELAY = 0.001 # Delay between signals to allow processing
def initialize
@accumulator = 0
@position = 0
@buffer = []
end
def accumulate_bit(bit)
@accumulator += (bit << @position)
@position += 1
if @position == 8 # Byte is complete
if @accumulator.zero? # Message complete - NULL terminator
decoded = @buffer.pack("C*").force_encoding('UTF-8')
yield(decoded) if block_given?
@buffer.clear
else
@buffer << @accumulator
end
@position = 0
@accumulator = 0
end
end
def send_message(message, pid)
message.each_byte do |byte|
8.times do |i|
bit = (byte >> i) & 1
signal = bit == 0 ? 'SIGUSR1' : 'SIGUSR2'
Process.kill(signal, pid)
sleep SIGNAL_DELAY
end
end
# Send NULL terminator (0000 0000)
8.times do
Process.kill('SIGUSR1', pid)
sleep SIGNAL_DELAY
end
end
end
If you’ve been following along, this shouldn’t be hard to understand, but I’ll break down how this beautiful piece of code works:
accumulate_bit method should be familiar from our earlier implementation, but it now accepts a closure (block) that lets the caller decide what to do with each decoded bytesend_message encodes a message into bits and sends them via UNIX signalsEverything in action:

How cool, amazing, wonderful, impressive, astonishing is that?
Yes, we built a message broker using nothing but UNIX signals and a bit of Ruby magic. Sure, it’s not production-ready, and you definitely shouldn’t use this in your next startup (please don’t), but that was never the point.
The real takeaway here isn’t the broker itself: it’s understanding how the fundamentals work. We explored binary operations, UNIX signals, and IPC in a hands-on way that most people never bother with. ` We took something “useless” and made it work, just for fun. So next time someone asks you about message brokers, you can casually mention that you once built (or saw) one using just two signals. And if they look at you weird, well, that’s their problem. Now go build something equally useless and amazing. The world needs more hackers who experiment just for the fun of it.
Happy hacking!
(备注: 转载自网络)
本站为个人网站,集网络美文、技术文章与原创生活记录等,系孤芳自赏、个人用途,内容如有侵权请联系站长删除。