【Linux】多路转接epoll、Linux高并发I/O多路复用
📚 博主的专栏
🐧 Linux | 🖥️ C++ | 📊 数据结构 | 💡C++ 算法 | 🅒 C 语言 | 🌐 计算机网络
上篇文章:五种IO模型与阻塞IO以及多路转接select机制编写echoserver
下篇文章: 利用多路转接epoll机制、ET模式,基于Reactor设计模式实现EchoServer
摘要:本文全面剖析Linux中epoll机制的原理与实现,详解其核心接口(
epoll_create
、epoll_ctl
、epoll_wait
)及底层数据结构(红黑树与就绪队列),对比水平触发(LT)与边缘触发(ET)模式的工作机制与适用场景。通过编写基于epoll的服务器实例,演示如何高效管理连接与数据读写,并深入探讨ET模式下非阻塞文件描述符的必要性。文章还对比select、poll与epoll的性能差异,强调epoll在高并发场景中的优势(无数量限制、事件回调机制、低内存拷贝开销)。最后,指出epoll的局限性与优化方向,为开发高性能服务器提供实践指导。
目录
epoll 的作用与定位
epoll接口
1. epoll_create:创建 epoll 实例
2. epoll_ctl:管理监控的文件描述符
示例
3. epoll_wait:等待事件就绪
epoll的工作原理
1.理解数据怎样到达主机
2.epoll原理
3. 工作流程
在内核当中,如何管理epoll
编写EpollServer实验echo_server1.0:
EpollServer.hpp1.0
处理事件HandlerEvent()
红黑树是如何提高epoll效率的
处理就绪的读事件(listen套接字的读事件)
处理普通套接字的读写事件
总结, epoll 的使用过程三部曲:
epoll 的优点(和 select 的缺点对应)
对比总结 select, poll, epoll 之间的优点和缺点
epoll 的工作方式边缘触发(ET)和水平触发(LT)模式:
理解 ET 模式和非阻塞文件描述符
对比 LT 和 ET
应用场景:
本篇文章的完整代码:epollserver-echoserver
epoll 的作用与定位
epoll 的作用
epoll 用于监控多个文件描述符(fd),当这些 fd 上出现新的事件并准备就绪时,epoll 会通知程序,此时可以进行 IO 数据拷贝操作。
epoll 的定位
epoll 的核心职责是事件监控与就绪通知,它仅负责等待事件就绪,并在就绪后完成事件派发。
epoll接口
1. epoll_create
:创建 epoll 实例
#include <sys/epoll.h>int epoll_create(int size); // 传统接口(已过时)
int epoll_create1(int flags); // 现代接口(推荐使用)
功能
-
创建一个新的 epoll 实例,返回一个文件描述符(
epfd
),后续所有操作均基于此描述符。
参数
size
(传统接口):
早期用于指定内核预分配的监控文件描述符数量,但内核会动态调整,实际已废弃,传入任意正整数均可(通常填
1
)。
flags
(现代接口epoll_create1
):
标志位,常用
0
(默认行为)或EPOLL_CLOEXEC
(设置文件描述符在执行exec
时自动关闭)。
返回值
成功:返回
epfd
(epoll 实例的文件描述符)。失败:返回
-1
,错误码在errno
中。
示例
int epfd = epoll_create1(0);
if (epfd == -1) {perror("epoll_create1 failed");exit(EXIT_FAILURE);
}
2. epoll_ctl
:管理监控的文件描述符
#include <sys/epoll.h>int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能
向
epoll
实例中添加、修改或删除需要监控的文件描述符(fd
)。
参数
epfd
:epoll_create
返回的 epoll 实例描述符。
op
:操作类型:
EPOLL_CTL_ADD
:添加fd
到监控列表。
EPOLL_CTL_MOD
:修改fd
的监控事件。
EPOLL_CTL_DEL
:从监控列表中删除fd
。
fd
:需要监控的目标文件描述符(如 socket)。
event
:指向struct epoll_event
的指针,描述监控的事件类型和用户数据。
struct epoll_event
结构体typedef union epoll_data {void *ptr; // 用户自定义指针(灵活,但需手动管理)int fd; // 关联的文件描述符(常用)uint32_t u32; // 32位整数uint64_t u64; // 64位整数 } epoll_data_t;struct epoll_event {uint32_t events; // 监控的事件集合(位掩码)epoll_data_t data; // 用户数据(事件触发时返回) };
events
字段的常用标志
标志 | 说明 |
---|---|
| 文件描述符可读(如 socket 接收缓冲区有数据) |
| 文件描述符可写(如 socket 发送缓冲区有空间) |
| 文件描述符发生错误(自动监控,无需显式设置) |
| 对端关闭连接(如 TCP 连接被关闭) |
| 设置为边缘触发(ET)模式(默认是水平触发 LT) |
| 事件触发后自动从监控列表移除,需重新添加(避免多线程重复处理) |
返回值
成功:返回
0
。失败:返回
-1
,错误码在errno
中(如EBADF
、EINVAL
等)。
示例
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 监听可读事件,使用 ET 模式
ev.data.fd = sockfd; // 关联 socket 文件描述符if (epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev) == -1) {perror("epoll_ctl ADD failed");close(sockfd);
}
3. 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
数组的最大容量(必须大于 0)。
timeout
:超时时间(毫秒):
-1
:永久阻塞,直到事件发生。
0
:立即返回,非阻塞模式。
>0
:等待指定毫秒数。
返回值
成功:返回 就绪事件的数量(填充到
events
数组中)。失败:返回
-1
,错误码在errno
中(如EINTR
被信号中断)。
示例
#define MAX_EVENTS 10
struct epoll_event events[MAX_EVENTS];int n = epoll_wait(epfd, events, MAX_EVENTS, 1000); // 等待 1 秒
if (n == -1) {perror("epoll_wait failed");
} else if (n == 0) {printf("Timeout!\n");
} else {for (int i = 0; i < n; i++) {if (events[i].events & EPOLLIN) {// 处理可读事件handle_read(events[i].data.fd);}if (events[i].events & EPOLLERR) {// 处理错误close(events[i].data.fd);}}
}
epoll的工作原理
1.理解数据怎样到达主机
数据包通过网络接口卡(NIC,Network Interface Card)被接收后,OS怎么知道网卡中有数据?操作系统(OS)通过硬件中断机制来感知网卡中是否有数据到达。
具体来说,当网卡接收到数据包时,它会触发一个硬件中断信号,通知操作系统有数据需要处理。以下是这一过程的详细说明:
网卡接收数据:当网络数据包到达网卡时,网卡会将这些数据存储在它的接收缓冲区(Rx Buffer)中。
触发硬件中断:网卡在数据存储完成后,会向CPU发送一个硬件中断信号(Interrupt Request, IRQ)。这个中断信号是通过主板上的中断控制器(如APIC或PIC)传递给CPU的。
中断处理程序(ISR):CPU接收到中断信号后,会暂停当前正在执行的任务,并根据中断向量表(Interrupt Vector Table)找到对应的中断处理程序(Interrupt Service Routine, ISR)。对于网卡中断,OS会调用专门的中断处理程序来处理网卡的数据。
读取数据:在中断处理程序中,操作系统会从网卡的接收缓冲区中读取数据,并将其传递给网络协议栈(如TCP/IP协议栈)进行进一步处理。
中断结束:数据处理完成后,中断处理程序会通知中断控制器中断处理已完成,CPU可以继续执行之前被中断的任务。
2.epoll原理
epoll 采用了事件驱动模型,其高效性主要源于以下设计:
- 红黑树结构: epoll 使用红黑树来存储所有被监听的文件描述符(红黑树的key),使得添加、删除和查找操作的时间复杂度为 O(log n)。
- 就绪队列: 当某个文件描述符的事件发生时,内核会将其放入就绪队列(底层一旦有用户关心的数据就绪了,“构建”就绪队列的节点,写清楚,什么是fd,什么事件就绪了,并链入就绪队列),
epoll_wait
只需从该队列中获取就绪的文件描述符,而不需要遍历所有监听的文件描述符。采用双向链表存储已就绪的fd,实现快速事件通知。- 边缘触发(ET)和水平触发(LT)模式:
模式
触发条件 特点 水平触发(LT) 缓冲区有数据即触发 类似poll机制 边缘触发(ET) 仅当状态变化时触发 需一次性读取数据
3. 工作流程
注册阶段:通过
epoll_ctl
将fd加入红黑树时,内核:
- 为每个fd设置回调函数
ep_poll_callback
- 绑定fd到设备等待队列
事件触发:
- 当设备数据到达时,硬件产生中断
- 内核中断处理程序调用回调函数
- 回调函数将对应fd加入就绪队列
事件获取:
epoll_wait
检查就绪队列是否为空- 非空时直接返回就绪事件,时间复杂度O(1)
在内核当中,如何管理epoll
epoll模型是需要被OS内核管理的,先描述,再组织。它由操作系统内核进行管理,通过一个核心的数据结构
struct eventpoll
来实现事件的管理和通知。这个结构体在epoll
模型中扮演着关键角色,它负责维护两个重要的数据结构:就绪队列和红黑树。
红黑树中每个节点都是基于epitem结构中的rdllink成员与rbn成员的意思是?
在红黑树的实现中,每个节点通常包含多个成员变量,用于维护树的结构和节点的属性。具体到
epitem
结构中的rdllink
成员和rbn
成员,它们在红黑树中有特定的作用:
rbn
成员:
rbn
是红黑树节点的核心成员,通常是一个结构体或联合体,包含了红黑树节点的关键信息。- 它通常包括以下字段:
color
:表示节点的颜色(红色或黑色),用于维护红黑树的平衡性。parent
:指向父节点的指针,用于在树中进行向上遍历。left
和right
:分别指向左子节点和右子节点的指针,用于维护树的结构。rbn
成员的作用是将epitem
结构嵌入到红黑树中,使得epitem
能够作为红黑树的一个节点存在,并参与红黑树的插入、删除和查找等操作。
rdllink
成员:
rdllink
通常是一个双向链表的节点结构,用于将epitem
节点连接到一个双向链表中。- 它通常包括以下字段:
prev
:指向前一个节点的指针。next
:指向后一个节点的指针。rdllink
成员的作用是将epitem
节点组织成一个双向链表,这种结构常用于实现事件循环或事件队列等场景。通过rdllink
,可以方便地对epitem
节点进行遍历、插入和删除操作。总结来说,
rbn
成员用于将epitem
结构嵌入到红黑树中,使其能够作为红黑树的一个节点参与树的平衡和维护;而rdllink
成员则用于将epitem
节点组织成一个双向链表,便于在事件处理等场景中进行快速访问和操作。两者共同协作,使得epitem
结构既能高效地参与红黑树的动态平衡,又能方便地参与到其他数据结构的操作中。
• 当某一进程调用 epoll_create 方法时, Linux 内核会创建一个 eventpoll 结构体, 这个结构体中有的就绪队列和红黑树与 epoll 的使用方式密切相关.
struct eventpoll
结构体通过两个指针分别指向就绪队列和红黑树:
rdllist
指针:指向就绪队列的链表头,用于快速获取已经就绪的文件描述符。rbr
指针:指向红黑树的根节点,用于管理所有被监控的文件描述符。这种设计使得
epoll
模型能够高效地处理大量并发连接。例如,在网络服务器场景中,epoll
可以同时监控成千上万的客户端连接,当某个连接有数据到达时,内核会将其添加到就绪队列中,用户程序通过epoll_wait
可以立即获取到这些连接并进行处理,而无需轮询所有连接,从而显著提高了系统的性能和响应速度。
编写EpollServer实验echo_server1.0:
准备好以下文件,可以在我的gitee获取点击链接,详细讲解可以看我之前的博客
.
├── EpollServer.hpp
├── InetAddr.hpp
├── LockGuard.hpp
├── Log.hpp
├── Main.cc
├── Makefile
└── Socket.hpp
EpollServer.hpp1.0
在服务器编程中,首先创建一个监听套接字
listensock
,绑定到特定 IP 和端口,并设置为监听状态。接着,通过epoll_create()
创建 epoll 实例,用于高效管理多个文件描述符的事件。使用epoll_ctl()
将listensock
添加到 epoll 监控列表,关联EPOLLIN
事件以监控新连接请求。epoll 持续监控listensock
,并在有新连接时通知应用程序。应用程序通过epoll_wait()
等待事件,检测到EPOLLIN
事件时调用accept()
接受新连接,并将新客户端套接字加入 epoll 监控列表,处理后续数据通信。
#pragma once #include <string> #include <iostream> #include <memory> #include <sys/epoll.h> #include "Log.hpp" #include "Socket.hpp" using namespace socket_ns; class EpollServer {const static int size = 128;const static int num = 128;public:EpollServer(uint16_t port) : _port(port), _listensock(std::make_unique<TcpSocket>()){_listensock->BuildListenSocket(port);_epfd = ::epoll_create(size);if (_epfd < 0){LOG(FATAL, "epoll create error\n");exit(1);}LOG(INFO, "epoll create success, epfd: %d\n", _epfd); // 4}void InitServer(){// 在获取连接前,需要先把listen套接字添加到epoll模型里(使用epoll_ctl)// 构建好epoll_event结构体struct epoll_event ev;// 新连接到来,需要关注的是读事件就绪ev.events = EPOLLIN;ev.data.fd = _listensock->Sockfd();// 关心listen套接字的ev事件int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->Sockfd(), &ev);if (n < 0){LOG(FATAL, "epoll_ctl error!\n");exit(2);}LOG(INFO, "epoll_ctl success! add new sockfd: %d\n", _listensock->Sockfd());}void Loop(){int timeout = 1000;while (true){// 通过epoll等待事件就绪后才能获取连接int n = ::epoll_wait(_epfd, revs, num, timeout); // 每隔1sswitch (n){case 0:LOG(INFO, "epoll time out...\n");break;case -1:LOG(ERROR, "epoll error...\n"); // 不会出现break;default:LOG(INFO, "haved event happend!, n: %d\n", n);break;}}}~EpollServer(){if (_epfd >= 0)::close(_epfd);_listensock->Close();}private:uint16_t _port;std::unique_ptr<Socket> _listensock;int _epfd;struct epoll_event revs[num]; };
运行结果: 这里我的timeout设置为1000ms也就是每等待1s监控是否有事件就绪。因为我并没有访问因此不断地显示超时。
接下来我将timeout设置为0,也就是非阻塞等待:只要没有就会一直检测并且发出时间超时的信号
将timeout设置为-1,也就是阻塞等待,直到有事件就绪。
使用浏览器,或者telnet访问服务器:事件就绪,n = 1,就绪事件为1
为什么会陷入死循环呢,因为事件就绪,但是并没有处理事件,同Select和poll(未将该文件描述符从就绪集合中移除,或者未重置其状态)。
在epoll:
事件被组织在一棵红黑树中,当某个事件被触发时,对应的节点会被标记为“激活”状态,并放入就绪队列中等待处理。如果上层应用程序未能及时从就绪队列中取出并处理该事件,或者未将节点的激活状态重置为未激活状态,那么该事件会一直保留在就绪队列中,导致
epoll
在下一次事件检测时再次返回该事件,从而形成死循环。在使用epoll
时,可以通过调用epoll_ctl
将文件描述符从红黑树中移除,或者通过正确处理事件来避免重复触发。
因此接下代码编写处理事件:
处理事件HandlerEvent()
需要明确:在我的代码里,就绪事件在revs这个数组里,就绪事件的个数为epoll_wait的返回值n,就绪事件在revs中,这些事件会按照顺序依次存放在
revs
数组中,从索引0
开始,直到索引n-1
结束。因此在遍历的时候我们只需要遍历就绪事件的个数次:
std::string EventsToString(u_int32_t events){std::string eventsstr;if (events & EPOLLIN)eventsstr = "EPOLLIN ";if (events & EPOLLOUT)eventsstr += "| EPOLLOUT";return eventsstr;}void HandlerEvent(int n){for (int i = 0; i < n; i++){int fd = revs[i].data.fd;uint32_t revents = revs[i].events;// 因为revents只是一个简单的标志位,因此我们设计一个接口能直接看到是什么事件LOG(INFO, "%d 上有事件就绪了,具体事件是:%s\n", fd, EventsToString(revents).c_str());}}
运行结果:上面的代码将我哪个fd上的什么就绪的事件打印出,方便观察,我通过telnet访问我的服务器,因此监听事件、代表有新连接到来连接,listen套接字的读事件就绪,因此我们接下来要获取连接。(在之后会有普通套接字就绪,我们的处理方式又会不同)
如何判断是listen套接字的事件就绪还是普通套接字的事件就绪可以通过fd比较的方式直接判断
在设置事件的时候,我们都会设置好listen套接字,在Tcp当中,当客户端发起连接请求时,监听套接字会变得可读,epoll_wait()函数会立即返回,并将该监听套接字的文件描述符放入就绪队列(ready list)中。由于监听套接字是服务器最先创建和监控的文件描述符,因此在epoll模型中,它通常是就绪队列和红黑树节点中第一个就绪的文件描述符。
因此这样判断:
if(revs[i].data.fd == _listensock->Sockfd())
通过
listen
套接字获取连接后,会获得一个新的sockfd
。对于这个新的sockfd
,我们无需等待写操作的就绪状态,因为它天生就具备写事件就绪,因此就可以send、write。然而,此时并不能进行读操作,因为我们无法确定对方是否已经发送了数据。如果没有数据发送,读操作将会被阻塞,因此此时不能read、recv。
epoll清楚底层有数据,因此需要将新的sockfd(也就是普通sockfd)添加到epoll中,通过之后的监管,就能知道什么事件就绪。
红黑树是如何提高epoll效率的
epoll 的内部实现依赖于红黑树(Red-Black Tree)这一数据结构来管理监控的文件描述符。红黑树是一种自平衡的二叉搜索树,具有以下特点:
- 查找效率高:红黑树的查找、插入和删除操作的时间复杂度均为 O(log n),这使得它在处理大量文件描述符时依然能够保持高效。
- 近似平衡:与 AVL 树相比,红黑树的平衡条件相对宽松,虽然不如 AVL 树严格平衡,但在实际应用中,红黑树的性能表现更为优越,尤其是在频繁插入和删除的场景下。
- 自动维护:红黑树在插入或删除节点后会自动调整结构以保持平衡,无需程序员手动干预。这种自动维护的特性大大简化了开发者的工作。
相比之下,传统的 Select 和 Poll 机制使用辅助数组来管理文件描述符,存在以下问题:
- 手动维护:程序员需要手动维护辅助数组,包括添加、删除和更新文件描述符,这不仅增加了代码复杂度,还容易引入错误。
- 效率低下:Select 和 Poll 需要遍历整个数组来检查每个文件描述符的状态,时间复杂度为 O(n),当监控的文件描述符数量较大时,性能会显著下降。
- 扩展性差:由于辅助数组的大小通常固定,当需要监控的文件描述符数量超过数组容量时,程序可能无法正常工作。
因此,epoll 通过红黑树的高效管理和自动维护特性,显著提升了 I/O 多路复用的性能和易用性,尤其适用于高并发场景。而 Select 和 Poll 的辅助数组则显得笨重且低效,逐渐被 epoll 所取代。
处理就绪的读事件(listen套接字的读事件)
void HandlerEvent(int n){for (int i = 0; i < n; i++){int fd = revs[i].data.fd;uint32_t revents = revs[i].events;// 因为revents只是一个简单的标志位,因此我们设计一个接口能直接看到是什么事件LOG(INFO, "%d 上有事件就绪了,具体事件是:%s\n", fd, EventsToString(revents).c_str());sleep(3);if (fd == _listensock->Sockfd()){InetAddr addr;int sockfd = _listensock->Accepter(&addr);if (sockfd < 0){LOG(ERROR, "获取连接失败\n");continue;}LOG(INFO, "得到了一个新的连接: %d, 客户端信息:%s:%d\n", sockfd, addr.Ip().c_str(), addr.Port());// 得到了一个新的sockfd,我们需不需要等待写的就绪,不需要,一个新的sockfd,先天就具备写事件就绪// 等底层有数据(读时间就绪),read/recv才不会被阻塞// epoll清楚底层有数据// 因此先将新的sockfd添加到epoll中,通过之后的监管,就能知道什么事件就绪struct epoll_event ev;ev.data.fd = sockfd;ev.events = EPOLLIN;::epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);LOG(INFO, "epoll_ctl success! add new sockfd: %d\n", sockfd);}}}
代码:实际上这里,我们只是用telnet和浏览器连服务器,按理来说telnet连接后获取到的fd是5,浏览器连接后获取到的fd是6,出现7的原因是,浏览器一旦连接了服务器不仅有一个新返回的读事件就绪,浏览器还会向服务器发送数据,但是由于我们还未处理写事件,因此写事件一直处于就绪状态,就陷入了死循环。
接下来处理普通套接字当中的事件:
处理普通套接字的读写事件
当客户端退出连接:
关闭连接时产生的套接字描述符,从epoll模型中移除该文件描述符?先后顺序是什么?
明确:要从epoll中移除一个fd,需要保证该fd是健康且合法的,否则会移除出错:这意味着在移除之前,fd仍然是一个有效的、未被关闭的描述符。
从epoll中移除文件描述符(fd)
先使用
epoll_ctl
系统调用,将fd从epoll实例中移除。通常使用EPOLL_CTL_DEL
操作。确保了epoll不再监控该fd,避免在fd关闭后epoll仍然尝试处理该fd的事件。epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);//红黑树底层是用的fd做的键值,因此并不需要写清楚事件是什么
关闭套接字描述符(fd):
在确认fd已经从epoll中移除后,调用
close
函数关闭该fdelse // 处理其他套接字 {char buffer[4096];// 不会阻塞,因为读事件就绪int n = ::recv(fd, buffer, sizeof(buffer) - 1, 0);if (n > 0){buffer[n] = 0;std::cout << buffer;// 构建一个简单的应答std::string response = "HTTP/1.0 200 OK\r\n";std::string content = "<html><body><h1>hello pupu, hello epoll</h1></body></html>";response += "Content-Type: text/html\r\n";response += "Content-Length: " + std::to_string(content.size()) + "\r\n";response += "\r\n";response += content;// 发送缓冲区有空间,写事件就就绪,不会被阻塞::send(fd, response.c_str(), response.size(), 0);}else if (n == 0) // 说明对方关闭了连接,因此需要close,并且删除{LOG(INFO, "client quit, close fd: %d\n", fd);// 关闭连接时产生的套接字描述符,从epoll模型中移除该文件描述符// 1.先移除epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr); // 红黑树底层是用的fd做的键值,因此并不需要写清楚事件是什么// 2.关闭文件描述符::close(fd);}else{LOG(ERROR, "recv error, close fd: %d\n", fd);// 1.先移除epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr); // 红黑树底层是用的fd做的键值,因此并不需要写清楚事件是什么// 2.关闭文件描述符::close(fd);} }
运行结果: 发送消息数据成功
退出成功:
优化代码结构,提升可读性:将连接获取与普通套接字IO事件处理逻辑分别封装为独立模块。
void Accepter(){InetAddr addr;int sockfd = _listensock->Accepter(&addr);if (sockfd < 0){LOG(ERROR, "获取连接失败\n");return;}LOG(INFO, "得到了一个新的连接: %d, 客户端信息:%s:%d\n", sockfd, addr.Ip().c_str(), addr.Port());// 得到了一个新的sockfd,我们需不需要等待写的就绪,不需要,一个新的sockfd,先天就具备写事件就绪// 等底层有数据(读时间就绪),read/recv才不会被阻塞// epoll清楚底层有数据// 因此先将新的sockfd添加到epoll中,通过之后的监管,就能知道什么事件就绪struct epoll_event ev;ev.data.fd = sockfd;ev.events = EPOLLIN;::epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);LOG(INFO, "epoll_ctl success! add new sockfd: %d\n", sockfd);}void HandlerIO(int fd){char buffer[4096];// 不会阻塞,因为读事件就绪int n = ::recv(fd, buffer, sizeof(buffer) - 1, 0);if (n > 0){buffer[n] = 0;std::cout << buffer;// 构建一个简单的应答std::string response = "HTTP/1.0 200 OK\r\n";std::string content = "<html><body><h1>hello pupu, hello epoll</h1></body></html>";response += "Content-Type: text/html\r\n";response += "Content-Length: " + std::to_string(content.size()) + "\r\n";response += "\r\n";response += content;// 发送缓冲区有空间,写事件就就绪::send(fd, response.c_str(), response.size(), 0);}else if (n == 0) // 说明对方关闭了连接,因此需要close,并且删除{LOG(INFO, "client quit, close fd: %d\n", fd);// 关闭连接时产生的套接字描述符,从epoll模型中移除该文件描述符// 1.先移除epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr); // 红黑树底层是用的fd做的键值,因此并不需要写清楚事件是什么// 2.关闭文件描述符::close(fd);}else{LOG(ERROR, "recv error, close fd: %d\n", fd);// 1.先移除epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr); // 红黑树底层是用的fd做的键值,因此并不需要写清楚事件是什么// 2.关闭文件描述符::close(fd);}}void HandlerEvent(int n){for (int i = 0; i < n; i++){int fd = revs[i].data.fd;uint32_t revents = revs[i].events;// 因为revents只是一个简单的标志位,因此我们设计一个接口能直接看到是什么事件LOG(INFO, "%d 上有事件就绪了,具体事件是:%s\n", fd, EventsToString(revents).c_str());if (fd == _listensock->Sockfd())Accepter();else // 处理其他套接字HandlerIO(fd);}}
以上:
服务器通过epoll机制持续监测事件状态,一旦检测到就绪事件,便立即进行处理。随后,系统会遍历就绪事件列表,根据事件类型将其分别派发给Accepter或HandlerIO模块进行后续处理。
总结, epoll 的使用过程三部曲:
• 调用 epoll_create 创建一个 epoll 句柄;
• 调用 epoll_ctl, 将要监控的文件描述符进行注册;
• 调用 epoll_wait, 等待文件描述符就绪;
epoll 的优点(和 select 的缺点对应)
• 接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开
• 数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频繁(而 select/poll 都是每次循环都要进行拷贝)
• 事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中, epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度 O(1). 即使文件描述符数目很多, 效率也不会受到影响.
• 没有数量限制: 文件描述符数目无上限
注意!!网上有些博客说, epoll 中使用了内存映射机制
• 内存映射机制: 内核直接将就绪队列通过 mmap 的方式映射到用户态. 避免了拷贝内存这样的额外性能开销.
这种说法是不准确的. 我们定义的 struct epoll_event 是我们在用户空间中分配好的内存. 势必还是需要将内核的数据拷贝到这个用户空间的内存中的.
对比总结 select, poll, epoll 之间的优点和缺点
- select 和 poll 适合处理少量或中等数量的连接,但性能较差,无法高效处理高并发场景。
- epoll 是 Linux 下处理高并发连接的最佳选择,性能优异,但缺乏跨平台支持。
- 根据具体需求选择合适的 I/O 多路复用机制,可以显著提升系统性能和可扩展性。
epoll 的工作方式边缘触发(ET)和水平触发(LT)模式:
假设你有一个水桶,水桶底部有一个水龙头,水龙头可以打开或关闭。
水桶代表一个文件描述符,水龙头的开关状态代表文件描述符的可读或可写状态。
水平触发(LT)模式: 在 LT 模式下,只要水桶里有水(即文件描述符处于可读状态),epoll 就会一直通知你。这就像你有一个朋友,他每隔一段时间就会过来看看水桶里是否有水。如果水桶里有水,他就会告诉你:“嘿,水桶里有水,你可以来取水了!”即使你取了一部分水,只要水桶里还有水,他下次来的时候还会继续提醒你。
边缘触发(ET)模式: 在 ET 模式下,epoll 只在水桶里的水从无到有的时候通知你一次。这就像你有一个朋友,他只在第一次发现水桶里有水的时候告诉你:“嘿,水桶里有水了!”之后,无论水桶里还有多少水,他都不会再提醒你。除非水桶里的水被取完,然后又重新有水,他才会再次提醒你。
在我们前面所编写的EchoServer1.0当中就是默认的LT模式:要设置成ET模式需要显示设置标记位
| 设置为边缘触发(ET)模式(默认是水平触发 LT) |
理解 ET 模式和非阻塞文件描述符
使用 ET 模式的 epoll, 需要将文件描述设置为非阻塞. 这个不是接口上的要求, 而是 "工程实践" 上的要求.
假设这样的场景:
服务器接收到一个 10k 的请求, 会向客户端返回一个应答数据. 如果客户端收不到应答, 不会发送第二个 10k 请求.
如果服务端写的代码是阻塞式的 read, 并且一次只 read 1k 数据的话(read 不能保证一次就把所有的数据都读出来, 参考 man 手册的说明, 可能被信号打断), 剩下的 9k 数据就会待在缓冲区中
此时由于 epoll 是 ET 模式, 并不会认为文件描述符读就绪. epoll_wait 就不会再次返回. 剩下的 9k 数据会一直在缓冲区中. 直到下一次客户端再给服务器写数据.epoll_wait 才能返回。
问题来了:
• 服务器只读到 1k 个数据, 要 10k 读完才会给客户端返回响应数据.
• 客户端要读到服务器的响应, 才会发送下一个请求
• 客户端发送了下一个请求, epoll_wait 才会返回, 才能去读缓冲区中剩余的数据
所以, 为了解决上述问题(阻塞 read 不一定能一下把完整的请求读完), 于是就可以使用非阻塞轮训的方式来读缓冲区, 保证一定能把完整的请求都读出来.
因此ET工作模式下,所有的fd都必须是非阻塞的
而如果是 LT 就不会有这个问题. 只要缓冲区中的数据没读完, 就能够让 epoll_wait 返回文件描述符读就绪。
对比 LT 和 ET
- 使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完.
- 相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的.
- 另一方面, ET 的代码复杂程度更高了.
ET模式下,高效在哪里?
- 可能给对方通告一个更大的接收窗口,增加IO效率
- ET通知效率更高(不需要一直重复拷贝)
- IO效率更高,可以尽快取走数据(具有强制性,因为是规定,LT没有被强制性)。
应用场景:
- LT 模式 适用于需要持续监控文件描述符状态的场景,比如一个简单的 HTTP 服务器,它需要持续监控客户端的连接请求。
- ET 模式 适用于需要高效处理大量事件的场景,比如一个高性能的 Web 服务器,它需要快速响应大量的并发请求,而不希望被重复通知同一个事件。
在下一篇技术文章中,我们将深入探讨如何使用ET(Edge Triggered)模式,基于多路转接机制
epoll
,结合Reactor设计模式来编写一个功能更为完善的服务器代码。本篇文章中我们实现的EchoServer虽然能够处理基本的网络通信任务,但在处理I/O事件时仍存在一些明显的缺陷。特别是在HandlerIO
函数中,当处理普通套接字的读写事件时,我们需要解决一个关键问题:如何确保从文件描述符(fd)读取的数据缓冲区(buffer)中包含的是一个完整的请求,而不是多个请求的混合数据。
在实际的网络通信中,数据可能以不完整的形式到达,或者多个请求的数据可能会被一次性读取到缓冲区中。如果每个文件描述符共享同一个缓冲区,那么不同fd的数据可能会相互覆盖,导致数据混乱或丢失。因此,我们必须确保每个文件描述符都有一个独立的缓冲区,以避免数据冲突。
为了更有效地处理这一问题,我们还需要引入一种协议机制。例如,可以使用定长协议或变长协议来标识每个请求的边界。定长协议假设每个请求的长度是固定的,而变长协议则需要在数据中包含长度信息,以便正确分割和解析请求。通过这种方式,我们可以确保每个请求被完整地读取和处理,而不会与其他请求的数据混淆。
在具体实现中,我们可以为每个文件描述符分配一个独立的缓冲区,并在读取数据时根据协议规则进行解析。例如,如果使用变长协议,可以在每个请求的前几个字节中存储请求的长度信息,然后根据该长度信息读取相应数量的字节,确保每个请求被完整处理。这种方法不仅提高了数据处理的准确性,还增强了服务器的健壮性和可维护性。
结语:
随着这篇博客接近尾声,我衷心希望我所分享的内容能为你带来一些启发和帮助。学习和理解的过程往往充满挑战,但正是这些挑战让我们不断成长和进步。我在准备这篇文章时,也深刻体会到了学习与分享的乐趣。
在此,我要特别感谢每一位阅读到这里的你。是你的关注和支持,给予了我持续写作和分享的动力。我深知,无论我在某个领域有多少见解,都离不开大家的鼓励与指正。因此,如果你在阅读过程中有任何疑问、建议或是发现了文章中的不足之处,都欢迎你慷慨赐教。
你的每一条反馈都是我前进路上的宝贵财富。同时,我也非常期待能够得到你的点赞、收藏,关注这将是对我莫大的支持和鼓励。当然,我更期待的是能够持续为你带来有价值的内容。