问题背景
在很多后端系统里,"本地多个进程把事件交给一个守护进程处理"是很常见的需求。例如:业务进程写审计日志,采集进程上报状态,脚本任务提交执行结果,最终都希望由一个常驻服务统一接收、落盘或转发。
最直接的做法是让每个进程写同一个文件,但这会带来几个问题:并发写入需要考虑追加原子性、日志轮转会影响句柄、消费者很难及时知道新数据到达。也可以使用 Unix Domain Socket,但如果需求只是单机、单向、文本事件流,Socket 的握手和连接管理可能显得偏重。
Linux 命名管道 FIFO 是一个折中选择:它像文件一样有路径,生产者只要打开路径并写入;消费者像读文件一样读取,但数据不会持久化到磁盘。若再结合 Reactor 模式,就可以把 FIFO、标准输入、定时器或未来新增的网络 fd 统一纳入一个事件循环,避免一个 fd 一个线程的粗放模型。
本文实现一个最小但可扩展的本地事件总线:多个写入端向 FIFO 写入 JSON Lines 风格事件,C++ 服务端使用 epoll 监听 FIFO 的可读事件,读取后按行解析并输出处理结果。示例重点是通信和事件循环结构,不依赖第三方库,也不假设特定发行版行为以外的通用 Linux 系统调用能力。
原理解释
命名管道由 mkfifo 创建,表现为文件系统中的一个特殊节点。普通文件写入后数据会落盘,而 FIFO 写入的数据只在内核缓冲区中流动:写端写入,读端读取;没有读端时,写端打开可能阻塞或失败;没有写端时,读端可能读到 EOF。
Reactor 模式的核心是"等待事件"和"处理事件"分离:主循环不主动轮询每个资源,而是把 fd 注册到多路复用器中,等内核告诉我们哪些 fd 可读、可写或异常,再分派到对应处理函数。Linux 中常用 epoll 实现这个多路复用器。
把 FIFO 放进 Reactor 有一个容易踩坑的点:如果所有写端关闭,读端读取可能返回 0,表示当前没有写者。若服务端此时直接退出,就不适合作为守护进程。常见处理方式是:服务端以非阻塞方式打开 FIFO 的读端,同时再打开一个写端作为"保活句柄",避免没有外部写者时读端反复收到 EOF。这个写端不写业务数据,只用于维持 FIFO 生命周期。
事件格式建议采用一行一个事件。原因很简单:FIFO 是字节流,不保留应用层消息边界。即使一次 write 写入了完整 JSON,读端也可能分多次读到,或者一次读到多条。按换行符切分可以用一个字符串缓冲区解决半包和粘包问题。若事件内容本身可能包含换行,应先做转义或改用长度前缀协议。
可执行步骤
1. 准备目录和 FIFO
以下命令创建运行目录和命名管道。生产环境可把目录放到 /run/your-app,并由 systemd 管理权限;本地实验使用 /tmp 更方便。
bash
mkdir -p /tmp/local-bus
rm -f /tmp/local-bus/events.fifo
mkfifo /tmp/local-bus/events.fifo
ls -l /tmp/local-bus/events.fifo
正常情况下可以看到文件类型位以 p 开头,表示这是一个 FIFO。
2. 编写 Reactor 服务端
保存为 event_bus.cpp。代码使用 epoll 监听 FIFO,可读时读取数据,按换行拆分事件。这里没有引入 JSON 解析库,是为了让示例集中在 IPC 和 Reactor 结构上;真实项目中可以在 handle_line 内调用结构化解析器。
cpp
#include <sys/epoll.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <string>
static int checked_open(const char* path, int flags) {
int fd = open(path, flags);
if (fd < 0) {
throw std::runtime_error(std::string("open failed: ") + std::strerror(errno));
}
return fd;
}
static void handle_line(const std::string& line) {
if (line.empty()) return;
std::cout << "event received: " << line << std::endl;
}
int main(int argc, char** argv) {
const char* fifo_path = argc > 1 ? argv[1] : "/tmp/local-bus/events.fifo";
if (mkfifo(fifo_path, 0660) < 0 && errno != EEXIST) {
std::cerr << "mkfifo failed: " << std::strerror(errno) << std::endl;
return 1;
}
int read_fd = checked_open(fifo_path, O_RDONLY | O_NONBLOCK);
int keepalive_fd = checked_open(fifo_path, O_WRONLY | O_NONBLOCK);
int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd < 0) {
std::cerr << "epoll_create1 failed: " << std::strerror(errno) << std::endl;
return 1;
}
epoll_event ev{};
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = read_fd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, read_fd, &ev) < 0) {
std::cerr << "epoll_ctl failed: " << std::strerror(errno) << std::endl;
return 1;
}
std::string pending;
epoll_event events[16];
char buffer[4096];
std::cout << "event bus listening on " << fifo_path << std::endl;
while (true) {
int n = epoll_wait(epfd, events, 16, -1);
if (n < 0) {
if (errno == EINTR) continue;
std::cerr << "epoll_wait failed: " << std::strerror(errno) << std::endl;
break;
}
for (int i = 0; i < n; ++i) {
if (events[i].data.fd != read_fd) continue;
while (true) {
ssize_t bytes = read(read_fd, buffer, sizeof(buffer));
if (bytes > 0) {
pending.append(buffer, static_cast<size_t>(bytes));
size_t pos = 0;
while ((pos = pending.find('\n')) != std::string::npos) {
std::string line = pending.substr(0, pos);
pending.erase(0, pos + 1);
handle_line(line);
}
continue;
}
if (bytes == 0) {
break;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break;
}
std::cerr << "read failed: " << std::strerror(errno) << std::endl;
break;
}
}
}
close(keepalive_fd);
close(read_fd);
close(epfd);
return 0;
}
这里使用了边缘触发 EPOLLET,所以每次收到可读事件后都要循环读取,直到 read 返回 EAGAIN 或 EWOULDBLOCK。如果只读一次就返回主循环,剩余数据可能不会再次触发事件,表现为"偶发卡住"。如果不熟悉边缘触发,可以先改成水平触发:把 ev.events = EPOLLIN | EPOLLET; 改为 ev.events = EPOLLIN;,逻辑更直观,但高并发场景下事件通知次数可能更多。
3. 编译和运行
bash
g++ -std=c++17 -O2 -Wall -Wextra event_bus.cpp -o event_bus
./event_bus /tmp/local-bus/events.fifo
另开一个终端写入事件:
bash
printf '{"type":"deploy","service":"billing","status":"started"}\n' > /tmp/local-bus/events.fifo
printf '{"type":"metric","name":"queue_depth","value":42}\n' > /tmp/local-bus/events.fifo
服务端应输出收到的两行事件。注意示例中的数字只是事件内容的一部分,不代表任何性能测试结果。
4. 用脚本模拟多个生产者
可以用几个后台进程并发写入,观察服务端是否持续处理。生产者只需要知道 FIFO 路径,不需要知道消费者进程号。
bash
for i in 1 2 3; do
(
for j in 1 2 3; do
printf '{"producer":%s,"seq":%s}\n' "$i" "$j" > /tmp/local-bus/events.fifo
sleep 1
done
) &
done
wait
如果要把它放入服务中,建议把 FIFO 路径、运行目录和权限写入配置文件或环境变量。例如:
bash
export LOCAL_BUS_FIFO=/tmp/local-bus/events.fifo
./event_bus "$LOCAL_BUS_FIFO"
本例没有密钥需求;如果事件处理逻辑后续需要访问数据库或外部服务,密钥应从环境变量或专用密钥管理系统读取,不应写入代码、配置仓库或命令历史。
扩展为更像生产的结构
第一,事件处理函数不应在 Reactor 线程里做长时间阻塞操作。当前示例直接 cout,只是演示。真实系统可以把解析后的事件放入内存队列,由工作线程处理落盘、网络请求或计算任务。这样主循环只负责快速读取 fd,避免 FIFO 缓冲区被写满。
第二,需要定义明确的消息协议。JSON Lines 适合可读性优先的场景,但要限制单行最大长度,防止异常生产者写入超大消息拖垮内存。更严格的协议可以采用"4 字节长度 + payload"的长度前缀格式,但实现复杂度会增加。
第三,权限要收紧。FIFO 是文件系统节点,任何有写权限的本地用户都可以投递事件。可以通过专用用户、专用组、chmod 660、受控目录权限来降低误写风险。若事件影响业务状态,还要在消息体中增加签名、来源字段或进程身份校验机制。
第四,要规划失败策略。FIFO 本身不持久化,消费者停止时写入端可能阻塞或失败,已经读出但尚未处理的事件也可能因进程崩溃丢失。如果事件不能丢,应使用消息队列、数据库 outbox、持久化日志或 Unix Domain Socket 加应用层确认机制。FIFO 更适合轻量通知、低成本本机事件汇聚和可接受短暂丢失的辅助通道。
常见问题
FIFO 和普通文件有什么区别?
普通文件保存数据,读写位置由文件偏移控制;FIFO 是内核中的字节流通道,写入的数据被读走后就不存在。它有路径,但不负责持久化。
为什么服务端要打开一个 keepalive 写端?
如果没有任何写者,FIFO 读端可能读到 EOF。守护进程通常不希望因为暂时没有生产者就退出。保留一个内部写端可以让读端保持稳定监听状态,但它不代替业务写入,也不解决持久化问题。
多个进程同时写会不会乱序?
FIFO 保证的是字节流语义,不是完整业务消息队列。对于不超过系统 PIPE_BUF 的单次写入,POSIX 语义下对管道的写入具有原子性;超过该大小或拆成多次写入,就可能与其他写者交错。实践中应控制单条事件长度,并让生产者一次写完整行。
为什么不用一个线程阻塞读取就好?
只有一个 FIFO 时,阻塞读取确实够用。Reactor 的价值在于可扩展:后续如果要同时监听多个 FIFO、Unix Socket、timerfd、signalfd 或控制端口,不需要改成多线程模型,只要把新 fd 注册进事件循环。
这个方案适合替代 Kafka 或 Redis Stream 吗?
不适合。FIFO 没有持久化、消费组、重放、确认、分区或跨主机能力。它适合本机、轻量、低依赖的事件通道。只要需求涉及可靠投递、历史回放或多消费者协调,就应该选择更完整的消息系统。
总结
命名管道解决的是本机进程之间低成本传递字节流的问题,Reactor 解决的是一个线程管理多个事件源的问题。两者结合,可以构建一个简单、清晰、可扩展的本地事件总线:生产者只写 FIFO,消费者用 epoll 统一监听并分发。
这个方案的优势是依赖少、部署轻、易于排查;限制也很明确:不持久化、不适合跨主机、不提供高级消息队列语义。因此,在使用前应先判断事件是否允许丢失、是否需要确认、是否需要多消费者。如果只是把同一台机器上的状态、日志或轻量任务通知汇聚到一个守护进程,FIFO + Reactor 是一个值得掌握的基础工具组合。