Redis 网络模型 -- 阻塞非阻塞IO、IO多路复用、epoll详解

引言

本篇前半部分属于知识点,后半部分的[手撕面答环节],以问题展开,应对面试场景作答,尽量简短,可以在学习了前置知识后,尝试自己作答复述喔。

本篇先简单介绍常见的IO模型,还未深入具体Redis中的应用,可以把这节当做【操作系统】来啃hhh

本篇脑图速览

常见的几种网络模型?

阻塞 IO

  • 过程 1:应用程序想要去读取数据,他是无法直接去读取磁盘数据的,他需要先到内核里边去等待内核操作硬件拿到数据,这个等待数据就绪的过程便是过程1。

  • 过程 2:内核态准备好了,开始拷贝数据给用户缓冲区,便是过程2。

    Redis 网络模型 -- 阻塞非阻塞IO、IO多路复用、epoll详解_第1张图片

用户去读取数据时,会去先发起 recvform 一个命令,去尝试从内核上加载数据,如果内核没有数据,那么用户就会等待,此时内核会去从硬件上读取数据,内核读取数据之后,会把数据拷贝到用户态,并且返回 ok,整个过程,都是阻塞等待的,这就是阻塞 IO

也就是两个过程都阻塞的话,便是阻塞IO

总结如下:

顾名思义,阻塞 IO 就是两个阶段都必须阻塞等待:

阶段一:

  • 用户进程尝试读取数据(比如网卡数据)
  • 此时数据尚未到达,内核需要等待数据
  • 此时用户进程也处于阻塞状态

阶段二:

  • 数据到达并拷贝到内核缓冲区,代表已就绪
  • 将内核数据拷贝到用户缓冲区
  • 拷贝过程中,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据

流程图

Redis 网络模型 -- 阻塞非阻塞IO、IO多路复用、epoll详解_第2张图片

非阻塞 IO

顾名思义,非阻塞 IO 的 recvfrom 操作会立即返回结果而不是阻塞用户进程。

阶段一:

  • 用户进程尝试读取数据(比如网卡数据)
  • 此时数据尚未到达,内核需要等待数据
  • 返回异常给用户进程
  • 用户进程收到 error 后,再次尝试读取【忙轮询】
  • 循环往复,直到数据就绪

阶段二:

  • 将内核数据拷贝到用户缓冲区
  • 拷贝过程中,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据

可以看到,非阻塞 IO 模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致 CPU 空转,CPU 使用率暴增。

Redis 网络模型 -- 阻塞非阻塞IO、IO多路复用、epoll详解_第3张图片

信号驱动

信号驱动 IO 是与内核建立 SIGIO 的信号关联并设置回调,当内核有 FD 就绪时,会发出 SIGIO 信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。

阶段一:

  • 用户进程调用 sigaction ,注册信号处理函数
  • 内核返回成功,开始监听 FD
  • 用户进程不阻塞等待,可以执行其它业务
  • 当内核数据就绪后,回调用户进程的 SIGIO 处理函数

阶段二:

  • 收到 SIGIO 回调信号
  • 调用 recvfrom ,读取
  • 内核将数据拷贝到用户空间
  • 用户进程处理数据

Redis 网络模型 -- 阻塞非阻塞IO、IO多路复用、epoll详解_第4张图片

缺点

当有大量 IO 操作时,信号较多,SIGIO 处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。

异步 IO

这种方式,不仅仅是用户态在试图读取数据后,不阻塞,而且当内核的数据准备完成后,也不会阻塞

两个过程都不阻塞

他会由内核将所有数据处理完成后,由内核将数据写入到用户态中,然后才算完成,所以性能极高,不会有任何阻塞,全部都由内核完成,可以看到,异步 IO 模型中,用户进程在两个阶段都是非阻塞状态。

Redis 网络模型 -- 阻塞非阻塞IO、IO多路复用、epoll详解_第5张图片

缺点

得做好限流,不然无脑的给内核去干,相当于领导不管用户死活,一股脑塞

Java中常见的IO模型

BIO

上文的阻塞IO

NIO

上文的非阻塞IO

AIO

其实就是上文的异步模型

什么是IO多路复用

定义 & 流程

Redis 网络模型 -- 阻塞非阻塞IO、IO多路复用、epoll详解_第6张图片

当用户进程调用了select,那么整个进程会被阻塞,而同时,内核会"监视"所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从内核拷贝到用户进程。

这个模型和阻塞IO的模型其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(select和recvfrom),而阻塞IO只调用了一个系统调用(recvfrom)。

  • 但是,用select的优势在于它可以同时处理多个连接。所以,如果系统的连接数不是很高的话,使用select/epoll的web server不一定比使用多线程的阻塞IO的web server性能更好,可能延迟还更大;select/epoll的优势并不是对单个连接能处理得更快,而是在于能处理更多的连接。

IO多路复用的三种实现方式

目前流程的多路复用 IO 实现主要包括四种: selectpollepollkqueue。下表是他们的一些重要特性的比较:

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,但是大小整体是有限制的 ),这个集合的长度大小是有限制的,同时在这个集合中,标明出来我们要控制哪些数据。

具体流程

用户态 :

  1. 创建 fd_set 集合,包括要监听的 读事件、写事件、异常事件的集合
  2. 确定要监听的 fd_set 集合
  3. 将要监听的集合作为参数传入 select () 函数中,select 中会将 集合复制到内核 buffer

内核态:

  1. 内核线程在得到集合后,遍历该集合
  2. 没有数据就绪,就休眠
  3. 当数据来时,线程被唤醒,然后再次遍历集合,标记就绪的 fd 然后将整个集合,复制回用户 buffer 中
  4. 用户线程遍历集合,找到就绪的 fd ,再发起读请求。

源码&流程

Redis 网络模型 -- 阻塞非阻塞IO、IO多路复用、epoll详解_第7张图片

不足之处

  1. select无法得知具体是哪个fd准备就绪了,每次都需要遍历一遍fd_set,效率很低

需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

  1. 集合大小固定为 1024 ,也就是说最多维持 1024 个 socket,在海量数据下,不够用
  2. 需要将整个fd_set从用户空间拷贝到内核空间,select结束后还需要再次拷贝回用户空间,涉及到 用户态和内核态的切换,非常影响性能

poll

poll 模式对 select 模式做了简单改进,但性能提升不明显。

具体流程:

  1. 创建 pollfd 数组,向其中添加关注的 fd 信息,数组大小自定义
  2. 调用 poll 函数,将 pollfd 数组拷贝到内核空间,转链表存储,无上限
  3. 内核遍历 fd ,判断是否就绪
  4. 数据就绪或超时后,拷贝 pollfd 数组到用户空间,返回就绪 fd 数量 n
  5. 用户进程判断 n 是否大于 0:【好像不重要】
    1. 大于 0 则遍历 pollfd 数组找到就绪的 fd

与 select 对比

大小方面:

  • select 模式中的 fd_set 大小固定为 1024,而 pollfd 在内核中采用链表理论上无上限,但实际上不能这么做,因为的监听 FD 越多,每次遍历消耗时间也越久,性能反而会下降

Redis 网络模型 -- 阻塞非阻塞IO、IO多路复用、epoll详解_第8张图片

epoll

Redis 网络模型 -- 阻塞非阻塞IO、IO多路复用、epoll详解_第9张图片

epoll 模式是对 select 和 poll 的改进,它提供了三个函数:eventpoll 、epoll_ctl 、epoll_wait

  • eventpoll 函数内部包含了两个东西 :

    • 红黑树 :用来记录所有的 fd
    • 链表 : 记录已就绪的 fd 、
  • epoll_ctl 函数 ,将要监听的 fd 添加到 红黑树 上去,并且给每个 fd 绑定一个监听函数,当 fd 就绪时就会被触发,这个监听函数的操作就是 将这个 fd 添加到 链表中去

  • epoll_wait 函数,就绪等待。一开始,用户态 buffer 中创建一个空的 events 数组,当就绪之后,我们的回调函数会把 fd 添加到链表中去

    • 当函数被调用的时候,会去检查链表(当然这个过程需要参考配置的等待时间,可以等一定时间,也可以一直等)
      • 如果链表中没有 fd ,则 fd 会从红黑树被添加到链表中,此时再将链表中的的 fd 复制到用户态的空 events中,并且返回对应的操作数量,用户态此时收到响应后,会从 events 中拿到已经准备好的数据,在调用 读方法 去拿数据。

总结

select 模式存在的三个问题:

  • 能监听的 FD 最大不超过 1024
  • 每次 select 都需要把所有要监听的 FD 都拷贝到内核空间
  • 每次都要遍历所有 FD 来判断就绪状态

poll 模式的问题:

  • poll 利用链表解决了 select 中监听 FD 上限的问题,但依然要遍历所有 FD,如果监听较多,性能会下降

epoll 模式中如何解决这些问题的?

  • 基于 epoll 实例中的红黑树保存要监听的 FD,理论上无上限 ,而且增删改查效率都非常高,性能不会随监听
  • 每个 FD 只需要执行一次 epoll_ctl 添加到红黑树,以后每次 epol_wait 无需传递任何参数,无需重复拷贝 FD 到内核空间
  • 利用 ep_poll_callback 机制来监听 FD 状态,无需遍历所有 FD,因此性能不会随监听的 FD 数量增多而下降

边缘触发和水平触发

epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT

  • 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;

  • 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;

这个过程是用户空间去读内核空间

水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。

如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。

边缘触发注意点

如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。

Redis 网络模型 -- 阻塞非阻塞IO、IO多路复用、epoll详解_第10张图片

因此,我们会循环从文件描述符读写数据【图中的④操作使用循环】,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。

所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。

一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。

select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。


手撕面答环节 -- 这是一条分割线

划掉的部分属于melo复述时,发送的疏漏之处/答错的地方hhh

select,poll,epoll的区别

select

用户注册了自己需要监听的设备,记录在一个fd数组里边,拷贝给内核态服务端,服务端那边若准备好了,会修改fd数组中对应设备的位置,值改为1,并且把整个fd数组拷贝回用户态

实际上服务端还要遍历一遍fd数组,标记就绪的fd为1,拷贝回用户态

用户态再遍历一遍fd数组,找到其中值为1的,说明准备好了,可以开始拷贝了。

不足之处

涉及到多次拷贝,用户态和内核态的切换

poll

跟select的区别主要在于,不是用fd数组了,而是用一个链表,理论上可以无限节点,但本质上,节点数量越多,效率自然随着降低,有没有能够解决这种节点数影响效率的限制呢?这个时候epoll就出来了,红黑树。

更具体一点是,用户态仍然是fd数组,转到内核态才变为链表存储

epoll

把要监听的设备,都注册到一棵红黑树上边,并给每个节点绑定监听函数,但服务端准备就绪时,会触发监听函数,把该节点拷贝到fd数组上边【是就绪链表上边】,并且返回给用户态【注意只返回准备好了的设备,这是跨时代的进步】

优点

select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。

边缘触发为何建议搭配非阻塞IO?

多路复用 API 返回的事件并不一定可读写的【select() 可能会将一个 socket 文件描述符报告为 "准备读取",而后续的读取块却没有。例如,当数据已经到达,但经检查后发现有错误的校验和而被丢弃时,就会发生这种情况

虚晃一枪,以为准备好了要给你数据了,但这时被丢弃了【又变成还没准备好的状态】,我们还傻傻的一直在等待读取

如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,

非阻塞 I/O的话,会忙等轮询,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。

阻塞IO:当你去读一个阻塞的文件描述符时,如果在该文件描述符上没有数据可读,那么它会一直阻塞(通俗一点就是一直卡在调用函数那里),直到有数据可读。当你去写一个阻塞的文件描述符时,如果在该文件描述符上没有空间(通常是缓冲区)可写,那么它会一直阻塞,直到有空间可写。

非阻塞IO:当你去读写一个非阻塞的文件描述符时,不管可不可以读写,它都会立即返回,返回成功说明读写操作完成了,返回失败会设置相应errno状态码,根据这个errno可以进一步执行其他处理。它不会像阻塞IO那样,卡在那里不动!!!

另一种答案

由于ET模式下,需要while循环调用read和wirte,直到最后返回特定的错误类型才退出循环。

如果采用非阻塞IO,则可能会在最后一次本应该跳出循环的read调用阻塞住。

epoll的ET和LT有什么区别

ET:edge trigger 边缘触发,指的是当socket准备好了,服务端只苏醒一次,所以用户缓冲区要一次性把内核缓冲区读完,nginx就是采用的ET

LT:level-trigger 水平触发,socket准备好了,服务端会不断苏醒,直到用户缓冲区把内核缓冲区读完了,redis就是采用的LT

边缘触发如何保证数据读完

while循环读写,直到最后一次返回特定的错误类型【EAGAIN错误】

ET模式下的accept问题

在某一时刻,有多个连接同时到达,服务器的 TCP 就绪队列瞬间积累多个就绪连接,由于是边缘触发模式,epoll 只会通知一次,accept 只处理一个连接,导致 TCP 就绪队列中剩下的连接都得不到处理。在这种情形下,我们应该如何有效的处理呢?

解决的方法是:解决办法是用 while 循环包住 accept 调用,处理完 TCP 就绪队列中的所有连接后再退出循环。

如何知道是否处理完就绪队列中的所有连接呢?

  • accept 返回 -1 并且 errno 设置为 EAGAIN 就表示所有连接都处理完。

epoll读到一半又有新事件来了怎么办?

避免在主进程epoll再次监听到同一个可读事件,可以把对应的描述符设置为EPOLL_ONESHOT,效果是监听到一次事件后就将对应的描述符从监听集合中移除,也就不会再被追踪到。读完之后可以再把对应的描述符重新手动加上。

你可能感兴趣的:(java,网络,redis,数据库)