Redis网络模型-IO多路复用
系统IO交互
IO多路复用概念
文件描述符(File Descriptor):简称FD,是一个从O开始递增的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。
I0多路复用︰是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
IO多路复用的实现方式
观前提醒
下文中经常同时出现fd和socket,fd为笼统说法,便于理解,详细解释流程时使用socket
select
select是Linux中最早的IO多路复用实现方案
linux下的select函数定义
int select (int maxfdp,
fd_set *readset,
fd_set *writeset,
fd_set *exceptest,
struct timeval *timeout);
//参数详解
nfds:select中监视的文件句柄数,一般设为要监视的文件中的最大文件号加一。
readfds:检测读是否就绪的文件描述符集合
writefds :检测写是否就绪的文件描述符集合
exceptfds:检测异常情况是否发生的文件描述符集合
(1、信包模式下伪终端主设备上从设备状态发生改变;2、流式套接字接收到了带外数据)
timeout: 设为NULL,等待直到某个文件描述符发生变化;设为0秒0毫秒,不等待直接返回;
设为大于0的值,有描述符变化或超时时间到才返回。
select返回值:负数表示有错误发生;大于等于0,表示有n个描述符就绪。具体是哪个,
还需要用FD_ISSET遍历判定。
fd_set操作接口
FD_CLR(inr fd,fd_set* set);将描述符fd从fdset所指向的集合中移除
FD_SET(int fd,fd_set*set);将描述符fd添加到fdset所指向的集合中
FD_ZERO(fd_set *set); 将fdset指向的集合初始化为空
FD_ISSET(int fd,fd_set *set);测试描述词组set中相关fd 的位是否为真
简要图解
详细图解
实现步骤配合文字食用更佳
- CPU 执行工作队列中的进程,进程A 获得 CPU 使用权,程序执行
- 进程A 内部绑定了监听端口,接受远端连接,每当远端连接过来时,就建立一个代表连接的 Socket 对象
- 将新建的 Socket 对象添加到进程A 维护的 Socket 列表中,调用 select 时将 Socket 列表传入,由内核负责遍历 Socket 列表中的每一个 Socket,检查其是否可以操作。
需注意,每次调用 select 都需要将 Socket 列表由用户进程拷贝到内核,当 Socket 列表比较大时,拷贝操作是一个不可忽视的开销,因此 Select 会限制监听 Socket 的最大数量
- 内核轮询 Socket 列表,如果没有任何 Socket 就绪,进程A 就需要在 select 调用处阻塞,也就是被从工作队列中移除。
需要注意,进程A 还需要被添加到它所监听的每一个 Socket 的等待列表中,也就是说这里存在遍历 Socket 列表并操作 Socket 的开销
- 当计算机接收到外部的网络数据时,首先会经由网卡将其写入内核缓冲区,完成后网卡发出中断信号通知 CPU 有数据到达
- CPU 收到中断信号,执行对应的中断程序
- 中断程序将内核缓冲数据拷贝到对应的 socketA 的接收缓冲区(用户)
- socketA 接收数据完毕,中断程序将进程A 重新添加到工作队列,并将进程A 从它所监听的每一个 Socket 的等待列表中移除,
此处依然存在遍历 Socket 列表并操作 Socket 的开销
- 进程A 再次获得 CPU 使用权后从 select 阻塞处重新执行,
此时进程A 知道它所监听的 Socket 列表中存在可以读写操作的 Socket,但是并不知道到底是哪一个 Socket,此处仍然需要遍历列表才能进行下一步操作
优势
- IO多路复用的第一阶段是使用select进程监听一批fd,一旦有fd就绪,通知recvfrom进行拷贝数据(第二阶段),即第二阶段不存在阻塞,其阻塞只存在于第一阶段,若一批fd都没有就绪,才会不可避免的阻塞进行等待,相对于阻塞式IO的recvfrom的单个调用,没有数据就阻塞,效率更高
- 跨平台,win、linux、macOS、类unix等
缺点
- 监听上限受文件描述符限制,最大为1024
poll
poll模式对select模式做了简单改进,但性能提升不明显
linux下的poll函数
//pollfd 中的事件类型
#define POLLIN //可读事件
#define POLLOUT //可写事件
#define POLLERR //错误事件
#define POLLNVAL //fd未打开
//pollfd结构
struct pollfd {
int fd;//错误事件
short int events; //要监听的事件类型:读、写、异常
short int revents;//实际发生的事件类型
};
//poll函数
int poll(
struct pollfd *fds,//pollfd数组,可以自定义大小
nfds_t nfds,//数组元素个数
int timeout //超时时间
);
实现步骤
- 创建pollfd数组,向其中添加关注的fd信息,数组大小自定义
- 调用poll函数,
将pollfd数组拷贝到内核空间
,转链表存储,无上限 - 内核遍历fd,判断是否就绪
- 数据就绪或超时后,
拷贝pollfd数组到用户空间
,返回就绪fd数量n - 用户进程判断n是否大于0
- 大于0则遍历pollfd数组,找到就绪的fd
对比select
- select模式中的fd_set大小固定为1024,而pollfd在内核中采用链表,理论上无上限
- 监听FD越多,每次遍历消耗时间也越久,性能反而会下降
epoll
linux下的epoll函数
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实例的句柄
int op,//要执行的操作,包括:ADD、MOD、DEL
int fd,//要监听的FD
struct epoll_event *event //要监听的事件类型:读、写、异常等
);
//3.检查rdlist列表是否为空,不为空则返回就绪的FD的数量
int epoll_wait(
int epfd,// eventpoll实例的句柄
struct epoll_event *events,// 空event数组,用于接收就绪的FD
int maxevents,//events数组的最大长度
int timeout//超时时间,-1用不超时;0不阻塞;大于0为阻塞时间
);
简要图解
详细图解
实现步骤配合文字食用更佳
- CPU 执行工作队列中的进程,进程A 获得 CPU 使用权,程序执行
- 进程A 内部绑定了监听端口,接受远端连接,每当远端连接过来时,就建立一个代表连接的 Socket 对象
- 将新建的 Socket 对象添加到进程A 创建的 epoll 对象内部的
rbr红黑树
结构中,也就是说进程A 监听的 Socket 集合交由内核维护。这样每次只需要将新的 Socket 对象单独拷贝到内核,开销很小,另外这个过程中也会设置回调函数到 Socket 的等待列表中
- epoll_wait 调用检查其内部
rdlist就绪列表
,如果没有任何 Socket 就绪,进程A 就需要在此处阻塞,也就是被从工作队列中移除。需要注意,进程A 此时只要被添加到 epoll 的wq等待列表中即可,不需要被添加到每一个 Socket 的等待列表
- 当计算机接收到外部的网络数据时,首先会经由网卡将其写入内核缓冲区,完成后网卡发出中断信号通知 CPU 有数据到达
- CPU 收到中断信号,执行对应的中断程序
- 中断程序将内核缓冲数据拷贝到对应的 socketA 的接收缓冲区
- socketA 接收数据完毕,中断程序将其添加到 epoll 的rdlist就绪列表
- socketA 数据就绪,触发步骤3设置的回调函数,唤醒进程A,将其从 epoll 的等待列表中移除。
因为进程A 未被直接添加到所有 Socket 的等待列表,所以此处也就不需要将其从每个 Socket 的等待列表移除
- 进程A 回到工作队列,再次获得 CPU 使用权后从 epoll_wait 阻塞处重新执行,
此时进程A 只需要读取 epoll 的rdlist就绪列表就能知道哪些 Socket 读写就绪,epoll中的events数组发挥作用,用于存储就绪的fd,直接操作就绪列表中 Socket 的即可,不需要再遍历查找
对比select和poll
select和poll的共同缺陷:select/poll 低效的原因之一是将 “添加 / 维护待检测任务” 和 “阻塞进程 / 线程” 两个步骤合二为一。每次调用 select 都需要这两步操作,然而大多数应用场景中,需要监视的 socket 个数相对固定,并不需要每次都修改。
epoll的优势:
-
epoll 将“添加 / 维护待检测任务” 和 “阻塞进程 / 线程” 这两个操作分开,先用 epoll_ctl() 维护等待队列,再调用 epoll_wait() 阻塞进程(解耦),效率得到了提升
-
基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高,性能不会随监听的FD数量增多而下降
-
每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
-
内核会将就绪的FD直接拷贝到用户空间的指定位置,用户进程无需遍历所有FD就能知道就绪的FD是谁
IO多路复用的事件通知机制
当FD有数据可读时,我们调用epoll_wait就可以得到通知。但是事件通知的模式有两种:
- LevelTriggered: 简称LT。当FD有数据可读时,会重复通知多次,直至数据处理完成。是Epoll的默认模式。
- EdgeTriggered:简称ET。当FD有数据可读时,只会被通知一次,不管数据是否处理完成。
例:
-
假设一个客户端socket对应的FD已经注册到了epoll实例中
-
客户端socket发送了2kb的数据
-
服务端调用epoll_wait,得到通知说FD就绪
-
服务端从FD读取了1kb数据命
-
回到步骤3(再次调用epoll_wait,形成循环)
其他网络模型补充
阻塞IO
阻塞IO就是两个阶段都必须阻塞等待
非阻塞IO
非阻塞IO的用户应用会频繁发起请求等待返回成功
信号驱动IO
信号驱动IO是与内核建立SIGlO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待
当有大量I0操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出而且内核空间与用户空间的频繁信号交互性能也较低。
异步IO
异步lO的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。
由于异步IO完全不阻塞,需要控制内核并发量,并发过高容易导致崩溃