当前位置: 首页 > news >正文

谁还需要 Kafka 啊?我用两个 UNIX 信号手捏了一个消息队列!

天下苦复杂中间件久矣!

看看我们现在的项目,动辄就要引入 Kafka、RabbitMQ、RocketMQ……光是部署这些环境、调优 JVM、配置集群,就能让一个好好的周末泡汤。你有没有在某个深夜抓狂时想过:“老夫就是想在两个进程之间传个字符串,难道就没有那种最极简、最硬核、最不需要第三方依赖的办法吗?”

如果你没这么想过,恭喜你,你是一个情绪稳定的正常程序员。

但我不仅想了,我还真干了!今天,我要带大家玩一把“文艺复兴”,彻底抛弃所有高级网络协议和中间件,仅用操作系统最底层的两个 UNIX 信号,从零手撸一个消息队列! 不管你是想轻松学点底层 IPC(进程间通信)知识,还是想复习一下快忘光的二进制位运算,亦或只是单纯想看我怎么一本正经地折腾没用的东西,这篇文章都绝对合你的胃口。

准备好了吗?我们要开始“作妖”了。


处在 IPC 鄙视链底端的“信号”

提到进程间通信(IPC, Inter-Process Communication),大家脑子里蹦出来的肯定是:

  1. Socket(套接字):老大哥,网络通信的绝对霸主。
  2. Pipe(管道):也就是我们常用的 | 符,比如 echo "hello" | grep "h",简单粗暴。
  3. Shared Memory(共享内存):性能怪兽,但处理同步问题能让人掉光头发。

相比之下,Signal(信号) 简直就是处于 IPC 鄙视链的绝对底端。为啥?因为它本来就不是设计用来传数据的!

按照 UNIX 系统的设定,信号就像是操作系统给进程发的“短消息通知”,通常只代表一个动作:

  • SIGKILL:阎王让你三更死,谁敢留人到五更。(强制结束,无法捕获)
  • SIGTERM:温柔一刀,给你个机会料理后事。(优雅停机)
  • SIGINT:你在终端疯狂按 Ctrl+C 产生的就是这玩意。

信号本身不携带任何数据载荷。这就好比我给你打了个响指,你只知道我打了响指,但没法通过响指本身知道我中午想吃黄焖鸡还是兰州拉面。

但是!(重点来了)

UNIX 系统非常贴心地留了两个“用户自定义信号”:SIGUSR1SIGUSR2。这就给了我们搞事情的绝佳机会。

脑洞大开:把信号变成摩斯密码

既然信号不能带数据,那我们怎么传消息?
很简单,回到计算机最本质的世界:0 和 1

只要是消息,不管多长多复杂,在内存里最终都是由 0 和 1 组成的二进制串。我们手头刚好有两个自定义信号,那不如这样约定:

  • 收到 SIGUSR1,就代表我给你发了一个 0
  • 收到 SIGUSR2,就代表我给你发了一个 1

这就是我们的“摩斯密码”!

以小写字母 h 为例:
它在 ASCII 码表里的十进制值是 104,转换成二进制就是 01101000
如果我们想把 h 发送给另一个进程,只需要按顺序给那个进程发送 8 次信号:
SIGUSR1 (0) -> SIGUSR1 (0) -> SIGUSR1 (0) -> SIGUSR2 (1) -> SIGUSR1 (0) -> SIGUSR2 (1) -> SIGUSR2 (1) -> SIGUSR1 (0)
(注:这里为了方便,我们假设从最低位 LSB 开始发送)

只要发送端负责把字母“拆”成位,接收端负责把位“拼”回字母,这不就成了吗?!


核心魔法:位运算的拆与拼

要实现这个脑洞,我们需要用一点点位运算(Bitwise Operations)。别一听位运算就跑,其实特别简单。这次我们用 Go 语言 来演示,因为 Go 处理并发和系统级 API 简直顺滑得不像话。

1. 发送端:怎么把一个字节“拆”成 8 个位?

假设我们有一个字节 byte,要想知道它第 i 位是 0 还是 1,我们可以用这个黄金公式:
bit = (byte >> i) & 1

原理剖析:

  • >> 是右移操作符。它能把二进制串整体向右推 i 个位置,原来在第 i 位的数据,就被推到了最右边(最低位)。
  • & 1 是按位“与”操作。因为 1 的二进制是 00000001,任何数和它做“与”操作,都会把前面的高位全部清零,只保留最右边那一位!

h (104, 01101000) 开刀,我们从第 0 位(最右侧)一直剥到第 7 位(最左侧):

  • 第 0 位:(104 >> 0) & 1 -> 104 与 1 -> 结果是 0
  • 第 1 位:(104 >> 1) & 1 -> 52 与 1 -> 结果是 0
  • 第 2 位:(104 >> 2) & 1 -> 26 与 1 -> 结果是 0
  • 第 3 位:(104 >> 3) & 1 -> 13 与 1 -> 结果是 1
  • 第 4 位:(104 >> 4) & 1 -> 6 与 1 -> 结果是 0
  • 第 5 位:(104 >> 5) & 1 -> 3 与 1 -> 结果是 1
  • 第 6 位:(104 >> 6) & 1 -> 1 与 1 -> 结果是 1
  • 第 7 位:(104 >> 7) & 1 -> 0 与 1 -> 结果是 0

完美!我们得到了序列 0, 0, 0, 1, 0, 1, 1, 0,接下来只要把它们换成 SIGUSR1SIGUSR2 发出去就行了。

2. 接收端:怎么把 8 个位“拼”回一个字节?

接收端的工作正好相反,它要用到的武器是左移操作(<<)。
一开始,我们搞一个空字节,值为 0。然后监听信号。

  • 如果来了个 0,不管它。
  • 如果来了个 1,我们就把它往左移 i 个位置,然后“加”到我们的空字节上。

公式:accumulator += (bit << position)

position 走到 8 的时候,说明凑齐了一桌麻将(一个字节),直接把这个字节转成字符打印出来,然后将 positionaccumulator 清零,准备迎接下一个字节。


废话少说,Show Me The Code!

先写接收端 (Consumer)

这哥们的主要任务就是老老实实呆在后台,监听我们要给它发的信号。

// consumer.go
package mainimport ("fmt""os""os/signal""syscall"
)func main() {// 打印 PID,不然发送端不知道要把信号打给谁fmt.Printf("😎 消费者已启动!我的进程 PID 是: %d\n", os.Getpid())fmt.Println("🎧 正在竖起耳朵等待 UNIX 信号...")// 注册通道,专门截获 SIGUSR1 和 SIGUSR2sigCh := make(chan os.Signal, 1)signal.Notify(sigCh, syscall.SIGUSR1, syscall.SIGUSR2)var accumulator byte = 0var position int = 0var buffer []byte // 用来存拼好的字符for sig := range sigCh {var bit byte = 0if sig == syscall.SIGUSR2 {bit = 1 // SIGUSR2 就是 1}// 拼图游戏开始,把 bit 推到正确的位置上并累加accumulator += (bit << position)position++// 收集满 8 个龙珠,召唤一个 Byteif position == 8 {if accumulator == 0 {// 我们约定,收到一个完全是 0 的字节(NULL),代表一句话说完了fmt.Printf("\n✨ 收到完整消息: %s\n", string(buffer))buffer = []byte{} // 清空,准备听下一句} else {// 没说完就先存进 bufferbuffer = append(buffer, accumulator)fmt.Printf("%c", accumulator) // 实时打印看看效果}// 重置状态accumulator = 0position = 0}}
}

再写发送端 (Producer)

发送端就是个无情的发报机,把我们的命令行参数拆碎了发射出去。

// producer.go
package mainimport ("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) & 1sig := syscall.SIGUSR1if 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 TDP1{Producer1} --> B{Broker}P2{Producer2} --> B{Broker}B --> C1{Consumer1}B --> C2{Consumer2}

Broker 到底是个啥角色?
其实,Broker 本质上就是一个“缝合怪”:它对外兼具了 Consumer 和 Producer 的功能。

  1. 作为接收方:它监听系统发来的 SIGUSR1SIGUSR2,按照我们上面的逻辑,把位拼成完整的字符串消息。
  2. 缓冲与路由:拼好一条消息后,它不打印,而是塞进自己内部的一个内存队列(比如 Go 的 Channel)。
  3. 作为发送方:它后台跑一个死循环,一旦发现队列里有消息,就去查找已注册的下游 Consumer 进程的 PID,然后把消息重新拆成 0 和 1 的信号,像机关枪一样发送过去。

这样一来,Producer 甚至不需要知道 Consumer 的 PID,只需要把信号发给 Broker 就行了,彻底实现了系统的解耦!(听上去是不是非常像大厂里的微服务架构介绍?)


灵魂拷问:这玩意能上生产环境吗?

如果你觉得这套架构很帅,打算明天去公司把它整合到你们的核心支付业务系统里去……那我劝你最好先准备好离职报告。

永远,绝对,不要在生产环境中这么做!

为什么?因为用 UNIX 信号当消息队列,缺陷多到令人发指:

  1. 慢如老牛:每发送一个“位”的数据,都会触发一次由系统空间到用户空间的上下文切换。为了防止信号丢失,我们在每次发送后都加了 Sleep。发一个字符要 16 毫秒,发一段 100 字的消息要将近 2 秒。这在现代软件里简直是世纪末的灾难。
  2. 极不可靠:信号是没有持久化机制的,发丢了就是丢了,没法重试,更没有“消息确认(ACK)”。
  3. 不支持并发:如果两个 Producer 同时向一个 Broker 狂发信号,0 和 1 就会严重交错污染。Broker 解析出来的绝对是一串毫无逻辑的外星文乱码。

那我们今天费这么大劲折腾这玩意,图个啥?

图的是看透本质的爽感

在这个大家都在卷各种上层框架、中间件 API 的时代,我们很容易迷失在各种高大上的术语里。但当我们扒掉 Kafka、RabbitMQ 华丽的封装外衣,下沉到操作系统的深水区时,你会发现,所有的“消息”、“通信”、“流转”,归根结底,也不过就是底层内存和 CPU 的 0 与 1 的游戏。

搞明白位运算,搞明白进程间的最原始交互方式,能在你以后排查那些极其诡异的架构 Bug 时,提供意想不到的直觉与灵感。

退一万步讲,下次面试官再问你:“除了常用中间件,你还了解哪些 IPC 方式?”
你就可以微微一笑,靠在椅背上淡淡地说:“我曾经仅用两个 UNIX 信号就手写了一个消息队列,虽然毫无卵用,但是非常酷。”

Happy Hacking!

http://www.jsqmd.com/news/410774/

相关文章:

  • 第九章(选学):字典——按名字找东西
  • 看完就会:8个AI论文平台测评,研究生写论文不再难!
  • 救命神器 8个AI论文网站测评:研究生毕业论文+学术写作必备工具推荐
  • 除妖建《层园》:李渔智破 “鬼宅” 迷局,把凶宅住成了杭州城的 “世外桃源”
  • 2026鲜牛肉市场:哪些供应商值得一试?白牦牛/新鲜牛肉/鲜牛肉/天祝白牦牛肉/牛肉/白牦牛肉,鲜牛肉厂家怎么选择 - 品牌推荐师
  • 收藏!今年最火行业薪资两极分化:顶尖年薪破亿,普通打工人仍月薪 6K
  • 改命篇】立 Flag 总倒?因为你姿势全错 新年啦,又到立flag的时候啦!
  • 面试官:公司项目中Java的多线程一般用在哪些场景?
  • JVM运行时数据区
  • 从零到一:基于STM32与云平台的多传感器火灾监测系统实战
  • 基于小波分解和ARMA预测附Matlab代码
  • SQL优化万能公式:5 大步骤 + 10 个案例
  • 基于蜻蜓算法优化Kmeans聚类分析附Matlab代码
  • 企业AI获客服务性价比怎么看,江西哪家供应商值得选? - myqiye
  • 【图像加密】基于行列像素加密和灰度加密算法研究附Matlab代码
  • 导师推荐! 降AIGC平台 千笔 VS PaperRed,MBA专属高效降重神器
  • LFU缓存算法详解:从零实现到面试应对
  • STM32 核心输入、输出模式
  • SS-31 ;D-Arg-Dmt-Lys-Phe-NH2
  • 看完就会:千笔ai写作,专科生论文神器
  • [项目]LNG接收站工艺设计平台(河北某石油研究院定制项目)
  • Java 常见常用算法详解
  • 变压器选购终极指南:五大核心场景解析与头部品牌深度推荐 - 博客湾
  • 运维不想干了,还能有什么工作能干的?
  • 开题卡住了?千笔,继续教育论文写作神器
  • Java IO流的核心概念与应用实践
  • 小区低压泵喷泉维护指南:附口碑服务公司参考,低压泵喷泉厂商综合实力与口碑权威评选 - 品牌推荐师
  • 【Copilot配置】—— Copilot 设置全解析 + 最佳实践|解锁 AI 辅助开发效率天花板
  • 建议收藏|自考必备的降AI率工具 千笔AI VS Checkjie
  • 2026年自动化设备零件CNC加工厂家推荐:聚焦非标定制与精益质量管控 - 余文22