什么是I/O
I/O就是input/output。冯诺依曼的五大构件中,输入输出设备必不可少,但是我们这里说的并不是输入输出设备,而是内存与网络之间的输入输出交互,unix提出了五种IO模型,分别是:阻塞I/O,非阻塞I/O,多路复用I/O,信号驱动I/O,异步I/O。
在说I/O之前,我们需要对真实的linux系统的网络传输进行简单的剖析。我们的例子是当一台主机向另一台主机传送消息的时候,linux是怎么工作的。其实这个我在之前的文章中有讲解:消息队列核心原理(二):Kafka,这里面最后部分谈及Kafka为什么快,其中一点原因是采用了零拷贝技术。
现在我们重温一下例子中linux做了什么:
是不是很简单?因为我这里是简单的消息发送,这与文件发送还是有区别的,文件的话需要从磁盘读取信息,放入内核缓冲区,再到用户缓冲区,再到socket发送缓存中。我们现在只考虑发送简单的消息,这个消息甚至不需要分片发送,最简单的情况,看看具体过程会遇到哪些问题。
由于主机1向主机2发送了一个消息,主机2会作何反应?这么说不是很清楚,换个例子:你和你女朋友微信聊天,算了,你没有女朋友。你和朋友微信聊天,你是一直在手机微信前等他回消息?还是趁这个时间去打局王者,等他回消息了之后再回复他?这就是两种不同的反应,同理,主机2在接收消息的时候,是一直等着对面传消息还是边做其他事情边等?由此就延伸出了不同种类的I/O模型。
阻塞式I/O
上面的情况中,如果主机2一直盲等主机1的消息,且不做任何事情,就是阻塞式的I/O。像极了爱情,你不给我回应,我就傻傻等待。这种有明显的缺点,那就是效率低下。毕竟除了等待消息,就什么都不做了。那我们有没有更高级一点的?有非阻塞I/O。
非阻塞I/O
咳,我变聪明了,女朋友不回我消息?那我就刷个LeetCode,增加我进BAT的概率,我隔一段时间看一眼手机就好,这就是非阻塞I/O。应用程序是怎么做的?一个永真循环,一直向特定的空间问:来消息了么?空间说:没有。然后就做其他的事情。
你可能有疑问了,不是说一直循环嘛,那怎么还能做其他事情?这就引出了我们要说的第一个关键词:poll。英语不懂?没关系,它的中文意思是民意调查,怎么调查?一个一个问,也就是轮询。写个伪代码:
int flag;
while(true){
if((flag = poll()) == 0) //消息来了
return success; //处理消息去了
if((flag = poll()) == 1) //消息没到
do LeetCode; //刷题,可能5分钟一道题,也就是每5分钟循环一遍。
if((flag = poll()) == -1) //告诉程序不等了
return error;
}
大概就是这样的,我一直取消息,直到返回给我一个正常的消息,或者给我一个停止取消息的命令。当然,实际上linux对这个有规定,如果消息没有准备好,会返回一个错误消息类型,告诉你继续轮询。当我拿到消息,我就去处理消息了,不再轮询了,除非告诉我还有消息我就重新轮询等待。
你看,我边刷LeetCode边等消息,这不是效率提高了么!但是呢,我可能因为无法“及时”回复女朋友的消息而惹得女朋友生气。这也是非阻塞I/O(poll)的缺点。那有没有更好的方法?有!换个女朋友!
多路复用I/O
这个女朋友太烦了,你们说,我要是海王,同时聊10个,100个,咳少一个就少一个嘛,不怕。计算机也是这么想的,我同时接收100个人的消息,提高效率!
但是吧,我作为一个男人,而不是鸣人,我也不会影分身,没办法“同时”聊这么多,怎么办?类似的,服务器可以分身,用线程,每个线程管理一个消息通讯,但是同时开启100个线程,对服务器也是一种挑战,那能不能不用或者少用线程,达到同时保持多个连天的功能呢?
我成立了一个海王公司,里面全是海王,然后把大家的手机都平摊在地面上,我是董事长,我只看有哪个手机来消息了,来消息后我就告诉我手下的牛马1,你去回复这个消息,回复完再放回来。如果同时有100个手机来消息,我就让牛马1~牛马100分别回复这些消息。你看,平时的时候,只有我这个董事长在工作,有消息了我再让牛马们去工作。这样就不用一个人维持好多聊天框了,否则女朋友再多一点,我们还要找新的牛马来干活,女朋友暂时有事没法回复消息,我还得养这么多人,多不值得!
计算机也是这么处理的,让一个线程监听所有的消息,一旦有消息需要处理,再开启其他的线程进行处理消息。于是linux提出了三个函数:poll,select,epoll来监听信息。
我们以poll为例写个伪代码,至于三者的区别我们一会说:
int flag;
while(true){
if((flag = poll()) == 0) //消息来了,让别人处理消息
new Thread(message, "success").start();
if((flag = poll()) == 1) //消息没来,让别人帮我刷题
new Thread(LeetCode, "求职,给我个工作").start();
if((flag = poll()) == -1) //不用等了
return error;
你看,这样一来,有事的话让别人去做,自己只管监控消息。
这是大体的流程,流程知道了,接下来就是细节,poll,select,epoll分别是怎么处理传来的消息的。
select
细节看源码:
这个是我的腾讯云的centOS系统的源码,目录是/usr/include/sys/select.h
。不同的版本可能会不太一样,但是原理都类似。__restrict可能会迷惑你,这是c语言的关键字,你可以忽略它。它的意思是,被该关键字修饰的内存区域,只能由定义它的指针访问。假设有代码:int *__restrict __ptr
,那么__ptr指向的内存区域只能由它访问,通过其他方式访问该区域的指针变量都是无效变量。说白了,就是保证安全性。
五个参数:
- __nfds:检查文件描述符的个数
- __readfds:判断其是否为可读状态
- __writefds:判断其是否为可写状态
- __exceptfds:例外情况,就是异常就返回到这里。
- timeout:如果不为空,超时后返回超时状态。
返回值:返回准备状态好的文件描述符的数量。如果出错返回-1。
还有一个点就是fd_set这个结构体,下面是源码:
就是一个长整型的数组,这个数组中的值会与文件建立联系。具体是怎样的联系,是mask掩码一样的东西,细节不表。
(我没找到源码,就是select.c文件,因此再细节的部分没法展示了)
select的大概工作思路:
- 将fd_set代表的数组从用户态拷贝到内核态。(耗时操作)
- select规定了fd_set的大小,最大是1024
- 无论有没有消息,select都会遍历所有的文件描述符,然后如果文件描述符有消息可以读取,则读取消息,重置fd,将新的fd_set状态拷贝回用户态。(耗时操作)
所以,select整体速度是偏慢的。
poll
不废话,看源码
相比select要简单一些,有几个参数说明一下:
- __fds:一个结构体,包含了三个变量:
- fd:要监听的文件描述符
- events:监听的事件(是否可读/可写)
- revents:监听的事件结果(0成功,-1失败)
- __nfds: 数组元素个数
- __timeout:超时时间
大体上与select一致,只是没有数组元素的数量限制。
epoll
epoll比poll和select更快,因为它与另外两个机制不同,另外两个是遍历,epoll是监听被唤醒的文件描述符。被唤醒的文件描述符会被保存到一个队列,epoll的任务是遍历队列,这样就大大加快了epoll的速度。
这是epoll提供的几个函数,核心函数分别为:
/*创建epoll实例,返回实例的文件描述符。size是该实例绑定的文件描述符的个数*/
int epoll_create(int __size);
/*控制epoll,epfd是epoll的实例(上面的返回值);
op是选项,我没截图,一会往下找截图;
fd是目标文件描述符
event是事件的信息*/
int epoll_ctl(int __epfd, int __op, int __fd, struct epoll_event *__event);
/*maxevents是指最大的等待事件数*/
int epoll_wait(int __epfd, struct epoll_event *__events, int __maxevents, int __timeout);
源码已经很清楚了,不赘述。接下来我们看epoll都做了什么。
- 生成一个新的epoll实例
- 设置监听的描述符,并对其做相应的操作,比如添加,删除或修改,操作在红黑树中完成。这个红黑树是epoll维护的一个结构,专门存放被监听的描述符。
- 等待事件发生。事件发生后会被送入ready数组,而这个ready数组会被复制进events数组,返回给用户态。events数组大小为maxevents,即最大复制这么多数据,多的数据会被放弃。
- timeout不赘述。
Epoll的两种触发机制
我们刚才知道,事件被触发后送入ready数组中,那么什么情况下事件才会被触发呢?
- 边缘触发(edge trigger,ET):
(1)缓冲区从空到不空触发读;从不空到空触发写。
(2)缓冲区数据量变大,有新数据到来触发读;反向触发写。
(3)缓冲区有数据可读,且对应的进程描述符EPOLL_CTL_MOD改成EPOLLIN。缓冲区有数据可写,且对应的进程描述符EPOLL_CTL_MOD改成EPOLLOUT。 - 水平触发(level trigger,LT):
(1)缓冲区不为空,读就绪
(2)缓冲区不满,写就绪
二者区别很明显,就是当缓冲区无变化的时候,即使读写可以就绪,ET也不会触发,而LT会一直触发,哪个更占CPU一目了然。当然也不能一概而论,举几个例子:
- 现在maxevents是100,但是有200个文件描述符被激活为读状态。那么ET会复制100个,剩下的100个不会通知,直到下一次的激活;而LT会一直通知,直到200个都读完。
- 做图形或者声音的同学,我们处理完数据后会有01的电波图。0表示无数据,1表示有数据。对于ET,只考虑上升沿和下降沿;对于LT只考虑电平的高低。
三者对比
- poll是链表存储,所以不存在fd最大值限制。
- select是数组,所以有最大值限制,是1024
- epoll在大量消息存在的情况下快,但是少量消息情况下,由于调用回调函数消息链路长,因此效率可能不如poll和select。
- epoll额外维护一个就绪数组。(能占多少空间?占不了多少)
参考:https://blog.csdn.net/weixin_42462202/article/details/95377075
https://zhuanlan.zhihu.com/p/159135478
刚接到鹅厂校招提前批面试通知,只有3天复习时间了,我需要复习去了。所以后面的内容我可能不会写的太生动了。
本打算后期追更netty的内容,等我面试结束后再说吧!
信号驱动式I/O
我现在依然是海王公司董事长,我连看都不看手机了,当有人给我发消息,我设置闹钟,有了闹钟就会通知牛马们去回复消息。我又解放了。
但是这种模型没有被广泛应用的原因是,这个闹钟未免太频繁了,而且可能我设置了明天下午面试的闹钟,就会和这些闹钟混在一起。闹钟并不会通知发生了什么事情,只是告诉你有事情发生。
并且我们的TCP已经保持了连接,这种情况下再进行通知有什么意义呢?如果放在UDP反倒有意义了。
异步I/O
我对海王们说,你们回家吧,用自己的手机,等我女朋友们给我发消息了,我会给你们转过去,你们不用再公司等了。
异步做的就是这个事。
主机向内核发起read请求,内核直接返回一个消息,大概是告诉你我知道了,我准备好在告诉你,然后进程开始搞其他事情。这时候内核收到了消息,并且消息准备就绪了,会将消息拷贝到进程指定区域,然后再发条消息给进程告诉他消息收到了,开始你的表演吧。进程就会去指定区域读取消息,结束。
其实我也有个不解的地方,内核向进程发送消息,告诉可以读取消息了,这个过程中,进程不也是需要一个类似于线程的东西等待内核的消息传递嘛?虽然不是进程挂起,那也是属于一种阻塞啊!
实际上它是用了一种future的异步通知。future相当于一个占位符,当内核收到取信息的消息时,直接返回futureData,是一个期权。当内核处理好真正要返回的消息的时候,再通知客户端。在此期间,客户端可以进行阻塞也可以做其他事情,如果其他事情做完了,但是消息还没有收到,那么就会等待消息传来。
没了,最后愿你我的offer和这个海王的女朋友一样多~