目录
简介:
一、IO 多路复用介绍
1、select,poll,epoll 引入
2、select,poll,epoll 区别分析
3、epoll 原理
3.1 epoll 相关函数介绍
1)epoll_create
2)epoll_ctl
3)epoll_wait
3.2 epoll 高效的原理
1)epoll 高效原因一
2)epoll 高效原因二
3.3 epoll 的水平触发(Level Triggered, LT)和边缘触发(Edge Triggered, ET)
1)水平触发(LT)
2)边缘触发(ET)
3.4 哪些 fd 可以用 epoll 来管理?
简介:
在 Linux 系统之中有一个核心武器:epoll 池,在高并发的,高吞吐的 IO 系统中常常见到 epoll 的身影。
一、IO 多路复用介绍
1 个程序就可以负责管理多个 fd 句柄,负责应对所有的业务方的 IO 请求。这种一对多的 IO 模式我们就叫做 IO 多路复用。
多路是指?多个业务方(句柄)并发下来的 IO 。
复用是指?复用一个处理程序。
1、select,poll,epoll 引入
写个 for 循环,每次都尝试 IO 一下,读/写到了就处理,读/写不到就 sleep 下。这样我们不就实现了 1 对多的 IO 多路复用嘛。
问题:for 循环每次要定期 sleep 1s,这个会导致吞吐能力极差,因为很可能在刚好要 sleep 的时候,所有的 fd 都准备好 IO 数据,而这个时候却要硬生生的等待 1s,可想而知。。。不sleep又会导致CPU占用过高。
我们再梳理下 IO 多路复用的需求和原理。IO 多路复用就是 1 个线程处理 多个 fd 的模式。我们的要求是:这个 “1” 就要尽可能的快,避免一切无效工作,要把所有的时间都用在处理句柄的 IO 上,不能有任何空转,sleep 的时间浪费。
Linux内核提供 select,poll,epoll 工具实现IO多路复用。
2、select,poll,epoll 区别分析
Linux 内核提供了 3 种工具 select,poll,epoll 实现IO多路复用
为什么有 3 种?
历史不断改进,矬 -> 较矬 -> 卧槽、高效 的演变而已。
这 3 种都能够管理 fd 的可读可写事件,在所有 fd 不可读不可写无所事事的时候,可以阻塞线程,切走 cpu 。fd 有情况的时候,都要线程能够要能被唤醒。
而这三种方式以 epoll 池的效率最高。为什么效率最高?其实无非就是 epoll 做的无用功最少,select 和 poll 或多或少都要多余的拷贝,需遍历fd ,所以效率自然就低了。
举个例子,以 select 和 epoll 来对比举例:池子里管理了 1024 个句柄,loop 线程被唤醒的时候,select 都是蒙的,都不知道这 1024 个 fd 里谁 IO 准备好了。这种情况怎么办?只能遍历这 1024 个 fd ,一个个测试。假如只有一个句柄准备好了,那相当于做了 1 千多倍的无效功。
epoll 则不同,从 epoll_wait 醒来的时候就能精确的拿到就绪的 fd 数组,不需要任何测试,拿到的就是要处理的。
3、epoll 原理
下面我们看一下 epoll 的使用和原理。
3.1 epoll 相关函数介绍
epoll 的使用非常简单,只有下面 3 个系统调用。
- epoll_create:负责创建一个池子,一个监控和管理句柄 fd 的池子;
- epoll_ctl:负责管理这个池子里的 fd 增、删、改;
- epoll_wait:就是负责打盹的,让出 CPU 调度,但是只要有“事”,立马会从这里唤醒;
1)epoll_create
int epoll_create(int size);
功能:创建一个epoll句柄
参数:
size:监听个数
2)epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- epfd: 要操作的 epoll 句柄,也就是使用 epoll_create 函数创建的 epoll 句柄
- op: 表示要对 epfd(epoll 句柄)进行的操作,可以设置为:
EPOLL_CTL_ADD | 向epfd添加文件参数fd表示的描述符 |
EPOLL_CTL_MOD | 修改参数fd的event事件 |
EPOLL_CTL_DEL | 从epfd删除fd描述符 |
- fd:要监视的文件描述符
- event: 要监视的事件类型,为 epoll_event 结构体类型指针, epoll_event 结构体类型如下所示:
struct epoll_event {uint32_t events; /* epoll 事件 */epoll_data_t data; /* 用户数据 */
};
结构体 epoll_event 的 events 成员变量表示要监视的事件,这些事件可以进行“或”操作,也就是说可以设置监视多个事件:
EPOLLIN | 有数据可以读取 |
EPOLLOUT | 可以写数据 |
EPOLLPRI | 有紧急的数据需要读取 |
EPOLLERR | 指定的文件描述符发生错 |
EPOLLHUP | 指定的文件描述符挂起 |
EPOLLET | 设置 epoll 为边沿触发,默认触发模式为水平触发 |
EPOLLONESHOT | 一次性的监视,当监视完成以后还需要再次监视某个 fd,那么就需要将fd 重新添加到 epoll 里面 |
3)epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
功能:等待事件的发生
- epfd: 要等待的 epoll
- events: 指向 epoll_event 结构体的数组,当有事件发生的时候 Linux 内核会填写 events,调用者可以根据 events 判断发生了哪些事件
- maxevents: events 数组大小,必须大于 0
- timeout: 超时时间,单位为 ms
3.2 epoll 高效的原理
Linux 下,epoll 的实现几乎没有做任何无效功,因此 epoll 作为高并发 IO 实现的秘密武器。 我们从使用的角度切入来一步步分析下。
首先,epoll 的第一步是创建 epoll 池。这个使用 epoll_create 来做:
示例:
epollfd = epoll_create(1024);
if (epollfd == -1) {perror("epoll_create");exit(EXIT_FAILURE);
}
这个池子对我们来说是黑盒,这个黑盒是用来装 fd 的,我们暂不纠结其中细节。我们拿到了一个 epollfd ,这个 epollfd 就能唯一代表这个 epoll 池。注意,这里又有一个细节:用户可以创建多个 epoll 池。
然后,我们就要往这个 epoll 池里放 fd 了,这就要用到 epoll_ctl 了
示例:
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, 11, &ev) == -1) {perror("epoll_ctl: listen_sock"); exit(EXIT_FAILURE);
}
上面,我们就把句柄 11 放到这个池子里了,op(EPOLL_CTL_ADD)表明操作是增加、修改、删除,event 结构体可以指定监听事件类型,可读、可写。
1)epoll 高效原因一
添加 fd 进池子也就算了,如果是修改、删除呢?怎么做到快速?
这里就涉及到你怎么管理 fd 的数据结构了。最常见的思路:用 list 链表,可以吗?功能上可以,但是性能上拉垮。list 的结构来管理元素,时间复杂度都太高 O(n),每次要一次次遍历链表才能找到位置。池子越大,性能会越慢。
epoll怎么做到快速增、删、改的?
红黑树。Linux 内核对于 epoll 池的内部实现就是用红黑树的结构体来管理这些注册进程来的句柄 fd。红黑树是一种平衡二叉树,时间复杂度为 O(log n),就算这个池子就算不断的增删改,也能保持非常稳定的查找性能。
2)epoll 高效原因二
怎么才能保证数据准备好之后,立马感知呢?
epoll_ctl 这里会涉及到一点。秘密就是:回调的设置。在 epoll_ctl 的内部实现中,除了把句柄结构用红黑树管理,另一个核心步骤就是设置 poll 回调。
思考来了:poll 回调是什么?怎么设置?
先说说 file_operations->poll 是什么?
这个是定制监听事件的机制实现。通过 poll 机制让上层能直接告诉底层,我这个 fd 一旦读写就绪了,请底层硬件(比如网卡)回调的时候自动把这个 fd 相关的结构体放到指定队列中,并且唤醒操作系统。
举个例子:网卡收发包其实走的异步流程,操作系统把数据丢到一个指定地点,网卡不断的从这个指定地点掏数据处理。请求响应通过中断回调来处理,中断一般拆分成两部分:硬中断和软中断。poll 函数就是把这个软中断回来的路上再加点料,只要读写事件触发的时候,就会立马通知到上层,采用这种事件通知的形式就能把浪费的时间窗就完全消失了。
划重点:这个 poll 事件回调机制则是 epoll 池高效最核心原理。
划重点:epoll 池管理的句柄只能是支持了 file_operations->poll 的文件 fd。换句话说,如果一个“文件”所在的文件系统没有实现 poll 接口,那么就用不了 epoll 机制。
第二个问题:poll 怎么设置?
在 epoll_ctl 下来的实现中,有一步是调用 vfs_poll 这个里面就会有个判断,如果 fd 所在的文件系统的 file_operations 实现了 poll ,那么就会直接调用,如果没有,那么就会报告响应的错误码。
static inline __poll_t vfs_poll(struct file *file, struct poll_table_struct *pt){if (unlikely(!file->f_op->poll))return DEFAULT_POLLMASK;return file->f_op->poll(file, pt);
}
ep_poll_callback
,这个函数其实很简单,做两件事情: - 把事件就绪的 fd 对应的结构体放到一个特定的队列(就绪队列,ready list);
- 唤醒 epoll ,活来啦!
epoll_wait
出唤醒。 这个对应结构体是什么?
结构体叫做 struct epitem ,每个注册到 epoll 池的 fd 都会对应一个 epitem。
- 当用户调用epoll_create()时,会创建eventpoll对象(包含一个红黑树和一个双链表);
- 而用户调用epoll_ctl(ADD)时,会在红黑树上增加节点(epitem对象);
就绪队列需要用很高级的数据结构吗?
就绪队列就简单了,因为没有查找的需求了呀,只要是在就绪队列中的 epitem ,都是事件就绪的,必须处理的。所以就绪队列就是一个最简单的双指针链表。
- 内部管理 fd 使用了高效的红黑树结构管理,做到了增删改之后性能的优化和平衡;
- epoll 池添加 fd 的时候,调用 file_operations->poll ,把这个 fd 就绪之后的回调路径安排好。通过事件通知的形式,做到最高效的运行;
3.epoll 池核心的两个数据结构:红黑树和就绪列表。红黑树是为了应对用户的增删改需求,就绪列表是 fd 事件就绪之后放置的特殊地点,epoll 池只需要遍历这个就绪链表,就能给用户返回所有已经就绪的 fd 数组;
3.3 epoll 的水平触发(Level Triggered, LT)和边缘触发(Edge Triggered, ET)
epoll 的水平触发(Level Triggered, LT)和边缘触发(Edge Triggered, ET)是两种不同的事件通知机制,它们定义了 epoll 如何向应用程序报告文件描述符上的事件。理解这两种模式的差异对于使用 epoll 处理并发网络连接是很重要的。
代码设置epoll触发模式:
int epollfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 边缘触发模式
ev.data.fd = sockfd;
epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev);
1)水平触发(LT)
在水平触发模式下,只要满足条件的事件仍然存在,epoll 就会重复通知这个事件。比如,如果一个文件描述符上有可读数据,那么只要没有读完,epoll_wait 就会不断报告该文件描述符是可读的。这种模式的特点是:
- 容错性较好,不易丢失事件。
- 更易于编程和理解。
- 可以用于多线程程序中,多个线程可以共享同一个 epoll 文件描述符。
2)边缘触发(ET)
边缘触发模式下,事件只在状态变化时被通知一次,之后无论是否读完,也不会再次通知,直到状态再次发生变化才会再次触发。例如,只有当新数据到达使得文件描述符从非可读变为可读时,epoll_wait 才会报告可读事件。边缘触发模式的特点是:
- 效率更高,因为它减少了事件的重复通知。
- 需要更加小心地处理每次通知,确保处理所有的数据,否则可能会丢失未处理完的数据。
- 更适合单线程或者每个线程使用独立 epoll 文件描述符的场景。
若还不明白水平触发和边缘触发的差异,可以看下这篇文章的例子:
https://zhuanlan.zhihu.com/p/719987328
3.4 哪些 fd 可以用 epoll 来管理?
由于并不是所有的 fd 对应的文件系统都实现了 poll 接口,所以自然并不是所有的 fd 都可以放进 epoll 池,那么有哪些文件系统的 file_operations 实现了 poll 接口?
最常见的就是网络套接字:socket 。网络也是 epoll 池最常见的应用地点。Linux 下万物皆文件,socket 实现了一套 socket_file_operations 的逻辑( net/socket.c ):
static const struct file_operations socket_file_ops = {.read_iter = sock_read_iter,.write_iter = sock_write_iter, .poll = sock_poll, // ...
};