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

发布于:2025-09-06 ⋅ 阅读:(17) ⋅ 点赞:(0)

在 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。

缺点:

  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 实例

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,表示客户端主动断开。

  • 我们需要:

    1. close(connfd) 关闭连接。

    2. epoll_ctl(..., EPOLL_CTL_DEL, ...) 从 epoll 里移除这个 fd,避免继续监听它。

8. 回显消息

printf("RECV: %s\n",buffer);
send(connfd,buffer,count,0);

如果收到数据,就打印出来,并用 send 回发给客户端(回显服务器)。

0voice · GitHub


网站公告

今日签到

点亮在社区的每一天
去签到