第十四章:网络编程——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模式追求极致性能。
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的复杂度往往体现在状态管理上——一个连接可能处于读、写、关闭等不同状态,如何优雅地切换,很考验设计功底。
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有个麻烦——你得处理EAGAIN和EWOULDBLOCK错误。这两个错误码表示“现在没数据,你过会儿再来”。很多新手看到recv返回-1就以为是出错,直接close连接,这是大忌。
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解决“怎么知道数据来了”,Reactor解决“数据来了怎么处理”,协程解决“处理逻辑怎么写更优雅”。
14.8 实战建议
如果你现在要开始写一个高性能网络服务,我建议你按这个顺序来:
- 先用epoll + LT模式搭一个简单的Reactor,跑通基本流程
- 加上非阻塞I/O,处理EAGAIN场景
- 引入libco,把回调改写成协程风格
- 性能调优:调整epoll事件大小、协程栈大小、线程池配置
记住一句话:没有银弹,只有权衡。epoll再快,也架不住业务逻辑写得烂。协程再优雅,也替代不了对网络协议的理解。打好基础,比什么都重要。
公众号:蓝海资料掘金营,微信deep3321