本篇前半部分属于知识点,后半部分的[手撕面答环节],以问题展开,应对面试场景作答,尽量简短,可以在学习了前置知识后,尝试自己作答复述喔。
本篇先简单介绍常见的IO模型,还未深入具体Redis中的应用,可以把这节当做【操作系统】来啃hhh
过程 1:应用程序想要去读取数据,他是无法直接去读取磁盘数据的,他需要先到内核里边去等待内核操作硬件拿到数据,这个等待数据就绪的过程便是过程1。
过程 2:内核态准备好了,开始拷贝数据给用户缓冲区,便是过程2。
用户去读取数据时,会去先发起 recvform 一个命令,去尝试从内核上加载数据,如果内核没有数据,那么用户就会等待,此时内核会去从硬件上读取数据,内核读取数据之后,会把数据拷贝到用户态,并且返回 ok,整个过程,都是阻塞等待的,这就是阻塞 IO
也就是两个过程都阻塞的话,便是阻塞IO
总结如下:
顾名思义,阻塞 IO 就是两个阶段都必须阻塞等待:
阶段一:
阶段二:
流程图
顾名思义,非阻塞 IO 的 recvfrom 操作会立即返回结果而不是阻塞用户进程。
阶段一:
阶段二:
可以看到,非阻塞 IO 模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致 CPU 空转,CPU 使用率暴增。
信号驱动 IO 是与内核建立 SIGIO 的信号关联并设置回调,当内核有 FD 就绪时,会发出 SIGIO 信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。
阶段一:
阶段二:
缺点
当有大量 IO 操作时,信号较多,SIGIO 处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。
这种方式,不仅仅是用户态在试图读取数据后,不阻塞,而且当内核的数据准备完成后,也不会阻塞
两个过程都不阻塞
他会由内核将所有数据处理完成后,由内核将数据写入到用户态中,然后才算完成,所以性能极高,不会有任何阻塞,全部都由内核完成,可以看到,异步 IO 模型中,用户进程在两个阶段都是非阻塞状态。
缺点
得做好限流,不然无脑的给内核去干,相当于领导不管用户死活,一股脑塞
上文的阻塞IO
上文的非阻塞IO
其实就是上文的异步模型
当用户进程调用了select,那么整个进程会被阻塞,而同时,内核会"监视"所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从内核拷贝到用户进程。
这个模型和阻塞IO的模型其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(select和recvfrom),而阻塞IO只调用了一个系统调用(recvfrom)。
目前流程的多路复用 IO 实现主要包括四种: select
、poll
、epoll
、kqueue
。下表是他们的一些重要特性的比较:
IO 模型 | 相对性能 | 关键思路 | 操作系统 | JAVA 支持情况 |
---|---|---|---|---|
select | 较高 | Reactor | windows/Linux | 支持,Reactor 模式 (反应器设计模式)。Linux 操作系统的 kernels 2.4 内核版本之前,默认使用 select;而目前 windows 下对同步 IO 的支持,都是 select 模型 |
poll | 较高 | Reactor | Linux | Linux 下的 JAVA NIO 框架,Linux kernels 2.6 内核版本之前使用 poll 进行支持。也是使用的 Reactor 模式 |
epoll | 高 | Reactor/Proactor | Linux | Linux kernels 2.6 内核版本及以后使用 epoll 进行支持;Linux kernels 2.6 内核版本之前使用 poll 进行支持;另外一定注意,由于 Linux 下没有 Windows 下的 IOCP 技术提供真正的 异步 IO 支持,所以 Linux 下使用 epoll 模拟异步 IO |
kqueue | 高 | Proactor | Linux | 目前 JAVA 的版本不支持 |
select
select 是 Linux 最早的 I/O 多路复用技术:
linux 中,一切皆文件,socket 也不例外,我们把需要处理的数据封装成 FD,然后在用户态时创建一个 fd_set 的集合(这个集合的大小是要监听的那个 FD 的最大值 + 1,但是大小整体是有限制的 ),这个集合的长度大小是有限制的,同时在这个集合中,标明出来我们要控制哪些数据。
具体流程
用户态 :
内核态:
源码&流程
不足之处
需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
poll
poll 模式对 select 模式做了简单改进,但性能提升不明显。
具体流程:
与 select 对比
大小方面:
epoll
epoll 模式是对 select 和 poll 的改进,它提供了三个函数:eventpoll 、epoll_ctl 、epoll_wait
eventpoll 函数内部包含了两个东西 :
epoll_ctl 函数 ,将要监听的 fd 添加到 红黑树 上去,并且给每个 fd 绑定一个监听函数,当 fd 就绪时就会被触发,这个监听函数的操作就是 将这个 fd 添加到 链表中去。
epoll_wait 函数,就绪等待。一开始,用户态 buffer 中创建一个空的 events 数组,当就绪之后,我们的回调函数会把 fd 添加到链表中去
总结
select 模式存在的三个问题:
poll 模式的问题:
epoll 模式中如何解决这些问题的?
epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)。
使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;
这个过程是用户空间去读内核空间
水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。
边缘触发注意点
如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。
因此,我们会循环从文件描述符读写数据【图中的④操作使用循环】,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。
所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。
select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。
划掉的部分属于melo复述时,发送的疏漏之处/答错的地方hhh
用户注册了自己需要监听的设备,记录在一个fd数组里边,拷贝给内核态服务端,服务端那边若准备好了,会修改fd数组中对应设备的位置,值改为1,并且把整个fd数组拷贝回用户态
实际上服务端还要遍历一遍fd数组,标记就绪的fd为1,拷贝回用户态
用户态再遍历一遍fd数组,找到其中值为1的,说明准备好了,可以开始拷贝了。
涉及到多次拷贝,用户态和内核态的切换
跟select的区别主要在于,不是用fd数组了,而是用一个链表,理论上可以无限节点,但本质上,节点数量越多,效率自然随着降低,有没有能够解决这种节点数影响效率的限制呢?这个时候epoll就出来了,红黑树。
更具体一点是,用户态仍然是fd数组,转到内核态才变为链表存储
把要监听的设备,都注册到一棵红黑树上边,并给每个节点绑定监听函数,但服务端准备就绪时,会触发监听函数,把该节点拷贝到fd数组上边【是就绪链表上边】,并且返回给用户态【注意只返回准备好了的设备,这是跨时代的进步】
select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
多路复用 API 返回的事件并不一定可读写的【select() 可能会将一个 socket 文件描述符报告为 "准备读取",而后续的读取块却没有。例如,当数据已经到达,但经检查后发现有错误的校验和而被丢弃时,就会发生这种情况】
虚晃一枪,以为准备好了要给你数据了,但这时被丢弃了【又变成还没准备好的状态】,我们还傻傻的一直在等待读取
如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,
非阻塞 I/O的话,会忙等轮询,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。
阻塞IO:当你去读一个阻塞的文件描述符时,如果在该文件描述符上没有数据可读,那么它会一直阻塞(通俗一点就是一直卡在调用函数那里),直到有数据可读。当你去写一个阻塞的文件描述符时,如果在该文件描述符上没有空间(通常是缓冲区)可写,那么它会一直阻塞,直到有空间可写。
非阻塞IO:当你去读写一个非阻塞的文件描述符时,不管可不可以读写,它都会立即返回,返回成功说明读写操作完成了,返回失败会设置相应errno状态码,根据这个errno可以进一步执行其他处理。它不会像阻塞IO那样,卡在那里不动!!!
由于ET模式下,需要while循环调用read和wirte,直到最后返回特定的错误类型才退出循环。
如果采用非阻塞IO,则可能会在最后一次本应该跳出循环的read调用阻塞住。
ET:edge trigger 边缘触发,指的是当socket准备好了,服务端只苏醒一次,所以用户缓冲区要一次性把内核缓冲区读完,nginx就是采用的ET
LT:level-trigger 水平触发,socket准备好了,服务端会不断苏醒,直到用户缓冲区把内核缓冲区读完了,redis就是采用的LT
while循环读写,直到最后一次返回特定的错误类型【EAGAIN错误】
在某一时刻,有多个连接同时到达,服务器的 TCP 就绪队列瞬间积累多个就绪连接,由于是边缘触发模式,epoll 只会通知一次,accept 只处理一个连接,导致 TCP 就绪队列中剩下的连接都得不到处理。在这种情形下,我们应该如何有效的处理呢?
解决的方法是:解决办法是用 while 循环包住 accept 调用,处理完 TCP 就绪队列中的所有连接后再退出循环。
如何知道是否处理完就绪队列中的所有连接呢?
避免在主进程epoll再次监听到同一个可读事件,可以把对应的描述符设置为EPOLL_ONESHOT,效果是监听到一次事件后就将对应的描述符从监听集合中移除,也就不会再被追踪到。读完之后可以再把对应的描述符重新手动加上。