第十四章:网络编程——epoll与Reactor模式、非阻塞I/O、协程与libco

网络编程这块,说实话是C/C++高性能服务端开发的核心硬骨头。很多新手写网络程序,上来就是阻塞式socket,一个accept等半天,再来个recv又卡住。嗯,单机玩玩还行,一旦要支撑几千上万的并发连接,立马崩给你看。

我个人习惯把高性能网络编程拆成三个层次:事件通知机制(epoll)、事件处理模型(Reactor)、异步化编程范式(协程)。今天咱们就一层层把它讲透。

14.1 从select/poll到epoll:为什么非它不可?

先说说历史。十年前我刚开始做服务器开发时,用的还是select。select有个硬伤——它最多只能监听1024个文件描述符。你想想看,一个稍微像样的聊天室,几千人在线,select直接就跪了。

poll解决了数量限制,但性能问题还在。每次调用poll,内核都要把整个文件描述符列表从用户态拷贝到内核态,然后线性扫描一遍。连接数一上万,CPU全耗在轮询上了。

epoll的出现,说白了就是解决了两个痛点:

  • 避免无差别轮询:只返回真正就绪的事件,不是全部fd
  • 减少内存拷贝:通过mmap共享内存,省掉用户态和内核态之间的数据搬运

我曾在项目中做过对比测试:同样是10万并发连接,select/poll的CPU占用率飙到90%以上,而epoll只用了不到20%。差距就是这么夸张。

14.2 epoll的三种工作模式

epoll提供了三种触发模式,很多人搞混,我帮你理清楚:

模式 触发方式 典型场景 注意事项
LT(水平触发) 只要fd有数据可读,每次epoll_wait都会返回 简单应用、新手友好 可能会重复通知,需要自己维护读取状态
ET(边缘触发) 仅当fd状态发生变化时才通知一次 高并发、高性能场景 必须配合非阻塞I/O,且要一次性读完
EPOLLONESHOT 事件触发一次后自动从epoll中移除 多线程处理同一连接 处理完需要重新添加

我个人建议:新手先从LT模式入手,逻辑简单不容易出bug。等你对事件驱动模型有感觉了,再切换到ET模式追求极致性能。

我曾经踩过的坑:ET模式下忘记设置socket为非阻塞,结果recv读到一半阻塞住,整个事件循环卡死。排查了整整一个下午才发现问题。记住:ET + 非阻塞,这是铁律。

14.3 Reactor模式:事件驱动的骨架

epoll只是工具,真正让网络编程变得优雅的是Reactor模式。说白了,Reactor就是一个事件分发器:你告诉它“我对这个socket的读事件感兴趣”,然后当数据到达时,它回调你的处理函数。

一个典型的Reactor架构包含这几个角色:

  • Event Handler:事件处理器,你写的业务逻辑
  • Reactor:事件循环,负责调用epoll_wait并分发事件
  • Demultiplexer:多路复用器,就是epoll本身

下面是一个简化版的Reactor核心循环:

// Reactor事件循环核心
void event_loop() {
    while (!stop) {
        int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        for (int i = 0; i < nfds; i++) {
            event_handler* handler = (event_handler*)events[i].data.ptr;
            if (events[i].events & EPOLLIN) {
                handler->on_read();   // 读事件回调
            }
            if (events[i].events & EPOLLOUT) {
                handler->on_write();  // 写事件回调
            }
        }
    }
}

你看,核心逻辑就这么几行。但实际项目中,Reactor的复杂度往往体现在状态管理上——一个连接可能处于读、写、关闭等不同状态,如何优雅地切换,很考验设计功底。

我的经验:Reactor中的回调函数一定要短平快。如果某个回调里做了耗时操作(比如数据库查询),整个事件循环都会被拖慢。解决方案?要么用线程池异步处理,要么用协程。

14.4 非阻塞I/O:别让程序“卡住”

非阻塞I/O和Reactor是天生一对。你想想看,如果socket是阻塞的,调用recv时没有数据,线程就挂起了。那Reactor还怎么处理其他连接?

设置非阻塞很简单:

// 设置socket为非阻塞
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

但非阻塞I/O有个麻烦——你得处理EAGAINEWOULDBLOCK错误。这两个错误码表示“现在没数据,你过会儿再来”。很多新手看到recv返回-1就以为是出错,直接close连接,这是大忌。

正确的做法:recv返回-1时,检查errno。如果是EAGAIN或EWOULDBLOCK,说明数据还没到,继续等待下一次epoll通知。其他错误才真正需要关闭连接。

14.5 协程与libco:让异步代码“看起来”像同步

说到协程,我得先吐槽一句:回调地狱真的让人崩溃。一个复杂的网络请求,可能涉及DNS解析、连接建立、数据收发、业务处理,每个步骤都是异步回调。代码层层嵌套,维护起来想死的心都有。

协程的出现,就是为了解决这个问题。它让你可以用同步的方式写异步代码。腾讯的libco就是C/C++领域非常成熟的协程库。

libco的核心思想是:hook住阻塞的系统调用。当你调用co_connect时,如果连接还没建立,libco会自动让出CPU,切换到其他协程。等连接就绪了,再切换回来继续执行。

看个例子:

// 使用libco的协程版本
void* co_client(void* arg) {
    int fd = co_connect("127.0.0.1", 8080);
    // 这里看起来是同步的,但底层是异步的
    co_write(fd, request, req_len);
    co_read(fd, response, resp_len);
    // 处理响应...
    return NULL;
}

// 创建1000个协程
for (int i = 0; i < 1000; i++) {
    stCoRoutine_t* co = NULL;
    co_create(&co, NULL, co_client, NULL);
    co_resume(co);
}

你看,代码逻辑是线性的,没有回调嵌套。但底层实际上是通过epoll + 非阻塞I/O实现的异步。这就是协程的魅力。

14.6 协程的代价与取舍

不过我得说句实话:协程不是银弹。它有几个代价你需要知道:

  • 栈空间:每个协程默认有128KB的栈,10万个协程就是12.8GB内存。虽然libco支持共享栈,但使用起来有坑。
  • 调度开销:协程切换虽然比线程轻量,但也不是零成本。频繁切换一样会影响性能。
  • 调试困难:协程的调用栈和线程不同,gdb调试时经常看不到完整的调用链。
我曾经踩过的坑:在协程里用了全局变量,结果多个协程同时修改,数据全乱了。记住:协程虽然共享同一个线程的地址空间,但每个协程要有自己独立的上下文。该加锁的地方还是得加锁。

14.7 知识体系总览

下面这张图总结了本章的核心脉络,我建议你把它记在脑子里:

高性能网络编程知识体系 目标:支撑高并发网络服务 事件通知:epoll 事件处理:Reactor 编程范式:协程 epoll核心机制 LT / ET / EPOLLONESHOT mmap共享内存 · 红黑树 Reactor架构 事件分发 · 回调机制 非阻塞I/O · 状态管理 libco协程 hook系统调用 · 栈管理 协程调度 · 共享栈 底层支撑:Linux系统调用 · socket · 文件描述符 · 内存管理

从这张图你能看到,epoll、Reactor、协程三者是层层递进的关系。epoll解决“怎么知道数据来了”,Reactor解决“数据来了怎么处理”,协程解决“处理逻辑怎么写更优雅”。

14.8 实战建议

如果你现在要开始写一个高性能网络服务,我建议你按这个顺序来:

  1. 先用epoll + LT模式搭一个简单的Reactor,跑通基本流程
  2. 加上非阻塞I/O,处理EAGAIN场景
  3. 引入libco,把回调改写成协程风格
  4. 性能调优:调整epoll事件大小、协程栈大小、线程池配置

记住一句话:没有银弹,只有权衡。epoll再快,也架不住业务逻辑写得烂。协程再优雅,也替代不了对网络协议的理解。打好基础,比什么都重要。

最后说一句:我见过太多人一上来就追求“百万并发”,结果连epoll的ET和LT都分不清。先把单机几千连接跑稳了,再去想分布式的事。地基不牢,楼盖得再高也是危房。

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