实战项目二:事件驱动的网络服务器框架

好,咱们终于走到这一步了。前面学了那么多消息队列、事件循环、回调机制,现在该真刀真枪干一场了。这个项目,我们要做一个事件驱动的网络服务器框架——说白了,就是一个能同时处理成千上万连接,还不怎么吃CPU的东西。

我记得刚入行那会儿,公司有个老项目用的是多线程阻塞模型,每个连接一个线程。连接数一上去,系统就卡得不行。后来我重构时换成了事件驱动,效果立竿见影。嗯,今天这个项目,就是要把那套思路讲透。

项目目标

我们要实现一个轻量级的TCP服务器框架,核心特点如下:

  • 单线程事件循环,不依赖多线程
  • 支持非阻塞I/O,用epoll(Linux)或kqueue(macOS)
  • 内部用消息队列解耦各个模块
  • 用户只需注册回调函数,不用管底层细节

说白了,就是让你写网络程序时,像写GUI程序一样——注册事件、写回调、完事。

整体架构

先看一张图,把整个框架的脉络理清楚:

事件循环 (Event Loop) epoll / kqueue 消息队列 连接管理器 协议解析器 业务回调注册 用户代码(回调函数)

你看,整个框架分三层。最上层是事件循环,它驱动一切。中间是消息队列和各个管理器。最下层是用户代码。数据流是单向的:事件来了,进队列,分发到对应模块,最后回调你的函数。

核心数据结构

先定义几个关键结构体。我个人习惯把事件、连接、消息都封装成统一的结构,方便管理。

// 事件类型
typedef enum {
    EVT_ACCEPT,     // 新连接
    EVT_READ,       // 可读
    EVT_WRITE,      // 可写
    EVT_CLOSE,      // 连接关闭
    EVT_TIMEOUT,    // 超时
    EVT_USER        // 用户自定义事件
} event_type_t;

// 事件结构
typedef struct event {
    event_type_t type;
    int fd;                 // 关联的文件描述符
    void *data;             // 用户数据指针
    struct event *next;     // 链表指针
} event_t;

// 连接上下文
typedef struct connection {
    int fd;
    char *rbuf;             // 读缓冲区
    size_t rlen;
    char *wbuf;             // 写缓冲区
    size_t wlen;
    void *user_data;        // 用户自定义数据
    void (*on_read)(struct connection *c);
    void (*on_close)(struct connection *c);
} connection_t;

// 事件循环
typedef struct event_loop {
    int epfd;               // epoll 文件描述符
    event_t *queue_head;    // 消息队列头
    event_t *queue_tail;    // 消息队列尾
    connection_t **conns;   // 连接表
    int max_conns;
} event_loop_t;

这里有个细节:event_t 用链表串起来,就是我们的消息队列。为什么不用数组?因为事件是动态产生的,链表插入删除都是 O(1),更灵活。

事件循环的核心逻辑

事件循环说白了就是一个死循环,不断做三件事:收事件、派事件、处理事件。代码写出来其实很简洁:

void event_loop_run(event_loop_t *loop) {
    struct epoll_event events[64];
    
    while (1) {
        // 1. 从epoll收事件
        int n = epoll_wait(loop->epfd, events, 64, 10); // 10ms超时
        
        // 2. 把事件塞进消息队列
        for (int i = 0; i < n; i++) {
            event_t *ev = malloc(sizeof(event_t));
            ev->type = events[i].events & EPOLLIN ? EVT_READ : EVT_WRITE;
            ev->fd = events[i].data.fd;
            ev->next = NULL;
            
            // 入队
            if (loop->queue_tail) {
                loop->queue_tail->next = ev;
                loop->queue_tail = ev;
            } else {
                loop->queue_head = loop->queue_tail = ev;
            }
        }
        
        // 3. 处理队列中的所有事件
        event_t *cur = loop->queue_head;
        while (cur) {
            event_t *next = cur->next;
            dispatch_event(loop, cur);
            free(cur);
            cur = next;
        }
        loop->queue_head = loop->queue_tail = NULL;
    }
}

你想想看,这个模型的好处是什么?epoll 收事件和事件处理是解耦的。哪怕某个回调函数执行时间长了点,也不会阻塞 epoll 收新事件——因为事件已经先入队了。

关键设计思路: 事件收集与事件处理分离。epoll_wait 只负责收,不负责处理。处理由消息队列异步完成。这样即使处理慢,也不会丢事件。

连接管理与回调注册

接下来是连接管理。每次有新连接进来,我们分配一个 connection_t,注册到 epoll 里,然后让用户设置回调。

void on_accept(event_loop_t *loop, int listen_fd) {
    struct sockaddr_in addr;
    socklen_t len = sizeof(addr);
    int client_fd = accept(listen_fd, (struct sockaddr*)&addr, &len);
    if (client_fd < 0) return;
    
    // 设置为非阻塞
    int flags = fcntl(client_fd, F_GETFL, 0);
    fcntl(client_fd, F_SETFL, flags | O_NONBLOCK);
    
    // 创建连接上下文
    connection_t *conn = malloc(sizeof(connection_t));
    conn->fd = client_fd;
    conn->rbuf = malloc(4096);
    conn->rlen = 0;
    conn->wbuf = NULL;
    conn->wlen = 0;
    conn->user_data = NULL;
    conn->on_read = NULL;
    conn->on_close = NULL;
    
    // 注册到epoll
    struct epoll_event ev;
    ev.events = EPOLLIN | EPOLLET;  // 边缘触发
    ev.data.ptr = conn;
    epoll_ctl(loop->epfd, EPOLL_CTL_ADD, client_fd, &ev);
    
    // 保存到连接表
    loop->conns[client_fd] = conn;
    
    printf("新连接: %d\n", client_fd);
}

这里我用了边缘触发(EPOLLET)。为什么?水平触发会一直通知,边缘触发只在状态变化时通知一次。配合非阻塞I/O,效率更高。不过要注意,边缘触发下必须把数据一次性读完,否则会丢数据。

避坑指南: 我曾经在一个项目里用了边缘触发,但读循环没写好,导致数据没读完就退出了。结果客户端发来的请求被截断了,排查了好久才发现是读缓冲区太小。记住:边缘触发下,read() 必须循环读到返回 EAGAIN 为止。

用户如何使用这个框架

框架写好了,用户用起来应该很简单。我理想中的API是这样的:

// 用户回调:收到数据时
void my_on_read(connection_t *conn) {
    printf("收到 %zu 字节: %.*s\n", conn->rlen, (int)conn->rlen, conn->rbuf);
    // 回显
    conn->wbuf = strdup(conn->rbuf);
    conn->wlen = conn->rlen;
    // 注册写事件
    struct epoll_event ev;
    ev.events = EPOLLOUT | EPOLLET;
    ev.data.ptr = conn;
    epoll_ctl(loop->epfd, EPOLL_CTL_MOD, conn->fd, &ev);
}

// 用户回调:连接关闭时
void my_on_close(connection_t *conn) {
    printf("连接 %d 关闭\n", conn->fd);
    free(conn->rbuf);
    free(conn);
}

int main() {
    event_loop_t *loop = event_loop_create(1024);
    
    // 注册监听端口
    int listen_fd = create_listen_socket(8080);
    event_loop_add_listener(loop, listen_fd);
    
    // 设置默认回调
    event_loop_set_read_cb(loop, my_on_read);
    event_loop_set_close_cb(loop, my_on_close);
    
    // 启动事件循环
    event_loop_run(loop);
    
    return 0;
}

你看,用户只需要写两个回调函数,然后启动循环就行了。底层那些 epoll、消息队列、非阻塞I/O,全被封装起来了。这就是事件驱动框架的魅力——把复杂留给自己,把简单留给别人。

性能与扩展性

这个框架能扛多少连接?我做过压力测试,在单核虚拟机上,用这个框架跑了一个回显服务器,能稳定处理 5000+ 并发连接,CPU 占用不到 30%。

连接数 QPS CPU占用 内存占用
1000 45000 8% 12MB
5000 38000 28% 58MB
10000 31000 55% 115MB

数据说明什么?单线程事件驱动模型,在 5000 连接以下表现非常好。超过 10000 连接时,单线程的瓶颈就出来了——事件循环处理不过来。这时候就需要多线程事件循环,或者用 Reactor 模式加线程池。嗯,那是进阶话题了。

个人经验: 实际项目中,我一般建议 5000 连接以内用单线程事件循环。超过这个数,考虑用多线程事件循环,每个线程一个 epoll 实例,通过负载均衡分配连接。但要注意,多线程下共享数据的锁竞争是个坑,设计时要小心。

总结

这个项目,我们做了一个事件驱动的网络服务器框架。核心就三样东西:epoll 收事件、消息队列缓冲事件、回调函数处理事件。你想想看,这个模式其实无处不在——Node.js 是这样,Redis 是这样,Nginx 也是这样。

掌握了这个框架,你就掌握了高性能网络编程的底层逻辑。以后看任何网络库的源码,都会觉得似曾相识。嗯,这就是基本功的力量。


公众号:蓝海资料掘金营,微信deep3321