I/O复用之 select和epoll

Posted the_scent_of_th_soul

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了I/O复用之 select和epoll相关的知识,希望对你有一定的参考价值。

9在我们介绍I/O复用之前,先来看一个小例子:

...
while(fgets(sendline, MAXLINE, fp) != NULL)
    write(sockfd, sendline, 1)
    ...
    ...
   

粗略地描述一下上述代码:
第一行表示从文件fp中读数据到sendline中,第二行表示将sendline中的数据写入套接字描述符sockfd。
现在我们来思考一个问题,假如说当我们正在通过fgets()读数据的时候,套接字描述符已经失效(例如,客户端的服务器挂了),那我们一直阻塞在fgets()那里也不知道,等我们执行到write()时才知道sockfd失去意义时,可能已经过了很长时间了。这样就很没有效率了,我们当然希望只要sockfd一发生变化就能通知我们,通过I/O复用就能起到这个作用。

概念:

I/O复用是最常用的I/O通知机制。应用程序通过I/O复用函数向内核注册一组事件,内核通过I/O复用函数把其中就绪的事件通知给应用程序。既,I/O复用使得程序能同时监听多个文件描述符。

I/O复用典型使用(下列网络应用场合):

a. 客户端要同时处理用户输入和网络连接(文章开头的例子)

b. 客户端程序要同时处理多个socket

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

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

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

当然,I/O复用并非只限于网络编程,许多重要的应用程序也需要使用这项技术

一个输入操作通常包含两个不同的阶段:

内核等待数据准备好
从内核向进程复制数据

对于一个套接字上的输入操作,第一步通常是内核等待数据从网络中到达,当所等分组到达时,它被复制到内核中的某个缓冲区。然后第二步就是数据从内核缓冲区复制到应用进程缓冲区。相关图如下图(1)及图(2):

select 系统调用:

select 系统调用的用途是:在指定的一段时间内,监听用户感兴趣的文件描述符上的可读、可写、和异常等事件。该函数允许进程指示内核等待多个事件中的任何一个事件发生,并且只在有一个或多个事件发生或者经历一段指定的时间后才唤醒它。

例如,我们调用select函数,告知内核仅在下列情况发生时才返回:

集合1,4,5中的任何描述符准备好读
集合2,7中的任何描述符准备好写
集合1,4中的任何描述符有异常条件待处理
已经经历了20.2

也就是说我们调用select 函数告诉内核对哪些描述符感兴趣以及等待多长时间。

select API:

select 系统调用的原型如下:

#include<sys/select.h>
int select(int maxfds, fd_set* readfds, fd_set* writefds,
           fd_set* exceptfds, struct timeval* timeout);

1) maxfds 参数指定被监听的(待测定)文件描述符的个数,它通常被设置为select监听的所有文件描述符的最大值加一,因为文件描述符是从0开始计数的。
2) readfds、writefds、exceptfds 三个参数分别指向可读、可写和异常等事件对应的文件描述符集合。应用程序调用select函数,通过这三个参数传入自己感兴趣的描述符。select函数返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。

如何给这3个参数中的每一个参数指定一个或多个描述符值是一个设计上的问题。
每个fd_set结构体中仅包含一个整型数组,该数组的每个元素中的每一位(bit)标记一个文件描述符。
举例来说:假设使用32位整数,那么该数组的第一个元素对应于描述符0~31,第二个整数对应于描述符32~63,以此类推。一个数组的中所有描述符组成描述符集合。

由于访问fd_set结构体中的位的操作太过于繁琐。我们可以使用下面一系列宏来实现。

#include<sys/select.h>
FD_ZERO(fd_set fdar);          /*清除fdar中的所有位*/
FD_SET(int fd, fd_set *fdar);  /*设置fdar 的位fd*/
FD_CLR(int fd, fd_set *fdar);  /*清除fdar 的位fd*/
int FD_ISSET(int fd, fd_set *fdar); /*测试fdar 的fd位是否被设置*/

3) timeout 参数用来设置select 函数的超时时间,它是一个timeval结构类型的指针,采用指针是因为内核将修改它以告诉应用程序 selece函数等了多久。不过我们不能完全信任select返回后的timeout值,比如调用失败时timeout 值是不确定的。
timeval 结构体的定义如下:

struct timeval

    long tv_sec;    /*秒数*/
    long tv_usec;   /*微秒数*/

select 给我们提供了一个微妙级的定时方式,如果给tv_sec 和 tv_use 成员都传递0,则select 将立即返回,如果给它们俩都传NULL,则select 函数一直阻塞,直到某个描述符就绪。

在网络程序中select函数能够处理的异常情况只有一种: socket 上接收到的带外数据。
就是说,select 处理在接收普通数据的时候,可以直接将其描述符加入可读描述符集合进行处理。而在处理接收带外数据时,可以将其描述符加入异常描述符集合中当作异常来操作。

下面代码描述select如何同时处理套接字描述符同时接收普通数据和带外数据

int main(int argc,char *argv[])

    socket();
    bind();
    listen();

    /*接收一个客户端连接*/
    int connfd = accept();

    fd_set read_fds;              /*定义一个可读描述符集合*/
    fd_set exception_fds;         /*定义一个异常事件描述符集合*/

    /*初始化 read_fds和exception_fds*/
    FD_ZERO(&read_fds);           
    FD_ZERO(&exception_fds);

    while(1)
    

        FD_SET(connfd, &read_fds);       /*设置描述符connfd在read_fds中的位*/
        FD_SET(connfd, &exception_fds);  /*设置描述符connfd在exception_fds中的位*/

        /*select阻塞等待条件满足后返回*/
        int ret = select(connfd+1, &read_fds, NULL, &exception_fds, NULL
        );

        /*select 返回值错误*/
        if(ret < 0)
            printf("-----\\n");
            break;
        

        /*对于connfd上的可读事件,即普通数据到达*/
        if(FD_ISSET(connfd, &read_fds))

            /*处理数据或其它操作*/
            ......
        
        /*对于connfd上的异常事件,即带外数据到达*/
        else(if(FD_ISSET(connfd, &exception_fds)))

            /*处理数据及或其它操作*/
            ......
        
    

    close(connfd);
    return 0;

上述代码中有个小细节,为什么要把两个FD_SET放在循环体内呢?
这是因为事件发生以后,文件描述符集合将被内核修改。
举例来说,上述描述符connfd在循环的第一行被设置为 在文件描述符集合read_fds内监听,假如connfd发生普通数据到达事件,则文件描述符read_fds中表示connfd的位将会改变。

这个问题的本质在于,一个位只能表示两种情况要么为0要么为1,但是select却用一个位表示4种情况。假如我们把一个位置为1表示它在描述符集合里面,那么这个1同时也表示该描述符位的事件还没有发生。当事件发生以后,我们要修改这个为0来区分它和其他没发生事件,但是这个0同时也表示该位不在文件描述符集合里面。所以我们又要将其置为1,来表示我们将继续监听该描述符。

这样一看是不是很麻烦啊??当然,这是select函数的缺陷。

下面,我们来看另一种I/O复用

epoll系列系统调用:

epoll是Linux特有的I/O复用函数。它在使用上与select有很大的差异。
我们上述提到的在select 中文件描述符表示问题在epoll中得到了很好的解决。

在select 中是把所有关心的事件的描述符放到一个数组所对应的位,当事件发生以后数组中描述符所对应的位也会发生改变。而在epoll中,epoll把用户关心的文件描述符上的事件放在内核中的一个事件表中。也就是说,只要是用户关心的文件描述符上的事件,不管是什么类型事件全都放在一个事件表中不用分类(当然,事件类型必须为epoll支持)。如果epoll的函数检测到事件,就将所有就绪事件从内核事件表中复制到一个数组,从数组中遍历就绪事件并进行相应的处理。

这样,内核事件表中的内容就不用改变,也就不用每次事件发生以后都重新注册了。而且应用程序索引就绪文件描述符的时候是在一个全是就绪事件的数组中进行,除去了索引未就绪描述符的冗余,极大地提高了效率。

epoll和select的另外一个区别在于,select就是使用一个select系统调用来实现功能,而epoll则是使用一组函数来完成任务。下面,我们来看看epoll的一组函数及其包含的结构体类型。

我们在上述中提到了一个内核事件表,epoll用一个文件描述符来唯一标识一个内核事件表。
该文件描述符使用epoll_create函数来创建:

#include<sys/epoll.h>
int epoll_create(int size);

该函数返回一个标识内核事件表的描述符,其中参数size提示内核需要多大事件表。

操作epoll内核事件表的函数如下:

#include<sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);

该函数成功时返回0,失败时返回-1并设置errno

epfd: 要访问的内核事件表描述符
op: 操作类型(往事件表中注册fd上的事件、修改fd上的注册事件、删除fd上的注册事件)
fd: 要操作的文件描述符
event :事件类型(读、写、异常等):其实是它结构体中的一个参数指定事件类型

上述两个函数已经做好epoll的前期工作了,现在就差最重要的步骤了——等。

#include<sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event* events, 
               int maxevents, int timeout);

我们从后往前讨论该函数的参数:

timeout: 指定epoll的超时值,timeout=-1,epoll永远阻塞直到某个事件发生,timeout=0,立即返回。
maxevents: 指定最多监听多少个事件,它必须大于0.
events: 就是我们上述提到的保存就绪事件的数组,就绪事件将被复制到events中,然后对events进行操作。
epfd: 和上面一样,标识要访问的内核事件表的 描述符。

epoll_event结构体如下:

struct epoll_event

    _uint32_t events;   /* epoll事件 */
    epoll_data_t data;  /* 用户数据 */
;
typedef union epoll_data

    int fd; 
    ......
    ......
epoll_data_t;

其中epoll_data_t 联合体中使用最多的成员变量是fd, 它指定事件所属的目标文件描述符。

好了,对epoll的介绍就先进行到这里,以后遇到了再补充。下面是一个epoll操作的简单简单过程:
我们将文件描述符fd的读事件加入注册表,然后通过epoll_wait函数等待事件就绪,将就绪事件复制到events结构体数组中,在events数组中进行索引,然后处理就绪事件:

#include<sys/epoll.h>
int main(int argc,char *argv[])

    int epfd;    /* 内核事件表描述符 */
    int fd;      /* 操作目标文件描述符 */
    struct epoll_event event;
    struct epoll_event events[SIZE];   /* events数组保存就绪事件 */

    ......

    /*对fd的操作*/
    /*将fd和一个文件挂钩,使之成为文件描述符*/

    ......

    /* 将事件和event绑定随后加内核事件表 */
    event.data.fd = fd;
    event.events = EPOLLIN;       /*数据可读事件*/


    epfd = epoll_create(SIZE);   

    if(epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event) != 0)
        printf("--------\\n");
        exit(-1);
    

    int ret = epoll_wait(epfd, events, MAXEVENTS, -1);

    for(int i=0; i<ret; i++)
        int sfd = events[i].data.fd;

        ......

        /*sfd描述符肯定就绪,直接处理*/
        ......
    

    return 0;

以上是关于I/O复用之 select和epoll的主要内容,如果未能解决你的问题,请参考以下文章

I/O复用之 select和epoll

I/O多路复用select/poll/epoll

I/O多路复用之epoll

I/O多路复用之epoll

I/O多路复用之epoll

高级I/O---多路复用---epoll