基于 epoll 的高并发服务器原理与实现(对比 select 和 poll)
在 Linux 网络编程中,我们经常会遇到一个问题:如何同时管理大量客户端的连接?
如果你只用 accept
+ recv
的最简单方式,每来一个客户端就 accept
一次,然后阻塞在 recv
上,那么同时支持的客户端数量就会非常有限。
为了解决这个问题,Linux 提供了 I/O 多路复用机制,常见的有三种:
-
select
-
poll
-
epoll
本文将通过一个简单的 C 语言服务器代码,结合 select/poll/epoll
三种方式的实现,重点讲清楚 epoll 的原理,并对比它和 select/poll
的区别。
一、先看一个最简单的服务器
最朴素的写法就是这样:
int clientfd = accept(sockfd, ...);
recv(clientfd, buffer, ...);
send(clientfd, buffer, ...);
这种方式有个致命缺陷:
服务器只能处理 一个客户端,因为 recv
会阻塞等待数据,如果客户端不发数据,服务器就卡住了。
二、select 的原理
select
的思想很直观:
-
你告诉内核:“我关心这些 socket(fd_set)上是否有事件(可读/可写/异常)”。
-
内核会帮你一个个去检查,然后告诉你 哪些 fd 上有事件。
-
你再去处理对应的 fd。
缺点:
-
fd_set 有上限(1024),不能同时监听太多连接。
-
每次调用
select
都要把整个 fd_set 从用户态复制到内核态,效率低。 -
内核帮你检查完毕后,还得你自己在用户态用循环一个个找出来。
三、poll 的原理
poll
和 select
类似,改进点在于:
-
使用了一个
pollfd
数组,没有 1024 的上限。 -
但是依旧需要 每次把整个数组拷贝进内核,然后再返回给用户态。
-
事件通知方式还是“轮询”——你得一个个去检查
revents
。
换句话说,poll
本质上是“加强版的 select”,但性能上并没有质变。
四、epoll 的原理
epoll
是 Linux 提供的一套高效 I/O 事件通知机制,用来“在一个线程里同时监控大量文件描述符(socket 等),并只把真正就绪的那部分交给用户程序处理”,从而避免select
/poll
在大量被监控 fd 上的 O(n) 全表扫描开销。
epoll
的核心思想是:
-
事件驱动(不再需要轮询所有 fd)
-
当某个 socket 上有事件发生时,内核主动把它放到一个就绪队列里。
-
你只需要从就绪队列里取就行,不用自己一个个遍历。
-
-
内核与用户态共享事件表
-
通过
epoll_ctl
注册监听的 fd(一次性告诉内核),以后不需要每次都拷贝。 -
epoll_wait
只会返回真正有事件的 fd,效率大幅提升。
-
-
更适合高并发场景
-
即使有 10 万个连接,只有少量活跃,
epoll
只返回活跃的部分,性能几乎不会下降。
-
五、文字流程图(epoll 工作流程)
服务器启动
↓
创建监听 socket(sockfd)
↓
epoll_create 创建 epoll 实例
↓
epoll_ctl(ADD, sockfd) 将 sockfd 加入监听
↓
进入循环 epoll_wait
↓
[事件1] sockfd 有新连接 → accept → epoll_ctl(ADD, clientfd)
↓
[事件2] clientfd 有数据 → recv → send
↓
[事件3] clientfd 断开 → close → epoll_ctl(DEL, clientfd)
↓
回到 epoll_wait 等待下一个事件
六、select / poll / epoll 区别总结
特点 | select | poll | epoll |
---|---|---|---|
fd 数量限制 | 1024 | 无固定上限 | 无固定上限 |
用户态/内核态拷贝 | 每次都要 | 每次都要 | 只需一次(注册时) |
时间复杂度 | O(n) | O(n) | O(1)(只返回就绪 fd) |
并发性能 | 一般 | 一般 | 高效(适合上万连接) |
七、epoll服务器核心代码讲解
1. 创建 epoll 实例
int epfd = epoll_create(1);
-
epoll_create(1)
创建一个 epoll 实例,返回一个文件描述符epfd
,它就像是一个“事件管理器”。 -
参数
1
其实没用(Linux 内核忽略它),随便填个大于 0 的值即可。
可以理解为:我们有了一个 “待办事件表”,之后把需要关注的 socket 都放进去。
2. 把监听套接字放入 epoll
struct epoll_event ev;
ev.events = EPOLLIN; // 关心读事件(有新连接到来)
ev.data.fd = sockfd; // 保存文件描述符信息
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
-
epoll_event
结构体描述要监听的事件。-
EPOLLIN
:表示关心 可读事件(有新数据或者新连接)。 -
ev.data.fd = sockfd
:把sockfd
(监听 socket)存进去,后面可以识别事件来源。
-
-
epoll_ctl
:向epfd
里 注册一个新的事件,相当于“告诉 epoll,我要关注这个 sockfd 的可读事件”。
这就让 epoll 开始监听服务器的主 socket,随时准备接收新连接。
3. 进入事件循环
while(1){struct epoll_event events[1024] = {0};int nready = epoll_wait(epfd, events, 1024, -1);
-
epoll_wait
就是 等待事件发生。 -
参数解释:
-
events[1024]
:用来存储返回的就绪事件。 -
1024
:最多监听 1024 个事件(实际数量 ≤ 1024)。 -
-1
:表示阻塞等待,直到有事件发生才返回。
-
-
返回值
nready
:本次有多少事件就绪。
可以理解为:epoll_wait
就像一个 事件闹钟,有事件发生时会通知我们。
4. 处理事件
for(int i = 0; i < nready; i++){int connfd = events[i].data.fd;
-
遍历所有就绪事件,一个一个处理。
-
通过
events[i].data.fd
拿到事件对应的文件描述符。
5. 新客户端连接
if (connfd == sockfd){ // 新客户端连接int clientfd = accept(sockfd,(struct sockaddr*)&clientaddr,&len);printf("accept finished: %d\n", clientfd);ev.events = EPOLLIN;ev.data.fd = clientfd;epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
}
-
如果
connfd == sockfd
,说明是 监听 socket 触发 → 有新客户端来连接。 -
调用
accept
拿到新的客户端clientfd
。 -
把
clientfd
也加入 epoll,关心它的EPOLLIN
(可读事件)。
这样以后 epoll 就会帮我们监控这个客户端的收发数据。
6. 客户端发来消息
}else if(events[i].events & EPOLLIN) { // 客户端发来消息char buffer[1024] = {0};int count = recv(connfd,buffer,1024,0);
-
如果触发的是
EPOLLIN
,并且不是sockfd
,说明是 某个客户端发来数据。 -
用
recv
把数据读出来。
7. 客户端断开连接
if(count == 0){ // 客户端断开printf("client disconnect: %d\n",connfd);close(connfd);epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL);continue;
}
-
如果
recv
返回0
,表示客户端主动断开。 -
我们需要:
-
close(connfd)
关闭连接。 -
epoll_ctl(..., EPOLL_CTL_DEL, ...)
从 epoll 里移除这个 fd,避免继续监听它。
-
8. 回显消息
printf("RECV: %s\n",buffer);
send(connfd,buffer,count,0);
如果收到数据,就打印出来,并用 send
回发给客户端(回显服务器)。
0voice · GitHub