epoll源码
一、温故 Socket 套接字
1.1 Socket 套接字是什么?
Socket,常被译为 “套接字”,它就像是网络通信世界里的万能转接器,一端连接着应用程序,另一端则对接网络协议栈 ,是实现进程间通信(IPC,Inter - Process Communication)的关键端点。通过 Socket,不同主机上的进程能够跨越网络进行数据的交换和传递,无论是网页浏览时客户端与服务器之间的数据请求与响应,还是即时通讯软件中消息的发送与接收,背后都离不开 Socket 的支持。
Socket 在进程间通信(IPC,Inter - Process Communication)和网络通信中起着关键作用。在本地进程间通信中,我们有管道(PIPE)、命名管道(FIFO)、消息队列、信号量、共享内存等方式。但当涉及到网络中的进程通信时,Socket 就成为了首选工具。网络中的不同主机,其进程的 PID(进程标识符)在本地虽能唯一标识进程,但在网络环境下,PID 冲突几率很大。而 Socket 利用 IP 地址 + 协议 + 端口号的组合,能够唯一标识网络中的一个进程,从而巧妙地解决了网络进程间通信的难题。

比如,我们日常使用的 Web 浏览器,当在浏览器地址栏输入网址并回车后,浏览器进程就会通过 Socket 向对应的 Web 服务器进程发起连接请求,服务器响应后,双方通过 Socket 进行数据传输,这样我们就能看到网页内容了。再如,即时通讯软件如 QQ、微信,通过 Socket 实现客户端之间或客户端与服务器之间的即时消息传输;网络游戏中,客户端通过 Socket 连接到游戏服务器,实现实时的游戏状态同步和玩家互动。Socket 就像一座无形的桥梁,跨越网络的边界,让不同主机上的进程能够顺畅地交流。
1.2 Socket 基础流程详解
在深入探讨 epoll 之前,先扎实掌握 Socket 基础流程是至关重要的,这是构建网络通信的基石 。Socket 作为网络编程的关键接口,为不同设备间的进程通信搭建了桥梁,理解其工作流程能帮助我们更好地理解网络数据的传输原理,也能为后续学习 epoll 这种高级 I/O 多路复用机制打下坚实基础。
(1)创建 Socket:使用 socket () 函数创建一个套接字,它就像是在网络世界中建立了一个通信端点。该函数需要传入三个参数,以 IPv4 和 TCP 协议为例,第一个参数 AF_INET 指定地址族为 IPv4 ,第二个参数 SOCK_STREAM 表示使用流式套接字(对应 TCP 协议),第三个参数通常设为 0,表示使用默认协议。
// 创建 IPv4 的 TCP 套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
如果创建成功,会返回一个文件描述符 sockfd,后续的操作都将基于这个描述符进行。
(2)绑定地址和端口:创建好 Socket 后,需要通过 bind () 函数将其与特定的 IP 地址和端口号绑定。这一步就好比给通信端点贴上一个独一无二的 “门牌号码”,让客户端能够准确找到服务器。首先要构建一个 sockaddr_in 结构体,填充其中的协议族(如 AF_INET)、端口号(需通过 htons () 函数将主机字节序转换为网络字节序)和 IP 地址(INADDR_ANY 表示绑定到本机所有可用 IP 地址)等信息 ,然后调用 bind () 函数。
struct sockaddr_in addr;
addr.sin_family = AF_INET; // 地址族为 IPv4
addr.sin_port = htons(8080); // 绑定端口 8080,转换为网络字节序
addr.sin_addr.s_addr = INADDR_ANY; // 绑定本机所有可用 IP
// 绑定套接字与地址端口
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
其中 sockfd 是前面创建的套接字描述符,addr 是填充好的地址结构体,sizeof (addr) 是结构体的大小。
(3)监听连接请求:调用 listen () 函数将套接字设置为监听状态,等待客户端的连接请求。这就像是在门口挂上 “欢迎光临” 的牌子,告诉客户端可以来连接了。listen () 函数的第一个参数是套接字描述符,第二个参数 backlog 表示内核为该套接字维护的未完成连接队列和已完成连接队列的最大长度之和。
// 将套接字设为监听状态,最大等待连接数为 10
listen(sockfd, 10);
这里设置 backlog 为 10,表示最多可以有 10 个连接请求在队列中等待处理。
(4)接受连接:当有客户端发起连接请求时,服务器通过 accept () 函数接受连接。这个函数会阻塞等待,直到有客户端连接到来。一旦有连接到达,它会返回一个新的套接字描述符,专门用于与这个客户端进行通信,而原来的监听套接字继续保持监听状态,等待其他客户端的连接。
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
// 阻塞等待并接受客户端连接,创建新的通信套接字
int client_sockfd = accept(sockfd, (struct sockaddr *)&client_addr, &client_addr_len);
其中 client_sockfd 是新的通信套接字描述符,client_addr 用于存储客户端的地址信息,client_addr_len 是地址结构体的长度。
二、select/poll 为何存在性能短板?
2.1 select 函数详解
在网络编程的早期,select 函数作为 I/O 多路复用的先驱,为开发者提供了一种能够同时监听多个文件描述符(fd,File Descriptor)的方法 。它的函数原型为:
#include <sys/select.h>
#include <sys/time.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
其中,nfds 是需要监视的文件描述符中最大值加 1 ,它就像是一个范围标记,告诉内核在检查事件时需要遍历到哪个文件描述符为止;readfds、writefds 和 exceptfds 分别是用于检测是否可以读取数据、是否可以写数据以及是否有异常的文件描述符集合,它们就像是三个不同的 “监视列表”,让开发者可以有针对性地关注不同类型的事件;timeout 用于设置 select 的等待时间,它有三种状态:如果传入 NULL,select 会进入阻塞状态,一直等待直到有文件描述符状态发生变化;如果设置为 0,则 select 会立即返回,无论是否有事件发生,这就是非阻塞模式;如果设置为一个大于 0 的时间值,select 会在等待指定时间后返回,若在这段时间内有事件发生则提前返回。
select 函数使用固定大小的位图(fd_set)来标记需要监听的文件描述符。fd_set 本质上是一个位数组,其大小通常被定义为 FD_SETSIZE,在大多数系统中,这个值为 1024 ,这也就限制了 select 最多能同时监听 1024 个文件描述符。在使用时,开发者通过 FD_ZERO 宏清空位图,然后使用 FD_SET 宏将需要监听的文件描述符对应的位设置为 1 ,表示关注该文件描述符的相关事件。例如:
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);
这里先创建了一个 readfds 集合,然后清空它,再将 sockfd 这个文件描述符添加到集合中,表示要监听 sockfd 的读事件。当 select 函数返回时,会修改这些集合,将未就绪的文件描述符对应的位清零,开发者通过 FD_ISSET 宏来检查某个文件描述符是否就绪。例如:
if (FD_ISSET(sockfd, &readfds)) {
// sockfd 已就绪,进行相应处理
}
2.2 poll 函数详解
随着网络应用的发展,select 函数的局限性逐渐显现,于是 poll 函数应运而生,它对 select 进行了一些改进。poll 函数的原型为:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
其中,fds 是一个 pollfd 结构体类型的数组,每个 pollfd 结构体表示一个需要监听的文件描述符及其相关事件;nfds 指定了数组 fds 的大小,也就是要监听的文件描述符的数量;timeout 同样用于设置超时时间,其取值含义与 select 函数中的 timeout 类似。
pollfd 结构体的定义如下:
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 用户设置的感兴趣的事件 */
short revents; /* 系统返回的就绪事件 */
};
events 字段用于设置用户感兴趣的事件,比如 POLLIN 表示读事件,POLLOUT 表示写事件等,通过位运算可以同时设置多个感兴趣的事件;revents 字段则由内核填充,用于返回文件描述符实际发生的事件。
与 select 不同的是,poll 使用动态的结构体数组来替代固定的位图,这就突破了 select 中文件描述符数量的限制,理论上可以监听的文件描述符数量仅受限于系统资源 。而且,poll 函数在检测完事件后,不会修改用户传入的 events 字段,而是将结果保存在 revents 字段中,这使得每次调用 poll 时无需重新设置监听事件,提高了易用性。例如:
struct pollfd fds[10];
for (int i = 0; i < 10; i++) {
fds[i].fd = some_fds[i];
fds[i].events = POLLIN;
fds[i].revents = 0;
}
int ret = poll(fds, 10, -1);
if (ret > 0) {
for (int i = 0; i < 10; i++) {
if (fds[i].revents & POLLIN) {
// fds[i] 的读事件已就绪,进行处理
}
}
}
2.3 select/poll 的性能问题分析
虽然 select 和 poll 函数在一定程度上解决了 I/O 多路复用的问题,但在高并发场景下,它们都暴露出了明显的性能问题。
无论是 select 还是 poll,在检查文件描述符是否就绪时,都需要线性遍历所有注册的文件描述符。当文件描述符数量较少时,这种遍历的开销还可以接受,但随着文件描述符数量的增加,比如在一个需要处理成千上万连接的服务器中,每次调用 select 或 poll 都要对所有文件描述符进行遍历,其时间复杂度为 O (n),这将导致性能急剧下降。就像在一个拥有大量员工的公司中,每次有任务分配时,都要挨个询问每个员工是否有时间,而不是通过更高效的方式直接找到有空的员工,效率自然低下。
在调用 select 或 poll 函数时,需要将用户态的文件描述符集合(select 中的 fd_set 或 poll 中的 pollfd 数组)拷贝到内核态,让内核进行事件检查。当内核检查完事件后,又要将结果从内核态拷贝回用户态。在高并发情况下,这种频繁的数据拷贝操作会消耗大量的系统资源和时间,严重影响性能。例如,在一个频繁进行数据交互的网络应用中,数据在用户态和内核态之间来回拷贝,就像在两个房间之间频繁搬运物品,会造成大量的时间和精力浪费。
三、epoll 的全面解析与实现
3.1 epoll 的诞生与优势
从技术原理的深度剖析来看,epoll 摒弃了传统 select 和 poll 采用的低效轮询机制。传统方式下,就如同在一个巨大的仓库里,不管货物有没有变化,都要逐个去查看,在连接数量众多时,大量的时间和资源就浪费在了这些无效的检查上。而 epoll 采用事件驱动机制,当文件描述符状态发生变化,比如有数据可读或可写时,内核会主动发出通知,应用程序只需关注这些有事件发生的文件描述符即可,这大大减少了无效操作,就好比仓库有了智能提示系统,货物一有变动就马上提醒,无需盲目查找。
epoll 的数据结构设计堪称精妙绝伦。它利用红黑树来管理大量的文件描述符,红黑树的特性使得插入、删除和查找操作的时间复杂度仅为 O (log N),即便面对海量的文件描述符,也能快速定位和处理。同时,epoll 维护着一个就绪链表,一旦文件描述符就绪,内核会迅速将其放入链表中。这样,当应用程序调用 epoll_wait 获取就绪事件时,只需遍历这个就绪链表,无需像传统机制那样对所有文件描述符进行全量扫描,大大提高了事件获取的效率,如同从精心整理的货架上快速找到所需物品。
epoll 在数据传输方面也有着独特优势。它借助 mmap 技术,在内核空间与用户空间建立起共享内存。在传统数据传输过程中,数据从内核缓冲区到用户空间应用程序缓冲区,往往需要多次拷贝,这无疑增加了时间和资源开销。而 epoll 通过共享内存,让数据传输更直接高效,减少了拷贝次数,加快了数据传输速度,就像开辟了一条数据传输的 “高速公路”。
举个例子,一个热门的网站服务器,每天都有大量的用户访问。服务器需要同时处理这些用户的连接请求,接收他们发送的数据(比如用户的登录信息、搜索关键词等),并返回相应的响应(比如网页内容、搜索结果)。如果使用 epoll,服务器就可以通过 epoll 来监听这些大量的用户连接对应的文件描述符,一旦有某个用户发送了数据过来,epoll 就能迅速感知到,并通知服务器程序去读取和处理这些数据 ,这样就能高效地应对高并发的网络请求了。
3.2 epoll 的核心函数与工作流程
(1)epoll_create 创建实例——在使用 epoll 进行 I/O 多路复用编程时,首先需要创建一个 epoll 实例,这可以通过 epoll_create 或 epoll_create1 函数来实现。在较新的 Linux 版本中,推荐使用 epoll_create1 函数,因为它提供了更多的功能和更好的安全性。其函数原型为:
#include <sys/epoll.h>
int epoll_create1(int flags);
flags 参数可以是 0 或者 EPOLL_CLOEXEC。当 flags 为 0 时,创建一个普通的 epoll 实例;当 flags 为 EPOLL_CLOEXEC 时,会为新创建的 epoll 文件描述符设置 close-on-exec 标志,这意味着在执行 exec 函数族时,该 epoll 文件描述符会被自动关闭,从而避免了一些潜在的文件描述符泄漏问题。
epoll_create1 函数执行时,内核会在内核空间中为这个 epoll 实例分配相应的资源,包括初始化红黑树和就绪链表等数据结构。如果创建成功,函数会返回一个非负的文件描述符,这个描述符就像一个 “通行证”,后续对 epoll 实例的所有操作,如注册文件描述符、等待事件等,都将通过这个描述符来进行;如果创建失败,函数会返回 -1,并设置 errno 以指示错误原因,例如 ENOMEM 表示内存不足,无法创建 epoll 实例。
(2)epoll_ctl 注册文件描述符——创建好 epoll 实例后,就需要使用 epoll_ctl 函数向这个实例中添加、修改或删除需要被监测的文件描述符及其相关事件。其函数原型为:
-
epfd:是由 epoll_create 或 epoll_create1 函数返回的 epoll 实例的文件描述符,它指定了要对哪个 epoll 实例进行操作。
-
op 为操作类型,包含三种取值,EPOLL_CTL_ADD 用于向 epoll 实例添加待监听的文件描述符及对应事件,EPOLL_CTL_MOD 用于修改已注册文件描述符的监听事件,EPOLL_CTL_DEL 用于从 epoll 实例中移除指定文件描述符、停止事件监听。
-
fd:是要操作的目标文件描述符,它可以是一个套接字描述符(如用于网络通信的 socket),也可以是其他类型的文件描述符(如管道、定时器等,只要支持事件驱动的 I/O 操作)。
-
event:是一个指向 epoll_event 结构体的指针,该结构体用于设置要监听的事件类型和关联的数据。epoll_event 结构体的定义如下:
struct epoll_event { uint32_t events; // 监听的事件类型(位掩码形式) epoll_data_t data; // 用户数据(通常保存 fd 或关联的指针) }; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;
在 events 字段中,可以通过位运算组合多个事件类型,常见的有:
-
EPOLLIN:表示对应的文件描述符可读,例如有新的数据到达套接字的接收缓冲区,或者监听套接字有新的连接请求到来。
-
EPOLLOUT:表示对应的文件描述符可写,通常用于非阻塞的写操作,当发送缓冲区有空间时会触发该事件。
-
EPOLLET:用于设置边缘触发模式,与默认的水平触发模式相对应,后面会详细介绍这两种模式的区别。
-
EPOLLERR:表示对应的文件描述符发生错误。
-
EPOLLHUP:表示对应的文件描述符被挂起。
epoll_ctl 函数成功执行时返回 0,失败时返回 -1,并设置 errno 以指示错误原因,比如 EBADF 表示 epfd 或 fd 不是有效的文件描述符,EEXIST 表示使用 EPOLL_CTL_ADD 操作时,文件描述符 fd 已经在 epoll 实例中注册过了。
(3) epoll_wait 等待事件——当完成文件描述符的注册后,就可以使用 epoll_wait 函数来阻塞等待事件的发生,并获取就绪的事件列表。其函数原型为:
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
-
epfd:同样是 epoll 实例的文件描述符。
-
events:是一个指向 epoll_event 结构体数组的指针,当有事件发生时,内核会将就绪事件的相关信息填充到这个数组中。
-
maxevents:指定了 events 数组的大小,即最多可以返回的就绪事件数量。
-
timeout:设置等待事件发生的超时时间,单位是毫秒。当 timeout 为 -1 时,表示无限期等待,直到有事件发生;当 timeout 为 0 时,表示立即返回,无论是否有事件发生;当 timeout 为一个大于 0 的值时,表示等待指定的时间,如果在这段时间内有事件发生则提前返回,否则在超时后返回。
epoll_wait 函数执行时,内核会检查 epoll 实例中注册的文件描述符的状态。如果有任何一个文件描述符上发生了注册的事件(如可读、可写、错误等),内核会将这些就绪事件的信息填充到 events 数组中,并返回就绪事件的数量;
如果在 timeout 时间内没有任何事件发生,函数会返回 0;如果发生错误,函数会返回 -1,并设置 errno,例如 EINTR 表示等待过程中被信号中断。通过 epoll_wait 函数返回的就绪事件列表,用户程序可以针对性地对发生事件的文件描述符进行相应的处理,从而实现高效的 I/O 多路复用。
3.3 水平触发(LT)与边缘触发(ET)
(1)水平触发(LT)原理——在 epoll 的事件通知机制中,水平触发(Level Triggered,简称 LT)是默认的工作模式。在 LT 模式下,只要文件描述符(fd)处于可读或可写状态,epoll_wait 函数每次调用都会返回相应的事件。具体来说,对于读事件,只要套接字的接收缓冲区中有数据可读,无论这次读取是否将缓冲区数据读完,下次调用 epoll_wait 时仍然会报告可读事件;对于写事件,只要套接字的发送缓冲区有空闲空间可写,epoll_wait 就会返回可写事件。
这种模式的特点是操作相对简单直观,开发者不需要特别关注一次性将数据处理完的问题,可以根据实际情况分多次读取或写入数据 。例如,在处理一个大文件的传输时,可以每次从接收缓冲区读取一部分数据进行处理,而不用担心会遗漏事件。
// LT 为 epoll 默认模式,无需额外设置触发方式
struct epoll_event ev;
ev.events = EPOLLIN; // 监听读事件,默认 LT 水平触发
ev.data.fd = client_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);
// 事件处理:单次读取即可,未读完数据下次 epoll_wait 仍会触发
char buf[1024] = {0};
// 阻塞读取,无需循环读空缓冲区
ssize_t n = read(client_fd, buf, sizeof(buf) - 1);
if (n > 0) {
printf("接收数据:%s\n", buf);
} else if (n == 0) {
close(client_fd);
}
// 缓冲区若残留数据,下一轮 epoll_wait 会再次检测并触发读事件
(2)边缘触发(ET)原理——边缘触发(Edge Triggered,简称 ET)模式则与 LT 模式有所不同。在 ET 模式下,只有当文件描述符的状态发生变化时,即从不可读变为可读、从不可写变为可写时,epoll_wait 才会触发一次事件通知。这就要求开发者在接收到事件通知后,必须尽可能一次性将所有可用数据读取完毕(对于读事件)或写入完毕(对于写事件) 。
因为如果没有一次性处理完所有数据,后续即使缓冲区中还有数据,也不会再次触发事件,直到新的数据到达或状态再次发生变化。例如,当一个套接字有新的数据到达时,epoll_wait 会触发一次读事件通知,此时需要通过循环读取,直到 read 函数返回 EAGAIN 或 EWOULDBLOCK 错误,才表示数据已全部读取完毕。在 ET 模式下,必须使用非阻塞 I/O,因为如果使用阻塞 I/O,在读取或写入大量数据时可能会导致线程阻塞,无法及时处理其他事件。
// 1. 将文件描述符设置为非阻塞模式(ET 模式必备)
int set_nonblock(int fd) {
int flag = fcntl(fd, F_GETFL);
return fcntl(fd, F_SETFL, flag | O_NONBLOCK);
}
// 2. 注册 ET 边缘触发读事件
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 显式指定 ET 触发模式
ev.data.fd = client_fd;
set_nonblock(client_fd); // 设置非阻塞 IO
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);
// 3. 事件处理:必须循环读取,直到数据读空
char buf[1024] = {0};
ssize_t n;
while (1) {
n = read(client_fd, buf, sizeof(buf) - 1);
if (n > 0) {
printf("接收数据:%s\n", buf);
memset(buf, 0, sizeof(buf));
} else if (n == 0) {
// 客户端断开连接
close(client_fd);
break;
} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 数据已全部读取完毕,退出循环,等待下一次状态变化
break;
} else {
// 读取异常,关闭文件描述符
perror("read error");
close(client_fd);
break;
}
}
在实际应用中,LT 模式适用于连接数不多、数据处理逻辑复杂的场景,比如一些小型的网络服务器,或者对实时性要求不是特别高的应用,开发者可以更方便地控制数据处理流程。而 ET 模式则更适合高并发、大流量的服务器程序,如大型的 Web 服务器、实时通信系统等,这些场景下对性能要求极高,ET 模式能够充分发挥其减少事件通知次数、提高处理效率的优势。但需要注意的是,使用 ET 模式时,开发者需要更加谨慎地编写代码,确保数据的完整处理,避免因处理不当而导致数据丢失或事件遗漏等问题。
四、epoll 核心源码分析
下面提供基于 epoll 的 C 语言高并发 TCP 服务端完整实现,程序可监听并快速建立客户端连接,可靠处理双向数据收发,防止数据丢失。借助 epoll 高效管理海量并发连接,适配物联网网关、大型网络服务等高并发场景,性能稳定。下面通过一段极简示例代码,直观演示 epoll 实际开发用法:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>
#define PORT 8888
#define MAX_EVENTS 1024
#define BUFFER_SIZE 1024
// 设置套接字为非阻塞模式
void setnonblocking(int sockfd) {
int flags = fcntl(sockfd, F_GETFL, 0);
if (flags == -1) {
perror("fcntl F_GETFL failed");
exit(EXIT_FAILURE);
}
if (fcntl(sockfd, F_SETFL, flags | O_NONBLOCK) == -1) {
perror("fcntl F_SETFL failed");
exit(EXIT_FAILURE);
}
}
int main() {
int listenfd, epfd;
struct sockaddr_in server_addr;
struct epoll_event event, events[MAX_EVENTS];
// 创建监听套接字
listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd == -1) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
// 设置服务器地址和端口
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
// 绑定地址和端口
if (bind(listenfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind failed");
close(listenfd);
exit(EXIT_FAILURE);
}
// 监听连接请求
if (listen(listenfd, 10) == -1) {
perror("listen failed");
close(listenfd);
exit(EXIT_FAILURE);
}
printf("Server is listening on port %d...\n", PORT);
// 创建 epoll 实例
epfd = epoll_create1(0);
if (epfd == -1) {
perror("epoll_create1 failed");
exit(EXIT_FAILURE);
}
// 注册监听套接字到 epoll 实例
event.data.fd = listenfd;
event.events = EPOLLIN;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &event) == -1) {
perror("epoll_ctl add listenfd failed");
close(epfd);
close(listenfd);
exit(EXIT_FAILURE);
}
while (1) {
// 等待事件发生
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
if (errno == EINTR) {
continue;
} else {
perror("epoll_wait failed");
break;
}
}
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == listenfd) {
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
int clientfd = accept(listenfd, (struct sockaddr *)&client_addr, &client_addr_len);
if (clientfd == -1) {
perror("accept failed");
continue;
}
setnonblocking(clientfd);
event.data.fd = clientfd;
event.events = EPOLLIN | EPOLLET;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &event) == -1) {
perror("epoll_ctl add clientfd failed");
close(clientfd);
}
} else {
int clientfd = events[i].data.fd;
char buffer[BUFFER_SIZE];
int n = read(clientfd, buffer, sizeof(buffer));
if (n == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
continue;
} else {
perror("read failed");
close(clientfd);
continue;
}
} else if (n == 0) {
close(clientfd);
} else {
buffer[n] = '\0';
// 处理接收到的数据,这里可以添加业务逻辑
printf("Received from client: %s\n", buffer);
const char *response = "Message received successfully!";
send(clientfd, response, strlen(response), 0);
}
}
}
}
// 关闭套接字和 epoll 实例
close(listenfd);
close(epfd);
return 0;
}
在 Linux 环境下进行编译和运行。编译时使用 gcc -o epoll_server epoll_server.c 命令(假设代码保存为 epoll_server.c),运行时执行./epoll_server。通过实际运行代码,观察服务器与客户端之间的通信过程,深入理解 epoll 在高性能网络编程中的应用,也可以根据实际需求对代码进行修改和扩展,以适应不同的业务场景。
4.1 创建 epoll 实例
对应的创建逻辑代码示例如下:
int epfd = epoll_create1(0);
if (epfd == -1) {
perror("epoll_create1 failed");
exit(EXIT_FAILURE);
}
这里使用 epoll_create1 函数创建一个 epoll 实例,参数 0 表示使用默认设置。如果创建失败,epoll_create1 函数返回 -1,并通过 perror 函数输出错误信息,然后程序以 EXIT_FAILURE 状态退出。创建成功后,返回的 epfd 将用于后续对 epoll 实例的操作,它就像是进入 epoll 世界的钥匙,通过它可以进行文件描述符的注册、事件的等待等操作。
4.2 注册监听套接字
实现该注册流程的完整代码如下:
struct epoll_event event;
event.data.fd = listenfd;
event.events = EPOLLIN;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &event) == -1) {
perror("epoll_ctl add listenfd failed");
close(epfd);
close(listenfd);
exit(EXIT_FAILURE);
}
首先定义一个 epoll_event 结构体变量 event,将需要监听的套接字 listenfd 赋值给 event.data.fd,表示这个事件与 listenfd 相关联。然后设置 event.events 为 EPOLLIN,表示关注该套接字的读事件,因为对于监听套接字来说,有新连接到来时会产生读事件。
接着使用 epoll_ctl 函数将 listenfd 添加到 epoll 实例中,操作类型为 EPOLL_CTL_ADD。如果添加失败,同样通过 perror 输出错误信息,关闭已经打开的 epoll 实例和监听套接字,并退出程序。这一步就像是在 epoll 这个 “大管家” 那里登记了一个需要特别关注的 “对象”,一旦这个 “对象” 有了相关事件,“大管家” 就会通知我们。
4.3 处理新连接
对应处理新客户端连接的核心代码如下:
if (events[i].data.fd == listenfd) {
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
int clientfd = accept(listenfd, (struct sockaddr *)&client_addr, &client_addr_len);
if (clientfd == -1) {
perror("accept failed");
continue;
}
setnonblocking(clientfd);
event.data.fd = clientfd;
event.events = EPOLLIN | EPOLLET;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &event) == -1) {
perror("epoll_ctl add clientfd failed");
close(clientfd);
}
}
在 epoll_wait 返回的事件列表中,如果某个事件对应的文件描述符是 listenfd,则说明有新的连接请求到来。此时通过 accept 函数接受连接,返回一个新的用于与客户端通信的套接字 clientfd。
如果 accept 失败,通过 perror 输出错误信息后继续循环,处理下一个事件。接着调用 setnonblocking 函数将 clientfd 设置为非阻塞模式,这在高并发场景下非常重要,可以避免在处理某个客户端连接时阻塞其他连接的处理。
然后将 clientfd 及其相关事件(这里设置为 EPOLLIN | EPOLLET,即边缘触发模式下的读事件)注册到 epoll 实例中,如果注册失败,则关闭 clientfd。这一系列操作就像是在新客户上门时,先热情迎接(accept),然后为其安排一个高效的服务模式(非阻塞),最后将其纳入 epoll 的管理体系中,以便后续能及时处理与该客户的通信。
4.4 处理客户端数据读写
实现客户端收发数据逻辑的代码示例:
else {
int clientfd = events[i].data.fd;
char buffer[BUFFER_SIZE];
ssize_t n;
// ET 边缘触发必须循环读取,直到缓冲区无数据
while ((n = read(clientfd, buffer, sizeof(buffer) - 1)) > 0) {
buffer[n] = '\0';
// 处理接收到的数据,这里可以添加业务逻辑
printf("Received from client: %s\n", buffer);
const char *response = "Message received successfully!";
send(clientfd, response, strlen(response), 0);
}
if (n == 0) {
// 客户端正常关闭连接
close(clientfd);
} else if (n == -1 && errno != EAGAIN && errno != EWOULDBLOCK) {
// 读取发生真实错误,关闭连接
perror("read failed");
close(clientfd);
}
}
当事件对应的文件描述符不是 listenfd 时,说明是已连接客户端有数据可读。由于客户端套接字采用 EPOLLET 边缘触发模式,仅在数据首次到达时触发一次事件,因此必须通过 while 循环持续读取,直到 read 返回 EAGAIN/EWOULDBLOCK,代表缓冲区数据全部读取完毕,防止数据滞留缓冲区造成事件永久丢失。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)