转载

理解网络IO模型 (dwz.cn)

1 前言

本系列从模型谈起,然后源码级分析其中的(流行的高效的)IO复用模型(select+poll+epoll),最后给出针对IO复用模型的具体实现对比。

2 模型概念

我们看下stevens的图:

理解网络IO模型 (dwz.cn)

如图,5种IO模型。我们逐一看看:

1)阻塞IOsocket默认都是阻塞的,进程在发出IO系统调用后一直堵塞,直到内核有数据且把数据拷贝给进程后,该进程才继续运行。

2)非阻塞IO设置socket为非堵塞的,进程反复调用IO系统调用,如果内核没数据就立即返回继续调用;否则堵塞直到内核把数据拷贝给该进程后,该进程继续运行。

3)IO复用进程调用IO系统调用,同时监测多个socket。如果所有socket都没有数据则堵塞;否则进程继续运行并通过另一个系统调用以堵塞IO方式去读取数据。

4)信号驱动IO进程调用IO系统调用后不受影响地继续运行,直到内核有数据并发信号给该进程。该进程处理信号时通过另一个系统调用以堵塞IO方式去读取数据。

5)异步IO进程发出IO系统调用后不受影响地继续运行,直到内核有数据并把数据拷贝给进程才发信号给该进程。该进程处理信号即可,无需读取。

我们的理解是:

非堵塞IO是内嵌了堵塞IO方式,而IO复用、信号驱动IO,则是追加使用了堵塞IO方式的。换句话说,相对于异步IO,堵塞IO、非堵塞IO、IO复用、信号驱动IO这四种都是同步的,因为都有堵塞发生(参考上图)。

接下来我们分析IO复用模型,其典型实现就是select、poll、epoll。

select是老字号,可移植性好,但是支持的文件描述符数量有限。poll解决了select的文件描述符数量问题,除此之外,和select机制基本一样。select/poll只支持LT,而epoll则追加支持ET;select/poll每次扫描所有文件描述符,而epoll则仅扫描活跃的(有数据的)文件描述符从而大大提高性能。

了解了以上内容,下次我们从select开始谈起。。。(未完待续)

上次我们讲了IO模型,这次我们来分析流行的IO复用模型的具体实现,先看select(基于linux kernel 4.1代码)。

0 调用关系

当userspace调到select系统调用后的调用关系:sys_select->core_sys_select->do_select->poll(此处的poll是指各个具体fd设备自定义的poll函数)。

我们依照调用关系看看。

1 sys_select

0)拷贝timeout结构体到kernel。

【拷贝操作,影响效率】

1)kernel据此计算出一个基于微秒级别的timeout值。

【微妙级别】

2)传入3个fd set和timeout调用core_sys_select,返回值是timeout还剩下的时间间隔。

【当然也可以不设置具体timeout,select会一直阻塞下去(直到有事件发生)】

3)拷贝剩下的时间间隔拷贝到userspace。

【拷贝操作,影响效率】

2 core_sys_select

0)在栈内分配一个long类型数组(含32个long,不够以后再动态申请)用于以bitmap形式存放6个fd set。【此处可见在fd数量大时会影响效率,每次select需要动态申请内存(每次都申请和释放)】

1)获取进程的支持的打开fd的最大数量以控制扫描时的范围【此处也体现效率低下,线性扫描范围的判断太简单】

2)kernel设置好6个fd set:in+out+exp+res_in+res_out+res_exp,其中前3个从userspace拷贝过来的。【由于select入参只有3个fd set,既作入参也作出参,这也是引入userspace和kernel之间多次拷贝操作的原因;而kernel是直接使用6个fd set,不需要复用】

【6个fd set,每次select/poll都要以bitmap方式扫描,可见效率。。。】

【对于kernel而言,select支持的fd个数是可以改大的,只是改后影响效率而已】

3)调用 do_selec t逐个处理每个fd set的每个fd,会调用到该fd的poll函数:

理解网络IO模型 (dwz.cn)

poll函数是该设备的具体实现,不管如何实现,都需要将代表本进程的poll_table结构(内嵌于poll_wqueues结构)挂入该fd的需要监测事件的等待队列里,如果发现监测事件是活跃的,则返回结果集。

【6个fd set(以bitmap形式)并行处理,这样直接掌控一个fd需要监测的所有事件(包括返回的结果集)。想想,这里仅是对一个fd的一次poll调用,但却是告知了该文件的所有需要监测事件】

【这里传入具体设备poll函数里的wait参数是关键,其实就是一个poll_wqueues结构,该结构连接select/poll框架和具体设备的实现,该结构体管理了select/poll的所有监测fd的所有监测事件的等待队列里的元素(即poll_table_entry结构)。】

【poll函数执行时的传入poll_table结构意味着该进程有可能在该fd的监测事件上睡眠。传入该结构体,就意味着poll函数可以通过该结构体分配的poll_table_entry结构去完成睡眠/唤醒的流程。】

【并不是每个fd在其poll函数执行时都要传入poll_table结构的,有两种情况是例外的:一是当逐个扫描时已经发现有活跃事件了,后续的fd就不需要传入了;二是已经全部扫描一遍了,没有发现活跃事件(这样所有fd的poll函数其实都已经传入了)然后睡眠去了,之后再被唤醒后的重新扫描时不需要再传入了。】

【poll函数每次传入poll_table结构体(select/poll虽然监测很多fd,但是其实是用的同一个poll_table结构体),该结构体的作用是分配poll_table_entry结构,分配的个数与fd的个数、fd的监测事件的个数成正比。如此多的poll_table_entry结构体的分配和释放在每次select/poll里都需要执行,可见效率。。。】

【每次select,需要遍历所有fd,对每个fd调用poll函数。这个遍历操作,在一次select里发生至少一次。】

4)拷贝监测结果(即poll函数返回的结果集)给userspace【拷贝操作,影响效率】

3 总结

总之select是IO复用模型的开山鼻祖(1983年bsd引入),年代久远,自然有些缺点(监控的fd个数越多,效率越低),后来的poll/epoll继续完善该模型,我们下次讲。。。(未完待续)

先回顾一下select。

核心的数据结构poll_wqueues,它作为沟通具体设备和select/poll框架的桥梁,非常关键。从设计上看,如果仅监控一个fd,那么完全可以直接把这个poll_wqueues当作一个poll_table用就可以了,但是现在是检测多个,所以就只能从poll_wqueues里分解出多个poll_table了(一个poll_table检测某一个fd的某一个事件)。

相比select,poll只是小的改动,那么我们就对比着看看:

1)fd set由数组改为链表【这个小改动大大简化了代码,再也不用担心fd最大数目的问题了,也不用再逐一bit比较每个fd set的bitmap了】

2)timeout支持到纳秒级别【更精确了】

3)支持restart poll【如果被信号中断,可以重新启动】

4)系统调用的参数中将检测事件和事件的结果分开【依然没改变kernel和userspace之间的拷贝次数】

除此之前,poll和select可以说完全一样,也就是说还是没有改变select里多次遍历所有fd这个最大缺点!我们下次看epoll如何解决的。。。(未完待续)

先回顾一下之前讲的select/poll。

poll对文件描述符的跟踪方式从array到list的改变,解决了监测文件个数有限的问题,但其它问题都没解决:1)每次select/poll都需要拷贝fd set。2)监测多个设备的事件时采用遍历的方式,每次select/poll时都至少一次的遍历。在监测的设备数量多且大部分不活跃的场景下,这都会大大降低效率!

那如何提高这个场景下的效率呢?epoll就是为此而生的:

Epoll is designed to function somewhat like select() or poll(), but with more options and with higher performance when large numbers of file descriptors are in use. Each call to select() orpoll() can involve an entirely new set of file descriptors, so the kernel must go through the process of validating each one, checking for I/O readiness, and adding the polling process to the appropriate wait queue. But the actual list of file descriptors tends not to change much between calls, so much of that work is unnecessary duplicated effort. The epoll calls get around this problem by separating that setup work from the act of waiting for a file descriptor to become ready.

先看下大致流程:

epoll里的add/del的实体是代表了fd的epitem,为了效率由rbtree管理。ep_pqueue相当于select/poll的poll_wqueues,用作epoll框架和具体设备实现之间的桥梁,由其上承载的epitem里的回调函数ep_ptable_queue_proc来完成:新建eppoll_entry,将其同时挂入epitem(用于epoll_ctl的删除操作)和具体设备(作用依旧,重要依旧)两者的等待队列里,另外,同时注册唤醒回调函数ep_poll_callback,用于唤醒进程前将epitem挂入eventpoll(epoll_create时创建)的rdlist里。

由以上流程分析,我们得出的理解是:

1)在select/poll没有将监测fd形式的实体独立出来,导致每次都需要重构所有实体。而epoll做到了,不再从大一统的poll_wqueues里直接分配新的entry,而是从独立出来的epitem里分配。可以说,这就是epoll_ctl的功劳!同时也消除了select/poll里每次调用都需要执行的内核和userspace之间的fd set拷贝。。。

2)宏观上看,eventpoll是一级fd,epitem是二级fd,具体设备的fd是三级fd,这样有了事件就会层层向上通知。当然,其实一个epoll里的一级fd(就是eventpoll)可能又是另一个epoll里的三级fd,如此递推。。。(想想多级文件系统的层层挂载)这样也就明白了,epoll_wait会将代表本进程的wait_queue_t挂在哪个结构的等待队列里。

3)由于每次selet/poll调用之间没有一个缓存结构,这样每次调用所有东西都得重来一遍,结果集自然也无法延续使用,根本实现不了诸如ET的新需求。而epoll呢,独立出一个eventpoll,其包含一个rdlist缓存(本次的epoll和下次的epoll就可以沟通了)。这样,ET的实现其实仅仅是一行代码:

if (!(epi->event.events & EPOLLET)) {

list_add_tail(&epi->rdllink, &ep->rdllist);

}

可以说,这就是epoll_create的功劳!综上,相对于select/poll,epoll的表象就是增加了epoll_create和epoll_ctl的系统调用,但本质是独立的力量。。。(全文完)

正文到此结束
Loading...