用 Reactor 和 Linux 命名管道实现一个本地事件总线

问题背景

在很多后端系统里,"本地多个进程把事件交给一个守护进程处理"是很常见的需求。例如:业务进程写审计日志,采集进程上报状态,脚本任务提交执行结果,最终都希望由一个常驻服务统一接收、落盘或转发。

最直接的做法是让每个进程写同一个文件,但这会带来几个问题:并发写入需要考虑追加原子性、日志轮转会影响句柄、消费者很难及时知道新数据到达。也可以使用 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 返回 EAGAINEWOULDBLOCK。如果只读一次就返回主循环,剩余数据可能不会再次触发事件,表现为"偶发卡住"。如果不熟悉边缘触发,可以先改成水平触发:把 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 是一个值得掌握的基础工具组合。

相关推荐
IT_Octopus1 小时前
Spring Boot 线程池关闭:destroyMethod 的作用与最佳实践
java·spring boot·后端
一只叫煤球的猫2 小时前
ThreadForge 源码解读三:从任务执行到并发编排,ScopeJoiner 是怎么工作的?
后端·性能优化·开源
无限压榨切图仔2 小时前
从 Claude Code 切到 Codex:我用 Agent、Skills、MCP 做完了一个内容运营工具
前端·后端
董员外2 小时前
RAG 系统进化论(一):纵览 RAG 的发展历程
前端·人工智能·后端
Yao8062 小时前
MyBatis-Plus LambdaQueryWrapper实战:告别手写SQL
后端
街头小霸王6262 小时前
微服务项目common配置文件模板
后端
Conan在掘金2 小时前
ArkTS 进阶之道(17):@Extend 专属属性扩展边界——为啥能收 fontSize 专属属性
后端
霸道流氓气质3 小时前
SpringBoot中事务内同步处理 + 事务后异步调用外部系统的通用模式示例
java·spring boot·后端
AskHarries4 小时前
用户埋点怎么设计
后端