手撕Redis底层2-网络模型深度剖析
1.用户态空间和内核态空间
这里我们以Linux系统为例去讲解,Linux有许多发行版操作系统,如CentOS,Ubantu,其系统内核均为Linux,我们计算机的应用程序需要通过操作系统内核来和硬件进行交互。
内核操作硬件需要不同设备的驱动,有了驱动之后,内核就可以对计算机进行内存管理,文件系统管理,进程管理等,内核想要应用软件来访问,就会对外暴露一些接口,用户通过调用接口从而实现对内核的操作,但是内核本身也是一个应用,所以内核的运行也需要内存,CUP等设备资源,而用户应用本身也在消耗这些资源,为了避免用户应用导致的冲突,用户应用和内核应用是分离的。我们把进程的寻址空间分为内核空间和用户空间。
寻址空间的概念:无论是应用程序还是内核空间都没法直接访问物理内存,我们的内核和应用程序去访问虚拟内存时,就需要一个虚拟地址,这个地址是一个无符号的整数,比如一个32位的操作系统,他的带宽就是32,他的虚拟地址就是2的32次方,也就是说他寻址的范围就是0~2的32次方, 这片寻址空间对应的就是2的32个字节,就是4GB,这个4GB,会有3个GB分给用户空间,会有1GB给内核系统。
在Linux中,他们权限分成两个等级,0和3,用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问内核空间可以执行特权命令(Ring0),调用一切系统资源,所以一般情况下,用户的操作是运行在用户空间,而内核运行的数据是在内核空间的,而有的情况下,一个应用程序需要去调用一些特权资源,去调用一些内核空间的操作,所以此时他俩需要在用户态和内核态之间进行切换。
比如:Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区
写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区
针对这个操作:我们的用户在写读数据时,会去向内核态申请,想要读取内核的数据,而内核数据要去等待驱动程序从硬件上读取数据,当从磁盘上加载到数据之后,内核会将数据写入到内核的缓冲区中,然后再将数据拷贝到用户态的buffer中,然后再返回给应用程序,整体而言,速度慢,就是这个原因,为了加速,我们希望read也好,还是wait for data也最好都不要等待,或者时间尽量的短。
2.IO网络模型
2.1 阻塞IO
应用程序想要读取数据是无法直接从磁盘中读取数据的,需要先到操作系统内核等待内核操作拿到数据,等到内核从磁盘上把数据加载出来后,再把数据写到用户空间缓冲区,如果是阻塞IO,那么整个过程,从用户发起请求开始,一直到读取数据,整个过程是一个阻塞状态。
IO阻塞模式下,用户进程在两个阶段均为阻塞状态
阶段一:用户进程尝试读取数据,此时数据尚未到达,内核需要等待数据,此时用户进程处于阻塞状态。
阶段二:数据到达并拷贝到内核缓冲区,代表已就绪,将内核数据拷贝进入用户缓冲区,拷贝过程中,用户进程依然阻塞等待,拷贝完成,用户进程解除阻塞,处理数据。
2.2 非阻塞IO
非阻塞IO模式下,recvfrom操作会立即结果而不是阻塞用户进程。
阶段一:用户进程尝试读取数据,此时数据尚未到达,内核需要等待数据,返回异常给用户应用,用户进程拿到异常后,会再次尝试读取,循环往复,直到数据就绪
阶段二:数据就绪,将内核数据拷贝到用户缓冲区,拷贝过程中用户进程依然阻塞等待,拷贝完成,用户进程解除阻塞,处理数据
非阻塞IO模型中,用户进程在第一阶段是非阻塞状态,第二阶段是阻塞状态,虽然是非阻塞,但是性能并没有得到提高,而且忙等机制会导致CPU空转,CPU使用率暴增。
2.2 IO多路复用
无论是阻塞IO还是非阻塞IO,用户应用在第一阶段都需要调用recvfrom来获取数据,差别在于有无数据的处理方案:如果调用recvfrom时,恰好没有数据,阻塞IO会使CPU阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。 如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据。因此,上述两种模式性能都不好。
在单线程的情况下,只能依次处理IO事件,如果正在处理的IO事件恰好未就绪,线程就会阻塞,所有的IO事件就必须阻塞,这样性能自然很差。
提高效率有两种方式:一种是增加线程数,使用多线程。另一种是等待数据就绪后,用户应用就去读取数据,在此之下,我们引入了多路复用模型。
所以接下来就需要详细的来解决多路复用模型是如何知道到底怎么知道内核数据是否就绪的问题了
文件描述符:简称FD,是一个从0开始的无符号整数,用于关联Linux中的一个文件,在Linux中,一切皆文件,例如常规文件,视频,硬件设备,网络套接字(Socket等)。
IO多路复用:利用单个线程来同时监听多个FD,并在某个FD可读可写时得到通知,从而避免无效等待,充分利用CPU资源。
阶段一:用户进程调用select,指定要监听的FD集合,内核监听FD对应的多个Socket,任意一个或多个Socket数据就绪就返回readable,此过程中用户进程阻塞等待。
阶段二:用户进程找到就绪的Socket,依次调用recvfrom读取数据,内核将数据拷贝到用户空间,用户进程处理数据。
IO多路复用监听FD的方式,通知有多种实现,常见的有select,poll,epoll。select和poll只会通知用户进程有FD就绪,但不确定具体是哪个FD,需要用户进程逐个遍历FD来确认。epoll则会在通知用户进程FD就绪的同时,把已就绪的FD写入用户空间。
2.2.1 Select模式
我们把需要处理的数据封装成FD,然后在用户态时创建一个fd的集合(这个集合的大小是要监听的那个FD的最大值+1,但是大小整体是有限制的 ),这个集合的长度大小是有限制的,同时在这个集合中,标明出来我们要控制哪些数据,比如要监听的数据,是1,2,5三个数据,此时会执行select函数,然后将整个fd发给内核态,内核态会去遍历用户态传递过来的数据,如果发现这里边都数据都没有就绪,就休眠,直到有数据准备好时,就会被唤醒,唤醒之后,再次遍历一遍,看看谁准备好了,然后再将处理掉没有准备好的数据,最后再将这个FD集合写回到用户态中去,此时用户态就知道了,有人准备好了,但是对于用户态而言,并不知道谁处理好了,所以用户态也需要去进行遍历,然后找到对应准备好数据的节点,再去发起读请求,我们会发现,这种模式下他虽然比阻塞IO和非阻塞IO好,但是依然有些麻烦的事情, 比如说频繁的传递fd集合,频繁的去遍历FD等问题。
2.2.2 Poll模式
2.2.3 epoll模式
epoll模式是对select和poll的改进,它提供了三个函数:
第一个是:eventpoll的函数,他内部包含两个东西
一个是:
1、红黑树-> 记录的事要监听的FD
2、一个是链表->一个链表,记录的是就绪的FD
紧接着调用epoll_ctl操作,将要监听的数据添加到红黑树上去,并且给每个fd设置一个监听函数,这个函数会在fd数据就绪时触发,就是准备好了,现在就把fd把数据添加到list_head中去
3、调用epoll_wait函数
就去等待,在用户态创建一个空的events数组,当就绪之后,我们的回调函数会把数据添加到list_head中去,当调用这个函数的时候,会去检查list_head,当然这个过程需要参考配置的等待时间,可以等一定时间,也可以一直等, 如果在此过程中,检查到了list_head中有数据会将数据添加到链表中,此时将数据放入到events数组中,并且返回对应的操作的数量,用户态的此时收到响应后,从events中拿到对应准备好的数据的节点,再去调用方法去拿数据。
小总结:
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数量增多而下降