基于 epoll 的高并发服务器原理与实现(对比 select 和 poll)

在 Linux 网络编程中,我们经常会遇到一个问题:如何同时管理大量客户端的连接?

如果你只用 accept + recv 的最简单方式,每来一个客户端就 accept 一次,然后阻塞在 recv 上,那么同时支持的客户端数量就会非常有限。

为了解决这个问题,Linux 提供了 I/O 多路复用机制,常见的有三种:

  • select

  • poll

  • epoll

本文将通过一个简单的 C 语言服务器代码 ,结合 select/poll/epoll 三种方式的实现,重点讲清楚 epoll 的原理 ,并对比它和 select/poll 的区别。

一、先看一个最简单的服务器

最朴素的写法就是这样:

cpp 复制代码
int clientfd = accept(sockfd, ...);
recv(clientfd, buffer, ...);
send(clientfd, buffer, ...);

这种方式有个致命缺陷:

服务器只能处理 一个客户端 ,因为 recv 会阻塞等待数据,如果客户端不发数据,服务器就卡住了。

二、select 的原理

select 的思想很直观:

  • 你告诉内核:"我关心这些 socket(fd_set)上是否有事件(可读/可写/异常)"。

  • 内核会帮你一个个去检查,然后告诉你 哪些 fd 上有事件

  • 你再去处理对应的 fd。

缺点:

  1. fd_set 有上限(1024),不能同时监听太多连接。

  2. 每次调用 select 都要把整个 fd_set 从用户态复制到内核态,效率低。

  3. 内核帮你检查完毕后,还得你自己在用户态用循环一个个找出来。

三、poll 的原理

pollselect 类似,改进点在于:

  • 使用了一个 pollfd 数组,没有 1024 的上限。

  • 但是依旧需要 每次把整个数组拷贝进内核,然后再返回给用户态。

  • 事件通知方式还是"轮询"------你得一个个去检查 revents

换句话说,poll 本质上是"加强版的 select",但性能上并没有质变。

四、epoll 的原理

epoll 是 Linux 提供的一套高效 I/O 事件通知机制,用来"在一个线程里同时监控大量文件描述符(socket 等),并只把真正就绪的那部分交给用户程序处理",从而避免 select/poll 在大量被监控 fd 上的 O(n) 全表扫描开销。

epoll 的核心思想是:

  1. 事件驱动(不再需要轮询所有 fd)

    • 当某个 socket 上有事件发生时,内核主动把它放到一个就绪队列里。

    • 你只需要从就绪队列里取就行,不用自己一个个遍历。

  2. 内核与用户态共享事件表

    • 通过 epoll_ctl 注册监听的 fd(一次性告诉内核),以后不需要每次都拷贝。

    • epoll_wait 只会返回真正有事件的 fd,效率大幅提升。

  3. 更适合高并发场景

    • 即使有 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 实例 ```cpp int epfd = epoll_create(1); ``` * `epoll_create(1)` 创建一个 **epoll 实例** ,返回一个文件描述符 `epfd`,它就像是一个"事件管理器"。 * 参数 `1` 其实没用(Linux 内核忽略它),随便填个大于 0 的值即可。 可以理解为:我们有了一个 **"待办事件表"**,之后把需要关注的 socket 都放进去。 ### 2. 把监听套接字放入 epoll ```cpp 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. 进入事件循环 ```cpp 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. 处理事件 ```cpp for(int i = 0; i < nready; i++){ int connfd = events[i].data.fd; ``` * 遍历所有就绪事件,一个一个处理。 * 通过 `events[i].data.fd` 拿到事件对应的文件描述符。 ### 5. 新客户端连接 ```cpp 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. 客户端发来消息 ```cpp }else if(events[i].events & EPOLLIN) { // 客户端发来消息 char buffer[1024] = {0}; int count = recv(connfd,buffer,1024,0); ``` * 如果触发的是 `EPOLLIN`,并且不是 `sockfd`,说明是 **某个客户端发来数据**。 * 用 `recv` 把数据读出来。 ### 7. 客户端断开连接 ```cpp if(count == 0){ // 客户端断开 printf("client disconnect: %d\n",connfd); close(connfd); epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL); continue; } ``` * 如果 `recv` 返回 `0`,表示客户端主动断开。 * 我们需要: 1. `close(connfd)` 关闭连接。 2. `epoll_ctl(..., EPOLL_CTL_DEL, ...)` 从 epoll 里移除这个 fd,避免继续监听它。 ### 8. 回显消息 ```cpp printf("RECV: %s\n",buffer); send(connfd,buffer,count,0); ``` 如果收到数据,就打印出来,并用 `send` 回发给客户端(回显服务器)。 [0voice · GitHub](https://github.com/0voice "0voice · GitHub")

相关推荐
Jtti3 小时前
在 Debian 系统上清理缓存的方式和具体操作方法
运维·缓存·debian
伐尘3 小时前
【mac】如何在 macOS 终端中高效查找文件:五种实用方法
运维·macos
zzu123zsw4 小时前
第15章 Jenkins最佳实践
运维·jenkins
万物得其道者成5 小时前
Cursor + 云效 DevOps MCP
运维·devops
wanhengidc5 小时前
云手机运行是否消耗自身流量?
运维·科技·安全·游戏·智能手机
wanhengidc5 小时前
云手机将要面临的挑战有哪些?
运维·网络·安全·游戏·智能手机
岚天start6 小时前
网络计算工具ipcalc详解
linux·运维·网络·网关·广播地址·掩码·ipcalc
SH11HF6 小时前
Jenkins调用ansible部署lnmp平台
运维·ansible·jenkins
小薛博客6 小时前
23、Jenkins容器化部署Vue应用
运维·vue.js·jenkins