IO复用总结

1. I/O复用

1.1 I/O复用的基本概念

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

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

IO复用总结

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函数的调用过程如下所示:

IO复用总结

(1)设置文件描述符:

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

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

IO复用总结

图中最左端的位表示文件描述符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函数的调用结果,通过下图解释这些函数的功能:

IO复用总结

(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变量中将产生如图所示的变化:

IO复用总结

由图可知: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就绪条件

读就绪:

  1)socket内核中,接收缓冲区中的字节数,大于等于低水位标记SO_RCVLOWAT. 此时可以无阻塞的读该文件描述符, 并且返回值大于0;
  2)socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0;
  3)监听的socket上有新的连接请求;
  4)socket上有未处理的错误;

写就绪:

  1)socket内核中,接收缓冲区中的字节数,大于等于低水位标记SO_RCVLOWAT. 此时可以无阻塞的读该文件描述符, 并且返回值大于0;
  2)socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0;
  3)监听的socket上有新的连接请求;
  4)socket上有未处理的错误;

异常就绪:

  socket上收到带外数据.

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通过内核和用户空间共享一块内存来实现的。

上一篇:C语言网络编程-tcp服务器实现


下一篇:select、poll和epoll