【C/C++】用 epoll 写一个 Reactor:连接对象、回调和状态机

【C/C++】用 epoll 写一个 Reactor:连接对象、回调和状态机

1. Reactor 解决了什么问题

epoll 版本里,主循环通常会写成这样:

c 复制代码
if (events[i].data.fd == sockfd) {
    accept(...);
} else {
    recv(...);
    send(...);
}

这种写法适合演示 API,但业务一复杂,主循环会越来越臃肿。比如 HTTP 要分"响应头"和"响应体";WebSocket 要分"握手阶段"和"帧数据阶段";长响应还要处理一次 send() 没写完的情况。

Reactor 模式的核心思路是:主循环只负责等事件和分发事件,真正的业务处理放到回调函数里。

本项目的 reactor.c 已经体现了这个结构:

  • epoll_wait() 等待事件。
  • 监听 fd 触发 accept_cb()
  • 客户端 fd 读事件触发 recv_cb()
  • 客户端 fd 写事件触发 send_cb()
  • 每个连接的数据缓冲、写偏移、状态都放在 connections[fd] 里。

2. connection:把连接上下文集中管理

server.h 里的 struct connection 是整个 Reactor 的核心数据结构:

c 复制代码
#define BUFFER_SIZE 1024

typedef int (*callback_t)(int fd);

struct connection
{
    int fd;

    char rbuffer[BUFFER_SIZE];
    int rlength;

    char wbuffer[BUFFER_SIZE];
    int wlength;
    int woffset;

    callback_t send_callback;
    union
    {
        callback_t recv_callback;
        callback_t accept_callback;
    } rcallback;

    FILE *fp;
    long file_size;
    long file_offset;

    char payload[BUFFER_SIZE];
    int payload_length;

    int state;
};

这里有几个字段很关键:

  • rbuffer/rlength:保存本次读到的数据。
  • wbuffer/wlength/woffset:保存待发送数据和当前发送偏移。
  • recv_callback/send_callback:把事件和处理函数绑定起来。
  • state:给 HTTP 或 WebSocket 这种分阶段协议使用。
  • fp/file_offset/file_size:用于 HTTP 大文件响应分块发送。

项目里直接用 connections[fd] 作为连接表,这样通过 fd 可以 O(1) 找到连接上下文。

3. 事件注册:epoll_ctl 封装成 set_event

reactor.cepoll_ctl() 封装成了 set_event()

c 复制代码
int set_event(int fd, uint32_t events, int opt)
{
    struct epoll_event ev;
    ev.events = events;
    ev.data.fd = fd;
    if (epoll_ctl(epoll_fd, opt, fd, &ev) < 0)
    {
        perror("epoll_ctl");
        close(fd);
        return -1;
    }
    return 0;
}

这样添加、修改、删除事件都可以复用同一个函数:

c 复制代码
set_event(client_fd, EPOLLIN, EPOLL_CTL_ADD);
set_event(fd, EPOLLOUT, EPOLL_CTL_MOD);
set_event(fd, EPOLLIN, EPOLL_CTL_DEL);

在 Reactor 中,事件不是一次性写死的。比如读到请求后,业务生成了响应数据,就应该把连接从"监听可读"切换到"监听可写"。

4. event_register:绑定 fd、事件和回调

新连接建立后,项目通过 event_register() 初始化连接上下文:

c 复制代码
int event_register(int fd, uint32_t events, callback_t recv_callback, callback_t send_callback)
{
    if (set_event(fd, events, EPOLL_CTL_ADD) < 0)
    {
        return -1;
    }

    connections[fd].fd = fd;
    connections[fd].rcallback.recv_callback = recv_cb;
    connections[fd].send_callback = send_cb;

    memset(connections[fd].rbuffer, 0, BUFFER_SIZE);
    connections[fd].rlength = 0;

    memset(connections[fd].wbuffer, 0, BUFFER_SIZE);
    connections[fd].wlength = 0;
    connections[fd].woffset = 0;

    connections[fd].fp = NULL;
    connections[fd].file_offset = 0;
    connections[fd].file_size = 0;
    connections[fd].payload_length = 0;
    connections[fd].state = 0;

    if (events & EPOLLIN)
    {
        connections[fd].rcallback.recv_callback = recv_callback;
    }

    if (events & EPOLLOUT)
    {
        connections[fd].send_callback = send_callback;
    }
    return 0;
}

这段代码做了三件事:

  1. 把 fd 加入 epoll。
  2. 初始化连接的读写缓存和状态。
  3. 绑定读写回调函数。

5. 主循环只做事件分发

Reactor 的主循环不再直接写业务逻辑,而是判断 fd 类型和事件类型,然后调用对应回调:

c 复制代码
while (1)
{
    int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
    if (n < 0)
    {
        perror("epoll_wait");
        break;
    }

    for (int i = 0; i < n; i++)
    {
        int fd = events[i].data.fd;
        if (find_server_fd(fd) != -1)
        {
            connections[fd].rcallback.accept_callback(fd);
        }
        else
        {
            if (events[i].events & EPOLLIN)
            {
                connections[fd].rcallback.recv_callback(fd);
            }
            if (events[i].events & EPOLLOUT)
            {
                connections[fd].send_callback(fd);
            }
        }
    }
}

这种结构的好处是清晰:事件循环是事件循环,协议处理是协议处理,两者不混在一起。

6. recv_cb 和 send_cb:读写事件如何切换

读事件回调把数据读入 rbuffer,然后交给业务函数处理。当前项目里接入的是 WebSocket:

c 复制代码
int recv_cb(int fd)
{
    ssize_t bytes_read = recv(fd, connections[fd].rbuffer, BUFFER_SIZE, 0);
    if (bytes_read <= 0)
    {
        set_event(fd, EPOLLIN, EPOLL_CTL_DEL);
        close(fd);
        return -1;
    }

    connections[fd].rlength = bytes_read;

    websocket_request(&connections[fd]);
    set_event(fd, EPOLLOUT, EPOLL_CTL_MOD);

    return 0;
}

当业务处理后需要响应客户端,就把事件改成 EPOLLOUT。写事件回调负责把 wbuffer 中的数据写出去:

c 复制代码
ssize_t bytes_sent = send(fd, connections[fd].wbuffer + connections[fd].woffset,
                          connections[fd].wlength - connections[fd].woffset, MSG_NOSIGNAL);

connections[fd].woffset += bytes_sent;
if (connections[fd].woffset >= connections[fd].wlength)
{
    connections[fd].woffset = 0;
    connections[fd].wlength = 0;
}

这里的 woffset 很重要。真实网络里一次 send() 不一定能把所有数据写完,必须记录已经写了多少。

7. 状态机示例:HTTP 图片响应

webserver.c 展示了另一个典型业务:HTTP 返回一张 c1000k.jpg。它把响应拆成两个阶段:

c 复制代码
if (conn->state == 0)
{
    conn->fp = fopen("c1000k.jpg", "r");
    fseek(conn->fp, 0, SEEK_END);
    conn->file_size = ftell(conn->fp);
    fseek(conn->fp, 0, SEEK_SET);
    conn->file_offset = 0;

    int n = sprintf(conn->wbuffer,
                    "HTTP/1.1 200 OK\r\n"
                    "Content-Type: image/jpeg\r\n"
                    "Content-Length: %ld\r\n\r\n",
                    conn->file_size);

    conn->wlength = n;
    conn->state = 1;
}
else if (conn->state == 1)
{
    int n = fread(conn->wbuffer, 1, BUFFER_SIZE, conn->fp);
    conn->wlength = n;
    conn->file_offset += n;
}

state == 0 时准备响应头,state == 1 时分块读取图片内容。这个例子说明 Reactor 不是只能处理简单 echo,它能自然承载"多次读写才能完成"的协议。

8. 编译运行

当前 reactor.c 中接入的是 WebSocket 业务:

bash 复制代码
gcc reactor.c websocket.c -o websocket -lssl -lcrypto
./websocket

服务端默认监听 8080:

text 复制代码
Server is listening on port 8080

如果你要把 HTTP 业务也接进 Reactor,可以把 recv_cb() / send_cb() 中的业务函数从 websocket_request() / websocket_response() 替换或抽象成可配置回调,再链接 webserver.c

9. 小结

Reactor 的核心不是某个 API,而是一种代码组织方式:

  • epoll 负责发现事件。
  • Reactor 主循环负责分发事件。
  • callback 负责处理事件。
  • connection 保存每个连接的上下文。
  • state 负责表达协议阶段。

学习链接: https://github.com/0voice