天下苦复杂中间件久矣!
看看我们现在的项目,动辄就要引入 Kafka、RabbitMQ、RocketMQ……光是部署这些环境、调优 JVM、配置集群,就能让一个好好的周末泡汤。你有没有在某个深夜抓狂时想过:“老夫就是想在两个进程之间传个字符串,难道就没有那种最极简、最硬核、最不需要第三方依赖的办法吗?”
如果你没这么想过,恭喜你,你是一个情绪稳定的正常程序员。
但我不仅想了,我还真干了!今天,我要带大家玩一把“文艺复兴”,彻底抛弃所有高级网络协议和中间件,仅用操作系统最底层的两个 UNIX 信号,从零手撸一个消息队列! 不管你是想轻松学点底层 IPC(进程间通信)知识,还是想复习一下快忘光的二进制位运算,亦或只是单纯想看我怎么一本正经地折腾没用的东西,这篇文章都绝对合你的胃口。
准备好了吗?我们要开始“作妖”了。
处在 IPC 鄙视链底端的“信号”
提到进程间通信(IPC, Inter-Process Communication),大家脑子里蹦出来的肯定是:
- Socket(套接字):老大哥,网络通信的绝对霸主。
- Pipe(管道):也就是我们常用的 <code>|</code> 符,比如 <code>echo "hello" | grep "h"</code>,简单粗暴。
- Shared Memory(共享内存):性能怪兽,但处理同步问题能让人掉光头发。
相比之下,Signal(信号) 简直就是处于 IPC 鄙视链的绝对底端。为啥?因为它本来就不是设计用来传数据的!
按照 UNIX 系统的设定,信号就像是操作系统给进程发的“短消息通知”,通常只代表一个动作:
- <code>SIGKILL</code>:阎王让你三更死,谁敢留人到五更。(强制结束,无法捕获)
- <code>SIGTERM</code>:温柔一刀,给你个机会料理后事。(优雅停机)
- <code>SIGINT</code>:你在终端疯狂按 <code>Ctrl+C</code> 产生的就是这玩意。
信号本身不携带任何数据载荷。这就好比我给你打了个响指,你只知道我打了响指,但没法通过响指本身知道我中午想吃黄焖鸡还是兰州拉面。
但是!(重点来了)
UNIX 系统非常贴心地留了两个“用户自定义信号”:<code>SIGUSR1</code> 和 <code>SIGUSR2</code>。这就给了我们搞事情的绝佳机会。
脑洞大开:把信号变成摩斯密码
既然信号不能带数据,那我们怎么传消息?
很简单,回到计算机最本质的世界:0 和 1。
只要是消息,不管多长多复杂,在内存里最终都是由 0 和 1 组成的二进制串。我们手头刚好有两个自定义信号,那不如这样约定:
- 收到 <code>SIGUSR1</code>,就代表我给你发了一个 0。
- 收到 <code>SIGUSR2</code>,就代表我给你发了一个 1。
这就是我们的“摩斯密码”!
以小写字母 <code>h</code> 为例:
它在 ASCII 码表里的十进制值是 <code>104</code>,转换成二进制就是 <code>01101000</code>。
如果我们想把 <code>h</code> 发送给另一个进程,只需要按顺序给那个进程发送 8 次信号:
<code>SIGUSR1 (0) -> SIGUSR1 (0) -> SIGUSR1 (0) -> SIGUSR2 (1) -> SIGUSR1 (0) -> SIGUSR2 (1) -> SIGUSR2 (1) -> SIGUSR1 (0)</code>
(注:这里为了方便,我们假设从最低位 LSB 开始发送)
只要发送端负责把字母“拆”成位,接收端负责把位“拼”回字母,这不就成了吗?!
核心魔法:位运算的拆与拼
要实现这个脑洞,我们需要用一点点位运算(Bitwise Operations)。别一听位运算就跑,其实特别简单。这次我们用 Go 语言 来演示,因为 Go 处理并发和系统级 API 简直顺滑得不像话。
1. 发送端:怎么把一个字节“拆”成 8 个位?
假设我们有一个字节 <code>byte</code>,要想知道它第 <code>i</code> 位是 0 还是 1,我们可以用这个黄金公式:
<code>bit = (byte >> i) & 1</code>
原理剖析:
- <code>>></code> 是右移操作符。它能把二进制串整体向右推 <code>i</code> 个位置,原来在第 <code>i</code> 位的数据,就被推到了最右边(最低位)。
- <code>& 1</code> 是按位“与”操作。因为 1 的二进制是 <code>00000001</code>,任何数和它做“与”操作,都会把前面的高位全部清零,只保留最右边那一位!
拿 <code>h</code> (104, <code>01101000</code>) 开刀,我们从第 0 位(最右侧)一直剥到第 7 位(最左侧):
- 第 0 位:<code>(104 >> 0) & 1</code> -> 104 与 1 -> 结果是 0
- 第 1 位:<code>(104 >> 1) & 1</code> -> 52 与 1 -> 结果是 0
- 第 2 位:<code>(104 >> 2) & 1</code> -> 26 与 1 -> 结果是 0
- 第 3 位:<code>(104 >> 3) & 1</code> -> 13 与 1 -> 结果是 1
- 第 4 位:<code>(104 >> 4) & 1</code> -> 6 与 1 -> 结果是 0
- 第 5 位:<code>(104 >> 5) & 1</code> -> 3 与 1 -> 结果是 1
- 第 6 位:<code>(104 >> 6) & 1</code> -> 1 与 1 -> 结果是 1
- 第 7 位:<code>(104 >> 7) & 1</code> -> 0 与 1 -> 结果是 0
完美!我们得到了序列 <code>0, 0, 0, 1, 0, 1, 1, 0</code>,接下来只要把它们换成 <code>SIGUSR1</code> 和 <code>SIGUSR2</code> 发出去就行了。
2. 接收端:怎么把 8 个位“拼”回一个字节?
接收端的工作正好相反,它要用到的武器是左移操作(<code><<</code>)。
一开始,我们搞一个空字节,值为 0。然后监听信号。
- 如果来了个 0,不管它。
- 如果来了个 1,我们就把它往左移 <code>i</code> 个位置,然后“加”到我们的空字节上。
公式:<code>accumulator += (bit << position)</code>
当 <code>position</code> 走到 8 的时候,说明凑齐了一桌麻将(一个字节),直接把这个字节转成字符打印出来,然后将 <code>position</code> 和 <code>accumulator</code> 清零,准备迎接下一个字节。
废话少说,Show Me The Code!
先写接收端 (Consumer)
这哥们的主要任务就是老老实实呆在后台,监听我们要给它发的信号。
// consumer.go
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
)
func main() {
// 打印 PID,不然发送端不知道要把信号打给谁
fmt.Printf("😎 消费者已启动!我的进程 PID 是: %d\n", os.Getpid())
fmt.Println("🎧 正在竖起耳朵等待 UNIX 信号...")
// 注册通道,专门截获 SIGUSR1 和 SIGUSR2
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGUSR1, syscall.SIGUSR2)
var accumulator byte = 0
var position int = 0
var buffer []byte // 用来存拼好的字符
for sig := range sigCh {
var bit byte = 0
if sig == syscall.SIGUSR2 {
bit = 1 // SIGUSR2 就是 1
}
// 拼图游戏开始,把 bit 推到正确的位置上并累加
accumulator += (bit << position)
position++
// 收集满 8 个龙珠,召唤一个 Byte
if position == 8 {
if accumulator == 0 {
// 我们约定,收到一个完全是 0 的字节(NULL),代表一句话说完了
fmt.Printf("\n✨ 收到完整消息: %s\n", string(buffer))
buffer = []byte{} // 清空,准备听下一句
} else {
// 没说完就先存进 buffer
buffer = append(buffer, accumulator)
fmt.Printf("%c", accumulator) // 实时打印看看效果
}
// 重置状态
accumulator = 0
position = 0
}
}
}
再写发送端 (Producer)
发送端就是个无情的发报机,把我们的命令行参数拆碎了发射出去。
// producer.go
package main
import (
"fmt"
"os"
"strconv"
"syscall"
"time"
)
func main() {
if len(os.Args) < 3 {
fmt.Println("❌ 姿势不对!正确用法: go run producer.go <PID> <你想发送的骚话>")
return
}
targetPid, _ := strconv.Atoi(os.Args[1])
message := os.Args[2]
fmt.Printf("🚀 准备向 PID %d 发射消息: [%s]\n", targetPid, message)
for i := 0; i < len(message); i++ {
b := message[i]
// 庖丁解牛:拆解 8 个位
for j := 0; j < 8; j++ {
bit := (b >> j) & 1
sig := syscall.SIGUSR1
if bit == 1 {
sig = syscall.SIGUSR2
}
// 发送信号!咻!
syscall.Kill(targetPid, sig)
// ⚠️ 极其关键的一步:休眠!
// 如果不睡一会儿,操作系统的内核可能会把密集发送的相同信号合并成一个
// 那样你的数据就全丢了。这就是底层 IPC 的残酷。
time.Sleep(2 * time.Millisecond)
}
}
// 消息发完了,最后发 8 个 0(NULL),告诉接收方“我说完了”
for j := 0; j < 8; j++ {
syscall.Kill(targetPid, syscall.SIGUSR1)
time.Sleep(2 * time.Millisecond)
}
fmt.Println("✅ 消息发送完毕,深藏功与名。")
}
见证奇迹的时刻
打开两个终端窗口。
在窗口 A 运行:
$ go run consumer.go
😎 消费者已启动!我的进程 PID 是: 8848
🎧 正在竖起耳朵等待 UNIX 信号...
在窗口 B 运行:
$ go run producer.go 8848 "Hello, UNIX!"
🚀 准备向 PID 8848 发射消息: [Hello, UNIX!]
✅ 消息发送完毕,深藏功与名。
此时,你会在窗口 A 看到字符一个一个地蹦出来:
H e l l o , U N I X !
✨ 收到完整消息: Hello, UNIX!
是不是有种黑客帝国里字符雨的快感?!
玩得再大点:三层架构的 Broker
既然搞了,干脆贯彻到底,弄个正儿八经的 Pub/Sub(发布/订阅)架构!
我们可以写一个中转站(Broker)进程。
graph TD
P1{Producer1} --> B{Broker}
P2{Producer2} --> B{Broker}
B --> C1{Consumer1}
B --> C2{Consumer2}
Broker 到底是个啥角色?
其实,Broker 本质上就是一个“缝合怪”:它对外兼具了 Consumer 和 Producer 的功能。
- 作为接收方:它监听系统发来的 <code>SIGUSR1</code> 和 <code>SIGUSR2</code>,按照我们上面的逻辑,把位拼成完整的字符串消息。
- 缓冲与路由:拼好一条消息后,它不打印,而是塞进自己内部的一个内存队列(比如 Go 的 Channel)。
- 作为发送方:它后台跑一个死循环,一旦发现队列里有消息,就去查找已注册的下游 Consumer 进程的 PID,然后把消息重新拆成 0 和 1 的信号,像机关枪一样发送过去。
这样一来,Producer 甚至不需要知道 Consumer 的 PID,只需要把信号发给 Broker 就行了,彻底实现了系统的解耦!(听上去是不是非常像大厂里的微服务架构介绍?)
灵魂拷问:这玩意能上生产环境吗?
如果你觉得这套架构很帅,打算明天去公司把它整合到你们的核心支付业务系统里去……那我劝你最好先准备好离职报告。
永远,绝对,不要在生产环境中这么做!
为什么?因为用 UNIX 信号当消息队列,缺陷多到令人发指:
- 慢如老牛:每发送一个“位”的数据,都会触发一次由系统空间到用户空间的上下文切换。为了防止信号丢失,我们在每次发送后都加了 <code>Sleep</code>。发一个字符要 16 毫秒,发一段 100 字的消息要将近 2 秒。这在现代软件里简直是世纪末的灾难。
- 极不可靠:信号是没有持久化机制的,发丢了就是丢了,没法重试,更没有“消息确认(ACK)”。
- 不支持并发:如果两个 Producer 同时向一个 Broker 狂发信号,0 和 1 就会严重交错污染。Broker 解析出来的绝对是一串毫无逻辑的外星文乱码。
那我们今天费这么大劲折腾这玩意,图个啥?
图的是看透本质的爽感。
在这个大家都在卷各种上层框架、中间件 API 的时代,我们很容易迷失在各种高大上的术语里。但当我们扒掉 Kafka、RabbitMQ 华丽的封装外衣,下沉到操作系统的深水区时,你会发现,所有的“消息”、“通信”、“流转”,归根结底,也不过就是底层内存和 CPU 的 0 与 1 的游戏。
搞明白位运算,搞明白进程间的最原始交互方式,能在你以后排查那些极其诡异的架构 Bug 时,提供意想不到的直觉与灵感。
退一万步讲,下次面试官再问你:“除了常用中间件,你还了解哪些 IPC 方式?”
你就可以微微一笑,靠在椅背上淡淡地说:“我曾经仅用两个 UNIX 信号就手写了一个消息队列,虽然毫无卵用,但是非常酷。”
Happy Hacking!