Redis 高级篇 Part 2
😉 本文共5094字,阅读时间约10min
Redis 网络模型
前置知识
用户态和内核态
-
在linux中,权限分成两个等级,0和3
- 用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源
- 必须通过内核提供的接口来访问内核空间执行特权命令(Ring0),调用一切系统资源
- 所以一般情况下,用户的操作是运行在用户空间,而内核运行的数据是在内核空间的,而有的情况下,一个应用程序需要去调用一些特权资源,去调用一些内核空间的操作,所以此时他俩需要在用户态和内核态之间进行切换。
-
例子:Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区:
- 写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
- 读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区
-
这整个过程是阻塞等待的,IO慢就是这个原因
网络模型 几种IO
在《UNIX网络编程》一书中,总结归纳了5种IO模型:
- 阻塞IO(Blocking IO)
- 非阻塞IO(Nonblocking IO)
- IO多路复用(IO Multiplexing)
- 信号驱动IO(Signal Driven IO)
- 异步IO(Asynchronous IO)
- 阻塞和非阻塞操作区别是发起的IO请求操作后设备数据未拷贝到内核空间是否立刻返回一个标志信息而不让请求线程等待。
- 同步与异步是基于应用程序和操作系统处理IO事件所采用的方式:
- 同步:应用程序要直接参与IO读写的操作。
- 异步:所有的IO操作交给操作系统去处理,应用程序只需要等待通知。
阻塞IO BIO
整个过程中,用户从发起读请求开始,一直到读取到数据,都是一个阻塞状态。
- 阻塞IO就是用户进程两个阶段都要阻塞等待
- 阶段一:内核等待设备数据就绪
- 用户进程尝试读取数据(比如网卡数据。此时数据尚未到达,内核需要等待数据。此时用户进程也处于阻塞状态。数据到达并拷贝到内核缓冲区,代表已就绪。
- 阶段二:将数据从内核缓冲区拷贝到用户缓冲区
- 将内核数据拷贝到用户缓冲区。拷贝过程中,用户进程依然阻塞等待。拷贝完成,用户进程解除阻塞,处理数据
- 拷贝到用户缓冲区后,用户进程解除阻塞
- 阶段一:内核等待设备数据就绪
非阻塞IO
同步非阻塞IO(Non-blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。注意这里所说的NIO并非Java的NIO(New IO)库。
应用程序发送一个读取 IO 的请求,如果数据还没有从网卡写入内核空间,直接返回未就绪,这样就做到了不需要程序死等到结果。等到写入内核空间以后,程序继续读取数据,这时候才会阻塞程序
- 阶段一:循环尝试获取数据,若内核空间未就绪直接返回
- 阶段二:将内核数据拷贝到用户缓冲区,拷贝过程种用户程序仍然阻塞
非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。
解决的是等内核空间数据就绪的阻塞
信号驱动IO
信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。也是第一个阶段不阻塞,第二个阶段阻塞。
当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。
异步IO
两个阶段都不阻塞。
他会由内核将所有数据处理完成后,由内核将数据写入到用户态中,然后才算完成,所以性能极高,不会有任何阻塞,全部都由内核完成,可以看到,异步IO模型中,用户进程在两个阶段都是非阻塞状态。
对比
IO多路复用
什么是IO多路复用?
可以给一个用户创建一个线程来进行BIO或者NIO的操作
- 等内核数据就绪阶段,无论是阻塞还是忙等,效率都太低
- 多线程频繁上下文切换也非常影响性能
IO多路复用是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源,以此来减少线程的创建和切换的开销,进而提高服务器的吞吐能力。。相当于单线程完成多个非阻塞IO的活,用到的是非阻塞的必要特性。
使用单个线程通过记录跟踪每一个流(I/O)的状态来同时管理多个I/O,以此来减少线程的创建和切换的开销,进而提高服务器的吞吐能力。
select channel buffer机制
文件描述符(File Descriptor):简称FD,是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。
通过FD,我们的网络模型可以利用一个线程监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
两个阶段
阶段一:用户进程调用select + 内核监听FD + 有就绪则通知用户进程
- 用户进程调用select,指定要监听的FD集合
- 内核监听FD对应的多个socket,任意一个或多个socket数据就绪则返回readable
- 此过程中用户进程阻塞
阶段二:用户进程找到就绪的fd读取 + 内核把数据拷贝到用户空间
- 用户进程找到就绪的socket,依次调用recvfrom读取数据
- 内核将数据拷贝到用户空间,用户进程处理数据
当用户去读取数据的时候,不再去直接调用recvfrom了,而是调用select的函数,select函数会将需要监听的数据交给内核,由内核去检查这些数据是否就绪了。
如果说这个数据就绪了,就会通知应用程序数据就绪,然后来读取数据,再从内核中把数据拷贝给用户态,完成数据处理,如果N多个FD一个都没处理完,此时就进行等待。
IO多路复用的三种实现
IO多路复用是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。不过监听FD的方式、通知的方式又有多种实现,常见的有:
- select
- poll
- epoll
其中select和poll相当于是当被监听的数据准备好之后,他会把你监听的FD整个数据都发给你,你需要到整个FD中去找,哪些是处理好了的,需要通过遍历的方式,所以性能也并不是那么好
epoll,相当于内核准备好了之后,他会把准备好的数据,直接发给你,咱们就省去了遍历的动作。
select模式
我们把需要处理的数据封装成FD,然后在用户态时创建一个fd的集合(这个集合的大小是要监听的那个FD的最大值+1,但是大小整体是有限制的 ),这个集合的长度大小是有限制的,同时在这个集合中,标明出来我们要控制哪些数据。
后将整个fd发给内核态,内核态会去遍历用户态传递过来的数据,如果发现这里边都数据都没有就绪,就休眠,直到有数据准备好时,就会被唤醒,唤醒之后,再次遍历一遍,看看谁准备好了,然后再将处理掉没有准备好的数据,最后再将这个FD集合写回到用户态中去。
对于用户态而言,并不知道谁处理好了,所以用户态也需要去进行遍历,然后找到对应准备好数据的节点,再去发起读请求.
- 问题:
- 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
- select无法得知具体是哪个fd就绪,需要遍历整个fd_set
- fd_set监听的fd数量不能超过1024
poll模式
poll模式对select模式做了简单改进,但性能提升不明显,部分关键代码如下:
IO流程: 和select差不多
- 创建pollfd数组,向其中添加关注的fd信息,数组大小自定义
- 调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限
- 内核遍历fd,判断是否就绪
- 数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
- 用户进程判断n是否大于0,大于0则遍历pollfd数组,找到就绪的fd
与select对比:
- select模式中的fd_set大小固定为1024,而pollfd在内核中采用链表,理论上无上限
- 监听FD越多,每次遍历消耗时间也越久,性能反而会下降
epoll函数
epoll模式是对select和poll的改进,epoll最大的好处在于它不会随着监听fd数目的增长而降低效率。
两个数据结构
要深刻理解epoll,首先得了解epoll的两大关键要素:红黑树、链表。
红黑树将存储epoll所监听的套接字,epoll在实现上采用红黑树去存储所有套接字,当添加或者删除一个套接字时(epoll_ctl),都在红黑树上去处理,红黑树本身插入和删除性能比较好,时间复杂度O(logN)。通过epoll_ctl函数添加进来的事件都会被放在红黑树的某个节点内,所以,重复添加是没有用的。当把事件添加进来的时候时候会完成关键的一步,那就是该事件都会与相应的设备(网卡)驱动程序建立回调关系。
当相应的事件发生后,就会调用这个回调函数,该回调函数在内核中被称为:ep_poll_callback,这个回调函数其实就是把这个事件添加到rdllist这个双向链表中。
那么当我们调用epoll_wait时,只用检查就绪链表中是否有数据,非常高效。如果有,此时将数据放入到events数组(wait数据结构里的东西)中,并且返回对应的操作的数量,用户态的此时收到响应后,从events中拿到对应准备好的数据的节点,再去调用方法去拿数据。
三个函数
epoll_create()
,创建一个epoll实例,并返回一个文件描述符fd,用于对epoll接口的所有后续调用,并且会创建一组数据结构,比如红黑树,就绪链表rdllist。
epoll_ctl()
,注册或删除要监听的fd,关联回调函数。当fd就绪之后,回调函数会把数据添加到链表中去。
epoll_wait
:如果检查到了就绪链表中有数据,此时将数据放入到events数组(wait数据结构里的东西)中,并且返回对应的操作的数量,用户态的此时收到响应后,从events中拿到对应准备好的数据的节点,再去调用方法去拿数据。
epoll中的ET和LT
默认LT
当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。但是事件通知的模式有两种:
- LevelTriggered:简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。
- EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知。
举个栗子:
- 假设一个客户端socket对应的FD已经注册到了epoll实例中
- 客户端socket发送了2kb的数据
- 服务端调用epoll_wait,得到通知说FD就绪
- 服务端从FD读取了1kb数据回到步骤3(再次调用epoll_wait,形成循环)
结论
如果我们采用LT模式,因为FD中仍有1kb数据,则第⑤步依然会返回结果,并且得到通知
如果我们采用ET模式,因为第③步已经消费了FD可读事件,第⑤步FD状态没有变化,因此epoll_wait不会返回,数据无法读取,客户端响应超时。
小总结
-
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的服务器流程
-
服务器启动以后,服务端会去调用epoll_create,创建一个epoll实例,epoll实例中包含
1、红黑树(为空):rb_root 用来去记录需要被监听的FD
2、链表(为空):rdllist,用来存放已经就绪的FD
-
创建好了之后,会去调用epoll_ctl函数,此函数会将需要监听的数据添加到rb_root中去,并且对当前这些存在于红黑树的节点设置回调函数,当这些被监听的数据一旦准备完成,就会被调用,而调用的结果就是将红黑树的fd添加到rdllist中去(但是此时并没有完成)
-
当第二步完成后,就会调用epoll_wait函数,这个函数会去校验是否有数据准备完毕(因为数据一旦准备就绪,就会被回调函数添加到rdllist中),在等待了一段时间后(可以进行配置),如果等够了超时时间,则返回没有数据.
- 如果有,则进一步判断当前是什么事件,总结就是针对事件处理,客户端连接事件来了,则建立连接并监听,客户端读事件则读取请求数据,异常事件则写出异常信息。
Redis是单线程的吗?
Redis到底是单线程还是多线程?
- 如果仅仅聊Redis的核心业务部分(命令处理),答案是单线程
- 如果是聊整个Redis,那么答案就是多线程
在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:
- Redis v4.0:引入多线程异步处理一些耗时较旧的任务,例如异步删除命令unlink
- Redis v6.0:在核心网络模型中引入 多线程,进一步提高对于多核CPU的利用率
因此,对于Redis的核心网络模型,在Redis 6.0之前确实都是单线程。是利用epoll(Linux系统)这样的IO多路复用技术在事件循环中不断处理客户端情况。
为什么Redis要选择单线程?
- 抛开持久化不谈,Redis是纯内存操作,执行速度非常快,性能瓶颈是网络IO而不是执行速度,也就是客户端和服务端之间的网络传输延迟,因此多线程并不会带来巨大的性能提升。
- 多线程会导致过多的上下文切换,带来不必要的开销
- 引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能会大打折扣
Redis的单线程模型-Redis单线程和多线程网络模型变更
当我们的客户端想要去连接我们服务器,会去先到IO多路复用模型去进行排队,会有一个连接应答处理器,他会去接受读请求,然后又把读请求注册到具体模型中去,此时这些建立起来的连接,如果是客户端请求处理器去进行执行命令时,他会去把数据读取出来,然后把数据放入到client中,命令请求处理器去解析当前的命令转化为redis认识的命令,接下来就开始处理这些命令,从redis中的command中找到这些命令,然后就真正的去操作对应的数据了,当数据操作完成后,会去找到命令回复处理器,再由他将数据写出。
Redis通信协议-RESP协议
- Redis是一个CS架构的软件,通信一般分两步(不包括pipeline和PubSub):
- 客户端(client)向服务端(server)发送一条命令
- 服务端解析并执行命令,返回响应结果给客户端
- 因此客户端发送命令的格式、服务端响应结果的格式必须有一个规范,这个规范就是通信协议。在RESP中,通过首字节的字符来区分不同数据类型
- 基于socket自定义一个redis的客户端
- Redis支持TCP通信,因此我们可以使用Socket来模拟客户端,与Redis服务端建立连接:
- 可以根据RESP的通信的这些首字节的字符来发送命令和接收redis的响应数据