Redis网络模型
- 一、用户空间和内核空间(前提)
- 问题来了:为啥要区分用户空间和内核空间呢?
- 我们来看看两个空间以及硬件是如何操作的
- 二、Linux中五种IO模型
- 1、 阻塞IO
- 2、非阻塞IO
- 3、IO多路复用
- 3.1、SELECT
- 3.2、poll
- 3.3、epoll
- 4、信号驱动IO
- 5、异步IO
- 三、Redis中的网络模型
- 四、Redis通信协议
- 定义
- RESP2协议-数据类型
- 五、Redis内存回收
一、用户空间和内核空间(前提)
我们知道操作系统采用的是虚拟地址空间,以32位操作系统举例,它的寻址空间为4G(2的32次方),这里解释二个概念:
寻址: 是指操作系统能找到的地址范围,32位指的是地址总线的位数,你就想象32位的二进制数,每一位可以是0,可以是1,是不是有2的32次方种可能,2^32次方就是可以访问到的最大内存空间,也就是4G。
虚拟地址空间: 为什么叫虚拟,因为我们内存一共就4G,但操作系统为每一个进程都分配了4G的内存空间,这个内存空间实际是虚拟的,虚拟内存到真实内存有个映射关系。例如X86 cpu采用的段页式地址映射模型。
操作系统将这4G可访问的内存空间分为二部分,一部分是内核空间,一部分是用户空间。
内核空间是操作系统内核访问的区域,独立于普通的应用程序,是受保护的内存空间。
用户空间是普通应用程序可访问的内存区域。
以linux(32位操作系统)为例:
将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间
每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有4G字节的虚拟空间。
问题来了:为啥要区分用户空间和内核空间呢?
其实早期操作系统是不区分内核空间和用户空间的,但是应用程序能访问任意内存空间,如果程序不稳定常常把系统搞崩溃,比如清除操作系统的内存数据。后来觉得让应用程序随便访问内存太危险了,就按照CPU 指令的重要程度对指令进行了分级,指令分为四个级别:Ring0~Ring3 (和电影分级有点像),linux 只使用了 Ring0 和 Ring3 两个运行级别,进程运行在 Ring3 级别时运行在用户态,指令只访问用户空间,而运行在 Ring0 级别时被称为运行在内核态,可以访问任意内存空间。
用户态的程序不能随意操作内核地址空间,这样对操作系统具有一定的安全保护作用。
我们来看看两个空间以及硬件是如何操作的
**写数据到磁盘:**首先将数据写到用户空间的缓冲区,然后调用内核,内核则将数据从空间缓冲区copy到内核缓冲区,然后再将缓冲区中的数据写入磁盘。
从网络中或者磁盘中读数据 :进程首先切换到内核态,调用内核从网卡或者磁盘中读,但是没有的时候就需要等待,有则将数据读到内核的缓冲区再copy到用户区使用。
其实这里就有很多空间进行IO优化了,比如没有数据读的时候需要一直等待(一直占用cpu资源),以及读写的多次拷贝(两个空间的来回切换)等等。
二、Linux中五种IO模型
1、 阻塞IO
顾名思义:没有数据的时候一直等待,有数据则拷贝到用户空间进行处理。
2、非阻塞IO
顾名思义:没有就返回错误,但是会一直请求,花里胡哨其实一点用也没有,用户还是停留在访问等待直到有数据(盲目等待),拷贝到用户空间进行处理。
注意:由于没有数据的时候一直访问,cpu一直执行指令可能性能更低(cpu空转),甚至不如阻塞IO。
3、IO多路复用
看到这里大家发现,其实不管阻塞IO还是非阻塞IO,第一阶段都需要等待数据(恰好没数据),但是当多个进程来时,等待会影响整个业务,下面用个生活中的例子来表示,排队点餐。
如果第一顾客还没想好自己要吃啥的时候,后面的顾客都需要等待(等待数据)
如果已经想好了,开始点餐(读取数据)
那我们如何提高点单速率呢?
- 多加几个前台(多线程)
- 不排队,谁想好了吃啥,服务员就先给谁点单(读数据)
显然第二种更好一点,不需开多线程去提高效率。
问题又来了:如何知道顾客已经想好了(数据就绪?)
这里需要了解一个概念-文件描述符(File Decriptor)简称FD,是一个从0开始的无符号整数(自然数),用来关联Linux中的一个文件。在Linux中一切皆文件,常规文件,视频,硬件等待,当然包括网络套接字(Socket)。
IO多路复用: 是利用单线程来同时监听多个FD,并且在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用了CPU的资源。
在等待数据方面,用户调用select方法,同时监听多个FD,如果都没有数据则等待(也就是所有顾客都没有想好吃啥)这个时候才阻塞等待,当有一个想好了,就可以进行点餐。从而避免了没有数据的时候直接调用recvfrom函数去内核空间中等待,浪费cpu资源。
那我们再深一层解析一下监听和通知
方式有三种 一个是上面说的select 还有poll 和epoll 他们有啥区别吗?
依旧使用前面的生活案例来解释:
select和poll: 当有顾客想好了,然后通知给点单前台,但是前台是不知道具体哪一位顾客准备好了,然后就一个一个问顾客(遍历),找到了再进行点单。
其实他的缺点也看到了就是有人下单了,无法立即确定是哪一个顾客。
epoll: 有一个顾客想好后,会显示到点单员电脑上,直接下单准备食物即可。
转换成计算机的话:
- select和poll:只会通知用户进程有FD就绪,但是不确定是哪一个FD,需要逐个遍历FD来确定
- epoll :则会通知用户进程FD就绪的同时,把已就绪FD写入用户空间中。更高效
3.1、SELECT
上源码:
本质是数组存二进制位,0表示未就绪,1则表示就绪,共有三个,一个是监听读事件,一个写数件,还一个是异常事件(1024位)。
select函数同时监听这三者的状态。
流程如图(假设8个位置)我们假设监听FD为1,2,5。
过程: 执行select函数,需要内核空间帮我监听,拷贝数组到内核空间,遍历数组,没有则休眠,有则唤醒,并且传回用户空间(又将数组拷贝回去),然后用户空间再遍历一次找到相关的就绪FD。
不足:
- 执行select需要拷贝一份到内核空间,监听完之后又要拷贝一份到用户空间(一次监听就要2次切换),在监听后续的fd又需要重复以上操作。
- 内核空间监听需要遍历整个数组,监听到返回给用户空间又要遍历一遍数组查找就绪fd(两次遍历)
- 大小不能超过1024,若超过需要修改源码(很麻烦)
3.2、poll
直接上源码
其实和select的数据结构差不多只是数组中添加了一个状态,从而无需三个数组(换汤不换药)
执行流程:
1、创建数组pollFD,添加监听的fd信息,数组大小自定义
2、调用poll函数,将数组拷贝到内核空间,转链表存储(无上限)
3、内核遍历,判断是否就绪
4、数据就绪或者超时后,拷贝数组到用户空间,返回就绪数量n
5、使用进程判断n大于零就开始遍历数组找就绪的fd
其实本质和select没啥区别,只不过数组自定义可以大于1024个fd同时监听,内核中采用链表存储,再一个fd越多遍历越慢,性能反而下降。(意义不大,所以很少使用这种方式)
3.3、epoll
上源码
底层结构和过程:本质是由一个红黑树和一个链表组成,调用创建函数(epoll_create)创建并且返回,epoll_ctl()函数将fd加入到红黑树中(监听作用)并且设置回调函数,一旦回调函数触发就将该fd添加到链表(list_head)中,再通过epoll_wait()函数检查链表,有则返回就绪fd数量,并且将链表中fd复制到events数组中给用户空间使用。
我们将之前select的不足截取过来对比:
最后总结: 我们来看看相对前面二者的不足,epoll做了啥优化。
- 对于解决监听上限的问题:基于epoll是使用红黑树存储fd,理论上数据可以很大而且红黑树查询性能不受很大的影响。
- 对于每次监听都需要将数组拷贝到内核空间:epoll只需要执行一次epoll_ctl()就将所有fd存入红黑树中,以后每次添加fd元素即可,在等待就绪时函数epoll_wait()无需传参,无需重新拷贝fd数组到内核空间。
- 对于将就绪的fd拷贝回用户空间:epoll无需遍历所有数组去找就绪的,而是返回的都是就绪的。
4、信号驱动IO
是与内核简历sigIO的信号关联并且设置回调,当有fd就绪就会发出sigIO信号通知用户,期间用户可以执行其他业务,无需阻塞等待。
特点:可以看到在等待数据阶段,是直接交给内核空间用户空间不管的
这里需要和非阻塞IO区分,等待阶段非阻塞IO是一直询问有木有数据(本质还是阻塞)
为啥我们不使用他呢?(有啥缺点?)
- 在大量IO操作的时候,信号较多,sigIO函数不能及时处理可能导致信号队列溢出
- 内核空间和用户空间的频繁信号交互性能也较低
5、异步IO
整个过程都是非阻塞的,用户进程调用完异步API就去做别的事情,数据等待和拷贝都是异步执行(一条龙服务)。
使用相对多一点,但是还是有个缺点:如果io多了,内核IO性能不是很高,导致内存消耗过多导致整个崩溃(就像老板一直把事情交给你,不管你死活)
总结:
前四种IO都是同步IO,只有最后一个是异步IO(以数据拷贝是否阻塞为基准)
三、Redis中的网络模型
简单解释:
底层就是使用IO多路复用+事件派发,首先是服务端可读,连接应答处理器将客户端socket(FD)注册到IO多路复用程序,进行客户端的读写监听,当客户端需要操作(就绪) 时(也就是客户端可读),会使用命令请求处理器,首先将请求的数据(Redis6.0前是单线程,之后引入多线程)写入缓冲区,再将数据转化为redis命令并且执行,将返回值写入缓冲区,当多个操作后就将多个client写入队列,再通过遍历队列绑定命令回复处理器,Redis6.0之前是单线程逐个处理,之后引进多线程提升了回复效率。
引入多线程: 对于redis来说在监听fd,以及命令执行的时候,(主线程)单线程是完成足够的(纯内存),真正影响性能的永远是IO,就是读命名和回复响应值(来回的拷贝)占用网络资源。
四、Redis通信协议
定义
Redis是一个CS架构软件,通信一般分两步:
- 客户端(Client)向服务端(Server)发生一条命令。
- 服务端解析并且执行命令,返回响应结果给客户端。
因此二者之间数据交互需要有个格式规范,这个规范就是通信协议。
而Redis中采用的就是RESP(Redis Serialization Protocol)协议
- Redis1.2版本引入该协议
- Redis2.0版本成为标准,称为RESP2
- Redis6.0版本升级为RESP3,增加了更多的新数据类型和特性–客户端缓存
但目前默认使用的是RESP2,也是我们下面学习的。
RESP2协议-数据类型
通过首字节的字符来区分不同的数据类型,常用的包括5种:
-
单行字符串:首字节是‘ +’,后面跟上单行字符串,以(“\r\n”)结尾。如:“ok” :“+ok\r\n”
-
错位(Error):首字母‘-’,与单行字符串一样,只不过是错误信息。如:“-Error Message\r\n”
-
数值:首字节为‘:’,后面跟上数字格式字符串也是(“\r\n”)结尾。如:“:10\r\n”
-
多行字符串:首字节为“$”,表示二进制安全(本质和SDS一样,记录字符串占用字节大小,内容中存在"\r\n"也没事),最大支持521MB
-
数组:首字节是 *,后面跟上数组元素个数,在跟上元素,类型不限(中文3个字节)
五、Redis内存回收
参考我另一篇文章–《redis面试篇》中的过期策略和淘汰策略