Linux 系统之IO多路复用应用

作者 | 京东云开发者-京东零售 石朝阳
在说 io 多路复用模型之前,我们先来大致了解下 linux 文件系统。在 linux 系统中,不论是你的鼠标,键盘,还是打印机,甚至于连接到本机的 socket client 端,都是以文件描述符的形式存在于系统中,诸如此类,等等等等,所以可以这么说,一切皆文件。来看一下系统定义的文件描述符说明:
 从上面的列表可以看到,文件描述符 0,1,2 都已经被系统占用了,当系统启动的时候,这三个描述符就存在了。其中 0 代表标准输入,1 代表标准输出,2 代表错误输出。当我们创建新的文件描述符的时候,就会在 2 的基础上进行递增。可以这么说,文件描述符是为了管理被打开的文件而创建的系统索引,他代表了文件的身份 id。对标 windows 的话,你可以认为和句柄类似,这样就更容易理解一些。 由于网上对 linux 文件这块的原理描述的文章已经非常多了,所以这里我不再做过多的赘述,感兴趣的同学可以从 wikipedia 翻阅一下。由于这块内容比较复杂,不属于本文普及的内容,建议读者另行自研,这里我非常推荐马士兵老师将 linux 文件系统这块,讲解的真的非常好。
select 模型
此模型是 io 多路复用的最早期使用的模型之一,距今已经几十年了,但是现在依旧有不少应用还在采用此种方式,可见其长生不老。首先来看下其具体的定义(来源于 man 二类文档):
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout); 这里解释下其具体参数:
参数一:nfds,也即 maxfd,最大的文件描述符递增一。这里之所以传最大描述符,为的就是在遍历 fd_set 的时候,限定遍历范围。
参数二:readfds,可读文件描述符集合。
参数三:writefds,可写文件描述符集合。
参数四:errorfds,异常文件描述符集合。
参数五:timeout,超时时间。在这段时间内没有检测到描述符被触发,则返回。
下面的宏处理,可以对 fd_set 集合(准确的说是 bitmap,一个描述符有变更,则会在描述符对应的索引处置 1)进行操作:
fd_clr (inr fd,fd_set* set) 用来清除描述词组 set 中相关 fd 的位,即 bitmap 结构中索引值为 fd 的值置为 0。
fd_isset (int fd,fd_set *set) 用来测试描述词组 set 中相关 fd 的位是否为真,即 bitmap 结构中某一位是否为 1。
fd_set(int fd,fd_set*set) 用来设置描述词组 set 中相关 fd 的位,即将 bitmap 结构中某一位设置为 1,索引值为 fd。
fd_zero(fd_set *set) 用来清除描述词组 set 的全部位,即将 bitmap 结构全部清零。
首先来看一段服务端采用了 select 模型的示例代码:
//创建server端套接字,获取文件描述符 int listenfd = socket(pf_inet,sock_stream,0); if(listenfd < 0) return -1; //绑定服务器 bind(listenfd,(struct sockaddr*)&address,sizeof(address)); //监听服务器 listen(listenfd,5); struct sockaddr_in client; socklen_t addr_len = sizeof(client); //接收客户端连接 int connfd = accept(listenfd,(struct sockaddr*)&client,&addr_len); //读缓冲区 char buff[1024]; //读文件操作符 fd_set read_fds; while(1) { memset(buff,0,sizeof(buff)); //注意:每次调用select之前都要重新设置文件描述符connfd,因为文件描述符表会在内核中被修改 fd_zero(&read_fds); fd_set(connfd,&read_fds); //注意:select会将用户态中的文件描述符表放到内核中进行修改,内核修改完毕后再返回给用户态,开销较大 ret = select(connfd+1,&read_fds,null,null,null); if(ret < 0) { printf(fail to select!); return -1; } //检测文件描述符表中相关请求是否可读 if(fd_isset(connfd, &read_fds)) { ret = recv(connfd,buff,sizeof(buff)-1,0); printf(receive %d bytes from client: %s ,ret,buff); } } 上面的代码我加了比较详细的注释了,大家应该很容易看明白,说白了大概流程其实如下:
首先,创建 socket 套接字,创建完毕后,会获取到此套接字的文件描述符。
然后,bind 到指定的地址进行监听 listen。这样,服务端就在特定的端口启动起来并进行监听了。
之后,利用开启 accept 方法来监听客户端的连接请求。一旦有客户端连接,则将获取到当前客户端连接的 connection 文件描述符。
双方建立连接之后,就可以进行数据互传了。需要注意的是,在循环开始的时候,务必每次都要重新设置当前 connection 的文件描述符,是因为文件描描述符表在内核中被修改过,如果不重置,将会导致异常的情况。
重新设置文件描述符后,就可以利用 select 函数从文件描述符表中,来轮询哪些文件描述符就绪了。此时系统会将用户态的文件描述符表发送到内核态进行调整,即将准备就绪的文件描述符进行置位,然后再发送给用户态的应用中来。
用户通过 fd_isset 方法来轮询文件描述符,如果数据可读,则读取数据即可。
举个例子,假设此时连接上来了 3 个客户端,connection 的文件描述符分别为 4,8,12,那么其 read_fds 文件描述符表(bitmap 结构)的大致结构为 00010001000100000....0,由于 read_fds 文件描述符的长度为 1024 位,所以最多允许 1024 个连接。
而在 select 的时候,涉及到用户态和内核态的转换,所以整体转换方式如下:
所以,综合起来,select 整体还是比较高效和稳定的,但是呈现出来的问题也不少,这些问题进一步限制了其性能发挥:
文件描述符表为 bitmap 结构,且有长度为 1024 的限制。
fdset 无法做到重用,每次循环必须重新创建。
频繁的用户态和内核态拷贝,性能开销较大。
需要对文件描述符表进行遍历,o (n) 的轮询时间复杂度。
poll 模型
考虑到 select 模型的几个限制,后来进行了改进,这也就是 poll 模型,既然是 select 模型的改进版,那么肯定有其亮眼的地方,一起来看看吧。当然,这次我们依旧是先翻阅 linux man 二类文档,因为这是官方的文档,对其有着最为精准的定义。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
其实,从运行机制上说来,poll 所做的功能和 select 是基本上一样的,都是等待并检测一组文件描述符就绪,然后在进行后续的 io 处理工作。只不过不同的是,select 中,采用的是 bitmap 结构,长度限定在 1024 位的文件描述符表,而 poll 模型则采用的是 pollfd 结构的数组 fds,也正是由于 poll 模型采用了数组结构,则不会有 1024 长度限制,使其能够承受更高的并发。
pollfd 结构内容如下:
struct pollfd { int fd; /* 文件描述符 */ short events; /* 关心的事件 */ short revents; /* 实际返回的事件 */}; 从上面的结构可以看出,fd 很明显就是指文件描述符,也就是当客户端连接上来后,fd 会将生成的文件描述符保存到这里;而 events 则是指用户想关注的事件;revents 则是指实际返回的事件,是由系统内核填充并返回,如果当前的 fd 文件描述符有状态变化,则 revents 的值就会有相应的变化。
events 事件列表如下:
revents 事件列表如下:
从列表中可以看出,revents 是包含 events 的。接下来结合示例来看一下:
//创建server端套接字,获取文件描述符 int listenfd = socket(pf_inet,sock_stream,0); if(listenfd < 0) return -1; //绑定服务器 bind(listenfd,(struct sockaddr*)&address,sizeof(address)); //监听服务器 listen(listenfd,5); struct pollfd pollfds[1]; socklen_t addr_len = sizeof(client); //接收客户端连接 int connfd = accept(listenfd,(struct sockaddr*)&client,&addr_len); //放入fd数组 pollfds[0].fd = connfd; pollfds[0].events = pollin; //读缓冲区 char buff[1024]; //读文件操作符 fd_set read_fds; while(1) { memset(buff,0,sizeof(buff)); /** ** select模型专用 ** 注意:每次调用select之前都要重新设置文件描述符connfd,因为文件描述符表会在内核中被修改 ** fd_zero(&read_fds); ** fd_set(connfd,&read_fds); ** 注意:select会将用户态中的文件描述符表放到内核中进行修改,内核修改完毕后再返回给用户态,开销较大 ** ret = select(connfd+1,&read_fds,null,null,null); **/ ret = poll(pollfds, 1, 1000); if(ret et mode: it was triggered onceget 1 bytes of content: a-->wait to read! 可以看到,由于 buffer 从空到非空,边缘触发通知产生,之后在 epoll_wait 处阻塞,继续等待后续事件。
这里我们变一下,输入 abcdefghijklmnopq,可以看到,客户端发送的字符长度超过了服务端 buffer size,那么输出结果将是怎么样的呢?
-->et mode: it was triggered onceget 9 bytes of content: abcdefghiget 8 bytes of content: jklmnopq-->wait to read! 可以看到,这次发送,由于发送的长度大于 buffer size,所以内容被折成两段进行接收,由于用了边缘触发方式,buffer 的情况是从空到非空,所以只会产生一次通知。
水平触发
水平触发则简单多了,他包含了边缘触发的所有场景,简而言之如下:
当接收缓冲区不为空的时候,有数据可读,则读事件会一直触发。
当发送缓冲区未满的时候,可以继续写入数据,则写事件一直会触发。
同样的,为了使表达更清晰,我们也来举个栗子,按照上述入输入方式来进行。
服务端开启,客户端连接并发送单字符 a,可以看到服务端输出情况如下:
-->lt mode: it was triggered once!get 1 bytes of content: a 这个输出结果,毋庸置疑,由于 buffer 中有数据,所以水平模式触发,输出了结果。
服务端开启,客户端连接并发送 abcdefghijklmnopq,可以看到服务端输出情况如下:
-->lt mode: it was triggered once!get 9 bytes of content: abcdefghi-->lt mode: it was triggered once!get 8 bytes of content: jklmnopq 从结果中,可以看出,由于 buffer 中数据读取完毕后,还有未读完的数据,所以水平模式会一直触发,这也是为啥这里水平模式被触发了两次的原因。
有了这两个栗子的比对,不知道聪明的你,get 到二者的区别了吗?
在实际开发过程中,实际上 lt 更易用一些,毕竟系统帮助我们做了大部分校验通知工作,之前提到的 select 和 poll,默认采用的也都是这个。但是需要注意的是,当有成千上万个客户端连接上来开始进行数据发送,由于 lt 的特性,内核会频繁的处理通知操作,导致其相对于 et 来说,比较的耗费系统资源,所以,随着客户端的增多,其性能也就越差。
而边缘触发,由于监控的是 fd 的状态变化,所以整体的系统通知并没有那么频繁,高并发下整体的性能表现也要好很多。但是由于此模式下,用户需要积极的处理好每一笔数据,带来的维护代价也是相当大的,稍微不注意就有可能出错。所以使用起来须要非常小心才行。
至于二者如何抉择,诸位就仁者见仁智者见智吧。
行文到这里,关于 epoll 的讲解基本上完毕了,大家从中是不是学到了很多干货呢?由于从 netty 研究到 linux epoll 底层,其难度非常大,可以用曲高和寡来形容,所以在这块探索的文章是比较少的,很多东西需要自己照着 man 文档和源码一点一点的琢磨(linux 源码详见 eventpoll.c 等)。这里我来纠正一下搜索引擎上,说 epoll 高性能是因为利用 mmap 技术实现了用户态和内核态的内存共享,所以性能好,我前期被这个观点误导了好久,后来下来了 linux 源码,翻了一下,并没有在 epoll 中翻到 mmap 的技术点,所以这个观点是错误的。这些错误观点的文章,国内不少,国外也不少,希望大家能审慎抉择,避免被错误带偏。
所以,epoll 高性能的根本就是,其高效的文件描述符处理方式加上颇具特性边的缘触发处理模式,以极少的内核态和用户态的切换,实现了真正意义上的高并发。


空调行业进入淡季市场需求萎靡,行业价格竞争激烈
中国被“卡脖子”的芯片产业过去三年改变了什么?
新增眼球追踪技术 三星galaxy s3将重新命名
创略科技获7100万元B轮融资,饿了么、沃尔玛均是客户
亚马逊发力量子计算,云计算会是它的成功关键吗
Linux 系统之IO多路复用应用
lcd1602时序图浅析
三相程控标准功率源XL-803在使用中应该注意哪些问题?
神秘面纱揭开!逆变变压器工作原理解析
简单认识发光二极管驱动器
MAX16055超小尺寸的微处理器监控电路,监控多达6路监测电压
微软Surface Pro5外部改为新的充电接口,内部硬件升级为Kaby Lake
2400人……又一汽车巨头宣布裁员,子公司9名高管被解雇!
如何用不同方法验证单片机变量的大小呢?
德州仪器推出无线连接方案组合SimpleLink产品系列
通过车规级认证的快速恢复二极管?重在高速整流!
电动汽车需求强劲,SUV成为车企新能源战略的重要发力点
卡尔动力在新疆开启自动驾驶货运运营
应用容器化后性能下降怎么办?
通通透透看MODEM