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是个联合体,你用fd字段还是ptr字段,取决于你后续怎么在事件回调里快速拿到上下文。单线程模型下我建议直接用fd就行,多线程需要携带上下文信息时用ptr指向一个结构体。
epoll_wait是阻塞等待的入口,timeout传-1表示无限期等待,传0表示立即返回(非阻塞轮询),传正整数表示最多等多少毫秒。返回值是就绪事件的个数,然后你遍历events数组处理每个就绪的fd。一个常见错误是反复在epoll_wait之后重新注册fd事件,其实修改事件只需要用EPOLL_CTL_MOD,不必删除再添加。
4. 服务器核心架构与代码实现
4.1 整体架构:单线程Reactor加上线程池
基础版本我做的单线程Reactor模型,就是主循环里epoll_wait拿到就绪事件,然后逐个处理:新连接来了就accept,已有连接可读就read,可写就write。这种模式的好处是代码简单、没有锁竞争、逻辑天然串行化,Redis就是用这种方式单线程跑到十几万QPS的。
但真实项目里有一个所有Reactor模型都逃不掉的问题:如果你的业务处理不是纯内存操作,而是要查数据库、调用下游接口、处理耗时计算,单线程把时间耗在这些地方,后面所有连接全都会堵住。
所以我在项目里加了一个线程池。具体做法是:epoll_wait负责接收IO事件,把事件打包成任务丢进任务队列,线程池里的worker线程来消费。这本质上是把IO线程和业务线程分离了。注意这里的任务队列需要加锁,因为IO线程往里面写,worker线程往里面读,这是多线程模型下唯一需要锁的地方。
对于数据报文的处理逻辑,比如解析协议、校验校验和、更新状态,都是在worker线程里完成的。读socket和写socket仍然在IO线程中做。
4.2 完整代码一步步写明白
下面是我整理后的核心代码,省去了无关的业务细节,保留了服务器骨架,方便你直接套用。
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <pthread.h>
#define MAX_EVENTS 1024
#define BUFFER_SIZE 4096
#define LISTEN_BACKLOG 128
// 每个连接对应一个结构体,存放读缓冲区和写缓冲区
typedef struct conn_info {
int fd;
char rbuf[BUFFER_SIZE];
int rlen;
char wbuf[BUFFER_SIZE];
int wlen;
} conn_info_t;
static int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
return -1;
}
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
static int create_server_socket(int port) {
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd < 0) {
perror("socket");
return -1;
}
// 关键:允许端口复用,不然服务器重启时经常报Address already in use
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(port);
if (bind(lfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
close(lfd);
return -1;
}
if (listen(lfd, LISTEN_BACKLOG) < 0) {
perror("listen");
close(lfd);
return -1;
}
set_nonblocking(lfd);
return lfd;
}
static void handle_accept(int epfd, int lfd) {
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
// 用while循环把accept队列里的连接全部接收完,否则LT模式下会反复触发
while (1) {
int cfd = accept(lfd, (struct sockaddr *)&cli_addr, &cli_len);
if (cfd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // 已经没有待accept的连接了
} else if (errno == EINTR) {
continue;
} else {
perror("accept");
break;
}
}
set_nonblocking(cfd);
conn_info_t *conn = (conn_info_t *)calloc(1, sizeof(conn_info_t));
conn->fd = cfd;
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLRDHUP; // EPOLLRDHUP能检测到对端关闭
ev.data.ptr = conn;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev) < 0) {
perror("epoll_ctl add");
close(cfd);
free(conn);
continue;
}
printf("[连接接入] fd=%d, 客户端=%s:%d\n",
cfd, inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port));
}
}
static void handle_read(int epfd, conn_info_t *conn) {
int fd = conn->fd;
while (1) {
ssize_t n = read(fd, conn->rbuf + conn->rlen, BUFFER_SIZE - conn->rlen);
if (n > 0) {
conn->rlen += n;
if (conn->rlen >= BUFFER_SIZE) {
// 缓冲区满了,这里可以扩展或视为协议错误
fprintf(stderr, "fd=%d 接收缓冲区溢出,连接关闭\n", fd);
handle_close(epfd, conn);
return;
}
} else if (n == 0) {
// 对端正常关闭了连接
printf("fd=%d 对端关闭连接\n", fd);
handle_close(epfd, conn);
return;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // 非阻塞模式下数据读完了
} else if (errno == EINTR) {
continue;
} else {
perror("read");
handle_close(epfd, conn);
return;
}
}
}
// 简单回显业务,实际项目在这里解析业务协议
if (conn->rlen > 0) {
handle_write(epfd, conn); // 把读到的数据丢给写处理
}
}
static void handle_write(int epfd, conn_info_t *conn) {
// 这里做简单的回显,实际项目中需要协议解析和业务处理
ssize_t n = write(conn->fd, conn->rbuf, conn->rlen);
if (n > 0) {
// 写出去多少,就把这部分数据移除缓冲区
memmove(conn->rbuf, conn->rbuf + n, conn->rlen - n);
conn->rlen -= n;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 写缓冲区满了,改注册EPOLLOUT事件,等待可写再继续
} else if (errno == EINTR) {
return;
} else {
perror("write");
handle_close(epfd, conn);
}
}
}
static void handle_close(int epfd, conn_info_t *conn) {
if (conn->fd > 0) {
epoll_ctl(epfd, EPOLL_CTL_DEL, conn->fd, NULL);
close(conn->fd);
}
printf("fd=%d 连接关闭\n", conn->fd);
free(conn);
}
int main(int argc, char *argv[]) {
int port = 8080;
if (argc > 1) {
port = atoi(argv[1]);
}
int lfd = create_server_socket(port);
if (lfd < 0) {
exit(EXIT_FAILURE);
}
int epfd = epoll_create(1);
if (epfd < 0) {
perror("epoll_create");
exit(EXIT_FAILURE);
}
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = lfd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev) < 0) {
perror("epoll_ctl add listen fd");
exit(EXIT_FAILURE);
}
struct epoll_event events[MAX_EVENTS];
printf("epoll TCP服务器已启动,监听端口 %d\n", port);
while (1) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (n < 0) {
if (errno == EINTR) {
continue;
}
perror("epoll_wait");
break;
}
for (int i = 0; i < n; i++) {
// data.ptr是conn_info_t*,而监听socket注册时用的是data.fd
if (events[i].data.fd == lfd) {
handle_accept(epfd, lfd);
continue;
}
conn_info_t *conn = (conn_info_t *)events[i].data.ptr;
// 出错或对端关闭,直接释放连接
if (events[i].events & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) {
handle_close(epfd, conn);
continue;
}
if (events[i].events & EPOLLIN) {
handle_read(epfd, conn);
}
if (events[i].events & EPOLLOUT) {
// 可写事件触发时,把之前未写完的数据继续写出去
handle_write_pending(epfd, conn);
}
}
}
close(epfd);
close(lfd);
return 0;
}
注意上面的代码里有两个函数我故意留了占位:handle_write里写缓冲区满的情况,以及handle_write_pending。因为这两处正是从“demo”过渡到“真实可用”的分水岭,我们下面展开详细讲。
4.3 处理“写缓冲区满了”这件事:EPOLLOUT的正确用法
TCP是流协议,发送缓冲区是有上限的。如果你的业务逻辑要往客户端回传数据,但客户端处理不过来,或者网络拥堵,write就会返回EAGAIN。这时候数据不能丢,得暂存在服务器内存里,等socket可写时再继续发。
正确姿势分三步:
- 当write返回EAGAIN,把剩余数据保存到这个连接的wbuf里;
- 通过epoll_ctl的EPOLL_CTL_MOD,给这个fd额外加上EPOLLOUT事件;
- 当epoll_wait返回这个fd的EPOLLOUT事件时,从wbuf里把剩余数据继续write出去;如果wbuf清空了,再把EPOLLOUT事件去掉,否则这个事件会一直触发,白白消耗CPU。
这段逻辑是很多网上抄来的demo不具备的,但真实的高并发服务器必须有。你可以想象一下:每个客户端下行带宽不一样,你对端读取速度慢,如果你在写事件里不做缓冲,数据发不出去就直接丢,那在弱网环境下业务根本没法跑。
4.4 线程池与事件循环的衔接
为了让epoll主循环不被耗时业务卡住,我把业务处理丢给线程池:
c复制typedef struct task {
conn_info_t *conn;
void (*handle)(conn_info_t *);
} task_t;
// 任务队列
#define TASK_QUEUE_SIZE 1024
static task_t task_queue[TASK_QUEUE_SIZE];
static int task_head = 0, task_tail = 0;
static pthread_mutex_t task_lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t task_cond = PTHREAD_COND_INITIALIZER;
static void task_push(conn_info_t *conn, void (*handle)(conn_info_t *)) {
pthread_mutex_lock(&task_lock);
int next = (task_tail + 1) % TASK_QUEUE_SIZE;
if (next == task_head) {
// 队列满了,这里可以选择丢弃或者主线程自己处理
pthread_mutex_unlock(&task_lock);
fprintf(stderr, "任务队列已满\n");
return;
}
task_queue[task_tail].conn = conn;
task_queue[task_tail].handle = handle;
task_tail = next;
pthread_cond_signal(&task_cond);
pthread_mutex_unlock(&task_lock);
}
static void *worker_thread(void *arg) {
while (1) {
pthread_mutex_lock(&task_lock);
while (task_head == task_tail) {
pthread_cond_wait(&task_cond, &task_lock);
}
task_t t = task_queue[task_head];
task_head = (task_head + 1) % TASK_QUEUE_SIZE;
pthread_mutex_unlock(&task_lock);
// 执行业务处理
t.handle(t.conn);
}
return NULL;
}
在handle_read里,读完数据后不再自己处理业务,而是构造一个任务投递:
c复制static void business_process(conn_info_t *conn) {
// 真正的业务解析、业务逻辑在这里执行
// ...
printf("fd=%d 收到数据: %s\n", conn->fd, conn->rbuf);
}
// handle_read中读完后,改为:
task_push(conn, business_process);
这里要注意一个细节:连接资源conn_info_t的生命周期管理变得复杂了。当业务线程还在处理一个连接时,主线程如果发现这个连接超时或者异常需要关闭,就要小心不能直接free掉conn结构体。我在实际项目中给conn结构体加了一个引用计数和关闭标记,业务线程开始处理前先ref一下,处理完unref,只有ref归零且关闭标记置位时才真正释放。这个细节是很多真实项目里最难调的部分,也是面试时最能体现水平的问题之一。
5. 实测中的问题排查与经验教训
5.1 我把服务器跑起来之后踩到的坑
第一版代码写完,我用并发客户端压测,很快发现问题。这里把我的排查过程记录下来,这些经验你在网上找demo的时候很难看到完整版。
坑一:服务端重启时bind失败。
症状:改了代码想重启服务,结果报错bind: Address already in use。原因:TCP连接关闭后,端口会进入TIME_WAIT状态,持续大约2MSL(Maximum Segment Lifetime,通常为1到2分钟),在这段时间内bind同一个端口会失败。解决:在bind之前设置SO_REUSEADDR。注意,这个选项和SO_REUSEPORT不一样,SO_REUSEPORT是让多个socket绑定同一个端口做负载均衡,别搞混。
坑二:ET模式下epoll_wait一直不返回read事件。
症状:客户端发来数据,服务器半天没反应。排查发现我在accept之后没有把监听socket设为非阻塞,也没有把连接socket设为非阻塞。ET模式强制要求非阻塞IO。如果你用ET但不设非阻塞,read可能只读一次就错过了后续数据。解决:accept后立刻设置O_NONBLOCK标志。
坑三:用LT模式回显一切正常,切ET后数据不完整。
症状:客户端发来比较大的数据包,服务器只处理到一部分。原因:ET模式下必须在一次触发中把read循环到EAGAIN为止,或者至少在注册事件时保证能持续读取。我在handle_read里用了while循环,但有个版本里没有处理EAGAIN,导致只读一次就退出了。解决:对ET模式socket,read循环读直到返回EAGAIN,这是铁律。
坑四:TIME_WAIT导致压力测试时端口不够用。
症状:压测跑了半小时,客户端报Cannot assign requested address。排查发现是客户端端口太多处于TIME_WAIT状态,把可用端口耗尽了。这不是服务端代码问题,而是压测工具的端口复用没开。可以用net.ipv4.tcp_tw_reuse=1配合socket选项SO_REUSEADDR缓解。注意这不是让你在生产环境乱开,压测时开一下是可以的。
5.2 每个问题对应的排查手段
面对上面的问题,我的排查思路可以总结成一套流程,特别适合新手“Debug网络服务器”的时候用:
- 先确认进程在跑:
ps -ef | grep server或者ss -lntp看监听端口。 - 如果没有监听,看启动日志和
dmesg,排除bind失败、权限问题。 - 如果监听了但客户端连不上,用
ss -tnp看三次握手是否建立,配合tcpdump -i lo port 8080抓包确认SYN、SYN-ACK、ACK是否正常交换。 - 如果连接建立了但数据接收不到,重点看epoll事件注册是否正确,EPOLLIN有没有设置,fd是否注册进对了epfd。
- 如果数据收得到但回不回,检查EPOLLOUT事件逻辑和写缓冲区处理。
这套流程是我在无数个问题中沉淀出来的,拿到线上问题先按这个顺序走,基本能定位掉八成的问题。
5.3 根因分析的一个核心思路
这里多说一句,不要一上来就猜是epoll的问题。我见过很多朋友遇到数据丢失,第一反应是“epoll是不是有bug”,其实epoll在内核里跑了几十年,成熟度非常高,出问题的概率极小。大概率问题出在你的应用层协议处理上,比如缓冲区没处理好、事件标志位漏了判断、或者多个连接共享了某个全局变量。
我给自己的规矩是:先证明代码逻辑正确,再怀疑内核。抓包是最有力的工具,tcpdump能看到内核收发的每一个包,如果你的应用没有收到数据,但tcpdump里客户端确实发了,那问题一定在应用层——要么epoll没注册这个fd,要么注册了但没触发,要么触发了但读的时候出了纰漏。
5.4 常见故障速查表
| 现象 | 可能原因 | 排查/处理方法 |
|---|---|---|
| bind报Address already in use | 端口处于TIME_WAIT | bind前设置SO_REUSEADDR |
| 客户端连接被拒 | 监听socket未listen或listen backlog太小 | ss -lntp检查监听状态,增大backlog |
| epoll_wait收不到读事件 | fd未注册到epfd,或事件类型错误 | 检查epoll_ctl返回值,打印errno |
| ET模式下数据读不全 | 未设置非阻塞或read未循环到EAGAIN | 设置O_NONBLOCK,while循环读到EAGAIN |
| 高并发时CPU飙升 | 注册了EPOLLOUT但一直可写,或业务逻辑过重 | 写完后注销EPOLLOUT,必要时引入线程池 |
| 客户端正常关闭但服务器没感知 | 未设置EPOLLRDHUP或未读取到EOF | 注册EPOLLRDHUP,或read返回0时关闭连接 |
| 数据在业务处理中丢失 | 缓冲区管理不当,或任务队列溢出 | 使用环形缓冲区分阶段处理,压测时监控队列长度 |
| 大量TIME_WAIT连接 | 服务器主动关闭连接 | 确认是正常关闭还是异常close,必要时调优tcp_tw_reuse |
6. 性能测试与调优过程
6.1 压测是怎么做的
代码写完不是终点,压测才是检验服务器性能的手段。我用的压测工具是wrk,它本身是HTTP压测工具,适合测长连接和短连接请求,也顺手写了几个简单的TCP客户端脚本做自定义协议压测。
压测时发现一个很重要的环境问题:默认的ulimit -n通常是1024,也就是说一个进程最多打开1024个文件描述符。我们的服务器要承载上万连接,第一步就要把限制调大:
bash复制ulimit -n 65535
如果是生产环境,建议改/etc/security/limits.conf,把nofile上限调大,并且让服务进程用独立的非root账号跑。
另外系统级的网络参数也要注意:
bash复制# 全连接队列长度
net.core.somaxconn = 4096
# 本地端口范围,影响客户端同时发起的连接数
net.ipv4.ip_local_port_range = 1024 65535
# 快速回收TIME_WAIT,压测时很有用
net.ipv4.tcp_tw_reuse = 1
注意,tcp_tw_reuse指的是客户端连接时的端口复用,不是服务器端规避TIME_WAIT的万能药。服务器端大量TIME_WAIT的根源通常是主动关闭连接,优化方向应该放在减少主动关闭频率上。
6.2 实测数据对比
我在一台4核CPU、8GB内存的虚拟机上跑了自己的压测:1000个并发连接,每个连接循环发送小包数据并等待回显。
LT模式下的结果:QPS大约2.8万,CPU使用率70%左右,表现稳定。切到ET模式后:QPS大约3.6万,CPU使用率约55%。差别主要体现在大数据包场景,因为ET模式每次触发读得更多,减少了内核态和用户态的切换次数。
单独用线程模型做同样的测试,1000个连接直接开始出现调度延迟,CPU飙升到接近100%,QPS只有1.2万左右。差距非常明显。
我对压测结果始终保留几分谨慎,因为这只是我这个测试机上的相对差异。真正的线上性能取决于硬件、业务逻辑复杂度、网络环境等众多因素。但有一点是确定的:连接数越大,epoll模型的优势越显著。
6.3 参数调优的关键点
服务端监听socket的listen backlog参数值得特别说一下。这个值决定了全连接队列的长度。如果客户端连接请求来得太密集,backlog太小,内核会直接丢弃多余的SYN包,客户端表现为连接建立非常慢甚至超时。在Linux上,这个值的上限受net.core.somaxconn影响,写代码时设置128可能不够,我一般设成512或者1024,同时把内核参数调上去。
epoll_wait的maxevents参数也要合理控制。我见过有人图省事传1万,然后每次epoll_wait返回后遍历几千个就绪事件,处理完发现时间已经过去很久,新的事件积累了一大堆。合理的做法是控制在256到1024之间,配合循环处理,保证每次事件循环都在一个好耗时范围内。
7. 关于LT和ET模式的深度对比
7.1 概念层面再理一遍
这里值得再多花点篇幅,因为这个是所有epoll文章逃不过的重点,也是面试时最常被问到的知识点。
LT是只要缓冲区里有数据,就一次次告诉你。你可以一次读一点,下次再读,不会丢。这种模式对编程者很友好,处理逻辑不用小心翼翼地读满所有数据。
ET是只在状态变化的那一刻告诉你一次。什么是状态变化?从无数据到有数据是一个边界,从有数据到无数据再到有数据是另一个边界。你在边界被通知后,必须把数据全部读完,到EAGAIN为止,否则剩余数据不会再有通知,可能会一直积压到下一次客户端再发数据。
想象成水龙头接水:LT是水一直在流,你随时去接都能接到;ET是只响一次铃告诉你水来了,你必须一次把水全接完,接不完水也不会再提醒你。
7.2 实际项目中到底选哪个
我个人的项目里最终使用了ET模式,原因有两个:一是连接数上来后,ET减少了很多无效的事件通知,CPU开销更低;二是ET模式强迫我把代码写得干净——所有读取必须是循环到EAGAIN的,所有写入必须处理掉缓冲,写出来的代码在极端网络条件下更不容易出问题。
但如果你写的是业务繁忙、连接并发高但每个连接数据量都很小的服务,LT模式完全够用,而且代码更好维护,排查问题也更容易。没有必要为了追求“高级感”强行上ET。技术选型永远看场景,不看出处。nginx、redis这些用了ET,不代表用LT就是低端。你自己写的服务器在几百上千连接的情况下,LT与ET的性能差距远没有网上吹得那么大。
如果你决定用ET,请记住三件事:非阻塞IO、读取循环到EAGAIN、EPOLLOUT事件的管理。这三条做到位,ET模式的坑你已经避开一大半了。
7.3 两种模式代码差异对照
| 对比项 | LT模式 | ET模式 |
|---|---|---|
| socket是否必须非阻塞 | 不是必须 | 必须 |
| 是否Read到EAGAIN | 非必须 | 必须 |
| CPU开销 | 高一些 | 低一些 |
| 代码复杂度 | 低 | 高 |
| 处理突发大包 | 多次通知 | 一次通知读完 |
| 写事件EPOLLOUT | 触发频率高 | 触发频率低 |
| 适合场景 | 新手练习、一般业务 | 高吞吐、高并发 |
8. 项目扩展方向与进阶玩法
8.1 从单机到多线程多进程
如果服务器要用满多核CPU,单线程Reactor模型显然浪费了其余核心。目前有两条路线:一是单进程多线程,主线程跑epoll,业务丢线程池,我上面展示的就是这种;二是多进程模式,每个进程一个Reactor,通过SO_REUSEPORT让内核把连接分发到不同进程,nginx就是这么干的。多进程模式的优点是进程间内存隔离更安全,缺点是状态同步和数据广播实现复杂度明显上升。
如果做多进程,建议先用SO_REUSEPORT把多个监听socket绑定到同一端口,然后每个进程各自跑一套独立的事件循环。这样代码改动范围可控,又能把多核用起来。不过多进程模式下,要考虑如何做全局连接管理,比如一个连接落在哪个进程就固定由哪个进程管,进程崩溃时连接要能迁移到其他进程。
8.2 协议层的升级:从TCP裸流到自定义协议
如果你要把它做成一个真正能对外的服务,TCP上要定义自己的应用层协议。我在项目里加了一个简单的长度字段协议:前4个字节是网络字节序的包体长度,后面跟着长度对应的载荷。然后我在缓冲区里先读4字节得到包长,再读对应长度的数据,凑齐了一个完整报文后再交给业务层处理。
这个过程中最需要注意的是TCP粘包和拆包问题。TCP是字节流协议,没有消息边界,你一次read到的数据可能包含半个包,也可能包含好几个完整的包。处理办法就是基于缓冲区去拼:总是先判断缓冲区的数据长度是否达到了一个完整包的长度,不达到继续等,达到了就提取一个包出来,剩余数据留在缓冲区继续处理。
很多线上问题,看起来像是服务器网络有毛病,实际是应用层协议解析没做好。你在设计协议时尽量采用“包头+长度+数据”这种经典格式,大部分粘包问题都能化解。
8.3 连接保活与超时管理
真实部署中还有一个服务器必须考虑的问题:连接建立后客户端可能一直不发数据,如果服务器不闻不问,这些半开连接会一直占着fd。TCP本身有keepalive机制,但默认间隔是2小时,对大多数业务来说太慢了。建议在应用层做心跳超时管理:每个连接记录一个last_active时间戳,epoll_wait每轮循环检查一次,超过阈值比如90秒没有数据活动,就主动关闭这个连接。
这个检查不能做得太频繁,否则影响性能。我是在epoll_wait阻塞返回后,顺手用一个时间轮或定时器结构检查超时连接。简单的实现可以用最小堆或者时间轮,避免每次扫描所有连接。
8.4 和现成开源框架的对比与取舍
最后说一个务实的话题。你可能会问,现在有libevent、libuv、Boost.Asio这么一堆成熟网络库,自己写epoll服务器是不是重复造轮子?
我的看法是,学习阶段必须自己造一遍轮子。学epoll就像学手动挡,开熟练了你才知道自动挡是怎么换挡的,出了问题你才有修车的能力。而真正做项目的时候,完全可以直接基于libevent或者libuv,它们封装了跨平台的事件循环和跨平台socket抽象,社区经过长期生产检验,坑少很多。
如果你做的是一次性工具、内网小服务、或者嵌入式环境不方便引入重量级依赖,自己写epoll是完全合理的。如果是在团队合作、长期维护的项目里,优先选成熟框架。我自己就是在用libevent做生产项目的同时,保留了一套自己写的epoll服务器作为验证新思路的试验田,两者并不冲突。
9. 收尾:我的一点实在心得
这个项目前前后后我从零写了三版。第一版照着教科书写,跑起来能回显,但一压测就崩;第二版改了ET模式,调了两周贴边问题,终于稳定了;第三版加入了线程池、协议解析和超时管理,才算真正能放到业务环境里去用。
要说最大的感悟,不是epoll的接口有多难记,而是网络编程里真正花时间的永远是那些“边缘情况”:连接半开怎么处理、缓冲区满了怎么办、对端突然断电怎么发现。这些情况在demo里永远不会出现,但在生产环境里几乎每天都会发生。你能提前设计好应对策略,你的服务器才算得上“可用”,而不是“能跑”。
如果这篇文章能帮你少踩几个坑,哪怕只是在关键时刻让你想起来“ET模式下一定要循环读”,我就觉得花时间写它是值得的。后续我也打算再写一篇关于epoll服务器做协议解析和内存池管理的文章,如果你在这个项目上有什么疑问,欢迎在评论区一起讨论。
