{T}

主从Reactor模式:epoll与多线程

在前面的第 27 讲和第 28 讲中,我们介绍了基于 poll 事件分发的 Reactor 反应堆模式以及主从 Reactor(Master-Slave Reactor)模式。与 poll 相比,Linux 提供的 epoll 是一种更为高效的事件分发机制。本文将切换到基于 epoll 实现的主从 Reactor 模式,并深入分析 epoll 相较于 poll 等传统事件分发机制的性能优势。

主从 Reactor 与 epoll 架构概览

在进入实现细节之前,先从架构层面理解主从 Reactor 模式与 epoll 的结合方式。

图表渲染中…

如图所示,主反应堆线程(Main Reactor)专注于监听套接字上的连接建立事件,而已连接套接字的 I/O 读写事件则由多个从反应堆线程(Sub-Reactor)负责处理。这种分工模式使得连接建立与数据处理互不阻塞,充分发挥多核 CPU 的并行处理能力。

如何切换到 epoll

所有代码已放置于 GitHub,可自行查看或下载。

该网络编程框架同时支持 poll 和 epoll 两种事件分发机制。切换至 epoll 的关键位于 lib/event_loop.c 文件中的 event_loop_init_with_name 函数,通过宏 EPOLL_ENABLE 控制选择哪种分发机制。

c
struct event_loop *event_loop_init_with_name(char *thread_name) {
  ...
#ifdef EPOLL_ENABLE
    yolanda_msgx("set epoll as dispatcher, %s", eventLoop->thread_name);
    eventLoop->eventDispatcher = &epoll_dispatcher;
#else
    yolanda_msgx("set poll as dispatcher, %s", eventLoop->thread_name);
    eventLoop->eventDispatcher = &poll_dispatcher;
#endif
    eventLoop->event_dispatcher_data = eventLoop->eventDispatcher->init(eventLoop);
    ...
}

在根目录的 CMakeLists.txt 中,通过 CheckSymbolExists 模块检测系统中是否存在 epoll_create 函数和 sys/epoll.h 头文件,自动决定是否开启 EPOLL_ENABLE 宏。

cmake
# check epoll and add config.h for the macro compilation
include(CheckSymbolExists)
check_symbol_exists(epoll_create "sys/epoll.h" EPOLL_EXISTS)
if (EPOLL_EXISTS)
    #    Linux 下设置为 epoll
    set(EPOLL_ENABLE 1 CACHE INTERNAL "enable epoll")
 
    #    Linux 下也设置为 poll
    #    set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
else ()
    set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
endif ()

为使编译器识别该宏,需通过 configure_file 命令将宏值写入 config.h 文件(基于模板 config.h.cmake),并通过 include_directories 确保编译器能找到该头文件。

cmake
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/config.h.cmake
        ${CMAKE_CURRENT_BINARY_DIR}/include/config.h)
 
include_directories(${CMAKE_CURRENT_BINARY_DIR}/include)

如此配置后,在 Linux 环境下默认使用 epoll 作为事件分发机制。

若需强制使用 poll(例如用于对比测试),可修改 CMakeLists.txt,将 Linux 分支下的 EPOLL_ENABLE 设为空:

cmake
# check epoll and add config.h for the macro compilation
include(CheckSymbolExists)
check_symbol_exists(epoll_create "sys/epoll.h" EPOLL_EXISTS)
if (EPOLL_EXISTS)
    #    Linux 下也设置为 poll
     set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
else ()
    set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
endif ()

样例程序

样例程序与第 28 讲逻辑一致,仅底层事件分发机制从 poll 切换为 epoll。

c
#include <lib/acceptor.h>
#include "lib/common.h"
#include "lib/event_loop.h"
#include "lib/tcp_server.h"
 
char rot13_char(char c) {
    if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
        return c + 13;
    else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
        return c - 13;
    else
        return c;
}
 
// 连接建立之后的 callback
int onConnectionCompleted(struct tcp_connection *tcpConnection) {
    printf("connection completed\n");
    return 0;
}
 
// 数据读到 buffer 之后的 callback
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
    printf("get message from tcp connection %s\n", tcpConnection->name);
    printf("%s", input->data);
 
    struct buffer *output = buffer_new();
    int size = buffer_readable_size(input);
    for (int i = 0; i < size; i++) {
        buffer_append_char(output, rot13_char(buffer_read_char(input)));
    }
    tcp_connection_send_buffer(tcpConnection, output);
    return 0;
}
 
// 数据通过 buffer 写完之后的 callback
int onWriteCompleted(struct tcp_connection *tcpConnection) {
    printf("write completed\n");
    return 0;
}
 
// 连接关闭之后的 callback
int onConnectionClosed(struct tcp_connection *tcpConnection) {
    printf("connection closed\n");
    return 0;
}
 
int main(int c, char **v) {
    // 主线程 event_loop
    struct event_loop *eventLoop = event_loop_init();
 
    // 初始化 acceptor
    struct acceptor *acceptor = acceptor_init(SERV_PORT);
 
    // 初始化 tcp_server,线程数为 4,表示 1 个 acceptor 线程 + 4 个 I/O 线程
    // 每个 I/O 线程自带一个 event_loop
    struct TCPserver *tcpServer = tcp_server_init(eventLoop, acceptor, onConnectionCompleted, onMessage,
                                                  onWriteCompleted, onConnectionClosed, 4);
    tcp_server_start(tcpServer);
 
    // main thread for acceptor
    event_loop_run(eventLoop);
}

缓冲区对象(Buffer)的设计意义

此前未详细展开的部分是缓冲区对象 buffer,这也是网络编程框架应考虑的核心组件。

框架的目标是对应用程序封装套接字的读写细节,转而提供基于 buffer 对象的读写操作。从套接字接收数据、处理异常、发送数据等操作均由 buffer 对象封装和屏蔽,应用程序仅需从 buffer 中获取已接收的字节流进行应用层处理,例如通过 buffer_read_char 逐字节读取。

同理,框架也必须提供基于 buffer 的套接字发送接口:应用程序先生成 buffer 对象,将编码后的数据填入,再调用 tcp_connection_send_buffer 将 buffer 中的数据通过套接字发送。

回调函数与线程模型

onMessageonConnectionClosed 等回调函数运行在 Sub-Reactor 线程中,即生成 buffer 对象和执行 encode 的代码均在 Sub-Reactor 线程中执行。回调函数本身仅提供 Handler 处理逻辑,具体执行由事件分发线程(event loop 线程)发起。

框架通过一层抽象,使应用程序开发者只需关注回调函数,回调函数中的对象也是 buffer 和 tcp_connection 等封装后的对象,套接字、字节流等底层实现细节完全由框架处理。

样例程序运行结果

启动服务器后,屏幕输出显示已使用 epoll 作为事件分发器:

plaintext
$./epoll-server-multithreads
[msg] set epoll as dispatcher, main thread
[msg] add channel fd == 5, main thread
[msg] set epoll as dispatcher, Thread-1
[msg] add channel fd == 9, Thread-1
[msg] event loop thread init and signal, Thread-1
[msg] event loop run, Thread-1
[msg] event loop thread started, Thread-1
[msg] set epoll as dispatcher, Thread-2
[msg] add channel fd == 12, Thread-2
[msg] event loop thread init and signal, Thread-2
[msg] event loop run, Thread-2
[msg] event loop thread started, Thread-2
[msg] set epoll as dispatcher, Thread-3
[msg] add channel fd == 15, Thread-3
[msg] event loop thread init and signal, Thread-3
[msg] event loop run, Thread-3
[msg] event loop thread started, Thread-3
[msg] set epoll as dispatcher, Thread-4
[msg] add channel fd == 18, Thread-4
[msg] event loop thread init and signal, Thread-4
[msg] event loop run, Thread-4
[msg] event loop thread started, Thread-4
[msg] add channel fd == 6, main thread
[msg] event loop run, main thread

开启多个 telnet 客户端连接服务器并进行交互:

plaintext
$telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
fafaf
snsns
^]
 
telnet> quit
Connection closed.

服务端输出显示 epoll_wait 不断返回处理 I/O 事件。其中主线程的 epoll_wait 仅处理 acceptor 套接字的事件(连接建立),Sub-Reactor 线程的 epoll_wait 处理已连接套接字的读写事件。

epoll 性能分析

epoll 的性能优势可从以下两个维度深入分析。

图表渲染中…

维度一:事件集合管理

每次使用 poll 或 select 之前,都需要准备一个感兴趣的事件集合,系统内核拿到该集合后在内核空间构建相应的数据结构完成注册。而 epoll 维护了一个全局的事件集合(通过红黑树实现),通过 epoll 句柄可对该集合进行增加、删除或修改操作。在绝大多数场景下,事件集合的变化幅度有限,epoll 无需每次重新扫描和构建内核数据结构,显著减少了内核与用户空间之间的数据拷贝和内存分配开销。

维度二:就绪列表(Ready List)

每次调用 poll 或 select 后,应用程序需要扫描整个感兴趣的事件集合,从中找出真正活跃的事件。当该列表增长到 10K 以上时,每次扫描的时间损耗极为可观,而实际活跃事件可能仅有寥寥数个。epoll 则直接返回活跃事件列表,应用程序免除了大量无效扫描的时间开销。

边缘触发(Edge-Triggered)与条件触发(Level-Triggered)

epoll 还提供了更高级的能力——边缘触发(Edge-Triggered,ET)。第 23 讲通过直观的示例讲解了边缘触发和条件触发的区别。

以下通过一个具体场景进一步说明:

  • 若某个套接字有 100 字节可读,边缘触发和条件触发都会产生 read ready notification 事件。
  • 若应用程序只读取了 50 字节:
    • 边缘触发(ET):不再产生新的事件通知,直到有新的数据到达。
    • 条件触发(LT):因为还有 50 字节未读取,会持续产生 read ready notification 事件。

在条件触发模式下,若某个套接字缓冲区可写,会无限次返回 write ready notification 事件。若应用程序未准备好发送数据,必须解除该套接字上的 ready notification 事件注册,否则将导致 CPU 空转。

内核版本注记:epoll 的 ET 和 LT 模式自 Linux 2.5.44 引入,在 Linux 7.0 中行为保持不变。EPOLLEXCLUSIVE 标志自 Linux 4.5 引入,用于避免惊群效应(thundering herd)。

性能对比总结

特性poll / selectepoll
事件集合管理每次调用传入完整集合红黑树持久维护
内核数据结构构建每次调用重新构建增量更新
就绪事件返回返回全部集合,应用自行过滤仅返回活跃事件
时间复杂度O(n)O(1)(活跃事件数)
最大 fd 数限制FD_SETSIZE 限制(select)无硬性限制
触发模式仅条件触发(LT)支持 LT 和 ET
适用场景少量连接大量并发连接(C10K+)

总结

本文将程序框架从 poll 切换至 epoll 版本。与 poll 版本相比,仅底层框架进行了更改,上层应用程序无需任何修改——这正是框架抽象的价值所在。与 poll 相比,epoll 从事件集合管理和就绪列表两个维度提升了程序性能,是 Linux 下高性能网络程序的首选事件分发机制。

结合多线程的主从 Reactor 模式,epoll 实现了连接建立与 I/O 处理的分离,充分发挥了多核 CPU 的并行处理能力,为构建高性能网络服务器奠定了基础。

思考题

  1. 阐述对边缘触发(Edge-Triggered)和条件触发(Level-Triggered)工作原理的理解,以及在何种场景下应选择哪种模式。
  2. 对于边缘触发和条件触发两种模式,onMessage 回调函数的处理逻辑需要注意哪些差异?

版本信息

  • 更新日期:2026-06-09
  • 目标内核:Linux 7.0
  • 关键 API 版本:epoll 自 Linux 2.5.44 引入;EPOLLEXCLUSIVE 自 Linux 4.5 引入;epoll_create1 自 Linux 2.6.27 引入