IO复用总结

1. I/O复用

1.1 I/O复用的基本概念

    在服务器开发中,为了构建并发服务器,一种方案是采用多进程或多线程的方式,只要有客户端连接请求就会创建新进程或线程。但这种方案并非十全十美,因为创建进程和线程会有一定的内存开销,如果连接数很多,会需要大量的运算和内存空间,也会存在进程和线程切换带来的内存开销。若不想使用多进程和多线程的方式,可以使用单进程或单线程的方式。对于Socket编程而言,可以将accept/recv/send函数设置为非阻塞模式。对多个I/O进行并发处理,采用的是I/O复用技术。

    I/O复用服务端模型如图所示:

I/O复用的使用场景:

①客户端程序要同时处理多个socket,比如非阻塞connect

②客户端程序要同时处理用户输入和网络连接,比如聊天室

③TCP服务器要同时处理监听socket和连接socket,这是I/O复用使用最多的场合

④服务器要同时处理TCP请求和UDP请求

⑤服务器要同时监听多个端口,或者处理多种服务器

Linux系统提供的接口有:select/poll/epoll

 

1.2 select

1.select函数的定义

select 函数是最具代表性的实现复用服务器的方法。在 Windows 平台下也有同名函数,所以具有很好的移植性。

select函数的定义如下:

#include <sys/select.h>
#include <sys/time.h>
​
int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);
​
/*
maxfd: 监视对象文件描述符数量。
readset: 将所有关注「是否存在待读取数据」的文件描述符注册到 fd_set 型变量,并传递其地址值。
writeset: 将所有关注「是否可传输无阻塞数据」的文件描述符注册到 fd_set 型变量,并传递其地址值。
exceptset: 将所有关注「是否发生异常」的文件描述符注册到 fd_set 型变量,并传递其地址值。
timeout: 调用 select 函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息。
返回值: 
(1)返回值小于0,表示出错。
(2)返回值等于0,表示select函数等待超时。
(3)返回值大于0,表示select由于监听的文件描述符就绪返回,并且返回结果就是就绪的文件描述符的个数。
*/

2.selet函数的功能

使用select函数时可以将多个文件描述符集中到一起统一监视,如下:

  • 是否存在套接字接收数据?

  • 需阻塞传输数据的套接字有哪些?

  • 哪些套接字发生了异常?

上述监视项称为”事件,发生监视项对应情况时,“称“发生了事件”。

select函数的调用过程如下所示:

(1)设置文件描述符:

利用 select 函数可以同时监视多个文件描述符。当然,监视文件描述符可以视为监视套接字。此时首先需要将要监视的文件描述符集中在一起。集中时也要按照监视项(接收、传输、异常)进行区分,即按照上述 3 种监视项分成 3 类。

利用fd_set数组变量执行此操作,如图所示,该数组是存有0和1的位数组。

图中最左端的位表示文件描述符0(所在位置),如果该位设置为1,则表示该文件描述符是监视对象,上图中文件描述符f1和f3为监视对象,在fd_set变量中注册或更改值的操作都由下列宏完成。

  • FD_ZERO(fd_set *fdset) :将 fd_set 变量所指的位全部初始化成0

  • FD_SET(int fd,fd_set *fdset) :在参数 fdset 指向的变量中注册文件描述符 fd 的信息

  • FD_SLR(int fd,fd_set *fdset) :从参数 fdset 指向的变量中清除文件描述符 fd 的信息

  • FD_ISSET(int fd,fd_set *fdset) :若参数 fdset 指向的变量中包含文件描述符 fd 的信息,则返回「真」

上述函数中,FD_ISSET用于验证select函数的调用结果,通过下图解释这些函数的功能:

(2)设置检查(监视)范围及超时:

根据select函数的定义可知,select 函数用来验证 3 种监视的变化情况,根据监视项声明 3 个 fd_set 型变量,分别向其注册文件描述符信息,并把变量的地址值传递到上述函数的第二到第四个参数。但在此之前(调用select 函数之前)需要决定下面两件事:

1)文件描述符的监视(检查)范围是?

文件描述符的监视范围和 select 的第一个参数有关。实际上,select 函数要求通过第一个参数传递监视对象文件描述符的数量。因此,需要得到注册在 fd_set 变量中的文件描述符数。但每次新建文件描述符时,其值就会增加 1 ,故只需将最大的文件描述符值加 1 再传递给 select 函数即可。加 1 是因为文件描述符的值是从 0开始的

2)如何设定select函数的超时时间?

select函数的超时时间与select函数的最后一个参数有关,其中timeval结构体定义如下:

struct timeval
{
    long tv_sec;
    long tv_usec;
};

本来 select 函数只有在监视文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。通过上述结构体变量,将秒数填入 tv_sec 的成员,将微秒数填入 tv_usec 的成员,然后将结构体的地址值传递到 select 函数的最后一个参数。此时,即使文件描述符未发生变化,只要过了指定时间,也可以从函数中返回。不过这种情况下, select 函数返回 0因此,可以通过返回值了解原因。如果不向设置超时,则传递 NULL 参数。

(3)调用select函数查看结果

select返回正整数时,怎样获知哪些文件描述符发生了变化?向select函数的第二到第四个参数传递的fd_set变量中将产生如图所示的变化:

由图可知:select函数调用完成后,向其传递的 fd_set 变量将发生变化。原来为 1 的所有位将变成 0,但是发生了变化的文件描述符除外,因此,可以认为值仍为 1 的位置上的文件描述符发生了变化

3.调用select函数示例

#include <stdio.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/select.h>
#define BUF_SIZE 30
​
int main(int argc, char *argv[])
{
    fd_set reads, temps;
    int result, str_len;
    char buf[BUF_SIZE];
    struct timeval timeout;
    FD_ZERO(&reads);     //初始化fd_set变量
    FD_SET(0, &reads);   //将文件描述符0对应的位设置为1
    /*
    timeout.tv_sec=5;
    timeout.tv_usec=5000;
    */
    while (1)
    {
        temps = reads;     //将准备好的fd_set变量reads的内容复制到temps变量,因为调用select函数后,除发生变化的                                  //文件描述符对应位外,剩下的所有位将初始化为0。因此,为了记住初始值,必须这样复制。
        timeout.tv_sec = 5;    //将初始化的time
        timeout.tv_usec = 0;
        result = select(1, &temps, 0, 0, &timeout); //调用select函数,如果有控制台输入数据,则返回大于0的数,如果没有                                                     //数据输入而引发超时,则返回0
        if (result == -1)
        {
            puts("select error!");
            break;
        }
        else if (result == 0)
        {
            puts("Time-out!");
        }
        else
        {
            if (FD_ISSET(0, &temps)) //验证发生变化的值是否是标准输入,若是,则从标准输入读取数据并向控制台输出
                                     //0表示输入 1表示输出 2表示错误
            {
                str_len = read(0, buf, BUF_SIZE);
                buf[str_len] = 0;
                printf("message from console: %s", buf);
            }
        }
    }
    return 0;
}

select函数的特点为:只使用一个进程,但是可以实现和多个客户端进行通信

4.socket就绪条件

读就绪:(内核通知我们套接字有数据可以读了,使用 read 函数不会阻塞)

  1)套接字接收缓冲区有数据可以读,如果我们使用 read 函数去执行读操作,肯定不会被阻塞,而是会直接读到这部分数据;
  2)socket TCP通信中,对端关闭连接,即对方发送了 FIN,使用 read 函数执行读操作,不会被阻塞,直接返回 0;
  3)监听的socket上有新的连接请求,此时使用 accept 函数去执行不会阻塞;
  4)socket上有未处理的错误,使用 read 函数去执行读操作,不阻塞,且返回 -1;

写就绪:(内核通知我们套接字可以往里写了,使用 write 函数就不会阻塞)

  1)套接字发送缓冲区足够大,如果我们使用套接字进行 write 操作,将不会被阻塞,直接返回;
  2)连接的写半边已经关闭,如果继续进行写操作将会产生 SIGPIPE 信号;
  3)套接字上有错误待处理,使用 write 函数去执行写操作,不阻塞,且返回 -1;

异常就绪:

5.select函数的优缺点总结

1)优点

select 的兼容性比较高,这样就可以支持很多的操作系统,不受平台的限制,使用 select 函数需要满足以下两个条件:

  • 服务器接入者少

  • 程序应该具有兼容性

2)缺点

select 复用方法无论如何优化程序性能也无法同时接入上百个客户端,所以select并不适合以 web 服务器端开发为主流的现代开发环境,主要有以下缺点:

  • 单个进程能够监视的文件描述符的数量存在最大限制,通常是1024M。由于select采用轮询的方式扫描文件描述符,每次调用select函数时都需要向该函数对象传递监视对象信息,文件描述符数量越多,性能越差。

  • 内核/用户空间内存拷贝问题,select需要大量句柄数据结构,产生巨大开销。

  • select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生过事件。

  • select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么select调用还会将这些文件描述符通知进程。

 

1.3 poll

poll与select类似,只是与select相比,poll使用链表保存文件描述符,所以才没有了监视文件数量的限制,但其他三个缺点依然存在。

poll函数的定义如下:

#include <poll.h>
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
    
/*
fds: 指向一个结构体数组的第0个元素的指针,每个数组元素都是一个struct pollfd结构,用于存放需要检测其状态的Socket描述符。
nfds: 表示fds结构体数组的长度。
timeout: 是poll函数调用阻塞的时间,单位:毫秒。
返回值:
(1)返回值小于0,表示出错。
(2)返回值等于0,表示poll函数等待超时。
(3)返回值大于0,表示poll由于监听的文件描述符就绪返回,并且返回结果就是就绪的文件描述符的个数。
*/

pollfd结构体:

struct pollfd 
{
    int fd;  /*文件描述符*/
    short events;  /* 等待需要监测的事件 */
    short revents;  /* 实际发生了的事件,也就是返回结果 */
};

events&revents的取值如下:

事件 描述 是否可作为输入(events) 是否可作为输出(revents)
POLLIN 数据可读(包括普通数据&优先数据)
POLLOUT 数据可写(普通数据&优先数据)
POLLRDNORM 普通数据可读
POLLRDBAND 优先级带数据可读(linux不支持)
POLLPRI 高优先级数据可读,比如TCP带外数据
POLLWRNORM 普通数据可写
POLLWRBAND 优先级带数据可写
POLLRDHUP TCP连接被对端关闭,或者关闭了写操作,由GNU引入
POPPHUP 挂起
POLLERR 错误
POLLNVAL 文件描述符没有打开

 

1.4 epoll

1.epoll理解及应用

(1)epoll功能

    epoll克服了select存在的缺点,epoll使用一个文件描述符管理多个文件描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。epoll是事件触发的,不是轮询查询的,没有最大的并发连接限制。

    下面是epoll函数的功能:

  • epoll_create:创建保存 epoll 文件描述符的空间

  • epoll_ctl:向空间注册并注销文件描述符

  • epoll_wait:与 select 函数类似,等待文件描述符发生变化

    select函数中为了保存监视对象的文件描述符,直接声明了fd_set变量,但epoll 方式下的操作系统负责保存监视对象文件描述符,因此需要向操作系统请求创建保存文件描述符的空间,此时用的函数就是epoll_create 。

    此外,为了添加和删除监视对象文件描述符,select 方式中需要 FD_SET、FD_CLR 函数。但在 epoll 方式中,通过 epoll_ctl 函数请求操作系统完成。

    最后,select 方式下调用 select 函数等待文件描述符的变化,而 epoll 则是调用 epoll_wait 函数。还有,select 方式中通过 fd_set 变量查看监视对象的状态变化,而 epoll 方式通过如下结构体 epoll_event 将发生变化的文件描述符单独集中在一起

struct epoll_event
{
    __uint32_t events;
    epoll_data_t data;
};
​
typedef union epoll_data 
{
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;

    声明足够大的epoll_event结构体数组,传递给epoll_wait函数时,发生变化的文件描述符信息将被填入数组。因此,无需像select函数那样针对所有文件描述符进行循环。

接下来给出epoll_event 的成员 events 中可以保存的常量及所指的事件类型。

  • EPOLLIN:需要读取数据的情况

  • EPOLLOUT:输出缓冲为空,可以立即发送数据的情况

  • EPOLLPRI:收到 OOB 数据的情况

  • EPOLLRDHUP:断开连接或半关闭的情况,这在边缘触发方式下非常有用

  • EPOLLERR:发生错误的情况

  • EPOLLET:以边缘触发的方式得到事件通知

  • EPOLLONESHOT:发生一次事件后,相应文件描述符不再收到事件通知。因此需要向 epoll_ctl 函数的第二个参数传递 EPOLL_CTL_MOD ,再次设置事件。

    可通过位运算同时传递多个上述参数。

(2)epoll函数

    epoll函数定义如下:

//epoll_create
#include <sys/epoll.h>
int epoll_create(int size);
/*
size:epoll 实例的大小。
返回值:成功时返回 epoll的文件描述符,失败时返回-1。
*/
​
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
epfd:用于注册监视对象的epoll例程的文件描述符。
op:用于制定监视对象的添加、删除或更改等操作。
fd:需要注册的监视对象文件描述符。
event:监视对象的事件类型。
返回值:成功时返回0,失败时返回-1。
*/
​
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
/*
epfd : 表示事件发生监视范围的epoll例程的文件描述符。
events : 保存发生事件的文件描述符集合的结构体地址值。
maxevents : 第二个参数中可以保存的最大事件数。
timeout : 以1/1000秒为单位的等待时间,传递-1时,一直等待直到发生事件。
返回值:成功时返回发生事件的文件描述符,失败时返回-1。
*/

    epoll_ctl调用示例

 epoll_ctl(A,1 EPOLL_CTL_ADD,B,C);    //epoll例程A中注册文件描述符B,主要目的是监视参数C中的事件
 epoll_ctl(A,EPOLL_CTL_DEL,B,NULL);   //从epoll例程A中删除文件描述符B

    从上述示例中可以看出,从监视对象中删除时,不需要监视类型,因此向第四个参数可以传递为 NULL。 ​ 下面是第二个参数的含义:

  • EPOLL_CTL_ADD:将文件描述符注册到 epoll 例程

  • EPOLL_CTL_DEL:从 epoll 例程中删除文件描述符

  • EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况

    epoll_wait调用示例(第二个参数所指缓冲需要动态分配)

int event_cnt;
struct epoll_event *ep_events;
......
ep_events=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);//EPOLL_SIZE是宏常量
...
event_cnt=epoll_wait(epfd,ep_events,EPOLL_SIZE,-1);
......

    调用函数后,返回发生事件的文件描述符,同时在第二个参数指向的缓冲中保存发生事件的文件描述符集合。因此,无需像 select 一样采用轮询的方式扫描文件描述符。

2.epoll流程总结

epoll的流程:

1)epoll_create 创建epoll事件驱动(epoll_fd) 。

2)epoll_ctl注册fd,并监控fd的可读事件

3)服务进程通过epoll_wait获取内核就绪事件处理(发生变化的文件描述符)。

4)当epoll_ctl监视的fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知,进而执行程序。

3.水平触发和边缘触发

水平触发(Level Trigger)和边缘触发(Edge Trigger)的区别在于发生事件的时间点,select和poll的触发方式只能是水平触发,而epoll的触发方式既可以是水平触发,又可以是边缘触发。

水平触发(LT模式):只要监听的文件描述符中有数据,就会触发epoll_wait有返回值,应用程序这次可以不处理,下次调用会再次响应。这是默认的epoll_wait的方式。

边缘触发(ET模式):只有监听的文件描述符的读/写事件发生,才会触发epoll_wait有返回值。应用程序必须立即处理该事件,如果不处理,下次调用时不会再次响应。

通过epoll_ctl函数,设置该文件描述符的触发状态即可,如下所示:

//水平触发
evt.events = EPOLLIN;  // LT 水平触发 (默认) EPOLLLT
evt.data.fd = pfd[0];
​
//边缘触发
evt.events = EPOLLIN | EPOLLET;  // ET 边缘触发
evt.data.fd = pfd[0];

 

1.5 总结

    select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

1.select、poll和epoll的区别

  select poll epoll
操作方式 遍历 遍历 回调
数据结构 bitmap 数组 红黑树
最大连接数 1024(x86)或 2048(x64) 无上限 无上限
最大支持文件描述符数 一般有最大值限制 65535 65535
fd拷贝 每次调用select,都需要把fd集合从用户态拷贝到内核态 每次调用poll,都需要把fd集合从用户态拷贝到内核态 fd首次调用epoll_ctl拷贝,每次调用epoll_wait不拷贝
工作模式 LT(水平触发) LT(水平触发) 支持ET(边缘触发)高效模式
工作效率 采用轮询方式来检测就绪事件,算法时间复杂度为O(n) 采用轮询方式来检测就绪事件,算法时间复杂度为O(n) 采用回调方式来检测就绪事件,算法时间复杂度为O(1)

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

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

2.支持一个进程所能打开的最大连接数

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

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

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

3.fd剧增后带来的IO效率问题

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

  • poll:同上

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

4.消息传递方式

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

  • poll:同上

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

 

1.6 IO多路复用和线程池的比较

    IO多路复用适用于需要保持大量闲置(区别于计算密集型)长连接的业务场景,例如聊天室。多路复用的主要优势在于使用更少的内存等资源来支持更高的并发数,这样的好处是能够避免不断的创建新线程,导致系统资源浪费。需要注意,IO多路复用本质上是复用单线程的,回调函数的执行必然是有可能长时间阻塞的,所以如果涉及到耗时的计算密集型任务,则会大大降低系统处理其它连接的响应速度。

    线程池则适合短连接并发的情况,比如普通的web业务系统,Tomcat的Servlet容器默认选择就是线程池(虽然3.0后支持异步,但一般情况下不常使用)。由于处理短连接的线程很快会退出,因此能够充分发挥线程池复用线程的好处,且开发简单, 容易维护。

    当然,在高并发场景下,IO多路复用不是独立的,往往是跟线程池结合使用的,效果会更好,但代码复杂度也会相应提高,需要更好的设计。建议根据业务场景选择相应的技术,避免过早优化。

 

参考:

  1. 《TCP-IP网络编程》 韩-尹圣雨

  2. 《Linux高性能服务器编程》

  3. https://blog.csdn.net/qq_37964547/article/details/80697530

  4. https://blog.csdn.net/org_hjh/article/details/109050629

posted @ 2021-10-11 22:48  烟消00云散  阅读(390)  评论(0编辑  收藏  举报