前言
知道了Redis 底层的数据结构,接下来看下 Redis 通信的网络模型。
一、用户态内核态介绍:
1.1 用户空间访问磁盘资源:
用户空间并不能直接的访问 系统资源,需要发送指令到内核空间
,然后内核空间执行指令 从系统获取磁盘,然后将获取到的资源拷贝到用户空间,然后在从用户空间拷贝到用户空间;
1.2 服务器之间的远程访问:
客户端在用户空间发情请求,通过内核态,在通过网卡将请求发出,同时等待服务器的应答
,获取到应答之后,在从网卡拷贝数据到内核,在从内核拷贝数据到用户空间;
1.3 性能瓶颈问:
- 用户态和内核态的状态切换,用户态到内核态和内核态到用户态的数据拷贝;
- 客户端在发起请求之后的阻塞等待数据返回;
二、五种网络模型:
以 用户空间访问硬件资源来说:
- IO 读取系统的文件数据时,用户空间发送指令到内核空间,然后等待硬件数据拷贝到内核缓冲区;
- 将内核缓冲区的数据拷贝到用户缓冲区;
2.1 阻塞IO(Blocking IO):
两个阶段都进行等待
:
- 用户空间发送 recvfrom ,此时内核空间还没有准备好数据,此时等待内核空间完成数据准备;
- 内核空间数据准备完成,将数据拷贝到用户空间之前也是进阻塞;
2.2 非阻塞IO(NO Blocking IO):
第二个阶段等待
:
- recvfrom ,此时内核空间还没有准备好数据 ,此时直接返回 资源不到位信息;
- 不断轮询recvfrom 询问内核空间是否完成准备;
- 当时内核数据完成准备,将数据拷贝到用户空间之前也是进阻塞;
2.3 阻塞IO和非阻塞IO :
可以看到,非阳寒I0模型中,用户进程在第一个阶段是非阳塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且会导致CPU空转,使CPU使用率暴增。非阻塞IO 只是进行轮询,达到不进行cpu的切换
;
无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据
,差别在于无数据时的处理方案:
- 如果调用recvfrom时,恰好没有数据,阻塞IO会使进程阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。
- 如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据;
2.4 多路复用IO :
IO多路复用:是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源
;IO 多路复用 多用于 服务端对于连接的建立;
FD:文件描述符(File Descriptor): 简称FD,是一个从0开始递增的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字 (Socket)。
相比于阻塞IO和非阻塞IO来说, 使用select 可以 监听多个FD, 只要有FD 可读可写,就进行处理 ,而不是死等一个某个FD
;其中IO多路复用底层有三种实现 select ,poll,epoll
,它们的主要区别为select和poll只会通知用户进程有FD就绪,但不确定具体是哪个FD,需要用户进程逐个遍历FD来确认epoll则会在通知用户进程FD就绪的同时,把已就绪的FD写入用户空间
;
2.4.1 select 模型:
select是Linux中最早的I/0多路复用实现方案:
l0流程:
- 将readfds,writefds,exceptfds fd数组 每个fd数组最大为1024,拷贝到内核空间 ;
- 内核将可读,可写,异常的 fd 分别放入到 readfds,writefds,exceptfds;
- 然后将内核空间中 readfds,writefds,exceptfds 数组写回到用户空间;
- 用户空间分别遍历 readfds,writefds,exceptfds 进行读写;
特点:
- 已经就绪的fd 需要从内核空间拷贝到用户空间;
- 监听的fd 数量最多 是1024;
- 返回就绪的fd 数量,需要遍历去看哪些fd 真正就绪;
2.4.2 poll 模型:
l0流程:
- 创建pollfd数组,向其中添加关注的fd信息,数组大小自定义;
- 调用poll函数,将pollfd数组拷贝到内核空间,转链表存储(理论上无上限);
- 内核循环遍历fd判断是否就绪 ;
- 如果有fd数据就绪或超时后 等待超时,拷贝pollfd数组到用户空间,并且返回就绪fd数量n;
- 用户进程判断就绪的fd 数量n是否大于0,大于0则遍历pollfd数组,找到就绪的fd 就绪数据读取;
特点:
- 已经就绪的fd 需要从内核空间拷贝到用户空间;
- 监听的fd 数量可以自定义 打破 1024 数量限制;
- 返回就绪的fd 数量,需要遍历去看哪些fd 真正就绪;
2.4.3 epoll 模型:
2.4.3.1 epoll 模型流程:
epoll模式是对select和poll的改进,它提供了三个函数:
流程:
- 将要监听的fd 存入到红黑树,并增加回调函数;
- 当fd 有事件就绪时,调用回调函数,将fd 添加到 rdlist 链表中;
- 将就绪的fd 放入到events[] 数组中,并拷贝到用户空间,然后返回就绪的fd 数量;
- 用户空间遍历就绪的events[] 数组 进行数据读取;
2.4.3.2 epoll 模型事件通知 ET/LT 模式:
当FD有数据可读时,我们调用epoll wait就可以得到通知。但是事件通知的模式有两种
- LevelTriggered: 简称LT。当FD有数据可读时,会重复通知多次,直至数据处理完成。是Epoll的默认模式。
- EdgeTriggered: 简称ET。当FD有数据可读时,只会被通知一次,不管数据是否处理完成;
如果是ET 模式则 直接通知1次虽然你没有读取数据完毕;
LT 没有读取完毕则下次还可以将这FD 放入到已就绪的列表,下次循环可以进行返回;
2.4.3.3 epoll 模式实现web 服务流程:
- 服务端创建epoll_create 实例 ,这个实例内部创建用于监听socket 的fd 文件,创建用于记录已就行的fd 链表;注册fd就需的回调函数将已经就绪完成的fd 放入到就绪链表中;
- 服务端创建 serversocket 得到fd 基座ssfd ,通过epoll_ctl 将ssfd 注册的监听fd 的红黑树中;
- 通过epoll_wait轮询去查看 就绪链表 的fd ,如果有值代表有FD 就绪 可读或者可写;
- 如果是ssfd 可读则代表有新的客户端连接上来,接调用accept 函数获取客户端的fd,并将fd 注册到监听的红黑树中;
- 如果是普通的socket 客户端可读,表示已经存在的客户端 发送请求进来,这个时候可以在业务端处理请求,并将结果写回;
- 如果是epollerror 异常事件,则将异常信息写回给客户端;
2.4.4 select/poll/epoll 模型对比:
- 每次调用select/poll 都需要拷贝 全部要监听的fd 到内核中,返回的时候也需要 返回所有的fd; 遍历所有fd 得到就绪的fd;
- epoll 每个fd 只需要进行一次添加 到 内核的红黑树中即可,当有就绪的fd 只需要从内核空间拷贝就绪的fd 即可;用户空间 遍历就绪的fd 就可以进行读写,有新的fd 增加只需要将这个新的fd 放到红黑树即可,及时要监听的fd 但是效率也比较好;
2.5 信号驱动IO:
信号驱动10是与内核建立SIGI0的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。
缺点:
当有大量I0操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出而且内核空间与用户空间的频繁信号交互性能也较低;
2.6 异步IO:
- 用户空间向内核要数据;
- 内核空间完成数据准备,将数据拷贝到用户空间之后,通知用户空间去处理数据;
缺点:
因为异步io 在向内核空间要数据,和从内核空间拷贝到用户空间,都是非阻塞的,所以在并发量大的时候 需要并发做限流的工作;
2.7 IO 的同步和异步:
IO操作是同步还是异步,关键看数据在内核空间与用户空间的拷贝过程(数据读写的IO操作),是同步还是异步:
三、redis 的网络模型:
我们知道redis 是单线程的,但是严格意义来时只有 命令处理部分 是单线程,redis 的命令解析,和发送客户端数据是多线程的;所以对于redis来说redis 的命令处理部分 是单线程,整个redis 内部是多线程
;
3.1 redis命令执行部分为什么使用单线程:
- 抛开持久化不谈,Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。
- 多线程会导致过多的上下文切换,带来不必要的开销;
- 引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大大折扣;
3.2 单线程模式下,怎么实现多路复用:
Redis通过10多路复用来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现进行封装,提供了统一的高性能事件库API库AE:
redis 根据不同的环境加载不同的api 实现:
3.3 多路复用的实现:
redis 的入口:
- initServer 中进行redis 服务的初始化:
先调用aeCreateEventLoop
: 创建红黑树,和就绪的链表
;创建serversocekt 的到fd
;
将serversocket的fd 注册到红黑树中进行监听
,并设置事件回调acceptTcpHandler
(当有新的客户端连接进入则 通过accept 得到socket 的fd 并将fd 注册到红黑树中监听起来
,然后绑定 客户端fd 的可读处理器readQueryFromClient
,如果客户端fd 可读则说明客户端发请求过来,则通过readQueryFromClient 函数进行处理)
因为在进行epoll_wait
获取就绪事件时很有可能此时并没有就绪事件,此时线程进行阻塞,在获取就绪事件之前先调用beforeSleep
函数遍历,将客户端的已就绪写事件也加入到客户端socket 的fd
中 ,这样在红黑树中的这个fd 就可以监听写事件
,并绑定写事件的处理函数sendReplyToClient
,这样当 客户端的fd 有写事件事件则调用sendReplyToClient 函数将响应信息写回到客户端
;
- asMain: 监听事件
循环通过aeProcessEvents 函数去获取就绪事件,然后遍历就绪的fd,调用注册的函数处理器进行处理;其中readQueryFromClient 处理客户端socket 的fd可读事件(可读表明客户端发送请求给了服务端,服务端解析客户端的命令进行命令执行,并将结果写到客户端缓冲区):
3.4 redis单线程和多线程网络模型:
3.4.1 redis 6.0 之前单线程网络模型:
3.4.2 redis 6.0 之后多线程网络模型:
在单线程中,对于事件的派发性能是比较好的,但是在对客户端数据进行读取和写入 会受到IO的网络读写;
Redis 6.0版本中引入了多线程,目的是为了提高IO读写效率。因此在解析客户端命令、写响应结果时采用了多线程。核心的命令执行IO多路复用模块依然是由主线程对于命令逐个执行;
并发情况下,可能出现多个客户端同时发送给redis 服务端,造成多个客户端的fd 都是可读的,此时redis 开启多个线程 去并行的解析 客户端的请求进行命令的解析,真正执行命令是有单个线程串行执行,然后将执行结果放入到链表中;然后在客户端触发写事件后有多个线程去处理将结果写回到客户端;
总结
redis 内部使用IO多路复用,在linux 环境下,通过创建红黑树来监听多个socket 的fd 文件,并将就绪的fd 写入到就绪链表中;这样服务端就可以对就绪链表进行遍历;
如果发现是ServerSocket 的可读事件则表示有新的客户端socket 进行了连接,则 调用accept 获取到客户端的socket 并将其fd 放入到监听的红黑树中,同时绑定fd 的可读事件处理函数;
当有socket 就绪的可读fd 则 解析客户端的请求数据,执行redis 命令 将结果写回到 客户端的缓存中,然后将改客户端记录到 链表中;
遍历客户端的链表,注册客户端的可写事件到客户端的fd,并绑定客户端写事件的处理函数,写处理函数将数据写回到客户端;