在本篇文章中,主要是对五种网络模型进行一个简单的介绍,然后对Redis4.0和6.0的网络模型进行一个概述。
用户空间和内核空间
在Linux系统上,分为用户空间、内核空间和硬件设备。硬件设备主要包括CPU、内存、网卡等物体,内核应用去使用硬件设备工作时,需要操纵其对应的驱动。通过不同驱动的分类,内核空间也就形成了内存管理、文件管理、进程管理以及网络管理等内容。用户应用在工作时,是不能直接操作硬件设备及其驱动,需要去调用内核应用,从而再调用驱动,最后调用到硬件设备。原因是因为内核应用也是软件,本身就会消耗一定的资源,而用户应用也会消耗一定的资源,为了避免用户应用和内核应用之间的冲突导致内核应用出现异常、甚至崩溃,计算机把用户应用和内核应用进行分离。并且,进程的寻址空间也会划分成两部分:内核空间和用户空间。
Linxu中把命令进行了划分,Ring3表示受限的命令,他是不能直接调用系统资源;Ring0表示特权命令,他能调用一切系统资源。
内核应用可以执行特权命令,调用一切系统资源。
用户应用只能执行受限的命令,所以,他是不能够直接调用系统资源,只能通过内核应用提供的接口才能间接去调用系统资源。
用户应用在执行受限命令时,他就是在用户空间做事,所以此时就是用户态。当执行到了特权命令时,应用对应的线程就会去内核应用做事,此时就是内核态。所以,用户态和内核态之间是可以进行切换的。
以读写IO为例来说明内核态和用户态的切换:首先,明确的一点就是为了提高IO效率,用户空间和内核空间都加入了缓冲区。当服务和客户端建立连接之后,客户端请求发送之后,服务就要接收数据,此时,用户应用就会向内核应用发送命令,表示自己要接收数据,内核空间就会去硬件设备这里接收数据,当数据从硬件设备到了内核缓冲区后,用户缓冲区就会读取这些数据,这就是读数据的过程。写数据时,用户应用先把数据从用户缓冲区拷贝到内核缓冲区,然后内核缓冲区再写入硬盘或者其他硬件设备中。
IO模型
在IO中,根据读取数据的来源可以分成磁盘IO和网络IO,磁盘IO就是对磁盘的读写工作,网络IO就是通过网络进行数据的拉取和输出。
- 网络IO:用户缓冲区等待数据进入网卡,然后从网卡中读取到内核缓冲区,再从内核缓冲区中拷贝到系统缓冲区中。
- 磁盘IO:把数据从磁盘读取到内核缓冲区,然后从内核缓冲区拷贝到系统缓冲区。
下述内容以读取数据为例来分别介绍这些模型:
服务器和客户端建立连接之后,当客户端有请求发送过来时,用户应用就会向内核应用发送读取数据的命令,内核应用等到网卡等硬件设备准备好数据后,内核缓冲区首先读取硬件设备中准备好的数据,然后再拷贝到应用缓冲区中,这就是读取数据的一个简单流程。
在网络IO模型中,主要是1步骤和2步骤之间处理数据有差异。
阻塞IO
阻塞IO(Blocking IO),顾名思义,就是读取数据时需要阻塞等待。
用户应用向内核应用发起recvfrom的读取命令之后,此时内核应用发现缓冲区中没有数据,于是就会等待,直到硬件设备中准备好数据之后,内核缓冲区首先从硬件设备中读取数据,然后再将数据拷贝到用户缓冲区中。
在这个过程中,第一个需要等待的地方就是用户应用发起命令之后,会一直阻塞等待,第二个需要等待的地方就是数据准备好之后,用户应用需要阻塞等待数据拷贝完成。
因此,阻塞IO其实就是命令发起之后,会一直等待,直到数据读取完毕。
非阻塞IO
非阻塞IO,即用户应用发起读取数据的命令之后,如果没有准备好数据,那么内核应用就会立即返回响应,表示数据没有准备好。然后,用户应用就会持续不断的发送命令,直到数据准备完成,然后就又会阻塞等待直到数据拷贝完成。
非阻塞IO在工作时,第一阶段不会阻塞等待,只有在第二个阶段读取数据时,才会进入阻塞状态。
本质上,非阻塞IO并没有啥用,甚至还有可能增加系统资源的消耗。本来在阻塞IO中,系统啥事也不干,等着就行。结果来到非阻塞IO这里,系统还得强迫一直发送请求,直到数据准备完成,这导致了CPU空转,使得CPU使用率暴增。
对于阻塞IO和非阻塞IO两种模型中,读取数据时都会阻塞等待,不过在第一阶段等待数据就绪时处理方式不同,阻塞IO是持续阻塞,直到数据准备好,而非阻塞IO则是一直发送读取数据的请求。不过两种方案本质上还是一直占用着当前线程,没有啥含金量。
假设当服务端处理客户端socket时,是单线程,一次只能处理一个socket,那无论是阻塞还是非阻塞,都是针对一个socket在干活,即使其他socket已经准备就绪,他也不会去读取其他数据。只有当这个socket的数据准备就绪并且读取完成之后,该线程才会去进行下一个socket的读取。假设当前是多线程,那每一个线程都会对应一个socket去读取,如果现在同时有十万个请求,那就得十万个线程,或者等待,无论是哪种情况,系统性能都是极差的。
于是,我们想,能不能一个线程去对应多个socket,这样,当哪个数据就绪之后,就去读哪个数据,让线程一直工作,这样就不用一对一,也不需要持续等待了,其实,这种方法就是接下来要讲的IO多路复用。
IO多路复用
概述
在Linux系统中,秉承着一个理念就是一切皆文件,常规文件就不用说了,视频、硬件设备以及网络套接字也属于是文件。并且,每一个文件都会对应一个文件描述符表,简称FD,是一个从0开始递增的无符号整数。
IO多路复用,就是先使用一个函数来监控这些socket,其实就是监控一堆FD,看看有没有就绪(可读或者可写),如果有就绪了的,那么就用户应用才会发送recvfrom命令去读取数据,此时就不需要等待了,因为肯定是有数据就绪的。
IO多路复用中,对于监听函数来说,一共是有3个:select、poll、epoll。对于select和poll来说,只会通知用户应用说有进程就绪,但是具体哪一个,不知道,需要用户应用自己来确定。而对于epoll来说,则在通知时不仅通知有数据准备就绪,还会具体说明是哪几个数据。
select
select函数是Linux中最早的多路复用的实现方案。
如上所示,是select函数的参数。
1. 创建fd_set,也就是要监听的fd集合,默认全部为0,并且是读事件集合set。
2. fd_set中是以比特位存储,最多存储1024个FD,假设要监听的fd为1,2,4,6,那就把对应的比特值置为1。
对于1024的来源,__FD_SETSIZE的大小是1024,__NFDBITS的大小是32,因此结果是32,所以该数组最多放32个元素,而数组的类型是long int,即4个字节,并且由于该数组保存是以比特位为单位的,所以最多保存32 * 4 * 8 = 1024个元素。
3. 调用select函数并传参select(7, set, null, null, 3) ,并且将集合从用户空间拷贝到内核空间。
4. 内核应用遍历一次传过来的集合,此次遍历到7,因为标记的FD最大值是6。查看是否有就绪的集合,如果有那么就返回结果。如果没有,那么就查看超时时间是啥,如果是0直接返回,如果是null表示持续等待直到有就绪的为止,如果大于0那么就是有固定等待时间,等到时间之后就会返回空,反之等待过程中被唤醒,然后返回就绪的。
5. 等待数据就绪唤醒之后,就会再次遍历,如果是1并且没就绪,那么就置为0,反之不管。这样,集合中保存的值是1的就是就绪的,并且,select函数会返回一个值,表示就绪的个数。
6. 把集合再次拷贝到应用空间中。
7. 应用空间遍及集合,找到就绪的FD,就会去发起读取数据的命令。
缺点:
1. select函数需要将整个fd_set集合从用户空间拷贝到内核空间,然后再拷贝回来。
2. select无法得知具体是哪个fd就绪,需要遍历整个集合。
3. fd_set集合的个数不能超过1024。
poll
poll函数是对select函数的改进,但是效果并不明显。
1. 创建pollfd数组,向其中添加要监听的FD信息,数组大小自定义(改进的点,不再是1024个)。
2. 调用poll函数,并且将pollfd数组拷贝到内核空间,把数组转成链表存储,没有上限。
3. 遍历链表,判断是否就绪。
4. 依旧是和select函数一样,跟超时时间有关。不过最终的结果就是把pollfd数组拷贝到用户空间,并且返回就绪的fd数量。
5. 用户应用判断是否有就绪的FD,有的话就遍历polfd数组,找到就绪的FD。
6. 找到之后,发起recvfrom命令去读取数据。
缺点:
可以看到,poll相比select唯一变的点就是可以存在无限个,其他的问题依旧没变。并且,随着而来的问题是,监听的FD越多,每次遍历消耗的时间也就越长,性能反而会下降。
epoll
epoll是对select和poll的巨大改进,他不仅解决了重复拷贝的问题,还能准确返回就绪的FD。
首先,调用epoll_create函数,直接会在内核空间中创建eventpoll结构体。结构体中是一个红黑树,一个链表,红黑树表示要监听的FD,链表则表示已经就绪的FD。创建好之后,还会返回对应的句柄epfd。
然后,调用epoll_ctl函数,其中的参数分别epfd,即句柄、op,表示要进行的操作,由于红黑树非常快,因此影响并不大、fd,表示要监听的FD、epoll_event,表示要监听的事件类型。假设是添加操作,那么还会设置一共回调函数,当回调函数被触发时,进行的操作是将FD添加到对应的就绪链表上。
最后,调用epoll_wait函数,其中的参数分别是epfd,即句柄、epoll_event,空event数组,用于接收就绪的FD、maxevents,要接受数组的最大长度、timeout,超时时间,对于超时时间来说,和上两种都一样(当有就绪FD时,直接返回;没有就绪FD时,判断等待时间,当时间为null时,等待直到有就绪FD返回,就绪时间为0,直接返回,就绪时间大于0,等待时间结束,如果还没有就绪的,那就返回,在这期间有就绪的就立即返回)。
在epoll中,唯一一次拷贝就是有FD就绪之后,把FD数组拷贝到应用空间中。
时间通知机制
当FD有数据可读时,我们调用epoll_wait就可以得到就绪的FD数组,但是事件通知的模式有两种(LT和ET):
LT:当FD有数据可读时,会重复通知多次,直至数据处理完成,是epoll的默认模式。
ET:当FD有数据可读时,只会被通知一次,不管数据是否处理完成。
web服务流程
1. 服务端调用epoll_create函数,在内核空间创建一个红黑树用于接收要监听的FD,创建一个链表用来记录就绪的FD。
2. 服务端会先创建一个ServerSocket,然后调用epoll_ctl函数,将其对应的FD注册进去。
3. 当有客户端要来连接服务端时,epoll_wait函数就会调用,将就绪的也就是ServerSocket拿到,然后将其拷贝到用户空间,用户空间就会去发起读取数据的命令。
4. 由于此时只有ServerSocket,所以读取到的一定就是客户端socket,然后再次调用epoll_ctl函数,将其注册到红黑树上。
5. 链表上再有就绪的FD时,此时就无法确定是要接受的命令还是要注册的FD,所以要判断一下,如果是命令的话,那么就读取数据,做出响应,反之就读取数据,进行注册。
6. 不过,其实在第一次读取数据时就会进行判断,防止大家迷糊,所以在第五步做了声明。
总结
select存在的三个问题是:
1. 监听的FD数量不能超过1024
2. 每次select都要把所有监听的FD拷贝到内核空间,有可能还要再次拷贝回来
3. 不确定就绪的FD是哪个,每次都要进行遍历
poll存在的问题是:
虽然解决了select上FD数量限制的问题,但是依然要循环遍历才能找到就绪的FD,如果FD过多,那么性能会下降。
epoll模式是如何解决这些问题的:
1. 针对数量问题,epoll采用红黑树来存储,理论上无上限,而且其增删改效率极高,性能不会随监听的FD数量增多而下降。
2. 针对多次拷贝的问题,把FD插入到红黑树之后,每次调用epoll_wait函数不用自带参数,也就是不用拷贝要查询的FD列表,只需要在有就绪FD的情况下,把就绪的FD拷贝一份即可。
3. 针对循环遍历FD数组的情况,epoll专门搞了一个链表来放就绪的FD,当epoll_wait来时,只需要把就绪的拿走就行,无需其他操作。
Redis网络模型
Redis到底是单线程还是多线程?
从Redis全局来说,他是多线程的,从Redis核心的操作,即在命令处理上,它是单线程的。并且,是否单线程还取决于他的版本,在4.0时,引入多线程处理一些耗时较长的任务,例如异步删除命令等,在6.0时,引入多线程在处理核心网络模型问题上,进一步提高对于多核CPU的利用率。
Redis为什么这么快
1. 首先,Redis是基于内存服务的,那么比起MySQL操作硬盘来说,他就一定快了许多。
2. 其次,Redis的性能瓶颈并不是在处理命令上而是网络延迟,因此对于核心命令的处理引入多线程并不会对处理命令有多大的性能提升。相反,引入多线程操作命令之后,还要考虑线程安全问题,这势必就要引入另一个内容,也就是锁来解决线程安全问题,这会使得实现复杂度增高,性能也会大打折扣。而且多线程还会引起线程上下文的切换,带来不必要的开销。
3. 然后,Reids本身操作的都是一些简单的数据结构,所以就比较快。
3. Redis引入了IO多路复用机制,并且在处理核心网络模型上,引入了多线程。
Redis的执行流程
Redis的网络模型是基于IO多路复用机制的,因此描述大致也是基于此。
1. 启动服务之后,首先要初始化服务
a. 第一步就是调用epoll_create,在内核空间生成一个红黑树和一个链表。红黑树用来监听FD,链表则用来放就绪的FD。
b. 第二步就是Redis服务生成一个ServerSocket,并且调用一个连接应答处理器(用于处理ServerSocket的读事件,也就是用来注册客户端FD的)。
1) 在连接应答处理器中,会调用epoll_ctl,将服务的FD监听起来,并且给它搞一个命令请求处理器(用于处理客户端的读事件,也就是把客户端发送的命令进行一系列的操作,最后处理该命令,处理之后再放到一个队列中,等待被写出)。
c. 第三步就是注册一个前置处理器,用处在第二大步骤。
2. 开始监听事件循环
a. 第一步就是调用前置处理器(前置处理器中,会调用这个队列,即要被写出的事件,如果有内容的话,那么就交给命令回复处理器,命令回复处理器的作用就是将事件写出)。
至此,大致流程基本结束。
通过大量的实际应用发现,对于命令读处理器和命令回复处理器,其实是把未操作的命令读入和写出,本质上跟处理命令没啥关系,所以6.0的时候,在这两步中加入了多线程,不过对于核心命令的执行来说,依旧是单线程。