2022-10-10 13:05:06 中国财经观察网 来源:IT之家 阅读量:16441
为了谈复用,当然还是要跟风,采用鞭尸的思路先说传统网络IO的弊端,通过拉踩来发挥复用IO的优势
为了方便理解,以下代码都是伪代码,知道什么意思就行了。
我们走吧
阻塞IO
为了处理客户端的连接和请求的数据,服务器编写以下代码。
listenfd = socket//打开一个网络通信端口绑定,//bind listen,//listen whileconn FD = accept,//阻塞连接建立intn=read,//块读取数据do something,//用读数据关闭做点什么,//关闭连接,循环等待下一个连接。
这段代码会出错,就像这样。
如你所见,服务器端线程在两个地方被阻塞,一个是接受函数,另一个是读取函数。
如果我们展开read函数的细节,我们会发现它分两个阶段被阻塞。
这就是传统的阻塞IO。
整体流程如下。
所以,如果这个连接的客户端一直不发送数据,服务器线程就会一直阻塞在read函数上,它就无法接受其他客户端的连接。
这肯定是不可能的。
非阻塞IO
为了解决上述问题,关键在于修改这个read函数。
一个巧妙的办法是每次创建一个新的进程或线程,调用read函数,做业务处理。
while confd = accept,//阻塞连接pthread_create的建立,//新建一个线程void DoWork intn = read ,//块读取数据do something,//用读数据关闭做点什么,//关闭连接,循环等待下一个连接。
通过这种方式,当客户端连接时,它可以立即等待新的客户端连接,而不会阻塞原始客户端的读取请求。
不过这不叫非阻塞IO,只是用多线程让主线程不卡在read函数里不下去操作系统提供的读取功能仍然被阻止
所以,真正的无阻塞IO不可能是我们用户层的一个小把戏,而应该要求操作系统给我们提供一个无阻塞的读取功能。
这个read函数的作用是,如果没有数据到达,它将立即返回一个错误值,而不是以阻塞的方式等待。
操作系统提供了这样的功能,只需在调用read之前将文件描述符设置为非阻塞即可。
fcntlintn=read!=成功),
这样,用户线程需要循环调用read,直到返回值不为—1,然后开始处理业务。
我们注意到了一个细节。
非阻塞读是指在数据到达之前,即数据到达网卡之前,或者数据到达网卡之前,但还没有复制到内核缓冲区,这个阶段是非阻塞的。
当数据到达内核缓冲区时,对read函数的调用仍然被阻塞,需要等待数据从内核缓冲区复制到用户缓冲区后才能返回。
总体流程图如下
IO多路复用
为每个客户端创建一个线程,服务器端的线程资源很容易被消耗。
当然,还有另一个聪明的方法每当我们接受一个客户端连接时,我们可以将这个文件描述符放在一个数组中
FD list . add,
然后获取一个新线程不断遍历数组,调用每个元素的非阻塞read方法。
while for(fdlt,— fdlist)if(读(fd)!=—1)do something,
这样,我们成功地用一个线程处理了多个客户端连接。
你认为这意味着某种复用吗。
但是和我们用多线程把阻塞IO转化为非阻塞IO是一样的这种遍历方式只是我们用户自己发明的一个小把戏每次遍历遇到—1的读返回,仍然是浪费资源的系统调用
在while循环中进行系统调用就像在分布式项目中进行rpc请求一样不划算
所以还是要请操作系统的老板给我们提供一个有这个效果的功能我们会通过系统调用向内核发送一批文件描述符,由内核层遍历,才能真正解决这个问题
挑选
Select是操作系统提供的系统调用函数。通过它,我们可以向操作系统发送一个文件描述符数组,供操作系统遍历,确定哪个文件描述符可以读写,然后告诉我们处理它:
select系统调用的函数定义如下。
intselect//nfds:被监控文件描述符集中最大的文件描述符加1//readfds:监控读取数据到达文件描述符集,传入和传出参数//writefds:监控写入数据到达文件描述符集,传入和传出参数//exceptfds:监控异常发生到达文件描述符集,传入和传出参数//timeout:计时阻塞监控时间,三种情况//1NULL,永远等待//2.设置timeval并等待固定时间//3.将timeval中的时间设置为0,检查描述字并立即返回和轮询
服务器端代码,这样写。
首先,一个线程不断接受客户机连接,并将套接字文件描述符放入一个列表中。
while confd = accept,fcntl(connfd,F_SETFL,O _ non block),FD list . add(conn FD),
然后,另一个线程调用select并将文件描述符列表交给操作系统来遍历,而不是自己遍历。
而//传递一个文件描述符列表给select函数//如果有ready文件描述符,则返回,nready表示有多少ready n ready = select(list),
但是,当select函数返回时,用户仍然需要遍历刚刚提交给操作系统的列表。
但是操作系统会标记准备好的文件描述符,用户层不再有无意义的系统调用开销。
while ready = select(list),//用户层还是要遍历,但是对于(fdlt— fdlist)if(fd!=—1)//只读ready文件描述符read(fd,buf),//总共只有n个ready ready描述符,所以if (if( — nready0)中断太多就不需要遍历了,
正如刚刚在动画中描述的那样,它的直观效果如下。
你可以看到一些细节:
1.SELECT调用需要传递到fd数组中,并且需要对内核进行复制这样的副本在高并发场景下消耗的资源是惊人的
2.select仍然通过遍历检查内核层中文件描述符的就绪状态,这是一个同步过程,但是没有系统调用切换上下文的开销。
3.select只返回可读文件描述符的数量,哪个是可读的或者需要用户自己遍历。
select的整个流程图如下。
可以看出,这样一个线程可以处理多个客户端连接,系统调用的开销减少(对于多个文件描述符只需要一次select的系统调用ready状态下文件描述符的n次read系统调用)。
投票
Poll也是操作系统提供的系统调用函数。
intpollstructpollfdintfd/*文件描述符*/short events,/*监视的事件*/short events,/*监视满足条件时返回的事件*/,
select和select的主要区别在于,它取消了SELECT只能监听1024个文件描述符的限制。
使用
Epoll是终极大boss,解决了select和poll的一些问题。
还记得上面提到的select的三个细节吗。
1.SELECT调用需要传递到fd数组中,并且需要对内核进行复制这样的副本在高并发场景下消耗的资源是惊人的
2.select仍然通过遍历检查内核层中文件描述符的就绪状态,这是一个同步过程,但是没有系统调用切换上下文的开销。
3.select只返回可读文件描述符的数量,哪个是可读的或者需要用户自己遍历。
所以epoll主要针对这三点进行改进。
1.在内核中保存一组文件描述符,不需要用户每次重新导入,只需要告诉内核修改的部分。
2.内核不再通过轮询找到就绪文件描述符,而是通过异步IO事件唤醒。
3.内核只会将带有IO事件的文件描述符返回给用户,用户不需要遍历整个文件描述符集。
具体来说,操作系统提供了这三个功能。
第一步是创建一个epoll句柄。
intepoll _ create
步骤2:向内核添加,修改或删除要监控的文件描述符。
Intel poll _ CTL,
在第三步中,类似地发起选择呼叫。
Intel poll _ wait,
使用时,其内部原理如下图所示。
附言
用白话总结。
一切从操作系统提供这个读取功能开始,它被屏蔽了我们称之为阻塞木卫一
为了打破这种局面,程序员在用户模式下通过多线程来防止主线程被卡住。
后来操作系统发现这个需求很大,于是在操作系统层面提供了一个非阻塞读取功能,让程序员可以在一个线程中读取多个文件描述符,这就是所谓的非阻塞IO。
可是,读取多个文件描述符需要遍历当高并发场景越来越多的时候,用户态遍历的文件描述符越来越多,相当于while循环中的系统调用越来越多
后来操作系统发现这个场景需求很大,于是提供了这样一种机制,在操作系统层面遍历文件描述符,这就是IO复用。
复用有三个作用,首先是select,然后发明了poll解决select文件描述符的限制,然后发明了epoll解决select的三个缺点。
所以IO模式的演变其实就是时代的变化,迫使操作系统在自己的内核中加入更多的功能。
如果你建立了这样的思维,很容易在网上发现一些错误。
例如,许多文章都说多路复用是高效的,因为一个线程可以监视多个文件描述符。
显然,我们知道为什么,但我们不知道为什么复用的效果完全可以通过用户状态遍历文件描述符,调用其非阻塞读函数来实现复用之所以快,是因为操作系统提供了这样的系统调用,使得原来while循环中的多个系统调用变成了一个系统调用+内核层遍历这些文件描述符
一个道理。