IO多路复用及事件处理模式基础知识整理

半同步半反应堆模型文章出处

I/O多路复用

I/O多路转接:当我们的客户端请求服务端时,会每次为一个请求写入一个文件描述符。为节省资源,我们选择使用一个线程监听多个文件描述符,提高程序的性能,这就是IO多路复用技术。IO多路复用有select,poll,epoll三种模型。(皆为同步)


模型

同步阻塞IO模型、同步非阻塞IO模型、信号驱动IO模型、异步IO模型、多路IO复用模型。
同步和异步的区别在于两者获取数据的方式不同,文件描述符上的事件是否就绪。同步IO需要使用系统调用将内核中的数据拷贝到指定位置,异步IO则由内核自动将数据拷贝到指定内存中去。多路IO则是能够在阻塞期间同时监听多个描述符上的事件发生。

阻塞(BIO)模型

每线程或进程对应一个client
accept(),read()函数阻塞Blocking
	
优点:线程,进程实现并发
缺点:线程或者进程会消耗资源
	 线程或进程调度消耗CPU资源
根本问题:Blocking

非阻塞,忙轮询(NIO)模型**

在一段时间间隔内不断询问,对客户端的数据进行判断
每循环内O(n)系统调用

优点:提高程序的执行效率
缺点:需要占用更多的CPU和系统资源
    
解决:使用I/O多路转接技术select/poll/epoll

I/O多路转接模型

select,poll,epoll都相当于快递代收站点,对于select和poll,它们对于具体的某一个快递查找需要挨个遍历,select有连接数的限制,而poll相当于select的优化。对于epoll的理解,如下图:

在这里插入图片描述

select

实现及其API(看看就行):

  1. 首先要构造一个关于文件描述符的列表,将要监听的文件描述符添加到该列表中。
  2. 调用一个系统函数,监听该列表中的文件描述符,直到这些描述符中的一个或者多个进行I/O
    操作时,该函数才返回。
    a.这个函数是阻塞
    b.函数对文件描述符的检测的操作是由内核完成的
  3. 在返回时,它会告诉进程有多少(哪些)描述符要进行I/O操作。
// sizeof(fd_set) = 128 1024 
#include <sys/time.h> 
#include <sys/types.h> 
#include <unistd.h> 
#include <sys/select.h> 
int select(int nfds, fd_set *readfds, fd_set *writefds, 
    	fd_set *exceptfds, struct timeval *timeout); 
	- 参数: 
        - nfds : 委托内核检测的最大文件描述符的值 + 1 
        - readfds : 要检测的文件描述符的读的集合,委托内核检测哪些文件描述符的读的属性 
                    - 一般检测读操作 
                    - 对应的是对方发送过来的数据,因为读是被动的接收数据,检测的就是读缓冲区 
                    - 是一个传入传出参数 
        - writefds : 要检测的文件描述符的写的集合,委托内核检测哪些文件描述符的写的属性 
                    - 委托内核检测写缓冲区是不是还可以写数据(不满的就可以写) 
        - exceptfds : 检测发生异常的文件描述符的集合 
        - timeout : 设置的超时时间    

select面试介绍

select在判断是否有事件就绪时,会把文件描述符从用户态拷贝到内核态,因为在内核判断效率比较高,
遍历完后会将结果直接返回。底层为数组,bitmap位图置位。
select文件描述符用fd_set集合存储,数量大小限制,32位下fd限制为1024,64位下限制为2048;
拷贝和遍历过程都需要消耗系统资源和时间,遍历时间复杂度O(n)。
在这里插入图片描述


poll

poll面试介绍

poll是select的改进,解决select中bitmap位图的置位问题;
poll是文件描述符存放在链表中,数量没有限制;
poll是每次检测需要从用户态拷贝到内核态
poll每次调用都需要将fd集合从内核遍历O(n)传递进来的所有fd

API实现

#include <poll.h> 
struct pollfd { 
	int fd; /* 委托内核检测的文件描述符,链表保存 */ 
	short events; /* 委托内核检测文件描述符的什么事件 */ 
	short revents; /* 文件描述符实际发生的事件 */ 
};

struct pollfd myfd; 
myfd.fd = 5; 
myfd.events = POLLIN | POLLOUT; 

int poll(struct pollfd *fds, nfds_t nfds, int timeout); 
    - 参数:
        - fds : 是一个struct pollfd 结构体数组,这是一个需要检测的文件描述符的集合 
        - nfds : 这个是第一个参数数组中最后一个有效元素的下标 + 1 
        - timeout : 阻塞时长 
             0 : 不阻塞 
            -1 : 阻塞,当检测到需要检测的文件描述符有变化,解除阻塞 
            >0 : 阻塞的时长 
    - 返回值: 
        -1 : 失败 
        >0(n) : 成功,n表示检测到集合中有n个文件描述符发生变化 

epoll(相对重要)

epoll_create创建一个新的epoll实例。在内核中创建了一个数据(红黑树和双向链表),红黑树存储检测的文件描述符的信息,就绪队列存放检测到数据发送改变的文件描述符信息(双向链表)。

#include <sys/epoll.h> 
typedef union epoll_data { 
    void *ptr; 
    int fd; 
    uint32_t u32; 
    uint64_t u64; 
} epoll_data_t; 

struct epoll_event { 
    uint32_t events; /* Epoll events */ 
    epoll_data_t data; /* User data variable */ 
};

常见的Epoll检测事件: 
    - EPOLLIN    //检测读事件
    - EPOLLOUT 	//检测写事件
    - EPOLLERR 

//epoll_create创建一个新的epoll实例
epoll_create创建一个新的epoll实例。在内核中创建了一个数据(红黑树和双向链表),
红黑树存储检测的文件描述符的信息,就绪队列存放检测到数据发送改变的文件描述符信息(双向链表)。

int epoll_create(int size); 
    - 参数:
    	size : 目前没有意义了。随便写一个数,必须大于0 
    - 返回值: 
        -1 : 失败 
        > 0 : 文件描述符,操作epoll实例的    
    
// 将文件描述符fd注册到内核中,避免了fd的拷贝,对epoll实例进行管理:添加文件描述符信息,删除信息,修改信息 
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 
    - 参数: 
        - epfd : epoll实例对应的文件描述符 
        - op : 要进行什么操作 
                EPOLL_CTL_ADD: 添加 
                EPOLL_CTL_MOD: 修改 
                EPOLL_CTL_DEL: 删除 
        - fd : 要检测的文件描述符 
        - event : 检测文件描述符什么事情 
    
// 检测函数 
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); 
    - 参数:
        - epfd : epoll实例对应的文件描述符 
        - events : 传出参数,保存了发送了变化的文件描述符的信息 
        - maxevents : 第二个参数结构体数组的大小 
        - timeout : 阻塞时间 
             0 : 不阻塞 
            -1 : 阻塞,直到检测到fd数据发生变化,解除阻塞 
            >0 : 阻塞的时长(毫秒) 
        - 返回值: 
            - 成功,返回发送变化的文件描述符的个数 > 0 
            - 失败 -1

Epoll 的工作模式:

  1. LT模式(水平触发)
    LT(level - trigger)是缺省的工作方式,并且同时支持 block 和 no-block socket。在这
    种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作 。
    如果你不作任何操作,内核还是会继续通知你的。(没全部读完就会一直通知
  2. ET 模式(Edge trigger边沿触发)
    第一次数据就绪到达时立即通知,之后就不再通知了
    假设委托内核检测读事件 -> 检测fd的读缓冲区
    读缓冲区有数据 - > epoll检测到了会给用户通知
    a.用户不读数据,数据一致在缓冲区中,epoll下次检测的时候就不通知了
    b.用户只读了一部分数据,epoll不通知
    c.缓冲区的数据读完了,不通知

区别:ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。
epoll 工作在 ET 模式的时候,使用非阻塞模式,以避免由于一个文件句柄的阻塞读/阻塞写操作把
处理多个文件描述符的任务饿死。

EpollOneshot
一线程读完某socket上数据后开始处理这些数据,此时该socket上又有新数据可读(即EPOLLIN再次被触发) -> 另一线程被唤醒读新的数据 =>造成2个线程同时操作一个socket的局面。

EPOLLONESHOT事件:保证一个socket连接在任一时刻只被一个线程处理。
注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写/异常事件。

并且只触发一次,除非使用epoll_ctl重置该fd上注册的EPOLLONESHOT事件。=>所以,注册了EPOLLONESHOT事件的socket一旦被某个线程处理完,该线程应立即重置该fd上注册的EPOLLONESHOT事件,以确保这个socket下次可读时,其EPOLLIN事件能被触发。

在这里插入图片描述

selectpollepoll
操作方式遍历遍历回调
底层实现数组链表红黑树+双向链表
I/O效率每次调用线性遍历,时间复杂度O(n)每次调用线性遍历,时间复杂度O(n)事件通知方式,每当 fd 就绪,系统注册的回调函数就会被调用,将就绪 fd 放入就绪队列中,时间复杂度O(1)
最大连接数1024(x86)或2048(x64)无上限无上限
fd拷贝每次调用select,都需要把fd集合从用户态拷贝到内核态每次调用poll,都需要把fd集合从用户态拷贝到内核态调用epoll_ctl时拷贝进内核并保存,之后每次epoll_wait()不拷贝

阻塞/非阻塞,同步/异步

典型的一次IO的两个阶段: 数据就绪 和 数据读写
陈硕:在处理 IO 的时候,阻塞和非阻塞都是同步 IO,只有使用了特殊的 API 才是异步 IO。
阻塞/非阻塞在文件描述符状态设置,而同步异步在于调用者调用的API是否消耗自身资源(非内核时间资源)

数据就绪:根据系统IO操作的就绪状态

  • 阻塞 :调用IO方法的线程进入阻塞状态,即在当前线程,无返回值
  • 非阻塞:不会改变线程的状态,通过返回值判断

数据读写:根据应用程序和内核的交互方式

  • 同步:调用同步IO接口,将内核区(TCP接收缓冲区)的数据主动搬到用户区的数据,当调用结束后返回,才能执行下一步
  • 异步:调用异步IO接口,数据来了之后,内核主动给我们装好,通过状态、通知和回调来通知我们调用者。
    在这里插入图片描述

官方面经

一个典型的网络IO接口调用,分为两个阶段,分别是“数据就绪” 和 “数据读写”,数据就绪阶段分为阻塞和非阻塞,
表现得结果就是,阻塞当前线程或是直接返回。

同步表示A向B请求调用一个网络IO接口时(或者调用某个业务逻辑API接口时),数据的读写都是由请求方A自己来完成的
(不管是阻塞还是非阻塞);

异步表示A向B请求调用一个网络IO接口时(或者调用某个业务逻辑API接口时),向B传入请求的事件以及事件发生时
通知的方式,A就可以处理其它逻辑了,当B监听到事件处理完成后,会用事先约定好的通知方式,通知A处理结果。

两种高效的事件处理模式

服务器程序通常需要处理三类事件:I/O 事件、信号及定时事件。
有两种高效的事件处理模式:Reactor和 Proactor。
同步 I/O 模型通常用于实现 Reactor 模式,异步 I/O 模型通常用于实现 Proactor 模式。

Reactor模式

要求主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元),将 socket可读可写事件放入请求队列,交给工作线程处理。
除此之外,主线程不做任何其他实质性的工作。读写数据,接受新的连接,以及处理客户请求均在工作线程中完成。

使用同步 I/O(以 epoll_wait 为例)实现的 Reactor 模式的工作流程是:

  1. 主线程往 epoll 内核事件表中注册 socket 上的读就绪事件。
  2. 主线程调用 epoll_wait 等待 socket 上有数据可读。
  3. 当 socket 上有数据可读时, epoll_wait 通知主线程。主线程则将 socket 可读事件放入请求队列。
  4. 睡眠在请求队列上的某个工作线程被唤醒,它从 socket 读取数据,并处理客户请求,然后往 epoll
    内核事件表中注册该 socket 上的写就绪事件。
  5. 当主线程调用 epoll_wait 等待 socket 可写。
  6. 当 socket 可写时,epoll_wait 通知主线程。主线程将 socket 可写事件放入请求队列。
  7. 睡眠在请求队列上的某个工作线程被唤醒,它往 socket 上写入服务器处理客户请求的结果。
    在这里插入图片描述

Proactor模式

Proactor 模式将所有 I/O 操作都交给主线程和内核来处理(进行读、写),工作线程仅仅负责业务逻辑。

使用异步 I/O 模型(以 aio_read 和 aio_write 为例)实现的 Proactor 模式工作流程是:

  1. 主线程调用 aio_read 函数向内核注册 socket 上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例)。
  2. 主线程继续处理其他逻辑。
  3. 当 socket 上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。
  4. 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求后,调用 aio_write 函数向内核注册 socket 上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序。
  5. 主线程继续处理其他逻辑。
  6. 当用户缓冲区的数据被写入 socket 之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。
  7. 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭 socket。

在这里插入图片描述


并发模型

并发模式指的是I/O处理单元和多个逻辑单元之间协调完成任务的方法。

同步异步区别

  • I/O模型中的同步异步:内核向应用程序通知的是就绪事件还是完成事件
  • 并发模型中的同步异步:程序执行顺序是否按照代码顺序执行。中断、信号。

模型:半同步/半反应堆

  • 异步线程只有一个,由主线程充当。负责监听所有socket上的事件。如果有新连接请求,主线程接受得到新的连接socket,往epoll内核事件表中竹醋socket上的读写事件。如果连接上有读写事件,主线程将该连接socket插入请求队列。所有工作线程(同步线程)睡眠在请求队列上,当有任务到来,通过竞争的方式获得任务管理权。
    • 上面的方式采用的事件处理模式为Reactor模式。要求工作线程自己从socket傻姑娘读取客户请求和往socket上写入服务器应答。
    • 也可以采用模拟的Proactor事件处理模式。要求主线程完成数据的读写,主线程将应用程序数据、任务类型等信息封装成一个任务对象,然后插入请求队列;工作线程从请求队列中取得任务对象后,直接处理即可,不需要读写操作。
  • 半同步/半反应堆的缺点:
    • 主线程和工作线程共享请求队列。主线程添加任务、工作线程取出任务,都需要对请求队列进行加锁保护,从而耗费cpu时间。
      • 每个工作线程在同一时间只能处理一个客户请求。
  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Q_Outsider

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值