高并发基石|深入理解IO复用技术之epoll
1.写在前面
-
IO复用的概念 -
epoll出现之前的IO复用工具 -
epoll三级火箭 -
epoll底层实现 -
ET模式<模式 -
一道腾讯面试题 -
epoll惊群问题
2.初识复用技术和IO复用
在了解epoll之前,我们先看下复用技术的概念和IO复用到底在说什么?
2.1 复用概念和资源特性
2.1.1 复用的概念
复用技术 multiplexing 并不是新技术而是一种设计思想,在通信和硬件设计中存在频分复用、时分复用、波分复用、码分复用等,在日常生活中复用的场景也非常多,因此不要被专业术语所迷惑。
从本质上来说,复用就是为了解决有限资源和过多使用者的不平衡问题,从而实现最大的利用率,处理更多的问题。
2.1.2 资源的可释放
举个例子:
不可释放场景:ICU 病房的呼吸机作为有限资源,病人一旦占用且在未脱离危险之前是无法放弃占用的,因此不可能几个情况一样的病人轮流使用。
可释放场景:对于一些其他资源比如医护人员就可以实现对多个病人的同时监护,理论上不存在一个病人占用医护人员资源不释放的场景。
所以我们可以想一下,多个 IO 共用的资源(处理线程)是否具备可释放性?
2.1.3 理解IO复用
I/O的含义:在计算机领域常说的IO包括磁盘 IO 和网络 IO,我们所说的IO复用主要是指网络 IO ,在Linux中一切皆文件,因此网络IO也经常用文件描述符 FD 来表示。
复用的含义:那么这些文件描述符 FD 要复用什么呢?在网络场景中复用的就是任务处理线程,所以简单理解就是多个IO共用1个处理线程。
IO复用的可行性:IO请求的基本操作包括read和write,由于网络交互的本质性,必然存在等待,换言之就是整个网络连接中FD的读写是交替出现的,时而可读可写,时而空闲,所以IO复用是可用实现的。
综上认为:IO复用技术就是协调多个可释放资源的FD交替共享任务处理线程完成通信任务,实现多个fd对应1个任务处理线程的复用场景。
现实生活中IO复用就像一只边牧管理几百只绵羊一样:
2.1.4 IO复用的出现背景
画外音:上面的一段话可能读起来有些绕,朴素的说法就是让任务处理线程以更小的资源消耗来协调更多的网络请求连接,IO复用工具也是逐渐演进的,经过前后对比就可以发现这个原则一直贯穿其中。
3. Linux的IO复用工具概览
3.1 先驱者select
/* According to POSIX.1-2001 */
#include <sys/select.h>
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
3.1.1 官方提示
Macro: int FD_SETSIZE
The value of this macro is the maximum number of file descriptors that a fd_set object can hold information about. On systems with a fixed maximum number, FD_SETSIZE is at least that number. On some systems, including GNU, there is no absolute limit on the number of descriptors open, but this macro still has a constant value which controls the number of bits in an fd_set; if you get a file descriptor with a value as high as FD_SETSIZE, you cannot put that descriptor into an fd_set.
3.1.2 存在的问题和客观评价
-
可协调fd数量和数值都不超过1024 无法实现高并发 -
使用O(n)复杂度遍历fd数组查看fd的可读写性 效率低 -
涉及大量kernel和用户态拷贝 消耗大 -
每次完成监控需要再次重新传入并且分事件传入 操作冗余
3.2 继承者epoll
-
对fd数量没有限制(当然这个在poll也被解决了) -
抛弃了bitmap数组实现了新的结构来存储多种事件类型 -
无需重复拷贝fd 随用随加 随弃随删 -
采用事件驱动避免轮询查看可读写事件
epoll出现之后大大提高了并发量对于C10K问题轻松应对,即使后续出现了真正的异步IO,也并没有(暂时没有)撼动epoll的江湖地位。
4. 初识epoll
4.1 epoll的基础API和数据结构
//用户数据载体
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
//fd装载入内核的载体
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
//三板斧api
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
4.2 epoll三级火箭的科班理解
-
epoll_create
该接口是在内核区创建一个epoll相关的一些列结构,并且将一个句柄fd返回给用户态,后续的操作都是基于此fd的,参数size是告诉内核这个结构的元素的大小,类似于stl的vector动态数组,如果size不合适会涉及复制扩容,不过貌似4.1.2内核之后size已经没有太大用途了; -
epoll_ctl
该接口是将fd添加/删除于epoll_create返回的epfd中,其中epoll_event是用户态和内核态交互的结构,定义了用户态关心的事件类型和触发时数据的载体epoll_data; -
epoll_wait
该接口是阻塞等待内核返回的可读写事件,epfd还是epoll_create的返回值,events是个结构体数组指针存储epoll_event,也就是将内核返回的待处理epoll_event结构都存储下来,maxevents告诉内核本次返回的最大fd数量,这个和events指向的数组是相关的;
4.3 epoll三级火箭的通俗解释
-
epoll_create场景
大学开学第一周,你作为班长需要帮全班同学领取相关物品,你在学生处告诉工作人员,我是xx学院xx专业xx班的班长,这时工作人员确定你的身份并且给了你凭证,后面办的事情都需要用到(也就是调用epoll_create向内核申请了epfd结构,内核返回了epfd句柄给你使用); -
epoll_ctl场景
你拿着凭证在办事大厅开始办事,分拣办公室工作人员说班长你把所有需要办理事情的同学的学生册和需要办理的事情都记录下来吧,于是班长开始在每个学生手册单独写对应需要办的事情:李明需要开实验室权限、孙大熊需要办游泳卡......就这样班长一股脑写完并交给了工作人员(也就是告诉内核哪些fd需要做哪些操作); -
epoll_wait场景
你拿着凭证在领取办公室门前等着,这时候广播喊xx班长你们班孙大熊的游泳卡办好了速来领取、李明实验室权限卡办好了速来取....还有同学的事情没办好,所以班长只能继续(也就是调用epoll_wait等待内核反馈的可读写事件发生并处理);
4.4 epoll官方demo
#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;
/* Set up listening socket, 'listen_sock' (socket(),
bind(), listen()) */
epollfd = epoll_create(10);
if(epollfd == -1) {
perror("epoll_create");
exit(EXIT_FAILURE);
}
ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if(epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
perror("epoll_ctl: listen_sock");
exit(EXIT_FAILURE);
}
for(;;) {
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_pwait");
exit(EXIT_FAILURE);
}
for (n = 0; n < nfds; ++n) {
if (events[n].data.fd == listen_sock) {
//主监听socket有新连接
conn_sock = accept(listen_sock,
(struct sockaddr *) &local, &addrlen);
if (conn_sock == -1) {
perror("accept");
exit(EXIT_FAILURE);
}
setnonblocking(conn_sock);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
&ev) == -1) {
perror("epoll_ctl: conn_sock");
exit(EXIT_FAILURE);
}
} else {
//已建立连接的可读写句柄
do_use_fd(events[n].data.fd);
}
}
}
5. epoll的底层细节
5.1 底层数据结构
红黑树节点定义:
#ifndef _LINUX_RBTREE_H
#define _LINUX_RBTREE_H
#include <linux/kernel.h>
#include <linux/stddef.h>
#include <linux/rcupdate.h>
struct rb_node {
unsigned long __rb_parent_color;
struct rb_node *rb_right;
struct rb_node *rb_left;
} __attribute__((aligned(sizeof(long))));
/* The alignment might seem pointless, but allegedly CRIS needs it */
struct rb_root {
struct rb_node *rb_node;
};
epitem定义:
struct epitem {
struct rb_node rbn;
struct list_head rdllink;
struct epitem *next;
struct epoll_filefd ffd;
int nwait;
struct list_head pwqlist;
struct eventpoll *ep;
struct list_head fllink;
struct epoll_event event;
}
eventpoll定义:
struct eventpoll {
spin_lock_t lock;
struct mutex mtx;
wait_queue_head_t wq;
wait_queue_head_t poll_wait;
struct list_head rdllist; //就绪链表
struct rb_root rbr; //红黑树根节点
struct epitem *ovflist;
}
5.2 底层调用过程
-
创建并初始化一个strut epitem类型的对象,完成该对象和被监控事件以及epoll对象eventpoll的关联;
-
将struct epitem类型的对象加入到epoll对象eventpoll的红黑树中管理起来;
-
将struct epitem类型的对象加入到被监控事件对应的目标文件的等待列表中,并注册事件就绪时会调用的回调函数,在epoll中该回调函数就是ep_poll_callback();
-
ovflist主要是暂态处理,调用ep_poll_callback()回调函数的时候发现eventpoll的ovflist成员不等于EP_UNACTIVE_PTR,说明正在扫描rdllist链表,这时将就绪事件对应的epitem加入到ovflist链表暂存起来,等rdllist链表扫描完再将ovflist链表中的元素移动到rdllist链表;
5.3 易混淆的数据拷贝
一种广泛流传的错误观点:
epoll_wait返回时,对于就绪的事件,epoll使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗
revents = ep_item_poll(epi, &pt);//获取就绪事件
if (revents) {
if (__put_user(revents, &uevent->events) ||
__put_user(epi->event.data, &uevent->data)) {
list_add(&epi->rdllink, head);//处理失败则重新加入链表
ep_pm_stay_awake(epi);
return eventcnt ? eventcnt : -EFAULT;
}
eventcnt++;
uevent++;
if (epi->event.events & EPOLLONESHOT)
epi->event.events &= EP_PRIVATE_BITS;//EPOLLONESHOT标记的处理
else if (!(epi->event.events & EPOLLET)) {
list_add_tail(&epi->rdllink, &ep->rdllist);//LT模式处理
ep_pm_stay_awake(epi);
}
}
6.LT模式和ET模式
6.1 LT/ET的简单理解
6.2 LT/ET的深入理解
6.2.1 LT的读写操作
6.2.2 ET的读写操作
6.2.3 一道腾讯面试题
使用Linux epoll模型的LT水平触发模式,当socket可写时,会不停的触发socket可写的事件,如何处理?
当需要向socket写数据时,将该socket加入到epoll等待可写事件。接收到socket可写事件后,调用write或send发送数据,当数据全部写完后, 将socket描述符移出epoll列表,这种做法需要反复添加和删除。
向socket写数据时直接调用send发送,当send返回错误码EAGAIN,才将socket加入到epoll,等待可写事件后再发送数据,全部数据发送完毕,再移出epoll模型,改进的做法相当于认为socket在大部分时候是可写的,不能写了再让epoll帮忙监控。
6.2.4 ET模式的线程饥饿问题
为每个已经准备好的描述符维护一个队列,这样程序就可以知道哪些描述符已经准备好了但是并没有被读取完,然后程序定时或定量的读取,如果读完则移除,直到队列为空,这样就保证了每个fd都被读到并且不会丢失数据。
6.2.5 EPOLLONESHOT设置
6.2.6 LT和ET的选择
7.epoll的惊群问题
你在广场喂鸽子,你只投喂了一份食物,却引来一群鸽子争抢,最终还是只有一只鸽子抢到了食物,对于其他鸽子来说是徒劳的。
8.巨人的肩膀
-
http://harlon.org/2018/04/11/networksocket5/ -
https://devarea.com/linux-io-multiplexing-select-vs-poll-vs-epoll/#.Xa0sDqqFOUk -
https://jvns.ca/blog/2017/06/03/async-io-on-linux--select--poll--and-epoll/ -
https://zhuanlan.zhihu.com/p/78510741 -
http://www.cnhalo.net/2016/07/13/linux-epoll/ -
https://www.ichenfu.com/2017/05/03/proxy-epoll-thundering-herd/ -
https://github.com/torvalds/linux/commit/df0108c5da561c66c333bb46bfe3c1fc65905898 -
https://simpleyyt.com/2017/06/25/how-ngnix-solve-thundering-herd/