🔥博客主页: 【小扳_-CSDN博客】
❤感谢大家点赞👍收藏⭐评论✍
文章目录
1.0 用户空间与内核空间概述
2.0 Redis 网络模型
2.1 Redis 网络模型 - 阻塞 IO
2.2 Redis 网络模型 - 非阻塞 IO
2.3 Redis 网络模型 - IO 多路复用
2.3.1 IO 多路复用 - select
2.3.2 IO 多路复用 - poll
2.3.3 IO 多路复用 - epoll
2.3.4 epoll 的 ET 和 LT 模型
2.3.5 基于 epoll 的服务端流程
2.4 Redis 网络模型 - 信号驱动 IO
2.5 Redis 网络模型 - 异步 IO
3.0 Redis 单线程及多线程网络模型
3.1 经典面试题:Redis 是单线程还是多线程?
3.2 经典面试题:为什么 Redis 要选择单线程?
3.3 Redis 网络模型的结构及具体流程
1.0 用户空间与内核空间概述
1)用户空间:
用户空间是指运行用户应用程序的内存区域。在这一空间中,应用程序可以执行其代码并处理数据,但不允许直接访问内核空间中的资源或数据结构。
每个用户程序在其独立的地址空间中运行,彼此之间是隔离的。这意味着一个程序不能直接干扰另一个程序的内存或资源。
2)内核空间:
内核空间是操作系统内核所占用的内存区域。内核负责管理硬件资源、进程调度、内存管理、文件系统以及网络协议等核心功能。
内核空间拥有对所有硬件和系统资源的权限,应用程序无法直接访问这一空间。
2.0 Redis 网络模型
Linux 系统为了提高 IO 效率,会在用户空间和内核空间都加入缓冲区:
以上是读数据的过程:
当用户要从网络中读取数据时,首先在用户空间中执行命令来调用内核空间中的命令,因为用户空间的命令不能直接来调用或者使用硬件资源。此时,需要等待内核空间调用命令来从网卡中获取数据,接着,将从网卡中获取到的数据先是拷贝到内核空间中的缓冲区,最后再从内核空间中的缓冲区数据拷贝到用户空间缓冲区中。
简单来说:
1)写数据时:要把用户缓冲数据拷贝到内核缓冲区,然后写入设备。
2)读数据时:要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区。
这一整个过程可以分为两个小过程:
1)等待数据就绪。
2)从内核拷贝数据到用户空间。
因此,通过对以上两个过程不同的处理就演变出不同的方式:阻塞 IO、非阻塞 IO、IO 多路复用、信号驱动 IO、异步 IO 。
2.1 Redis 网络模型 - 阻塞 IO
顾名思义,阻塞 IO 就是两个阶段都必须阻塞等待。
当用户来读取数据时,此时内核还没有准备好数据,那么进程就直接 "硬等",直到内核准备好数据,所以,第一个过程是当前线程阻塞为阻塞状态。由于第一个过程还没获取到数据,还在等待数据,自然而然的,第二个过程也就没有数据拷贝到用户缓冲区中,也就是说,第二个过程同样是阻塞状态。
2.2 Redis 网络模型 - 非阻塞 IO
顾名思义,非阻塞 IO 的 recvfrom 操作会立即返回结果而不是阻塞用户进程。
可以看到,非阻塞 IO 模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制导致 CPU 空转,CPU 使用率暴增。
2.3 Redis 网络模型 - IO 多路复用
无论是阻塞 IO 还是非阻塞 IO,用户应用在一阶段都需要调用 recvfrom 来获取数据,差别在于无数据时的处理方案:
1)如果调用 recvfrom 时,恰好没有数据,阻塞 IO 会使进程阻塞,非阻塞 IO 使 CPU 空转,都不能发挥 CPU 的作用。
2)如果调用 recvfrom 时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据。
比如服务端处理客户端 Socket 请求时,在单线程情况下,只能依次处理每一个 Socket,如果正在处理的 Socket 恰好未就绪,线程就会被阻塞,所有其他客户端 Socket 都必须等待,性能自然会很差。
因此,可以采用 IO 多路复用的方式来解决。
IO 多路复用方式简单来说,就是通过一个用户进程来监视内核中的多个数据,并在某个数据准备好则进行读写处理。
那么用户进程如何知道内核中数据是否就绪呢?
可以通过文件描述符表(File Descriptor):简称 FD,是一个从 0 开始递增的无符号整数,用来关联 Linux 中的一个文件。在 Linux 中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。
因此 IO 多路复用:是利用单个线程来同时监听多个 FD,并在某个 FD 可读、可写时得到通知,从而避免无效的等待,充分利用 CPU 资源。
在 IO 多路复用中,获取到就绪的 FD,然后根据 FD 的信息再来调用 recvfrom 命令,此时,内核中的缓冲区一定会有相对应的数据,直接从内核中拷贝回用户缓冲区即可。
需要注意的是,如果等待数据过程中,没有监听到已就绪的 FD,仍旧是阻塞等待,所以第二个阶段也是处于阻塞状态。不过,概率很小,因为 FD 很多,总有很大可能在短时间内获取到已就绪的 FD。
在 IO 多路复用中,对于如何监听 FD 的方式、通知的方式又有多种实现,常见的有:select、poll、epoll 三种常见的方式。
select、poll、epoll 的主要差异:
1)select 和 poll 只会通知用户进程有 FD 就绪,但不确定具体是哪个 FD,需要用户进程逐个遍历 FD 来确认。
2)epoll 则会在通知用户进程 FD 就绪的同时,把已就绪的 FD 写入用户空间。
2.3.1 IO 多路复用 - select
select 是 Linux 中最早的 IO 多路复用实现方案:
select 的相关源码:
在源码中,可以看到有 int select() 函数,里面的主要字段:
1)int nfds:要监视的 fd_set 的最大 FD + 1,也就是集合中有多个 FD,按照顺序从 1 到 1024 存放在集合中,nfds 表示监视的范围从 0 到最大的 FD + 1 之内。
2)fd_set *readfds:需要监视读事件的 FD 集合。
3)struct timeval *timeout:监听的超时时间,null 表表示永不超时、0 表示不阻塞等待、大于 0 表示等待的时间。
fd_set:表示一种类型,实际就是一个整型数组,数组大小是固定为 1024 个比特位。每一个 bit 表示一个 FD,0 表示未就绪,1 表示已就绪。
select 具体监听 FD 的过程:
在用户进程中创建一个 fd_set 集合,也就是一个 1024 比特大小的整型数组,再收集需要监听的 FD 并且存放在该数组中,接着调用 select() 方法,开始监听:首先将收集好 FD 数组拷贝到内存缓冲区中,接着在内核空间对该数组进行遍历查看是否有相对应的数据,如果一个都没有找到就绪的 FD,则休眠,直到等到的数据已就绪或者超时就会被唤醒,假设 FD = 1 数据就绪了,接着将对应的 FD 设置为 1,其他设置为 0,再拷贝回用户缓冲区中,最后再由用户空间对数组进行遍历,找到就绪的 FD,调用 recvfrom 命令获取数据。
select 模式存在的问题:
1)需要将整个 fd_set 从用户空间拷贝到内核空间,select 结束还要再次拷贝回用户空间。
2)select 无法得知具体哪个 fd 就绪,需要遍历整个 fd_set 。
3)fd_set 监听的 fd 数量不能超过 1024 。
2.3.2 IO 多路复用 - poll
poll 模式对 select 模式做了简单改进,但性能提升不明显,部分关键代码如下:
poll() 函数的主要字段:
1)struct pollfd *fds:是一个 pollfd 类型的数组,pollfd 的内部结构封装了要监听的 FD、events 要监听的事件类型、revents 实际发生的事件类型。该数组可以自定义大小,这解决了 select 模式中能最大监听 1024 个 FD 的问题。
2)nfds_t nfds:数组元素个数。
3)int timeout:超时时间。
poll 模式监听 FD 的具体流程:
1)创建 pollfd 数组,向其中添加关注的 fd 信息,数组大小自定义。
2)调用 poll 函数,将 pollfd 数组拷贝到内核空间,转链表存储,无上限。
3)内核遍历 fd,判断是否就绪。
4)数据就绪或超时后,拷贝 pollfd 数组到用户空间,返回就绪 fd 数量 n 。
5)用户进程判断 n 是否大于 0 。
6)大于 0 则遍历 pollfd 数组,找到就绪的 fd 。
与 select 对比:
1)select 模式的 fd_set 大小固定为 1024,而 pollfd 在内核中采用链表,理论上无上限。
2)监听 FD 越多,每次遍历消耗时间也越久,性能反而会下降。
2.3.3 IO 多路复用 - epoll
epoll 模式是对 select 和 poll 的改进,提供了三个函数。
相关源码如下:
1)int epoll_create():会直接在内核创建 eventpoll 结构体,一颗红黑树,用来记录要监听的 FD,另一个链表,用来记录已就绪的 FD。返回对应的句柄 epfd,用来标记。
2)int epoll_ctl():该方法主要是将要监听的 FD 添加到内核中的红黑树中。
主要参数是 epfd,记录 epoll 实例的句柄;op,要执行的类型,包含:ADD、MOD、DEL;fd,要监听的 FD;
3)int epoll_wait():该方法主要是用来等待接收已就绪的 FD。
主要参数是 epfd,eventpoll 实例的句柄;*events,用来接收已就绪的 FD;maxevents,数组的最大长度;timeout,超时时间;
epoll 模式监听 FD 的具体流程:
首先在内核中创建一颗红黑树,用来接收需要监听的 FD 和一个接收已就绪的链表。接着用户进程会将需要监听的 FD 直接添加到红黑树中,并且设置 ep_poll_callback,当 callback 自动触发时,就把对应的已就绪的 FD 从红黑树加入到链表中。此时,list_head 就会通知用户进程来接收链表中已就绪的 FD,将其拷贝到 events 数组中,此时的数组中为已就绪的 FD,可以直接知道已就绪的 FD,不需要遍历。最后就可以根据已就绪的 FD 来调用 recvfrom 命令来获取数据了。
小结:
1)select 模式存在的三个问题:
每监听的 FD 最大不超过 1024 。
每次 select 都需要把所有要监听的 FD 拷贝到内核空间。
每次都要遍历所有 FD 来判断就绪状态。
2)poll 模式的问题:
poll 利用链表解决了 select 中监听 FD 上限的问题,但依然要遍历所有 FD,如果监听较多,性能会下降。
3)epoll 模式中如何解决这些问题:
基于 epoll 实例中的红黑树保存要监听的 FD,理论上无上限,而且增删改查效率都非常高,性能不会随监听的 FD 数量增多而下降。
每个 FD 只需要执行一次 epoll_ctl 添加到红黑树,以后每次 epol_wait 无需传递任何参数,无需重复拷贝 FD 到内核空间。
内核会将就绪的 FD 直接拷贝到用户空间的指定位置,用户进程无需遍历所有 FD 就能知道就绪的 FD 是谁。
2.3.4 epoll 的 ET 和 LT 模型
当 FD 有数据可读时,调用 epoll_wait 就可以得到通知。但是事件通知的模式有两种:
1)LevelTriggered:简称 LT 。当 FD 有数据可读时,会重复通知多次,直到数据处理完成。是 epoll 的默认模式。
具体流程:
当内核中的链表中存在已就绪的 FD,那么就会通知多次给用户进程来获取数据到 events 集合中,可能数据量比较大,一次性拷贝不了全部数据,那么就会分多次进行拷贝,在这个阶段,LT 模式会重复多次发送通知给用户来拷贝数据,这个过程中,链表中的数据是不会删除,依旧会在链表中存储,直到数据处理完成。
2)EdgeTriggered:简称 ET。当 FD 有数据可读时,只会被通知一次,不管数据是否处理完成。
具体流程:
在内核中链表已存储就绪的 FD 时,就会给用户进程发送一次通知,当用户进程来拷贝数据的时候,链表中的 FD 就会自动删除,不管数据是否处理完毕。
举个例子:
假设一个客户端 socket 对应的 FD 已经注册到了 epoll 实例中,客户端 socket 发送了 2kb 的数据,服务端调用 epoll_wait,得到的通知说 FD 就绪。服务端从 FD 读取了 1kb 数据,接着再次调用 epoll_wait,形成循环。
如果采用 ET 模式,每一次调用 epoll_wait 函数服务端都会得到通知来读取剩下的数据。
如果采用 LT 模式,只有第一次调用 epoll_wait 函数的时候服务端才会得到通知,循环再次调用 epoll_wait 函数时,不再会发送通知给服务端,那么剩下的数据就会丢失。
解决方案:
1)在第一次拷贝数据完之后,再继续调用添加 FD 的函数,则在红黑树中的已就绪的 FD 又会再次拷贝到链表中,继续下一次通知用户进程。
2)在第一次拷贝的时候,使用循环,将其数据全部拷贝完毕。
小结:
ET 模式避免了 LT 模式可能出现的惊群现象。
ET 模式最好结合非阻塞 IO 读取 FD 数据,相比 LT 会复杂一些。
2.3.5 基于 epoll 的服务端流程
基于 epoll 模式的 web 服务的基本流程:
首先调用 epoll_create 函数创建实例,在内核中创建一颗红黑树,接收需要监听的 FD 和一个链表,接收已就绪的 FD。
接着创建服务端 serverSocket 并将得到的 FD 添加到红黑树中进行监听,调用 epoll_ctl 函数进行添加 FD 到红黑树中,还要给注册 ep_poll_callback,当 FD 就绪时,将就绪的 FD 记录到链表中。
再接着就是调用 epoll_wait 函数进行等到链表中已就绪的 FD,此时会进行休眠,当超时或者被通知的时候,线程就会被唤醒,如果时超时导致的唤醒,证明目前链表中还没得到已就绪的 FD,那么就需要继续调用 epoll_wait 函数继续等待;如果被通知链表中存在就绪的 FD 被唤醒的时候,就会接着判断事件类型。
此时,当服务端的 FD 已就绪时,则 serverSocket 接收到 socket 客户端,将对应的 scoket 的 FD 添加到红黑树中,并且注册 ep_poll_callback,对客户端进行监听。
对客户端进行监听,当监听到了客户端的 FD 状态已就绪了,则说明有请求发送到服务端中,接着就会通知给用户进程,得到对应的 FD,再接着调用 recvfrom 命令来读取内核中的请求数据。
这就是基于 epoll 模式的 web 服务的基本流程。
2.4 Redis 网络模型 - 信号驱动 IO
信号驱动 IO 是与内核建立 SIGIO 的信号关联并设置回调,当内核有 FD 就绪时,会发生 SIGIO 信号通知用户,期间用户应用可以执行其他业务,无需阻塞等待。直到数据就绪,递交 SIGIO 信号,也就是得到通知,告诉用户进程,数据准备好了,可以从内核缓冲区获取了。
在第一阶段,也就是等待数据是不阻塞的,进程可以执行其他业务,而第二个阶段,拷贝数据是阻塞的。
信号驱动 IO 存在的问题:
当有大量 IO 操作时,信号较多,SIGIO 处理函数不能及时处理可能导致信号队列溢出而且内核空间与用户空间的频繁信号交互性能也较低。
2.5 Redis 网络模型 - 异步 IO
异步 IO 的整个过程都是非阻塞的,用户进程调用完异步 API 后就可以去做其他事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。
可以看到,异步 IO 模型中,用户进程在两个阶段都是非阻塞状态。
3.0 Redis 单线程及多线程网络模型
3.1 经典面试题:Redis 是单线程还是多线程?
1)如果对于 Redis 的核心业务部分,也就是命令处理的部分,Redis 就是单线程。
2)如果对于 Redis 整体来说,那么 Redis 就是多线程。
在 Redis 版本迭代过程中,在两个重要的时间节点引入了多线程的支持:
1)Redis v4.0:引入多线程异步处理一些耗时较长的任务,例如异步删除命令 unlink。
2)Redis v6.0:在核心网络模型中引入多线程,进一步提高对于多核 CPU 的利用率。
3.2 经典面试题:为什么 Redis 要选择单线程?
1)抛开持久化不谈,Redis 是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。
2)多线程会导致过多的上下文切换,带来不必要的开销。
3)引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣。
3.3 Redis 网络模型的结构及具体流程
1)Redis 网络模型流程:第一个阶段
创建 serverSocket 服务端,调用 aeEventLoop 在内核中创建红黑树和链表,接着将服务端的 FD 添加到内核的红黑树中进行监听,再接调用 aeApiPoll 函数进行等待数据。一旦监听到服务端的 FD 就绪,就会调用 tcpAccepthandler 连接处理器,简单来说,该处理器就是将连接到服务端的客户端的 FD 添加到内核红黑树中进行监听处理。
如果是已经添加到红黑树的客户端的 FD 已就绪了,就会将 FD 添加到链表中,且通知用户进程来获取 FD,再接着使用 recvfrom 命令来获取该客户端的请求数据。因此,readQueryFromClient 命令请求处理器的作用是:读取请求数据。
2)Redis 网络模型流程:第二个阶段
对于 readQueryFromClient 命令请求处理器的作用是请求数据,那么具体是如何读取的呢?
相关的源代码:
可以看到里面有三个函数,readQueryFromClient() 调用了 processCommand(),而 processCommand() 调用了 addReply() 。
该函数具体的任务:
1)readQueryFromClient():获取当前客户端,客户端中有缓冲区用来读和写。读取请求数据到缓冲区和解析缓冲区字符串,转为 Redis 命令参数存入到数组中。
2)processCommand():根据命令名称,寻找对应的 command,执行命令,得到结果。
3)addReply():尝试把结果写到客户端缓存区,如果 buf 写不下,则写到 reply,这是一个链表,容量无上限。再接着将客户端添加到队列中,等待被写出。
具体结构如下:
在读取客户端发送过来的请求过程中,是 IO 操作。
3)Redis 网络模型流程:第三个阶段
现在结果已经存在到队列中了,等待被读取,也就是输出到对应的客户端,该过程进行 IO 操作。
相关源码如下:
在等待数据之前,会调用 beforeSleep 函数,监听 socket 的 FD 读事件,并且绑定写处理器,可以把响应写到客户端 socket 。一旦客户端写操作 FD 就绪了,则将队列中的结果写入到客户端中。
Redis 网络模型单线程的最终形态:
Redis 6.0 版本引入了多线程,目的是为了提高 IO 读写效率。因此解析客户端命令、写响应结果时采用多线程。而核心的命令执行 IO 多路复用模块依然是由主线程执行。
Redis 网络模型多线程的最终形态:
在单线程的基础上,在 IO 读写步骤中添加多线程进行操作:在读取请求数据、解析数据的步骤、将队列中的结果写入到对应的客户端中,这些步骤都是可以使用多线程进行操作。