IO多路复用模型(poll、select、epoll的原理及区别)

IO多路复用模型

IO:input和output,一般指数据的写入、数据的读取。IO主要分成两类:硬盘IO和网络IO,本内容主要针对网络IO。复用的含义可以理解为重复使用某个事物,而在本文,这个事物是指一个线程。因此,IO多路复用,是指并发socket连接复用一个IO线程(只需要一个线程,即可为多个client同时提供socket连接请求)。如果用户程序要将数据写入或者读取数据,那么它在底层必须通过文件描述符才能达到相应的操作,因此IO多路复用与文件描述符密切相关联。

(一)文件描述符

IO多路复用的select、poll和epoll机制正是通过操作文件描述符集合类处理IO事件。
文件描述符是一个索引号,是一个非负整数,它是指向普通的文件或者I/O设备,它是连接用户空间和内核空间的纽带。在linux系统上内核(kernel)利用文件描述符来访问文件。打开现存文件或者新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。(在windows系统上,文件描述符被称为文件句柄)。

在Linux系统上,每个进程都有属于自己的stdin、stdout、stderr。标准输入(standard input)的文件描述符是0,标准输出符是1,标准错误是2。
进程只有拿到文件描述符才能向它指向的物理文件写入数据或者读取数据,然后再把这些数据用socket方式远程传输给client。
文件描述符就是操作系统为了高效管理已打开文件所创建的一个索引。给os.write写入fd,进程非常迅速通过fd找到已经打开的文件,进程高效率了,作为操作系统当然也更高效管理这些进程。

(二)IO模型的介绍

IO模型基本分类:

1、Blocking I/O(同步阻塞IO):最常见也最传统IO模型,即代码语句按顺序执行,若某一条语句执行需等待,那么后面的代码会被阻塞,例如常见顺序步骤:读取文件、等待内核返回数据、拿到数据、处理输出。

2、同步非阻塞IO(Non-blocking IO):默认创建的socket为阻塞型,将socket设置为NONBLOCK,业务流程则变为同步非阻塞IO。

3、IO多路复用(IO Multiplexing ):即经典的Reactor设计模式,有时也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型。

4、异步IO(Asynchronous IO):即经典的Proactor设计模式,也称为异步非阻塞IO。

同步和异步

同步 是指用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行;例如内核读文件需要耗时10秒,那么用户线程发起读取文件IO后,等待内核从磁盘拷贝到内存10秒,接着用户线程才能进行下一步对文件内容进行其他操作,按顺序执行。

异步 是指用户线程发起IO请求后仍继续执行,当内核IO操作完成后会通知用户线程,或者调用用户线程注册的回调函数。

阻塞和非阻塞

阻塞 是指内核空间IO操作需要等待直到把数据返回到用户空间;

非阻塞 是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。

同步阻塞IO

在这里插入图片描述
用户线程通过系统调用recvfrom方法向内核发起IO读文件操作(application switch to kernel),后面的代码被阻塞,用于线程处于等待当中,当内核已经从磁盘拿到数据并加载到内核空间,然后将数据拷贝到用户空间(kernel switch to application),用户线程再进行最后的data process数据处理。

缺点分析:
  用在多线程高并发场景(例如10万并发),服务端与客户端一对一连接,对于server端来说,将大量消耗内存和CPU资源(用户态到内核态的上下文切换),并发能力受限。

同步非阻塞IO

同步非阻塞IO是在同步阻塞IO的基础上,将socket设置为NONBLOCK。这样做用户线程可以在发起IO请求后可以立即返回,原理图如下:
在这里插入图片描述
在该图中,用户线程前面3次不断发起调用recvfrom,内核还未准备好数据,因此只能返回error of EWOULDBLOCK,直到最后一次调用recvfrom时,内核已经将数据拷贝到用户buffer端,此次可读取到数据,接下来就是process the data。

该模式有两个明显的缺点:

第一点:即client需要循环system call,尝试读取socket中的数据,直到读取成功后,才继续处理接收的数据。整个IO请求的过程中,虽然用户线程每次发起IO请求后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求。如果有10万个客户端连接,那么将消耗大量的serverCPU资源和占用带宽。

第二点:虽然设定了一个间隔时间去轮询,但也会发生一定响应延迟,因为每间隔一小段时间去轮询一次read操作,而任务可能在两次轮询之间的任意时间就已经完成,这会导致整体数据吞吐量的降低。

IO多路复用模式

前面两种模式缺点明显,那么 IO多路复用模式就是为了解决以上两种情况,IO多路复用是指 内核一旦发现进程指定的一个或者多个IO事件准备读取,它就通知该进程 ,原理图如下:
在这里插入图片描述
前面两种IO模型用户线程直接调用recvfrom来等待内核返回数据,而IO复用则通过调用select(还有poll或者epoll)系统方法,此时用户线程会阻塞在select语句处,等待内核copy数据到用户态,用户再收到内核返回可读的socket文件描述符
此IO模型优点:
  用户线程终于可以实现一个线程内同时发起和处理多个socket的IO请求,用户线程注册多个socket,(对于内核就是文件描述符集合),然后不断地调用select读取被激活的socket 文件描述符。(在这里,select看起就像是用户态和内核态之间的一个代理)
缺点在下文会谈到。

IO多路复用适用场景:
  从Redis、Nginx等这些强大的用于高并发网络访问的中间件可知,IO多路复用目前使用最突出的场景就是:socket连接,也即web服务,一般指高性能网络服务。
  与多进程和多线程技术的简单粗暴的业务实现不同,I/O多路复用技术的最大优势是系统开销小,系统不必创建多进程或者多线程,也不必维护这些进程/线程的复杂上下文以及内存管理,从而大大减小了系统的开销,极大提升响应时间。

select的底层原理

#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, 
            fd_set *exceptfds, struct timeval *timeout);
//参数nfds是需要监听的最大的文件描述符值+1
//rdset,wrset,exset分别对应需要检测的可读文件描述符的集合,
//可写文件描述符的集合集异常文件描述符的集合
//参数timoout为结构timeval,用来设置select()的等待时间

1、用户将自己所关心的文件描述符添加进描述符集中,并且明确关心的是读、写,还是异常事件
2、select通过轮询的方式不断扫描所有被关心的文件描述符,具体时间由参数timeout决定。
3、执行成功则返回文件描述符状态已改变的个数。
4、具体哪一个或哪几个文件描述符就绪,则需要文件描述符集传出,它既是输入型参数,又是输出型参数。
5、fd-set是用位图存储文件描述符的,因为文件描述符是唯一且递增的整数。

操作fd_set的一组接口

void FD_CLR(int fd, fd_set *set);//清除set中相关fd的位,即将其置为0
void FD_ISSET(int fd, fd_set *set);//测试set中相关fd是否存在,即该位是否被置1
void FD_SET(int fd, fd_set *set);//设置set中相关fd的位,即将其置1
void FD_ZERO(fd_set *set);//清空set中所有的位,即全置为0

特点:
1、可关心的文件描述符数量是有上限的,取决于fd_set的大小。
2、每次调用select前,都要把文件描述符重新添加进去fd_set中,因为fd_set也是输出型参数,在函数返回后,fd_set中只有就绪的文件描述符。
3、通常我们要关心的文件描述符不止一个,所以首先用数组保存文件描述符,每次调用select前通过遍历数组逐个添加进去。

缺点:
1、每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
2、同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
3、select支持的文件描述符数量太少。默认是1024。

poll的底层原理

#include <sys/poll.h>
int poll(struct pollfd* fds, nfds_t nfds, int timeout);

//pollfd结构
struct pollfd{
    int fd; 
    short events;  
    short revents;
};
//events 是我们要关心的事件,revents是调用后操作系统设置的参数,
//也就是表明该文件描述符是否就绪

首先创建一个pollfd结构体变量的数组fd_list,然后将我们关心的fd放置在数组中的结构体变量中,并添加我们所关心的事件,调用poll函数,函数返回后我们再通过遍历的方式去查看数组中那些文件描述符上的事件就绪了。

特点(相对与select来说):
1、每次调用poll之前不需要手动设置文件描述符集。
2、poll将用户关心的事件和发生的事件进行了分离。
3、支持的文件描述符数量理论上是无上限的(基于链表),其实也有,因为一个进程能打开的文件数量是有上限的 ulimit -n 查看进程可打开的最大文件数。
缺点:
1、poll返回后,也需要轮询pollfd来获取就绪的描述符
2、同时连接的大量客户端可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
3、大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义

epoll的底层原理

#include <sys/epoll.h>
int epoll_create(int size);
//epoll_create的作用是创建一个epoll模型,该模型在底层建立了->
//**红黑树,就绪队列,回调机制**
//size可以被忽略,不做解释

int epoll_ctl(int epfd, int op, int fd, struct epoll_events *event);
//epfd:epoll_create()的返回值(epoll的句柄,本质上也是一个文件描述符)
//op:表示动作,用三个宏来表示
//    EPOLL_CTL_ADD:注册新的fd到epfd中
//    EPOLL_CTL_MOD:修改已经注册的fd的监听事件
//    EPOLL_CTL_DEL:从epfd中删除一个事件
//fd:需要监听的文件描述符
//event:具体需要在该文件描述符上监听的事件

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, 
                int timeout);
//函数调用成功,返回文件描述符就绪的个数,也就是就绪队列中文件描述符的个数,
//返回0表示超时,小于0表示出错

//epoll_event结构体
struct epoll_event{
        uint32_t events;  /* Epoll events */
        epoll_data_t data;/* User data variable */
}__EPOLL_PACKED;
//events可以是一堆宏的集合,这里介绍几个常用的
//  EPOLLIN:表示对应的文件描述符可以读(包括对端socket正常关闭)
//  EPOLLOUT:表示对应的文件描述符可以写
//  EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,
//          默认情况下epoll为水平触发(Level Triggered)模式
typedef union epoll_data{
        void *ptr;
        int fd;
        uint32_t u32;
        uint64_t u64;
}epoll_data_t;
//联合体里通常只需要填充fd就OK了,其他参数暂时可以不予理会

工作原理:
1、创建一个epoll对象,向epoll对象中添加文件描述符以及我们所关心的在该文件描述符上发生的事件。事件驱动(每个事件关联上fd)
2
、通过epoll_ctl向我们需要关心的文件描述符中注册事件(读、写、异常等),操作系统将该事件和对应的文件描述符作为一个节点插入到底层建立的红黑树中。
3、添加到文件描述符上的事件都会与网卡建立回调机制,也就是事件发生时会自主调用一个回调方法,将事件所在的文件描述符插入到就绪队列中。
4、应用程序调用epoll_wait就可以直接从就绪队列中将所有就绪的文件描述符拿到。

水平触发工作方式(LT)
处理socket时,即使一次没将数据读完,下次调用epoll_wait时该文件描述符也会就绪,可以继续读取数据。

边沿触发工作方式(ET)
处理socket时没有一次把数据读取完,那么下次再调用epoll_wait时,该文件描述符将不再显示就绪,除非有新数据写入。
在该工作模式下,当一个文件描述符就绪时,我们要一次性地将数据读完。

隐患问题:
当我们调用read读取缓冲区数据时,如果读取完了,对端没有关闭写端,read就会被阻塞,影响后续逻辑。
解决方式就是将文件描述符设置为非阻塞的,当没有数据的时候,read也不会被阻塞,可以处理后续的逻辑。

ET的性能要好于LT,因为epoll_wait返回的次数比较少,Nginx中默认采用ET模式使用epoll

特点:
1、采用回调机制,与轮询区别看待。
2、底层采用红黑树结构管理已经注册的文件描述符。
3、采用就绪队列保存已经就绪的文件描述符。

优点:
1、文件描述符数目无上限:通过epoll_ctl注册一个文件描述符后,底层采用红黑树结构来管理所有需要监控的文件描述符节点。
2、基于事件的就绪通知机制:每当有文件描述符就绪时,该响应事件会调用回调方法将该文件描述符插入到就绪队列中,不需要内核每次取轮询式查看每个被关心的文件描述符。
3、维护就绪队列:当文件描述符就绪的时候,就会被放到内核中的一个就绪队列中,调用epoll_wait可以直接从就绪队列中获取就绪的文件描述符。
4、内存拷贝:利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。

三者区别

1、支持一个进程所能打开的最大连接数
select:
单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是3232,同理64位机器上FD_SETSIZE为3264),当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响,这需要进一步的测试。

poll:
poll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的。

epoll:
虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接。

2、FD剧增后带来的IO效率问题

select:
因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。

poll:
同上

epoll:
因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。

3、 消息传递方式

select:
内核需要将消息传递到用户空间,都需要内核拷贝动作

poll:
同上

epoll:
epoll通过内核和用户空间共享一块内存来实现的。

总结

(1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。

(2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。

  • 2
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值