reids网络模型

用户空间和内核空间

服务器大多采用Linux系统,所以以Linux为例:

任何Linux发行版,其系统内核都是Linux。我们的应用都需要通过Linux内核与硬件交互。

 

 

用户应用是无法直接访问计算机硬件,只能访问内核,基于内核操作计算机硬件

 

 

为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的:

l 进程的寻址空间会划分为两部分:内核空间、用户空间(32位的系统,它的寻址空间是2的32次方,也就是4GB)

 

 

l 用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问

l 内核空间可以执行特权命令(Ring0),调用一切系统资源

 

Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区:

l 写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备

l 读数据时,要从设备(磁盘或网卡,或者说本地或网络)读取数据到内核缓冲区,然后拷贝到用户缓冲区

 

 

IO读写效率不好就因为这个,读要用户态切换到内核态,然后等待硬件的数据存入内核态的缓冲区,然后内核态的缓冲区要复制到用户态的缓冲区。反过来,写要先写入用户态的缓冲区,然后存到内核态的缓冲区,最后存入硬件。

因此提高IO的效率,主要是两个点

  1. 减少无效等待的时间
  2. 减少用户态和内核态缓冲区的数据拷贝

阻塞IO与非阻塞IO

阻塞IO

顾名思义,阻塞IO就是两个阶段都必须阻塞等待:

 

 

调用revfrom函数的时候,内核没有数据,有两种处理结果,一个是返回失败的信息,一个是等待,而阻塞IO的选择是等待。

可以看到,阻塞IO模型中,用户进程在两个阶段都是阻塞状态。

非阻塞IO

顾名思义,非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。

 

 

与阻塞IO不同的是,内核没有数据它不等待,而是返回失败,过一会儿再访问数据,这样往复循环,这期间内核还是会去硬件获取数据,终归有一次recvfrom内核会有数据,而用户应用在等待数据的阶段是非阻塞状态,但是在数据拷贝的阶段,非阻塞IO在此期间依然是阻塞状态。

非阻塞IO与阻塞IO相比,并没有什么提升,虽然在等待数据阶段是没有阻塞,但是一直盲目的轮询之外没做任何其他的事,反而因为不停地调用命令,使CPU的使用率暴增。所以并没有提升整个进程的性能,甚至可能还不如阻塞IO(指的是当前这个非阻塞IO的应用,如何用好,看IO多路复用)。

IO多路复用

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

  •  如果调用recvfrom时,恰好没有数据,阻塞IO会使进程阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。
  •  如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据。

比如 服务端处理客户端Socket请求时,在单线程情况下,只能依次处理每一个Socket,如果正在处理的Socket恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有其他客户端socket都必须等待,性能自然会很差。

 

 

多线程确实是可以提高效率,但也不是绝对的,因为CPU在多个线程间的上下文切换开销也很大,如果线程过多,反而会降低效率。

 

用户进程如何知道内核中数据是否就绪呢?

文件描述符(Flie Descriptor):简称FD,是一个从0开始递增的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网路套接字(Socket)。

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

 

 

与阻塞IO区别在哪里呢?区别在IO多路复用调用的是select命令,它包含的是对多个FD的监听,一旦有一个或者多个FD就绪了,就立马返回,然后用户态再调用recvfrom,准确的调用就绪的FD,如果多个FD都没有就绪,也会阻塞等待,但是这种可能性很小,但凡等待过程中有一个FD就绪了,就立马进行下一步了。

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

  •  Select
  •  Poll
  •  Epoll

差异:

  •  select和poll只会通知用户进程有FD就绪,但不确定具体是哪个FD,需要用户进程逐个遍历FD来确认
  •  epoll则会再通知用户进程FD就绪的同时,把已就绪的FD写入用户空间

Select

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

 

 

nfds是FD遍历的一个上限,遍历到这个值的时候,就意味着不用再往后去遍历了。

fds_bits 是存储1024个比特位,代表1024个fd,这个数量是__d_mask四个字节共32个比特位乘以fds_bits的32长度得到的

Select的执行流程:

 

 

根据fd的整数值,把对应的比特位赋值为1

 

 

传递进内核空间,遍历,有就绪的就后续操作,没有就绪的就休眠

 

 

有就绪的或者休眠唤醒有就绪数据,就根据队对应的fd修改,就绪的还是1,未就绪的就删除或者标记为0

 

 

然后把结果拷贝回用户空间,然而并不知道到底哪个fd就绪了,就要遍历找到就绪的fd。

 

Select模式存在的问题:

  •  需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
  •  Select无法得知具体是哪个fd就绪,需要遍历整个fd_set
  •  fd_set监听的fd数量不能超过1024

Poll

Poll模式对select模式做了简单改进,但性能提升不明显,部分关键代码如下:

 

 

如果设置超时时间,当超时时间过了,pollfd的fd没有就绪,revents就会赋值为0

IO流程:

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

② 调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限

③ 内核遍历fd,判断是否就绪

④ 数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n

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

⑥ 大于0 则遍历pollfd数组,找到就绪fd

与select对比:

l Select模式中的fd_set大小固定位1024,而pollfd在内核中采用链表,理论上无上限

l 监听FD越多,每次遍历消耗时间也越久,性能反而会下降

Epoll

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

 

 

 

 

 

 

 

Epoll有没有解决之前select或者poll的问题?

  •  select或者poll把要监听的数组或集合拷贝到内核空间,等待FD就绪,就绪后,还要拷贝回用户空间。
  •  epoll把select函数的功能拆分开了,建立eventpoll以后,有需要监听的FD,用epoll_ctl添加进rb_root就行了,之后就会一直在红黑树里监听,不用反复的在用户态和内核态之间反复拷贝;而在返回FD的过程中,只是从内核态的list_head里只拷贝了就绪的FD,数量就少了很多。
  •  相对于select或者poll,epoll返回的FD一定是已经就绪的,不用再遍历判断是否就绪。
  •  Select最多能监听1024个FD,而poll无上限,但只是理论上的,数量太多效率就太低了。而epoll,添加的FD都会放到红黑树上,而红黑树的增删改查的性能不会随着元素的数量增加有太多的波动。

 

epoll事件通知机制

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

  •  LevelTriggered:简称LT。当FD有数据可读时,会重复通知多次,直到数据处理完成。是epoll的默认模式。
  •  EdgeTriggered:简称ET。当FD有数据可读时,只会被通知一次,不管数据是否处理完成。

LT举个例子:

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

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

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

④ 服务端从FD读取了1kb数据

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

而ET的话,再次步骤3的时候,就不会通知就绪了。

内部实现差异:

调用epoll_wait的时候,list_head会断开与链表的连接,在链表拷贝到过用户态后,会判断是ET还是LT,ET的话,就不恢复连接了,那么list_head就会是空;而LT的话,如果链表里面还有数据,就会恢复连接。

ET如何解决数据读取问题?

第一种方式:手动LT,也就是说,断开连接拷贝完数据后,如果还有数据,就会调用epoll_ctl,判断哪些数据就绪了,然后再添加到list_head

第二种方式:在步骤4循环读取,全都读完为止(不能用阻塞IO的模式去读取,阻塞IO读完了不是返回错误,而是会等待,导致进程被阻塞;而非阻塞IO,有数据返回,没数据返回无数据的标识)

LT的问题:

重复通知对于效率和性能会有影响

LT可能会出现惊群的现象(在多线程的情况下,都在调用epoll_wait获取就绪的FD,而因为任何一个进程它通知完了一个,因为FD还在list_head里面,所以其他监听FD的进程都会被通知到,其实有可能就绪FD就被第一个或者第二个进程处理完了, 后续的进程没有必要被唤醒,而ET就没有这个情况)

结论:

  • ET模式避免了LT模式可能出现的惊群现象
  • ET模式最好结合非阻塞IO读取FD数据,相比LT会复杂一些(性能会更好一些)

基于epoll的服务端流程

 

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

 

 

信号驱动IO

信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待

 

 

缺点:

  1. 当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出
  2. 而且内核空间与用户空间的频繁信号交互性能也较低

 

异步IO

异步IO的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。

 

 

可以看到,异步IO,用户进程在两个阶段都是非阻塞的状态

缺点:高并发的情况下,内核里积累的IO读写任务会越来越多,可能会导致整个系统因为内存占用过多而崩溃的现象,所以需要做好并发访问的限流,但是限流的工作和回调函数的机制,实现起来的代码复杂度就会高很多

同步和异步

IO操作是同步还是异步,关键看数据在内核空间与用户空间的拷贝过程(数据读写的IO操作),也就是阶段二,是同步还是异步:

 

Redis是单线程还是多线程?

Redis到底是单线程还是多线程?

  •  如果仅仅聊Redis的核心的业务处理部分(命令处理),答案是单线程
  •  如果是聊整个Redis那么答案是多线程

在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:

  • Redis v4.0:引入多线程异步处理一些耗时较长的任务,例如异步删除命令unlink
  • Redis v6.0:在核心网络模型中引入多线程,进一步提高对于多核CPU的利用率

为什么Redis要选择单线程?

  •  抛开持久化不谈,Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。(相比纯内存操作,IO多路复用只是削微的提升了速度)
  •  多线程会导致过的上下文切换,带来不必要的开销(单核的情况下,即使是后来加入了多线程,也是跟CPU的核数对应的,最多是1到2倍)
  •  引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣

Redis网络模型

Redis通过IO多路复用来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现进行封装,提供了统一的高性能事件库API库AE:

 

 

ae.c对当前的系统环境做一个判断

 

 

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

 

 

 

 

 

 

 

 

 

 

 

Redis 6.0版本中引入了多线程,目的是为了提高IO读写效率。因此在解析客户端命令、写响应结果时采用了多线程。核心的命令执行。IO多路复用模块依然是由主线程执行。

单线程的瓶颈:

命令请求处理器里面的读和命令回复处理器的写会涉及网络IO的操作,会受到网络带宽,网络状态等影响。而命令的处理和IO多路复用和事件的监听没有问题,主线程即可。

多线程后虽然单次请求处理的响应时间没有什么变化,但是整体的吞吐量增加了

 

posted @ 2024-06-03 17:02  蓝海的bug本  阅读(2)  评论(0编辑  收藏  举报