1. 项目概述:为什么我需要自己写一个并发服务器
看到“基于epoll实现的tcp并发服务器”这个标题,估计不少朋友第一反应是:这不就是Linux网络编程的经典作业题吗?网上一搜一大把,有什么好写的。
说实话,我最初也是这么想的。直到我在实际业务里被几个坑折腾得够呛——连接数一上来莫名其妙丢消息、客户端频繁掉线、CPU忽高忽低——才意识到,把epoll服务器写出个能跑的demo是一回事,写出一个能扛住真实流量、稳定运行几个月的服务器是另一回事。
这个项目核心要做的,就是基于Linux平台的epoll事件驱动模型,实现一个TCP并发服务器。它要解决什么问题?最直接的就是高并发连接下的IO处理瓶颈。传统的多线程连接模型,每来一个客户端就开一个线程,连接数到几百上千时线程切换开销和内存占用会非常难看。epoll把这件事彻底改掉了,它让服务器用单线程就能同时管理成千上万个TCP连接,只有在连接上有数据可读、可写时才去处理,其余时间内核帮你盯着。
适合谁来学?我觉得两类人最需要。一类是刚接触Linux网络编程的学生或者转行的朋友,通过这个项目把socket编程、TCP状态、epoll事件机制完整地串起来;另一类是在做后端服务、嵌入式网关、小型业务服务器、私有协议网关的开发者,可能你手头用的框架封得太死,出了问题查不透,自己实现一遍心里才有底。这篇文章我尽量按我实际写的版本来讲,从原理到代码再到排查问题,把能踩的坑都提前给你标出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体设计思路与技术选型:为什么偏偏是epoll
2.1 并发模型怎么选才合理
设计一个TCP并发服务器,摆在面前的第一道选择题就是:并发模型用哪种。
早年最常见的做法是多线程模型——主线程accept连接,每来一个连接就创建一个线程去处理。这种做法实现简单,思路直白,就像餐馆里每来一桌客人就安排一个服务员全程伺候。问题也很明显:当客人(连接)多了以后,服务员(线程)数量暴增,光切换上下文就让厨房(CPU)疲于奔命。线程本身还有默认8MB的栈空间(取决于配置),一千个连接光线程栈就是8GB的虚拟内存开销。实际上跑个几百连接还能凑合,上万连接直接不现实。
epoll代表的是另一条路线——事件驱动模型。它相当于餐馆里改成了叫号系统:只有客人喊“点菜”“结账”的时候服务员才过去,没喊的时候就一个人盯着叫号屏,同时服务几百桌。内核替你盯住所有socket,哪个有数据了通知你一下,你再去处理那一个,全程不阻塞。这是目前Linux下高并发网络服务器的主流方案,nginx、redis、nodejs底层都是类似思路。
2.2 三种IO多路复用机制对比
在具体写代码之前,务必要把select、poll、epoll三者的区别想明白,因为这也直接影响你后续做技术方案时怎么跟别人解释。
select的局限在三个地方:第一,它对单个进程能监视的文件描述符数量有上限,默认1024,靠改FD_SETSIZE重新编译才能放大,但性能随之下降;第二,每次调用select都要把整个fd集合从用户态拷贝到内核态,返回后又得把整个集合拷回来,光是这份拷贝在连接数多的时候就消耗巨大;第三,内核不会告诉你到底是哪个fd就绪,你只能把所有fd遍历一遍,O(n)的复杂度,连接越多越吃力。
poll解决了第一个问题,把fd数量上限去掉了,但后面两个老毛病还在。它的模型和select本质上一样,就是拿一个“你有没有事”的清单,每次轮询所有连接,仍然要全部扫描一遍。
epoll则是把路子改成了“回调通知”——你注册感兴趣的socket和事件,内核维护一棵红黑树来做增删查,另一个就绪链表记录真正有事件发生的fd。当你调用epoll_wait时,内核直接把这个就绪链表拷贝给你,你只需要处理真正活跃的连接。数据量越大,优势越明显,性能曲线基本是平缓的,这就是它能支持百万级连接的根本原因。
2.3 什么场景下不适合epoll
这里必须先泼一盆冷水,免得你盲目迷恋epoll。如果你要写的是一个只服务三五个客户端的工具型程序,epoll完全没必要。select轮询那点损耗在几十个连接下根本感觉不到,反而代码写起来更简单。另外,如果你的IO操作本身就是耗时操作——比如每个连接要读一个大文件或者要等待外部API响应——即使epoll告诉你“可读”,你读完之后依然要阻塞在处理逻辑上,这种情况下单纯用epoll并不能提升吞吐量,必须配合多线程或者多进程把耗时逻辑并行化。
所以这个项目里我采用的技术路线是:epoll负责IO事件管理,线程池处理业务逻辑。后面第4章我会详细讲这个组合是怎么落地的。
3. epoll内部机制详解:搞清楚底层才好写代码
3.1 epoll在内核中到底保存了什么
不理解epoll的三个关键数据结构,你写代码就是照葫芦画瓢。我建议你脑子里有一个画面:
- 红黑树:所有你调用epoll_ctl注册过的socket fd都存在这棵树上,用来快速增删改查。当你调用epoll_ctl把一个新的fd加进来时,内核就是在往这棵红黑树里插入一个节点。
- 就绪链表:当某个fd上有你关注的事件发生时,内核通过回调机制把这个fd对应的节点链接到就绪链表里,这个链表里的节点就是“有动静”的。
- 等待队列:在epoll_wait中睡眠的进程会被挂到等待队列上。当就绪链表非空时,内核会唤醒等待队列中的进程,把数据拷贝到用户态。
这就是epoll的三个关键对象:eventpoll实例本身、红黑树上的epitem节点、以及就绪链表。回忆一下你调用epoll_create时,内核做的就是在创建eventpoll结构体;调用epoll_ctl就是在操作红黑树;调用epoll_wait就是检查就绪链表有没有东西,有就返回,没有就睡眠等待中断唤醒。
这套机制带来的好处有两个:一是你不需要再每次把几百上千个fd从用户态拷到内核态,注册一次就完事,以后只传递就绪列表;二是复杂度从O(n)降到了O(1)——注意这里的O(1)是指获取就绪事件的复杂度,并非注册事件的复杂度,注册到红黑树还是O(log n)的,但相比每次扫描全部fd已经是天壤之别。
3.2 触发模式LT与ET:影响你代码写法的关键
epoll有两种工作模式,水平触发(Level Triggered,LT)和边缘触发(Edge Triggered,ET),绝大多数新手在这儿最容易栽跟头。
LT模式是默认模式,也是select和poll的取经方式。它的特点是:只要fd上还有数据没读完,每次调用epoll_wait都会返回这个fd。如果你读了一半,下次还会再通知你再读。这种模式写代码非常舒服,不容易丢数据,代价是效率略低——如果读完忘了处理或者处理太慢,会重复触发。
ET模式是epoll的“绝活”,只在状态变化时通知一次。比如客户端从无数据变为有数据,或者数据从有到无再变有,边界处才触发。这意味着你在一次通知里必须把所有数据都读完,否则剩下的数据可能永远不再通知你——除非新的数据又发过来。所以ET模式要求你把fd设置为非阻塞,然后用循环读取直到read返回EAGAIN。
我的建议是:如果你是新手,第一版先用LT模式把服务器跑通,整体框架没问题之后再切ET。如果一开始就在ET模式下苦战,你很难判断是epoll机制理解不到位,还是业务逻辑有bug。我自己实际项目里选的是ET模式,因为业务对单连接吞吐量和CPU开销比较敏感,等第5章我们专门讲ET模式下踩过的坑。
3.3 epoll的三个关键系统调用怎么用
这里把三个调用放在一起理一遍,后面看核心代码会更顺畅。
c复制#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll_create的新版本可以直接传0,因为内核早就不依赖这个size参数了,它只是给内核一个初始大小的建议。历史版本里size大于0,但它不是最大fd数量,这点不少文档都解释得不清不楚。
epoll_ctl负责往红黑树里添加节点(EPOLL_CTL_ADD)、修改节点事件(EPOLL_CTL_MOD)、删除节点(EPOLL_CTL_DEL)。关键在struct epoll_event结构体:
c复制struct epoll_event {
uint32_t events; // epoll事件,EPOLLIN/EPOLLOUT/EPOLLERR等
epoll_data_t data; // 用户数据,通常放fd或者指针
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
这里特别提醒:data是个联合体,
