网络模型
用户空间和内核空间
任何Linux发行版,其系统内核都是Linux。我们的应用都需要通过Linux内核与硬件交互。
- **内核:**本质上可以理解为一种软件,控制计算机的硬件资源,并提供上层应用程序运行的环境。
- 为了保证系统安全,有些指令不能随便被执行去操作底层资源,这些内核指令只能在内核空间执行,普通程序在用户空间执行,但有时用户程序中可能涉及到内核指令的调用,此时就需要陷入内核中运行,CPU从用户态转换到内核态。
- **用户空间:**上层应用程序活动的空间。只能执行非特权指令,不能直接调用系统资源(包括CPU资源、存储资源、I/O资源等),必须通过内核提供的接口(系统调用)来访问。
- 内核空间可以执行特权命令和非特权指令,调用一切系统资源
- **系统调用:**为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。
- 系统调用的过程可以简单理解为:
1.用户态程序将一些数据值放在寄存器中,或者使用参数创建一个堆栈,以此表明需要操作系统提供的服务。
2.用户态程序执行系统调用。
3.CPU切换到内核态,并跳到位于内存指定位置的指令。
4.系统调用处理器(system call handler)会读取程序放入内存的数据参数,并执行程序请求的服务。
5.系统调用完成后,操作系统会重置CPU为用户态并返回系统调用的结果。
进程的寻址空间会划分为两部分:内核空间、用户空间(寻址空间:内核,应用程序都没法直接访问物理内存,而是给他们分配不同的虚拟内存空间。映射到不同物理内存。内核、应用在访问虚拟内存时,就需要对应的虚拟地址,虚拟地址表示范围取决于CPU地址总线和寄存器带宽,如32位系统,能表示范围将就是0-2^32-1,一个值就表示一个存储单元,这些空间又会被划分为内核空间和用户空间)
用户程序在调用内核指令时就会使得CPU从用户态切换到内核态
以IO读写为例:
Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区
- 写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
- 读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区
假设一个应用,做简单字符串处理时,这些命令都是基本命令,在用户空间就可完成。当要将结果写入磁盘,就需要调内核指令,CPU切换到内核态,将数据写到内核缓冲区,将缓存数据写入到磁盘。
读请求来的时候,切换到内核态,等待磁盘寻址完成或者网卡上的数据发送到内核缓冲区,数据准备就绪,发送到用户缓冲区,即可从缓冲区得到数据
上述过程中,可以看出影响读写的两个环节:等待数据就绪,数据在用户缓冲区和内核缓冲区的拷贝
所以linux5种IO模型也是针对这两个环节进行优化。
阻塞IO | 非阻塞IO
还是以读写为例:
当用户应用想读取硬件设备上的数据时,不管是磁盘(存储在磁盘上的数据)还是网卡(从网卡传过来的数据),是没有权限直接操作的,必须去调内核对外提供的指令和函数,CPU切换到内核态去调内核指令后,如果数据还没有准备好,就必须等待数据就绪(如磁盘寻址完成,并且将磁盘的数据读取到内核缓存区,这时候才算数据准备好了),然后就可以将数据拷贝到用户缓冲区。
当用户进程用recvfrom尝试读取数据,会检查内核缓存有没有数据,发现没有数据。阻塞IO就会等待数据就绪,然后内核就绪数据拷贝到用户空间,最后返回OK;非阻塞IO立即返回失败结果,然后又不停询问,直到内核缓冲区有数据,将其拷贝到用户缓冲区。
阻塞IO用户进程在两个阶段都处于阻塞状态
非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。
IO多路复用
无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理方案:
- 如果调用recvfrom时,恰好没有数据,阻塞lO会使进程阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。
- 如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据
比如服务端处理客户端Socket请求时,在单线程情况下,只能依次处理每一个socket,如果正在处理的socket恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有其它客户端socket都必须等待,性能自然会很差。
上图中:
方案一开多线程开销大,CPU要在多个线程间做上下文切换;
方案二设置一个监听,去监听客户哪个需要点餐,需要则服务员去处理。IO多路复用即用了这个思想。
文件描述符(File Descriptor):简称FD,是一个从0开始递增的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。
**IO多路复用:**是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源
首先不知道哪个FD准备成功了。用户进程首先调用select,select将要监听的多个FD传到内核,检查哪个FD准备就绪了。如果没有就会等待一会,只要有一个或多个就绪,就会返回可读。然后循环调用recvfrom去读准备好的FD。
处理完这一趟又调select,继续执行下一波。
(上述过程中虽然也是要等待就绪,但是recvfrom是只能监听当前FD是否就绪,此时若有其他FD准备好了那么就绪阻塞其他FD的读取,select可以监听多个,谁准备就绪了就读谁)
IO多路复用:是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
不过监听FD的方式、通知的方式又有多种实现,常见的有:
- select
- poll
- epoll
差异:
select和poll只会通知用户进程有FD就绪,但不确定具体是哪个FD,需要用户进程逐个遍历FD来确认
epoll则会在通知用户进程FD就绪的同时,把已就绪的FD写入用户空间
select
select是Linux中最早的I/O多路复用实现方案:
//定义类型别名__fd_mask,本质是long int,即4字节
typedef long int __fd_mask;
/* fd_set记录要监听的fd集合,及其对应状态*/
typedef struct {
//__fd_mask类型是long int,即 fds_bits是long类型数组,每个元素4字节,占32位
// fds_bits可存__FD_SETSIZE / __NFDBITS=32个元素,所有元素总共占1024bit
// 共1024个bit位,每个bit位代表一个fd,0代表未就绪,1代表就绪
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];//4字节32bit位,该数组大小32
// ...
}fd_set;
// select函数,用于监听多个fd的集合
int select(
int nfds,//要监视的fd_set的最大fd + 1
//linux把IO事件分为三种,可读、可写、异常
fd_set *readfds,//要监听读事件的fd集合
fd_set *writefds,//要监听写事件的fd集合
fd_set *exceptfds,//要监听异常事件的fd集合
//等待某个FD数据就绪的超时时间,null-永不超时;0-不阻塞等待;大于o-固定等待时间
struct timeval *timeout
心
);
select执行流程:
用户进程创建要监听的fd{1,2,5}集合,fds_bits的每个bit位都会初始化为0(有1024位,这里方便演示就用8位)。然后根据要监听的fd集合将对应bit位上的值改为1,即00010011(即要监听的fd是多少就第几位置位1)。初始数据后调用select(5+1,rfds,null,null,3)【参数1:要监听的集合中最大的fd值+1;参数二:读事件的fd集合;参数三:写时间的fd集合;参数四:异常事件的fd集合;参数5:超时时间】。
在执行select时把要监听的fd集合发送到内核(用户态到内核态切换),内核就可以监听这些fd,内核会从低位到高位遍历置为1对应的fd有无就绪,如果没有一个就绪,则进入休眠,然后会有一个进程去监听,只要有一个可读,就被唤醒,再次挨个遍历,保留就绪的fd对应的bit位,没有就绪的置为0:,如下
上图中00000001保留的就是就绪的fd,然后select会返回有几个就绪了,但并没有把就绪的结果告诉用户,会把fd结果拷贝到原来的rfds
拷贝过去后又重新遍历这1024个bit位哪些是1,找出就绪的fd,处理对应数据。以上就是一轮的select执行流程,每一轮处理完后又继续执行select继续下一轮。
select模式存在的问题:
- 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝
回用户空间 - select无法得知具体是哪个fd就绪,需要遍历整个fd_set
- fd_set监听的fd数量不能超过1024
poll
poll模式对select模式做了简单改进,但性能提升不明显,部分关键代码如下:
IO流程:
创建pollfd数组,向其中添加关注的fd信息(每个fd中只有属性fd,events赋值了,而revents还没有赋值),数组大小自定义
调用poll函数(在调用poll函数的时候,fds指向一个pollfd数组,装着一个个pollfd),将pollfd数组拷贝到内核空间,转链表存储,无上限
内核遍历fd,判断是否就绪(当监听过程中,某事件数据就绪,就会把事件类型放在该pollfd的revents中,若超时时间过了都还没有发生,就将该revents置为0)
数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
用户进程判断n是否大于0
大于0则遍历pollfd数组,找到就绪的fd
与select对比:
- select模式中的fd_set大小固定为1024,而pollfd在内核中采用
链表,理论上无上限 - 监听FD越多,每次遍历消耗时间也越久,性能反而会下降
- 事件不分类存放,而是在pollfd中设置events属性来区分
epoll
epoll模式是对select和poll的改进,它提供了三个函数:
struct eventpoll {
//...
struct rb_root rbr; //一颗红黑树,记录要监听的FD
struct list_head rdlist;// 一个链表,记录就绪的FD
//...
};
//1.会在内核创建eventpoll结构体,返回对应的句柄epfd
int epoll_create(int size);
//2.将一个FD添加到epoll的红黑树中,并设置ep_poll_callback
// callback触发时,就把对应的FD加入到rdlist这个就绪列表中
int epoll_ctl(
int epfd,//epoll实例的句柄,epfd可以看成eventpoll的唯一标识。
int op,//要执行的操作,包括:ADD(将当前fd添加到eventpoll的红黑树上)、MOD(修改)、DEL(删除)
int fd,//要监听的FD
struct epoll_event *event //要监听的事件类型:读、写、异常等
);
//3.检查rdlist列表是否为空,不为空则返回就绪的FD的数量
int epoll_wait(
int epfd,
// eventpoll实例的句柄
struct epolh event *events,//空event数组,用于接收就绪的FD
int maxevents,// events数组的最大长度
int timeout//超时时间,-1用不超时;0不阻塞;大于0为阻塞时间
);
epoll执行流程:
调用epoll_create(1)创建eventpoll实例(此时rb_root和list_head都是空的),eventpoll创建完就会返回对一个的epfd句柄,每个epfd对应一个epoll实例。
接着调用epoll_ctl(),将一个fd添加到epoll的红黑树中【epoll_ctl()只做添加,不做等待,他功能只具备一部分select(监听,等待),即将一个fd添加到eventpoll中去(即监听)】,添加的fd会关联一个回调函数,在fd就绪时会触发该回调函数执行(该回调函数逻辑很简单:数据就绪,就会把该事件添加到就绪链表中)
接下来就是执行epoll_wait等待事件就绪,即select,poll下部分功能,等待。在用户空间创建events实例,用于接收就绪fd。检查就序列表(不会检查整个红黑树)如果在等待时间内还没有就绪事件,返回0,一旦有就绪了,就会触发就绪fd的回调函数将fd假如就绪链表中。wai函数发现就绪链表有就绪fd了,发现就绪fd个数,并将就绪链表放到用户空间的events实例中
1、select需要把所有事件拷贝到内核,而epoll将监听的fd、等待就绪的fd拆分成两个数组,在执行过程中,要监听一个fd时,执行epoll_ctl()把他加到树上就可以了,在以后的IO轮次中不用再添加该事件,只用循环epoll_wait函数,大大减少拷贝数组
2、select返回就绪个数,拷贝回去的是所有数组,需要挨个便利哪些就绪,而epoll返回就绪个数,但只拷贝就绪事件
3、select最多监听1024,而epoll虽然没限制,但是个数越多,遍历就越多,所以就不能太多
4、红黑树元素增删改查效率不会随元素变多而有太大波动
总结
·select模式存在的三个问题:
·能监听的FD最大不超过1024
·每次select都需要把所有要监听的FD都拷贝到内核空间
·每次都要遍历所有FD来判断就绪状态
poll模式的问题:
·poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降
epoll模式中如何解决这些问题的?
·基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高,性能不会随监听的FD数量增多而下降
·每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
·内核会将就绪的FD直接拷贝到用户空间的指定位置,用户进程无需遍历所有FD就能知道就绪的FD是谁
IO多路复用-事件通知机制
当FD有数据可读时,我们调用epoll_wait就可以得到通知。但是事件通知的模式有两种;
- LevelTriggered:简称LT。当FD有数据可读时,会重复通知多次,直至数据处理完成。是Epoll的默认模式。
- EdgeTriggered:简称ET。当FD有数据可读时,只会被通知一次,不管数据是否处理完成。
举个栗子:
1、假设一个客户端socket对应的FD已经注册到了epoll实例中客户端socket发送了2kb的数据
2、服务端调用epoll_wait,得到通知说FD就绪
3、服务端从FD读取了1kb数据
回到步骤3(再次调用epoll_wait,形成循环,如果是LT,会得到通知说FD就绪,如果是ET则没有)
为什么会不通知呢,
假如此时就绪链表中有就绪事件,去调用epoll_wait(),会返回就绪fd个数,并将就绪链表断开,然后拷贝到用户空间进行处理。假设数据没有处理完,只处理了1KB,socket上还有数据。内核还会做一次检查,看采用的是LD还是ET,如果是ET,就绪链表是空的,结束,如果是LT,看还有数据,就会将数组添加到就绪链表中,下次掉调poll_wait看就绪链表中还有待处理事件,就会返回就绪fd个数,并将就绪链表断开,然后拷贝到用户空间进行处理。
所以ET会存在数据丢失问题
如果想要使用ET模式,也可以手动处理哈:
法一:内核不将数组添加到就绪链表,那第一次读完后我们可以手动将数组添加到链表中,即调用epoll_ctl()将未处理完事件加到红黑树上
法二:既然只读一次,那就在当前轮次利用一个循环将数据读完,注意循环读时不要用阻塞IO,因为数据读完线程会阻塞等待,非阻塞IO每次读会返回结果,没有数据返回0。
LT也有缺点:
1、数据没读完就要一直等待通知,影响效率;
2、任何一个进程通知完成后,fd还会存在链表中,就会导致所有监听链表的进程都会被通知到。即有一个就绪,所有的进程都会被唤醒。而真正处理时可能前面一两个进程就能把所有数据读取完,后续唤醒进程没有必要唤醒。而ET不会存在此问题,只通知一次
所以具体用两种,根据情况选择
结论:
ET模式避免了LT模式可能出现的惊群现象
ET模式最好结合非阻塞lO读取FD数据,相比LT会复杂一些
IO多路复用-web服务流程
基于epoll模式的web服务的基本流程如图:
流程分析:
服务端调用epoll_create()在内核空间创建一棵红黑树(记录要监听的FD)和一个就绪链表(记录就绪FD),初始化serverSocket,这里是一个web服务,web服务基于TCP的,服务端就相当于一个server_Socket(serverSocket也是一个文件,也有文件描述符),在Linux会被看成一个fd,通过epoll_ctl将其注册到rb_root上,并给fd绑定一个回调函数,等待直到fd就绪加到就绪链表。
epoll_wait等待,他会查看list是否有就绪fd,没有则等待指定时间返回0,有则返回就绪fd数(在web服务中,什么时候会有就绪fd?当有客户端向他申请连接,因为server_Socket目的就是接收客户端请求,一旦有任何客户端Socket尝试跟server_socke建立连接,这时候上面就会产生fd事件),判断fd的事件类型:
- 如果是EPOLLIN,代表他是一个读事件,如果要读的是serverSocket(ssfd),则表示有客户端过来请求连接了,调用accept找到对应客户端的fd,通过epoll_ctl将其注册到红黑树上,如果再有客户端上来,一直循环。ssfd不可读,则证明当前要读的是一个普通客户端连接,读取请求数据,写到客户端socket中。
- 如果是EPOLLERR异常事件,将异常信息写回客户端。
总结就是针对事件处理,客户端连接事件来了,则建立连接并监听,客户端读事件则读取请求数据,异常事件则写出异常信息。
上图中上半部分操作都是在内核空间,下部分在用户空间
信号驱动IO
信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。
上图中用户进程上来调用sigaction指令,这个指令会向内核中指定一个fd,并将其绑定一个信号处理函数,内核会监听fd有无数据,如果fd有数据了,就会唤醒用户进程,并且递交一个信号给用户进程,之前注册的信号处理函数就可以处理信号,知道有数据就绪了,调用recvfrom去处理。在整个过程中,用户进程不用阻塞等待数据就绪,有数据后在调用recvfrom对拷贝数据。
缺点:当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出而且内核空间与用户空间的频繁信号交互性能也较低。
一阶段非阻塞,2阶段阻塞
异步IO
异步lO的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。
异步IO调用aio_read就不用干啥了,交由内核监听fd就绪,就绪后直接把数据拷贝到用户空间。
可以看到,异步IO模型中,用户进程在两个阶段都是非阻塞状态。
缺点:因为用户进程不会阻塞,如果不挺有用户进程的请求过来,内核积累的IO就越来越多,高并发环境下可能因为内存占用过多而崩溃。要使用aio_read,就要控制好aio_rea并发,这个控制实现起来复杂。
异步|同步
lO操作是同步还是异步,关键看数据在内核空间与用户空间的拷贝过程(数据读写的IO操作),也就是阶段二是同步还是异步:
Redis网络模型
Redis到底是单线程还是多线程?
- 如果仅仅聊Redis的核心业务部分(命令处理),答案是单线程
- 如果是聊整个Redis,那么答案就是多线程
在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:
Redis v4.0: 引入多线程异步处理一些耗时较长的任务,例如异步删除命令unlink
Redis v6.0:在核心网络模型中引入多线程,进一步提高对于多核CPU的利用率
为什么Redis要选择单线程?
- 抛开持久化不谈,Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。
- 多线程会导致过多的上下文切换,带来不必要的开销
- 引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣
- 命令是依次执行,就不会有线程安全问题,多线程就要加锁保证安全。
Redis网络模型
Redis通过IO多路复用来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现进行封装,提供了统一的高性能事件库API库AE:
来看下Redis单线程网络模型的整个流程:
1)Redis启动后,服务端创建ServerSocket并将它的fd注册到aeEventLoop实例上,并给该事件绑定一个连接应答处理器tcpAcceptHandler,然后调用beforesleep,然后执行aeApiPoll等待;
2)如果有客户端连接上来,会产生一个AE_READABLE事件,I0多路复用程序监听到ServerSocket上有事件就绪,就会调用连接应答处理器,和客户端建立连接,创建客户端对应的socket,获取socket的AE_READABLE事件,为其绑定命令请求处理器关联,将其注册到aeEventLoop中红黑树上,使得客户端可以向主服务器发送命令请求。
3)除了第一次监听的ServerSocket,之后循环等待的不只一个事件,有ServerSocket,也有客户端socket读。当客户端向Redis发请求时(不管读还是写请求),客户端socket都会产生一个AE_READABLE事件,触发命令请求处理器,将客户端封装成一个Client实例,将请求数据放到queryBuf,然后请求参数解析放到数组中,找到对应命令执行对应函数(为什么要找命令呢而不是由客户端指令执行?为什么要找呢,因为每个命令都有对应的Command函数,命令和函数做成映射关系放在字典中,key->命令,value->函数,由这些函数处理对应命令)
4、当Redis服务器准备好给客户端的响
应数据后,结果会先放在客户端缓冲区,并将客户端放在队列中等待写出,什么时候将数据写出呢,即执行beforesleep()时,会挨个遍历client,执行connSetwriteHandlerwithBarrier (),该函数做两件事,监听FD,并设置写处理器,注册到aeEventLopp中,等待就绪。如下
client实例:每一个客户端socket只要跟服务器连接,就会把它封装成一个client实例,包含了客户端所有信息,甚至客户端请求信息(set,add…)
客户端传过来的请求在Connecion中,从Connecion读数据到输入缓冲区(querybuffer),处理请求参数,因为读到的是字节,所以然后是处理缓冲区字符,set name,命令解析后处理命令
服务端会将socket的AE_WRITABLE事件和命令回复处理器关联,当客户端准备好读取响应数据时,会在socket产生一个AE_WRITABLE事件,由对应命令回复处理器处理,即将准备好的响应数据写入socket,供客户端读取。
5、命令回复处理器全部写完到socket后,就会删除该socket的AE_WRITABLE事件和命令回复处理器的映射。
整个流程:
事件无非三种,servet_socket可读,客户端socket可读可写
以上都是单线程。执行命令速度很快,但是命令解析处理慢,命令处理要从客户端读请求,然后解析命令对应command函数执行,这过程中涉及到IO的读,效率低。
写事件,也受到网络带宽,网络阻塞影响。影响性能永远是IO。
Redis 6.0版本中引入了多线程,目的是为了提高IO读写效率。因此在解析客户端命令、写响应结果时采用了多线程。核心的命令执行、IO多路复用模块依然是由主线程执行。
总结:
Redis网络模型基于epoll实现的,通过IO多路复用机制来监听多个Socket,Socket存放在队列中,当有事件就绪就会被取出,根据Socket上的事件类型执行该事件对应的事件处理器来处理事件。
具体流程:
Redis启动后,服务端创建ServerSocket并将他的fd注册到aeEventLoop实例上,并给该事件绑定一个连接应答处理器tcpAcceptHandler,然后等待客户端的请求
如果有客户端连接上来,会产生一个AE_READABLE事件,IO多路复用程序监听到ServerSocket上有事件,就会调用连接应答处理器,和客户端建立连接,创建客户端对应的socket,同时将这个socket的AE_READABLE事件和命令请求处理器关联,注册到等待队列中,使得客户端可以向主服务器发送命令请求。
3、当客户端向Redis发请求时(不管读还是写请求),客户端socket都会产生一个AE_READABLE事件,触发命令请求处理器,处理器读取解析客户端的命令内容,然后传给相关程序执行。
4、当Redis服务器准备好给客户端的响应数据后,会将socket的AE_WRITABLE事件和命令回复处理器关联,当客户端准备好读取响应数据时,会在socket产生一个AE_WRITABLE事件,由对应命令回复处理器处理,即将准备好的响应数据写入socket,供客户端读取。
5、命令回复处理器全部写完到 socket后,就会删除该socket的AE_WRITABLE事件和命令回复处理器的映射。