一文带你熟悉Redis网络模型原理

在介绍Redis的网络模型之前,先来介绍用户态和内核态、常见的网络模型种类,再来详解网络模型在Redis中的具体实现,从而理解Redis网络模型的来龙去脉。

1、用户态和内核态空间

  • 服务器大多都采用Linux系统,ubuntu和Centos 都是Linux的发行版,发行版可以看成对linux包了一层壳,任何Linux发行版,其系统内核都是Linux,软件应用都需要通过Linux内核与硬件交互。

  • 用户的应用,比如redis,mysql等都是没有办法去直接执行、访问操作系统硬件的,需要通过Linux发行版(centos、ubuntu)去访问内核,再通过内核去访问计算机硬件
    在这里插入图片描述
    在这里插入图片描述

  • 计算机硬件包括,如cpu,内存,网卡等,内核(通过寻址空间)可以操作硬件的,但是内核需要不同设备的驱动,通过设备驱动,内核就可以实现计算机硬件的内存管理,文件系统管理,进程管理等。

  • 如果要让用户应用来访问,计算机就必须要对外暴露一些接口,才能访问,实现对内核的操控,但是内核本身上来说也是一个应用,所以他本身也需要一些内存,cpu等设备资源,用户应用本身也在消耗这些资源,如果不加任何限制,用户去操作随意的去操作我们的资源,就有可能导致一些冲突,甚至有可能导致我们的系统出现无法运行的问题,因此我们需要把用户空间和内核空间隔离开

  • 进程的寻址空间划分成两部分:内核空间、用户空间

  • 在linux中,他们权限分成两个等级,0和3,用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问内核空间可以执行特权命令(Ring0),调用一切系统资源,所以一般情况下,用户的操作是运行在用户空间,而内核运行的数据是在内核空间的,而如果应用程序需要去调用一些特权资源,执行关于内核空间的操作,就需要在用户态和内核态之间进行切换

  • 比如:

    • Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区
    • 写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
    • 读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区
  • 针对这个操作:用户在写读数据时,会去向内核态申请,想要读取内核的数据,而内核数据要去等待驱动程序从硬件上读取数据,当从磁盘上加载到数据之后,内核会将数据写入到内核的缓冲区中,然后再将数据拷贝到用户态的buffer中,然后再返回给应用程序,整体而言,磁盘I/O的速度慢,因此,我们希望read也好,还是wait for data,还是用户空间和内核空间的缓冲区之间的数据拷贝,最好都不要等待,或者时间尽量的短。

2、网络模型之阻塞IO

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

  • 阻塞IO(Blocking IO)

  • 非阻塞IO(Nonblocking IO)

  • IO多路复用(IO Multiplexing)

  • 信号驱动IO(Signal Driven IO)

  • 异步IO(Asynchronous IO)

    网络模型的关键在于等待数据就绪 和读取数据这两个过程,应用程序想要去读取数据,是无法直接去读取磁盘数据的,需要先到内核态去等待 内核操作硬件拿到数据,这个过程就是1,是需要等待的,等到内核从磁盘上把数据加载出来之后,再把这个数据写给用户的缓存区,这个过程是2,如果是阻塞IO,那么整个过程中,用户从发起读请求开始,一直到读取到数据,都是一个阻塞状态。​
    在这里插入图片描述

阻塞IO就是等待数据就绪和读取数据这两个阶段都必须阻塞等待:

阶段一:

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

阶段二:

  • 数据到达并拷贝到内核缓冲区,表示数据准备已经就绪
  • 将内核数据拷贝到用户缓冲区,拷贝过程中,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据
    可以看到,阻塞IO模型中,用户进程在两个阶段都是阻塞状态。
    在这里插入图片描述

3、网络模型之非阻塞IO

  • 非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程,如果没有获取到数据,其后,会一直调用recvfrom来尝试获取数据。

阶段一(非阻塞状态):

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

阶段二(阻塞状态):

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

4、网络模型之IO多路复用

  • 在许多高性能的网络框架中,IO多路复用模型使用得比较常见(IO多路复用,复用的是单个线程)

  • 无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理方案:

    • 如果调用recvfrom时,恰好没有数据,阻塞IO会使CPU阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。
    • 如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据
    • 而在单线程情况下,只能依次处理IO事件,如果正在处理的IO事件恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有IO事件都必须等待,性能自然会很差。
  • 就比如服务员给顾客点餐,分两步:顾客思考要吃什么(等待数据就绪)、顾客想好了,开始点餐(读取数据)

  • 要提高效率可以考虑的办法,方案一:增加更多服务员(多线程);方案二:不排队,谁想好了吃什么(数据就绪了),服务员就给谁点餐(用户应用就去读取数据)
    那么问题来了:用户进程如何知道内核中数据是否就绪呢

    • 这个问题的解决依赖于 文件描述符(File Descriptor)
  • 文件描述符(File Descriptor):简称FD,是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)

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

阶段一:

  • 用户进程调用select,指定要监听的FD集合和监听FD对应的多个socket
  • 任意一个或多个socket数据就绪则返回readable(只要有一个顾客想要点餐,服务员就去服务)
  • 此过程中用户进程阻塞

阶段二:

  • 用户进程找到就绪的socket
  • 依次调用recvfrom读取数据
  • 内核将数据拷贝到用户空间,用户进程处理数据

当用户去读取数据的时候,会调用select函数,select函数会将需要监听的数据交给内核,由内核去检查这些数据是否就绪了,当数据就绪后会通知应用程序来读取数据,再从内核中把数据拷贝给用户态,完成数据处理,(保证去读取数据的时候,数据一定存在,避免CPU空转的情况),如果N多个FD一个都没处理完,此时就进行等待。
在这里插入图片描述

  • 监听FD、通知的方式又有多种实现,常见的有:select、poll、epoll
    • select和pool相当于是当被监听的数据准备好之后,把要监听的FD集合都发给你,你需要到FD集合中去遍历查找数据,故而,性能也并不是太好
    • 而epoll,则是将内核的FD数据准备好了之后,会只把准备好的数据返回给用户应用,省去了遍历操作。

4.1、select模式

  • select是Linux最早是由的I/O多路复用技术,简单来说,就是用户应用把需要处理的数据封装成FD(文件描述符),然后在用户态时创建一个fd的集合(这个集合的大小是要监听的那个FD的最大值+1,但是集合长度是有限制的 ),同时在这个集合中,标明出来我们要控制哪些数据
    在这里插入图片描述

  • 假设监听数据是index为1,2,5的三项数据,此时会执行select函数,然后将整个fd发给内核态,内核态会去遍历用户态传递过来的数据,如果发现监听数据都没有就绪,就休眠,直到有数据准备好时才唤醒,唤醒后再次遍历,查找已经就绪的数据,最后再将这个FD集合写回到用户态中,此时用户态就知道有数据已准备就绪了,但是对于用户态而言,并不知道到底是哪些数据就绪,所以用户态也需要去遍历找到就绪数据的节点,再去发起读请求,这种模式尽管比阻塞IO和非阻塞IO好,但有要频繁地传递fd集合 和 遍历FD等问题。

  • 在这里插入图片描述

  • Select模式存在的问题:

    • 需要将整个fd_set从用户空间拷贝到内核空间,Select函数结束后,还需要再次拷贝回用户空间
    • Select模式下,无法得知具体是哪个fd就绪,需要遍历整个fd_set(fd_set集合的结构以bit位的形式展现)
    • fd_set监听的fd数量不能超过1024(fd_set的大小为1024bit),并行处理文件的数量(客户端请求量)只能是1024

4.2、poll模式

  • poll模式对select模式做了简单改进,但性能提升不明显,部分代码如下:
    在这里插入图片描述

IO流程:

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

与select对比:

  • select模式中的fd_set大小固定为1024,而poll模式中的pollfd在内核中采用链表,理论上无上限(调用poll函数,将pollfd数组拷贝到内核空间,转为链表存储)
  • 监听FD越多,每次遍历消耗时间也越久,性能反而会下降

4.3、epoll模式

  • epoll模式是对select和poll的改进,它提供了三个函数:
    在这里插入图片描述

1)eventpoll

  • 使用红黑树,记录要监听的FD,即 需要将监听的fd加入到红黑树中,为 struct rb_root rbr;
  • 使用链表,记录就绪的FD,为 struct list_head rdlist;

2)epoll_ctl

  • 调用epoll_ctl操作,将要监听的数据添加到红黑树上去,并且给每个fd设置一个监听函数,监听函数会在fd数据就绪时触发,即数据就绪了,其fd数据就会添加到list_head中去。

2)epoll_wait

  • 用户态创建一个空的events数组,当数据就绪之后,回调函数会把数据添加到list_head中去,当调用这个函数的时候,会去检查list_head,该过程需要参考配置的等待时间,可以等一定时间,也可以一直等, 如果在此过程中,检查到了list_head中有数据会将数据添加到链表中,此时将数据放入到events数组中,并且返回对应的操作的数量,此时,用户态收到响应后,从events中拿到对应准备好的数据的节点,再去调用方法去读取数据。
    在这里插入图片描述

4.4、小总结:

select模式存在的问题:

  • 能监听的FD最大不超过1024,即监听的FD数量有限
  • 每次select都需要把所有要监听的FD都拷贝到内核空间,Select结束后,还需要将要监听的FD 拷贝到用户空间
  • 每次都要遍历所有FD来判断就绪状态

poll模式的问题:

  • poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降
  • 当链表中的fd过多,遍历链表就会导致代价过大(可以监听的fd数量理论上无限,但会受限于性能)

epoll模式中如何解决这些问题的?
总结起来,需要解决监听FD数量有限、无法确切获取就绪状态的fd(需要遍历)、拷贝fd开销大

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

5、事件通知机制

当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。但是事件通知的模式有两种:

  • LevelTriggered:简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知
  • EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知

举个栗子:

  • 1)假设一个客户端socket对应的FD已经注册到了epoll实例中
  • 2)客户端socket发送了2kb的数据
  • 3)服务端调用epoll_wait,得到通知说FD就绪
  • 4)服务端从FD读取了1kb数据后,会再次调用epoll_wait,形成循环

结论

  • 如果我们采用LT模式,因为FD中仍有1kb数据,则最后依然会返回结果,并且得到通知
  • 如果我们采用ET模式,因为第③步已经消费了FD可读事件,最后FD状态没有变化,因此epoll_wait不会返回,数据无法读取,客户端响应超时。
  • 使用ET模式,如果想要保证数据不丢失,则需要将对应的数据节点连接到list_head(记录就绪状态的链表)

总结:

  • LT:事件通知频率较高,会有重复通知,影响性能,实现较简单
  • ET:仅通知一次,效率高,可以基于非阻塞IO,循坏读取数据,从而解决数据读取不完整的问题; ET模式避免了LT模式可能出现的惊群现象ET模式最好结合非阻塞IO读取FD数据,相比于LT会更加复杂
  • select和poll模式仅仅支持LT模式,epoll可以自由选择LT和ET两种模式

6、基于epoll的服务器端流程

  • 服务器启动以后,服务端会调用epoll_create,创建一个epoll实例,epoll实例中包含两个数据

    • 红黑树(为空):rb_root 用来去记录需要被监听的FD
    • 链表(为空):list_head,用来存放已经就绪的FD
  • 创建好epoll实例之后,会调用epoll_ctl函数,此函数会将需要监听的数据添加到rb_root(红黑树)中去,并且对当前这些存在于红黑树的节点设置回调函数当这些被监听的数据一旦准备就绪,就会被调用,而调用的结果就是将红黑树的fd添加到list_head中去

  • 调用epoll_wait函数,就会去校验是否有数据准备完毕(因为数据一旦准备就绪,就会被回调函数添加到list_head中),在等待了一段时间后(时间可以配置),如果超过了超时时间,则返回没有数据,如果有,则进一步判断当前是什么事件,如果是建立连接时间,则调用accept() 接受客户端socket,拿到建立连接的socket,然后建立起来连接,如果是其他事件,则把数据进行写出

  • 在这里插入图片描述

7、其他网络模型

7.1、信号驱动IO

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

阶段一:

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

阶段二:

  • 收到SIGIO回调信号
  • 调用recvfrom,读取
  • 内核将数据拷贝到用户空间
  • 用户进程处理数据
    在这里插入图片描述
  • 当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。

7.2、异步IO

  • 异步IO,不仅仅是用户态在试图读取数据时不阻塞,而且当内核的数据准备完成后,也不会阻塞
  • 由内核将所有数据处理完成后,将数据写入到用户态中,然后才算完成,所以性能极高,不会有任何阻塞,全部都由内核完成,可以看到,异步IO模型中,用户进程在两个阶段都是非阻塞状态。
    在这里插入图片描述

7.3、同步和异步的对比

  • IO操作是同步还是异步,关键是看数据在内核空间和用户空间的拷贝过程(数据读写的IO操作),也就是阶段二是同步还是异步
    • 阶段1:等待数据就绪
    • 阶段2:将数据从内核空间拷贝到用户空间(阻塞就是同步I/O,非阻塞就是异步I/O)
      在这里插入图片描述

8、Redis的网络模型

8.1、Redis是单线程?为什么使用单线程

Redis到底是单线程还是多线程,需要具体场景具体分析,简而言之:

  • 如果仅仅聊Redis的核心业务部分(命令处理),答案是单线程
  • 如果是聊整个Redis,那就是多线程

在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:

  • Redis v4.0:引入多线程来异步处理一些耗时较久的任务,例如异步删除命令unlink
  • Redis v6.0:在核心网络模型中引入 多线程,进一步提高对于多核CPU的利用率

因此,对于Redis的核心网络模型,在Redis 6.0之前确实都是单线程,是利用epoll(Linux系统)这样的IO多路复用技术在事件循环中不断处理客户端请求

8.2、为什么Redis要选择单线程

  • Redis是纯内存操作,执行速度非常快,其性能瓶颈是网络延迟而不是执行速度,因此,多线程并不会带来巨大的性能提升
  • 多线程会导致过多的上下文切换,带来不必要的开销
  • 引入多线程会面临线程安全的问题,如要解决,还要引入线程锁之类的安全手段,会导致实现复杂度高,性能降低

8.3、Redis网络模型详解

  • Redis通过IO多路复用来提高网络性能,并且支持各种不同的多路复用实现,以及封装实现,提供了统一的高性能事件库API库 AE:
    在这里插入图片描述
  • Redis6.0中引入了多线程,其目的是为了提高IO读写速率,因此,在解析客户端命令,写响应结果(即将请求数据写入缓冲区、解析缓冲区数据将其转化为Redis命令)时,采用了多线程,而核心的业务,如命令执行、IO多路复用模块依然是由主线程执行
    在这里插入图片描述

8.4、Redis 多线程网络模型的设计方案

  • 使用 I/O 线程实现网络 I/O 多线程化,I/O 线程只负责网络 I/O 和命令解析,不执行客户端命令。
  • 利用原子操作+交错访问实现无锁的多线程模型。
  • 通过设置 CPU 亲和性,隔离主进程和其他子进程,让多线程网络模型能发挥最大的性能。

9、结语

引自:https://zhuanlan.zhihu.com/p/356059845
Redis 自 2009 年发布第一版之后,其单线程网络模型的选择在社区中从未停止过讨论,多年来一直有呼声希望 Redis 能引入多线程从而利用多核优势,但是作者 antirez 是一个追求大道至简的开发者,对 Redis 加入任何新功能都异常谨慎,所以在 Redis 初版发布的十年后才最终将 Redis 的核心网络模型改造成多线程模式,这期间甚至诞生了一些 Redis 多线程的替代项目。虽然 antirez 一直在推迟多线程的方案,但却从未停止思考多线程的可行性,Redis 多线程网络模型的改造不是一朝一夕的事情,这其中牵扯到项目的方方面面,所以我们可以看到 Redis 的最终方案也并不完美,没有采用主流的多线程模式设计。

参考资料:


版权声明:本文为FlyingFish868原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。