Redis面试之IO多路复用和零拷贝详解

544 阅读10分钟

Redis为什么快?

  1. 单线程处理命令不需要线程上下文切换的开销。
  2. 网络多线程+后台主从复制和刷盘等操作使用fork子进程的方式在另一个cpu核上操作较少网络开销。
  3. 网络多线程实际上是使用的IO多路复用机制。
  4. 设计了多种高效的数据结构以及数据库使用哈希表存储数据的方式。
  5. RESP这种简单的网络传输协议避免序列化和网络传输占用太多时间。

本来主要讨论的是Redis的IO多路复用机制。

网络模型

在《UNIX网络编程》一书中,总结归纳了5种IO模型:

  • 阻塞IO(Blocking IO)
  • 非阻塞IO(Nonblocking IO)
  • IO多路复用(IO Multiplexing)
  • 信号驱动IO(Signal Driven IO)
  • 异步IO(Asynchronous IO)

阻塞和非阻塞

阻塞和非阻塞对于操作系统来说是针对于系统调用来体现的。如果发生IO类的系统调用,必须要等到系统调用完成再返回,或者中途被中断、出错等情况才返回,这就是阻塞式IO。如果不管IO系统调用成功与否直接返回的话就是非阻塞式IO的调用。

同步和非同步

这里的同步与非同步,也是指I/O操作。当把阻塞、非阻塞、同步和非同步放在一起时,会让我们非常混乱。

同步是否就是阻塞,非同步是否就是非阻塞呢

实际上在I/O操作中,它们是不同的概念。同步既可以是阻塞的,也可以是非阻塞的,而常用的Linux的I/O调用实际上都是同步的。这里的同步和非同步,是指I/O数据的复制工作是否同步执行。

  • 就是说当数据到来了,读取数据的过程是否需要等待,不等待数据获取完成就是异步,等待数据获取完成就是同步。
  • 异步的话就需要再数据获取完成之后再进行回调。

同步阻塞IO

应用程序想要去读取数据的时候是无法直接去读取磁盘数据的,需要先到内核里边去等待内核操作硬件拿到数据,这个过程就是1,是需要等待的,等到内核从磁盘上把数据加载出来之后,再把这个数据写给用户的缓存区,这个过程是2,如果是阻塞IO,那么整个过程中,用户从发起读请求开始,一直到读取到数据,都是一个阻塞状态。 以下是具体流程: image.png DMA 的工作方式如下:

  • CPU 需对 DMA 控制器下发指令,通知它需要读取的数据以及存放的位置;
  • 接下来,DMA 控制器会向磁盘控制器发出指令,通知它从磁盘读数据到其磁盘缓冲区中,接着磁盘控制器将缓冲区的数据传输到内核缓冲区;
  • 当磁盘控制器把数据传输到内存的操作完成后,磁盘控制器在总线上发出一个确认成功的信号到 DMA 控制器;
  • DMA 控制器收到信号后,DMA 控制器发中断通知 CPU 指令完成,CPU 就可以直接用内存里面现成的数据了;

可以看到,CPU 当要读取磁盘数据的时候,只需给 DMA 控制器发送指令,然后返回去做其他事情,当磁盘数据拷贝到内存后, DMA 控制机器通过中断的方式,告诉 CPU 数据已经准备好了,可以从内存读数据了。仅仅在传送开始结束时需要 CPU 干预。

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

  • 阻塞IO就是这1和2两个阶段都需要等待

阶段一:

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

阶段二:

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

1653897270074.png

同步非阻塞IO

阶段一:

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

阶段二:

  • 将内核数据拷贝到用户缓冲区
  • 拷贝过程中,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据
  • 可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转CPU使用率暴增

1653897490116.png

IO多路复用

如果调用recvfrom时,恰好没有数据,阻塞IO会使CPU阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。 如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据。

IO多路复用依赖于文件描述符机制(File Descriptor)。

文件描述符

简称FD,是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。

网络模型可以利用一个线程监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。

IO多路复用的实现

  • 什么是I/O多路复用?

    • IO 多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;
    • 一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;
    • 没有文件句柄就绪就会阻塞应用程序,交出CPU。
  • 为什么有I/O多路复用机制?

    • 没有之前有BIO和NIO两种模式

    • BIO

      • 同步阻塞
      • 服务端有多少线程只能监控多少任务
    • NIO

      • 服务端每ac一个请求就加入集合,然后轮询使用recv接收数据,没有数据立即返回错误,每次轮询浪费cpu

IO多路复用在LInux系统下有三种实现方式:

  • select
  • poll
  • epoll
  • 其中selectpoll是在数据准备好的时候将所有FD都发送给监听线程,在所有准备好的FD中去查找,效率为O(N)
  • epoll则是将准备好的数据直接发送给对应的FD,时间复杂度为O(1)

IO多路复用-select方式

  • select
    • 监听的文件描述符fd_set上限1024个
    • 每次需要把fd_set从用户态复制到内核态,select结束还需要再次拷贝到用户空间,耗费资源
    • 在内核态需要遍历fd_set标记可读/可写,用户态读取也需要遍历,也就是两次遍历和两次复制操作,耗时。
    • 轮询的方式检查事件是否就绪,时间复杂度O(N)

如下图所示:

1653900022580.png

IO多路复用-poll方式

IO流程:

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

poll

  • 解决了select的第一个问题 - 监听文件描述符的上限
  • 使用了链表解决
  • 还是存在第二个和第三个问题,甚至遍历的时间由于监听FD的增加还会增加耗时

1653900721427.png

IO多路复用模型-epoll函数

  • epoll 事件驱动

    • 使用红黑树监听fd

      • 只在乎活跃事件,不活跃的事件的连接数对其无影响,只有就绪才会触发事件回调,高效的查找事件是否重复
      • 没有监听上限
    • 双向链表保存就绪事件

      • 找的时候返回链表中的就绪事件即可,时间复杂度O(1)
    • 使用了mmap减少了内存复制的开销

    • LT(水平触发)(默认)和ET(边沿触发)

      • LevelTriggered:简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。
      • EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知。ET需要保证每次都读完数据,以及读取时候的异常处理要求非常高,所以一般用LT。

image-20221025161847503.png

零拷贝

首先介绍DMA的发展过程。

无 DMA 控制器 IO 过程

image.png

  • 用户线程发起read()系统调用,从用户态转到内核态。
  • 操作系统收到请求,CPU发出对应的控制指令给磁盘控制器,cpu释放执行其他任务。
  • 磁盘控制器收到指令将对应的数据读取到磁盘控制器的内部缓冲区,然后产生中断通知CPU。
  • CPU收到中断信号,将磁盘控制器缓冲区的数据读入寄存器,然后从寄存器读到内核缓冲区,这个过程CPU无法处理其他事情。
  • 当内核缓冲区数据足够的时候,CPU将内核缓冲区的数据读入用户缓冲区。

DMA 控制器

  • 如果 CPU 搬运的数据很小,那么问题并不大,但是如果需要拷贝大量数据肯定是不行的,因为 CPU 的资源十分宝贵。为了解决这个问题,于是出现了 DMA(Direct Memory Access,直接内存访问) 技术。

  • 简单来说就是:在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。

DMA参与的拷贝过程如下图所示:

image.png

  • 用户进程发起 read 调用,向操作系统发送 I/O 请求,进程进入阻塞状态;

  • 操作系统收到请求后,CPU 发送一个请求给 DMA , CPU 释放执行其它任务。

  • DMA 进一步将 I/O 请求发送磁盘控制器,磁盘控制器收到指令后,开始准备数据并将数据放入磁盘控制器的内部缓冲区,然后产生一个中断。

  • DMA 收到中断信号后将磁盘控制器缓冲区的数据拷贝到内核缓存区(此时不占用 CPU 资源,CPU 可以执行其它任务)。

  • 当 DMA 读取到足够多的数据到内核缓冲区后,发送中断信号给 CPU ,CPU 将内核缓冲区的数据拷贝到用户缓冲区中。

注:早期 DMA 只存在于主板上,现在 I/O 设备越来越多,数据传输需求也越来越复杂,所以每个 I/O 设备中都有自己的 DMA 控制器。

零拷贝的实现

这里直接参考 [小林coding的讲解](9.1 什么是零拷贝? | 小林coding (xiaolincoding.com))即可。

参考文献

  1. 小林coding
  2. 黑马Redis原理篇