搞懂这篇文章,关于 IO 复用的问题就信手拈来了
以一个生活中的例子来解释.
假设你在大学中读书,要等待一个朋友来访,而这个朋友只知道你在 A 号楼,但是不知道你具体住在哪里,于是你们约好了在 A 号楼门口见面.
如果你使用的阻塞 IO 模型来处理这个问题,那么你就只能一直守候在 A 号楼门口等待朋友的到来,在这段时间里你不能做别的事情,不难知道,这种方式的效率是低下的.
进一步解释 select 和 epoll 模型的差异.
select 版大妈做的是如下的事情:比如同学甲的朋友来了,select 版大妈比较笨,她带着朋友挨个房间进行查询谁是同学甲,你等的朋友来了,于是在实际的代码中,select 版大妈做的是以下的事情:
epoll 版大妈就比较先进了,她记下了同学甲的信息,比如说他的房间号,那么等同学甲的朋友到来时,只需要告诉该朋友同学甲在哪个房间即可,不用自己亲自带着人满大楼的找人了.于是 epoll 版大妈做的事情可以用如下的代码表示:
n = epoll_wait(epfd,events,20,500);
for(i=0;i<n;++i) { do_something(events[n]);
}
在 epoll 中,关键的数据结构 epoll_event 定义如下:
可以看到,epoll_data 是一个 union 结构体,它就是 epoll 版大妈用于保存同学信息的结构体,它可以保存很多类型的信息:
fd,指针,等等.有了这个结构体,epoll 大妈可以不用吹灰之力就可以定位到同学甲.
别小看了这些效率的提高,在一个大规模并发的服务器中,轮询 IO 是最耗时间的操作之一.再回到那个例子中,如果每到来一个朋友楼管大妈都要全楼的查询同学,那么处理的效率必然就低下了,过不久楼底就有不少的人了.
对比最早给出的阻塞 IO 的处理模型, 可以看到采用了多路复用 IO 之后, 程序可以自由的进行自己除了 IO 操作之外的工作, 只有到 IO 状态发生变化的时候由多路复用 IO 进行通知, 然后再采取相应的操作, 而不用一直阻塞等待 IO 状态发生变化了.
从上面的分析也可以看出,epoll 比 select 的提高实际上是一个用空间换时间思想的具体应用.
【文章福利】小编推荐自己的 linuxC/C++语言交流群:832218493,整理了一些个人觉得比较好的学习书籍、视频资料共享在里面,有需要的可以自行添加哦!~
更多优秀文章在公众号
二、深入理解 epoll 的实现原理:开发高性能网络程序时,windows 开发者们言必称 iocp,linux 开发者们则言必称 epoll。
大家都明白 epoll 是一种 IO 多路复用技术,可以非常高效的处理数以百万计的 socket 句柄,比起以前的 select 和 poll 效率高大发了。
我们用起 epoll 来都感觉挺爽,确实快,那么,它到底为什么可以高速处理这么多并发连接呢?
先简单回顾下如何使用 C 库封装的 3 个 epoll 系统调用吧。
使用起来很清晰,首先要调用 epoll_create 建立一个 epoll 对象。参数 size 是内核保证能够正确处理的最大句柄数,多于这个最大数时内核可不保证效果。
epoll_ctl 可以操作上面建立的 epoll,例如,将刚建立的 socket 加入到 epoll 中让其监控,或者把 epoll 正在监控的某个 socket 句柄移出 epoll,不再监控它等等。
epoll_wait 在调用时,在给定的 timeout 时间内,当在监控的所有句柄中有事件发生时,就返回用户态的进程。
从上面的调用方式就可以看到 epoll 比 select/poll 的优越之处:
因为后者每次调用时都要传递你所要监控的所有 socket 给 select/poll 系统调用,这意味着需要将用户态的 socket 列表 copy 到内核态,如果以万计的句柄会导致每次都要 copy 几十几百 KB 的内存到内核态,非常低效。
而我们调用 epoll_wait 时就相当于以往调用 select/poll,但是这时却不用传递 socket 句柄给内核,因为内核已经在 epoll_ctl 中拿到了要监控的句柄列表。
所以,实际上在你调用 epoll_create 后,内核就已经在内核态开始准备帮你存储要监控的句柄了,每次调用 epoll_ctl 只是在往内核的数据结构里塞入新的 socket 句柄。
在内核里,一切皆文件。所以,epoll 向内核注册了一个文件系统,用于存储上述的被监控 socket。
当你调用 epoll_create 时,就会在这个虚拟的 epoll 文件系统里创建一个 file 结点。当然这个 file 不是普通文件,它只服务于 epoll。epoll 在被内核初始化时(操作系统启动),同时会开辟出 epoll 自己的内核高速 cache 区,用于安置每一个我们想监控的 socket,这些 socket 会以红黑树的形式保存在内核 cache 里,以支持快速的查找、插入、删除。
这个内核高速 cache 区,就是建立连续的物理内存页,然后在之上建立 slab 层,简单的说,就是物理上分配好你想要的 size 的内存对象,每次使用时都是使用空闲的已分配好的对象。
static int __init eventpoll_init(void) {
… …
/* Allocates slab cache used to allocate “struct epitem” items /
epi_cache = kmem_cache_create(“eventpoll_epi”, sizeof(struct epitem),0,SLAB_HWCACHE_ALIGN| EPI_SLAB_DEBUG|SLAB_PANIC, NULL, NULL);
/ Allocates slab cache used to allocate “struct eppoll_entry” */
pwq_cache = kmem_cache_create(“eventpoll_pwq”, sizeof(struct eppoll_entry), 0, EPI_SLAB_DEBUG|SLAB_PANIC, NULL, NULL);
… …
epoll 的高效就在于,当我们调用 epoll_ctl 往里塞入百万个句柄时,epoll_wait 仍然可以飞快的返回,并有效的将发生事件的句柄给我们用户。
这是由于我们在调用 epoll_create 时,内核除了帮我们在 epoll 文件系统里建了个 file 结点,在内核 cache 里建了个红黑树用于存储以后 epoll_ctl 传来的 socket 外,还会再建立一个 list 链表,用于存储准备就绪的事件,当 epoll_wait 调用时,仅仅观察这个 list 链表里有没有数据即可。有数据就返回,没有数据就 sleep,等到 timeout 时间到后即使链表没数据也返回。所以,epoll_wait 非常高效。
那么,这个准备就绪 list 链表是怎么维护的呢?当我们执行 epoll_ctl 时,除了把 socket 放到 epoll 文件系统里 file 对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪 list 链表里。
所以,当一个 socket 上有数据到了,内核在把网卡上的数据 copy 到内核中后就来把 socket 插入到准备就绪链表里了。
如此,一颗红黑树,一张准备就绪句柄链表,少量的内核 cache,就帮我们解决了大并发下的 socket 处理问题。
执行 epoll_create 时,创建了红黑树和就绪链表,执行 epoll_ctl 时,如果增加 socket 句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行 epoll_wait 时立刻返回准备就绪链表里的数据即可。
最后看看 epoll 独有的两种模式 LT 和 ET。无论是 LT 和 ET 模式,都适用于以上所说的流程。
区别是,LT 模式下,只要一个句柄上的事件一次没有处理完,会在以后调用 epoll_wait 时次次返回这个句柄,而 ET 模式仅在第一次返回。
这件事怎么做到的呢?当一个 socket 句柄上有事件时,内核会把该句柄插入上面所说的准备就绪 list 链表,这时我们调用 epoll_wait,会把准备就绪的 socket 拷贝到用户态内存,然后清空准备就绪 list 链表,最后,epoll_wait 干了件事,就是检查这些 socket,如果不是 ET 模式(就是 LT 模式的句柄了),并且这些 socket 上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了。
所以,非 ET 的句柄,只要它上面还有事件,epoll_wait 每次都会返回。而 ET 模式的句柄,除非有新中断到,即使 socket 上的事件没有处理完,也是不会次次从 epoll_wait 返回的。
三、扩展阅读(epoll 与之前其他相关技术的比较):
Linux 提供了 select、poll、epoll 接口来实现 IO 复用,三者的原型如下所示,本文从参数、实现、性能等方面对三者进行对比。
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
select、poll、epoll_wait 参数及实现对比
1、select 的第一个参数 nfds 为 fdset 集合中最大描述符值加 1,fdset 是一个位数组,其大小限制为__FD_SETSIZE(1024),位数组的每一位代表其对应的描述符是否需要被检查。
select 的第二三四个参数表示需要关注读、写、错误事件的文件描述符位数组,这些参数既是输入参数也是输出参数,可能会被内核修改用于标示哪些描述符上发生了关注的事件。
所以每次调用 select 前都需要重新初始化 fdset。
timeout 参数为超时时间,该结构会被内核修改,其值为超时剩余的时间。
select 对应于内核中的 sys_select 调用,sys_select 首先将第二三四个参数指向的 fd_set 拷贝到内核,然后对每个被 SET 的描述符调用进行 poll,并记录在临时结果中(fdset),如果有事件发生,select 会将临时结果写到用户空间并返回;当轮询一遍后没有任何事件发生时,如果指定了超时时间,则 select 会睡眠到超时,睡眠结束后再进行一次轮询,并将临时结果写到用户空间,然后返回。 select 返回后,需要逐一检查关注的描述符是否被 SET(事件是否发生)。
2、poll 与 select 不同,通过一个 pollfd 数组向内核传递需要关注的事件,故没有描述符个数的限制,pollfd 中的 events 字段和 revents 分别用于标示关注的事件和发生的事件,故 pollfd 数组只需要被初始化一次。
poll 的实现机制与 select 类似,其对应内核中的 sys_poll,只不过 poll 向内核传递 pollfd 数组,然后对 pollfd 中的每个描述符进行 poll,相比处理 fdset 来说,poll 效率更高。 poll 返回后,需要对 pollfd 中的每个元素检查其 revents 值,来得指事件是否发生。
3、epoll 通过 epoll_create 创建一个用于 epoll 轮询的描述符,通过 epoll_ctl 添加/修改/删除事件,通过 epoll_wait 检查事件,epoll_wait 的第二个参数用于存放结果。 epoll 与 select、poll 不同,首先,其不用每次调用都向内核拷贝事件描述信息,在第一次调用后,事件信息就会与对应的 epoll 描述符关联起来。另外 epoll 不是通过轮询,而是通过在等待的描述符上注册回调函数,当事件发生时,回调函数负责把发生的事件存储在就绪事件链表中,最后写到用户空间。
作者:金发萌音
链接:https://www.jianshu.com/p/b5bc204da984
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
评论 (2 条评论)