This browser does not support music or audio playback. Please play it in WeChat or another browser.
格列.弗雷 张博林 - 时光机
0.概述
-
I/O复用的定义和产生背景
-
Linux系统的I/O复用工具
-
epoll设计的基本构成
-
epoll高性能的底层实现
-
epoll的ET模式和LT模式
1.复用技术和I/O复用
-
复用的概念
-
资源的可释放性
-
理解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复用就像一只边牧管理几百只绵羊一样:
-
IO复用的设计原则和产生背景
高效IO复用机制要满足:协调者消耗最少的系统资源、最小化FD的等待时间、最大化FD的数量、任务处理线程最少的空闲、多快好省完成任务 等。
在网络并发量非常小的原始时期,即使per req per process地处理网络请求也可以满足要求,但是随着网络并发量的提高,原始方式必将阻碍进步,所以就刺激了IO复用机制的实现和推广。2.Linux中IO复用工具
在Linux中先后出现了select、poll、epoll等,FreeBSD的kqueue也是非常优秀的IO复用工具,kqueue的原理和epoll很类似,本文以Linux环境为例,并且不讨论过多select和poll的实现机制和细节。-
开拓者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);
作为第一个IO复用系统调用,select使用一个宏定义函数按照bitmap原理填充fd,默认大小是1024个,因此对于fd的数值大于1024都可能出现问题,看下官方预警:
Macro: int FD_SETSIZEThe value of this macro is the maximum number of file descriptorsthat 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.
也就是说当fd的数值大于1024时在将不可控,官方不建议超过1024,但是我们也无法控制fd的绝对数值大小,之前针对这个问题做过一些调研,结论是系统对于fd的分配有自己的策略,会大概率分配到1024以内,对此我并没有充分理解,只是提及一下这个坑。
存在的问题:
-
可协调fd数量和数值都不超过1024 无法实现高并发
-
使用O(n)复杂度遍历fd数组查看fd的可读写性 效率低
-
涉及大量kernel和用户态拷贝 消耗大
-
每次完成监控需要再次重新传入并且分事件传入 操作冗余
-
继承者epoll
-
对fd数量没有限制(当然这个在poll也被解决了)
-
抛弃了bitmap数组实现了新的结构来存储多种事件类型
-
无需重复拷贝fd 随用随加 随弃随删
-
采用事件驱动避免轮询查看可读写事件
3.epoll的基本实现
-
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 */ }; //三板斧apiint 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);
-
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指向的数组是相关的;
-
epoll_event是用户态需监控fd的代言人,后续用户程序对fd的操作都是基于此结构的;
-
通俗描述:
-
epoll_create场景:
大学开学第一周,你作为班长需要帮全班同学领取相关物品,你在学生处告诉工作人员,我是xx学院xx专业xx班的班长,这时工作人员确定你的身份并且给了你凭证,后面办的事情都需要用到(也就是调用epoll_create向内核申请了epfd结构,内核返回了epfd句柄给你使用); -
epoll_ctl场景:
你拿着凭证在办事大厅开始办事,分拣办公室工作人员说班长你把所有需要办理事情的同学的学生册和需要办理的事情都记录下来吧,于是班长开始在每个学生手册单独写对应需要办的事情: 李明需要开实验室权限、孙大熊需要办游泳卡......就这样班长一股脑写完并交给了工作人员(也就是告诉内核哪些fd需要做哪些操作); -
epoll_wait场景:
你拿着凭证在领取办公室门前等着,这时候广播喊xx班长你们班孙大熊的游泳卡办好了速来领取、李明实验室权限卡办好了速来取....还有同学的事情没办好,所以班长只能继续(也就是调用epoll_wait等待内核反馈的可读写事件发生并处理);
-
官方DEMO
#define MAX_EVENTS 10struct 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); } }}
在epoll_wait时需要区分是主监听线程fd的新连接事件还是已连接事件的读写请求,进而单独处理。
4.epoll的底层实现
epoll底层实现最重要的两个数据结构:epitem和eventpoll。 可以简单的认为epitem是和每个用户态监控IO的fd对应的,eventpoll是用户态创建的管理所有被监控fd的结构,详细的定义如下:#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;};
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; }
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;}
-
底层调用过程
-
创建并初始化一个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链表中;
-
如图展示了红黑树、双链表、epitem之间的关系:
-
epoll_wait的数据拷贝
关于epoll_wait使用共享内存的方式来加速用户态和内核态的数据交互,避免内存拷贝的观点,并没有得到2.6内核版本代码的证实,并且关于这次拷贝的实现是这样的:常见错误观点: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); }}
5.ET模式和LT模式
-
简单理解
-
深入理解
-
LT的读写操作
天下没有免费的午餐,总是无代价地提醒是不可能的,对应write的过度提醒,需要使用者随用随加,否则将一直被提醒可写事件。
-
ET的读写操作
-
一道面试题
这道题目对LT和ET考察比较深入,验证了前文说的LT模式write问题。使用Linux epoll模型的LT水平触发模式,当socket可写时,会不停的触发socket可写的事件,如何处理?
网络流传的腾讯面试题
普通做法:
当需要向socket写数据时,将该socket加入到epoll等待可写事件。接收到socket可写事件后,调用write()或send()发送数据,当数据全部写完后, 将socket描述符移出epoll列表,这种做法需要反复添加和删除。 改进做法: 向socket写数据时直接调用send()发送,当send()返回错误码EAGAIN,才将socket加入到epoll,等待可写事件后再发送数据,全部数据发送完毕,再移出epoll模型,改进的做法相当于认为socket在大部分时候是可写的,不能写了再让epoll帮忙监控。 上面两种做法是对LT模式下write事件频繁通知的修复,本质上ET模式就可以直接搞定,并不需要用户层程序的补丁操作。-
ET模式的线程饥饿问题
-
EPOLLONESHOT设置
-
两种模式的选择
在2.6.18内核中accept的惊群问题已经被解决了,但是在epoll中仍然存在惊群问题,表现起来就是当多个进程/线程调用epoll_wait时会阻塞等待,当内核触发可读写事件,所有进程/线程都会进行响应,但是实际上只有一个进程/线程真实处理这些事件。
在epoll官方没有正式修复这个问题之前,Nginx作为知名使用者采用全局锁来限制每次可监听fd的进程数量,每次只有1个可监听的进程,后来在Linux 3.9内核中增加了SO_REUSEPORT选项实现了内核级的负载均衡,Nginx1.9.1版本支持了reuseport这个新特性,从而解决惊群问题。EPOLLEXCLUSIVE是在2016年Linux 4.5内核新添加的一个 epoll 的标识,Ngnix 在 1.11.3 之后添加了NGX_EXCLUSIVE_EVENT选项对该特性进行支持。EPOLLEXCLUSIVE标识会保证一个事件发生时候只有一个线程会被唤醒,以避免多侦听下的惊群问题。
7.参考资料-
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/
分享和关注是对作者最大的鼓励!
持续感谢审核委员会鹅厂JC同学!