迎难而上,挑战Redis网络模型

最近在背Redis的八股,学到Redis的IO多路复用卡住了,之前学操作系统的时候IO这一块也被卡住了,今天就花一天时间通过黑马的Redis课程认真学习一下Redis的网络模型。

一、用户空间和内核空间

为了避免用户应用导致冲突甚至内核崩溃,用户应用和内核是分离的。有些指令很危险,像内存分配,文件读写,这些指令就不能让用户直接使用,容易对内核造成影响。进程的寻址空间就会划分为两部分:内核空间用户空间。什么是寻址空间呢,就是内核空间和用户空间都不能直接访问物理内存,都只能访问虚拟内存,通过映射再到物理内存。访问虚拟内存就需要寻址,这就是寻址空间。32位系统寻址空间最大就是4GB。

用户空间只能执行受限的命令(Ring3),而不能直接调用系统资源,必须通过内核提供的接口才能访问。内核空间可以执行特权命令(Ring0),调用一切系统资源。进程运行在用户空间就叫用户态,运行在内核空间就叫内核态。

举个例子,就比如Linux系统为提高IO读写效率,会在用户空间和内核控件都加入缓冲区:

· 写数据时,要把用户缓冲区数据拷贝到内核缓冲区,然后写入设备。这个过程中,写入用户缓冲区时进程处于用户态,从用户缓冲区复制到内核缓冲区,就是用户态变成内核态,最后写入磁盘。

· 读数据时,要从磁盘里读,进程从用户态变为内核态,先判断磁盘中有没有数据。读磁盘的时候就要寻址,这个过程就需要等待。先把磁盘里的数据拷贝到内核缓冲区,再拷贝到用户缓冲区。

整个过程中,有多次状态切换,比较耗时的操作就是拷贝,从这个缓存拷贝到另一个缓存,来回拷贝。还有读数据时的等待。比较影响应能,所以提升IO的关键就是减少无效的等待以及减少用户态和内核态缓冲区之间的拷贝

下面学习Linux五种不同的IO模型

二、阻塞IO

以读取数据为例,不管是都磁盘还是读网卡,你没有权限直接操作,必须调用内核函数。调用的时候数据还没准备好,就必须等待响应。磁盘选址完成并且复制到内核缓冲后,数据才算准备好。然后将数据从内核缓冲区拷贝到用户缓冲区。不同的IO模型就差别在等待数据就绪和读取数据处理上。

阻塞IO就是用户进程在读取数据和等待数据就绪都会阻塞。

三、非阻塞IO

非阻塞IO的recvfrom操作会立即返回结果而不是在阻塞用户进程。第一阶段没读取到的时候不会阻塞,立即返回。但是第二阶段读取的时候,等待数据拷贝时是要阻塞的。

非阻塞IO也一样,和阻塞IO也没啥差别,轮询也起不到作用,轮询是靠CPU询问,挺浪费CPU资源。但是不代表没用。

四、IO多路复用

无论阻塞IO和非阻塞IO,用户在一阶段都需要调用recvfrom来获取数据,区别在于无数据时处理方案:

· 如果调用recvfrom时,恰好没有数据,阻塞IO会进行阻塞,非阻塞IO会让CPU进行空转,都不能充分发挥CPU作用

· 如果调用recvfrom时,有数据,则用户就可以进入第二阶段,读取处理数据。

比如服务端处理客户端socket请求时,单线程情况下,一个线程只能处理一个socket,假如这个socket要读取的数据恰好没就绪就需要等待,线程就会阻塞住,导致后面的socket都得等待,性能会差。

要提高效率的方法:

1、多线程

2、socket不排队了,让线程监听所有的socket,任意有一个数据就绪了,线程就去处理那个socket。

问题:用户进程是如何知道数据就绪?

文件描述符:简称FD,是一个从0开始递增的无符号整数,用来关联Linux中的一个文件。在Linux中一切皆是文件,包括网络套接字(Socket)。

一旦有了文件描述符,就可以实现一个线程监听多个socket。

IO多路复用:利用单个线程监听多个FD,并在某个FD可读、可写的时候得到通知,从而避免无效等待,充分利用CPU资源。

用户线程上来不调用recvfrom读取数据了,recvfrom是直接读取数据,具体某一个FD,但是不知道FD到底有没有数据就绪。如果没有就绪就要阻塞等待。所以我们要先知道哪些FD有数据就绪,这样就要监听FD。先执行select,可以监听多个FD,可以把每一个客户端Socket的FD都传给select,然后传给内核,内核检查有没有监听的FD有没有数据就绪的。有就绪的就会直接返回,告诉你有数据就绪了,有就绪的就可以调用recvfrom去读取数据了。当然会有多个FD数据就绪,所以要多次recvfrom读取数据,就要循环操作,直到读完所有FD。

IO多路复用和前两种区别就是,原来是直接调用recvfrom读,只能读一个FD,其他的就阻塞,哪怕后面有就绪好的FD。而IO多路复用就会监听多个FD,只要有FD数据就绪就可以读取。

监听的方式、通知的方式又有多种实现,常见的有:

· select

· poll

· epoll

早期select和poll的方案就是,监听到有FD就绪,只会通知有FD就绪,但不确定哪个FD,就会遍历所有的FD,找到是哪个就绪了。epoll会好一些,能直接知道是哪个FD就绪了,把已就绪的FD写入用户空间。

五、IO多路复用-select

select是Linux中最早的IO多路复用实现方案:

最关键的部分就是select函数,里面区分了fd类型。

上来先创建好要监听的fd集合fd_set,fd_set是一个结构体,结构体里面的属性是一个数组,大小算出来就是32,但不是说fd_set里面就只能装32个FD。这数组类型是int,4字节,也就是32个bit,数组一共是1024个bit。fd_set保存fd的时候是安装bit位保存的,每一个bit位就是一个fd。其实就可以理解为一个bit数组,bit[1024]。

创建好fd_set后,要监听的设为1,然后把set拷贝到内核空间,内核去监听这些FD。先遍历一遍,从最低位开始遍历,看看被标记的FD是否被监听。假如一开始没有就绪,就要睡眠,内核会去监听,只要有任意一个fd可读,那就会被唤醒。假如现在fd1数据就绪了,唤醒了内核,内核会从低到高遍历set,找到对应的fd。数据就绪的fd设为1,数据没就绪的就是0,保存的就是就绪的fd。select还会返回就绪的数量,但是只会返回就绪的数量,不会返回是哪就绪了。那怎么办?其实背后有一个隐藏操作,就是把内核中的set拷贝到用户空间,这样用户空间就有一个fd集合,标记了就绪的fd,用户空间再去遍历set,找到就绪的fd,再去读取。select其实挺麻烦的。

最后总结一下select的缺点:

1.需要先将fd_set从用户态拷贝到内核态,select完成后还要把fd_set从内核态拷贝到用户态

2.select无法得知是哪个fd数据就绪了,需要遍历整个fd_set

3.fd_set监听的fd数量不能超过1024

六、IO多路复用-poll

poll模式对select模式进行改进,但提升不明显

主要就是poll函数,等同于select,作用就是监听多个fd形成的集合,集合没有上限。和select区别最大的就是集合不区分类型了,内核将来通过数组中元素的类型知道要监听哪种事件。数组中的元素就是pollfd,这也是结构体。调用poll方法要创建多个pollfd就放入集合中,只需要指定fd和events不需要指定revents。内核在监听的过程中如果发现事件就绪,就会把事件类型放入revents中。

IO流程:

1.创建pollfd数组,向其中添加关注的fd信息,数组自定义大小

2.调用poll函数,将pollfd从用户空间拷贝到内核空间,转成链表存储,没有上限

3.内核遍历fd,看是否有fd就绪

4.数据就绪或者超时后,将pollfd拷贝回用户空间,并且返回就绪的个数

5.用户进程判断是否n大于0

6.大于0则遍历pollfd,找到就绪的fd

和select相比,仍需要将集合从用户空间拷贝到内核空间,poll完成后再将集合从内核空间拷贝到用户空间。而且还是要遍历pollfd才能找到就绪的fd。改进就是select中fd_set固定大小为1024,而pollfd在内核中采用链表,理论上无上限。但是监听的FD越多,遍历时间越久,性能反而下降。

七、IO多路复用-epoll

epoll是对select和poll的改进,提供了三个函数:

红黑树记录的所有的FD,链表记录的是就绪FD。epfd是eventpoll唯一标识,每调用一次eventpoll就会创建一个新的epfd。

这里的ep_poll_callback函数,会在fd就绪的时候回调,也就相当于监听。触发之后把fd记录在rdlist,相当于记录下了就绪FD。所以这个epoll_ctl只负责添加fd。

最后是wait方法,等待FD就绪。调用epoll_wait会去检查rdlist就绪列表。等待到超时时间,看看有没有就绪的。如果超时后都没有就绪,就返回0。如果有两个就绪了,就会执行两次callback,把fd添加到rdlist,然后通过epoll_wait返回就绪的数量。空数组enents就用来接收就绪链表中的元素,这样用户就知道哪个FD就绪了。

思考一下是否解决了select的问题,select第一个问题就是调用通过select就要两次拷贝fd_set。在epoll中,epoll把select功能拆分开了,第一就是拷贝,第二就是等待就绪。epoll_ctl就是监听,把FD拷贝到内核空间,epoll_wait就是等待就绪。在监听过程中,监听一个新的FD只需要拷贝一次进入内核空间,之后循环过程中,就不需要再次执行epoll_ctl进行拷贝了。所以epoll_ctl对于每一个新的FD来讲就只用执行一次就行。之后循环处理时间过程中,就循环执行epoll_wait中,只需要拷贝就绪的FD,相比于select少了太多拷贝了。select另一个问题就是不知道用户线程不知道哪个fd是就绪的,要遍历所有的fd,而epoll返回的就是就绪的fd。epoll的树结构也是可以尽可能监听更多的FD。

最后再总结一下

1.select模式存在的三个问题:

· 能监听的FD最大不超过1024

· 每次select都需要把所有要监听的FD都拷贝到内核空间

· 每次都要遍历所有FD来判断就绪状态

2.poll模式的问题:

· poll利用链表解决了FD上限的问题,但依然要遍历所有FD,但是FD越多,监听的越多,性能会下降。

3.epoll怎么解决问题

· 利用红黑树存储要监听的FD,理论上数量无限,性能也不会因为FD数量多而下降

· 每个FD只需要执行一次epoll_ctl添加到红黑树,不需要重复拷贝FD。

· 内核只会将就绪的FD拷贝到用户空间

八、IO多路复用-事件通知机制

当FD有数据可读时,我们调用epoll_wait就可以得到通知,事件通知的模式有两种:

· LT:当FD有数据可读时,会重复发送通知,通知多次,直到数据处理完成。是Epoll的默认模式

· ET:当FD有数据可读时,只会通知一次,不管数据是否处理完成。

例如:

1.假设一个客户端socket对应的FD已经注册到epoll实例中

2.客户端socket发送了2kb的数据

3.服务端调用epoll_wait,得到通知说FD就绪

4.服务端从FD中读取了1kb数据

5.回到步骤3(再次调用epoll_wait,形成循环)

这是LT的情况,如果是ET,你读取1kb之后不会再读了,收不到通知了,没读完就是没读完,就算再调用epoll_wait也没有用,因为剩余的数据已经被内核删除了。内核会做这么一件事,当你调用一次epoll_wait将rdlist的fd拷贝到events后,会将rdlist断开,相当于删除了就绪的FD。内核会判断当前模式是LT还是ET,如果是LT就会恢复链表,如果是ET就彻底消失。如果要用ET也能读取完整的数据,是比较复杂的。

九、IO多路复用-web服务流程

基于epoll模式的web服务的基本流程如图:

serverSocket,因为是web服务,web服务都是基于TCP协议,TCP中服务端就是severSocket。作为socketServer,什么时候就绪?只有一种情况,就是有客户端向服务端申请连接。因为serverSocket唯一目的就是接受客户端请求,一旦有任何客户端socket尝试连接,就会产生FD事件,而且这是一个读事件。随着程序运行,监听的FD会越来越多,类型也会越来越多,所以FD就绪之后要判断事件类型是什么。如果事件类型是EPOLLIN,那真的是读事件。还要判断读事件是不是读ssfd,如果是才能证明有客户端连上来了。客户端连接之后就会发请求,也会有fd,这个fd也是读事件。

epoll实现web服务,不管什么事件来了,就分门别类的处理,事件也就是客户端连接事件、客户端数据可读、异常。客户端连接来了,就建立新连接。客户端请求来了,就读请求返回结果就行。接收事件,把事件派发,派发给不同业务进行处理。

信号驱动IO和异步IO暂时先跳过,着重学习下面的Redis网络模型。

十、Redis网络模型

Redis是单线程还是多线程?

· 如果光看命令处理,答案是单线程

· 如果是整个Redis,就是多线程

Redis两个重要时间点上引入多线程:

· Redis4.0:引入多线程异步处理一些耗时较长的任务,如异步删除命令unlike

· Redis6.0:在核心网络模型中引入多线程,进一步提高对于多核CPU的利用率

来看下Redis单线程网络模型的整个流程:

初始化服务就相当于之前的epoll_create,只不过是创建的EventLoop实例。

createSocketAcceptHandler作用:监听的ServerSocket,等待ServerSocket就绪之后,代表有客户端连接,我们就需要accpet客户端socket。这还是一个处理器,一旦serverSocket发生了什么还要进行处理。就是做两件事,内部调用aeApiAddEvent函数,添加一个FD到EventLoop,实现对他的监听,添加的FD就是serverSocket,对他进行监听。还要设置处理方法,accpetTcpHandler。

aeMain才是真正的等待FD就绪,aeProcessEvents进行循环处理事件。for循环里就是调用acceptHandler,如果是客户端申请连接,就进行连接。而且创建了读处理器,就是用来处理客户端的读事件。如果是serverSocket可读就是调用acceptHandler,什么时候serverSocket可读,就是有客户端Socket连上来了,如果是客户端客户就调用readQueryFromClient。

流程:

readQueryFromClient方法内部源码如下:

执行完readQueryFromClient后,还没给客户端写出结果,都在队列里等待写出,如下图所示:

在beforesleep中要监听的是客户端写事件(connSetWriteHandleWithBarrier),在这个方法里取出客户端待写等待队列中的客户端给每一个客户端socket绑定一个写处理器,写处理器叫做sendReplyToClient。

简化一下就是:

Redis6.0引入了多线程,目的是为了提高IO读写效率。因此在解析客户端命令、写响应结果时采用多线程。

客户端可读,在高并发下会有无数请求过来,都挡在了命令请求处理器上。如果用单线程,读事件特别多,一个个都就会忙不过来了,所以在这个位置开启多线程,主线程以轮询的形式将多个客户端Socket分发给不同的线程,由它们解析命令,转换成Redis命令。然后写操作是网络IO,是个瓶颈,也用多线程。

#晒一晒我的offer##2022毕业即失业取暖地##2022毕业生求职现身说法##2022毕业的你对23届的寄语#
全部评论

相关推荐

无敌战神大菜鸡:计算机来卷嵌入式?疯啦
点赞 评论 收藏
分享
评论
1
1
分享
牛客网
牛客企业服务