Linux IO多路复用之epoll机制

迄今为止,epoll是Linux操作系统中最优秀的多路复用机制,它是select和poll的增强版,是2.6内核版本才引进的机制。本文是通过学习网上相关文章,以及相关书籍后的笔记总结。

1. 文件IO

1.1 非阻塞IO(进程忙等,不断查询)

设置某个文件I/O操作为非阻塞I/O,即相当于告诉内核,如果当前没有数据可操作将不阻塞当前进程,而是返回一个错误信息。使用非阻塞IO方式虽然不阻塞当前进程,但需要反复尝试,比如说,为了从文件(描述符)中获取数据,当前进程需要反复调用read/recv函数直至读取到数据。

1.2 阻塞IO

阻塞式IO模式是最普遍使用的文件IO模式,大部分app对文件进行访问使用的都是阻塞式IO。比如app期望读取数据,就调用read/write函数,进程从函数调用开始一直到返回,这段时间内都为阻塞状态。阻塞状态时时,等待操作是交给DMA做的,也就是说进程会让出CPU,但是进程不能继续运行。

1.3 多路复用

多路复用方式仍然是以阻塞方式等待文件IO准备好,但它可以同时等待多个文件描述符。假设多路复用监测的是多个socket描述符,那么当前有一个或者多个socket的状态发生变化时,则从阻塞状态返回,转而处理该套接字描述符操作。实现多路复用的系统调用有select()和poll()。

2. select()函数

以select()为例叙述多路复用,该函数的原型为:

int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout);

nfds:要监测的文件描述符的范围,监测范围在0到最大文件描述符之间,因此此值为最大文件描述符值+1
readfds、writefds、exceptfds分别是:包含所有可能因状态变成可读、可写、发生特殊异常而触发select()返回的文件描述符
timeout: 指定阻塞超时时间,原型为:

struct timeval {
               long    tv_sec;         /* seconds */
               long    tv_usec;        /* microseconds */
           };

返回值:
函数执行错误,返回-1;
超时返回,表名timeout所描述的时间范围内没有任何文件描述符有需要操作,返回0,且将该时间结构体清空为0;
如果因一个或多个文件描述符需要处理而返回,其返回值为产生异常的文件描述符数,并在相应文件描述符中清除不需要处理的文件描述符。所以,返回后可以根据文件描述符集合的记录值判断哪些文件描述符需要处理。
示例伪代码:

int sockfd;
fd_set rfds;        //定义fd_set集合来保存要监测的socket
struct timeval tv;  //定义一个时间变量来保存超时时间
FD_ZERO(&rfds);     //调用select函数之前先把集合清零

//创建套接字、绑定、监听、等待链接

FD_SET(sockfd + 1, &rfds);  //把要监测的文件描述符sockfd加入到集合中

tv.tv_sec = 1;
tv.tv_usec = 500;           //设置select等待的最大时间是1S加500ms

int ret = select(sockfd + 1, &rfds, NULL, NULL, &tv);

if (ret < 0)                //select出错
{
    perror("select");
}
else if (ret == 0)                  //超时,说明在超时时间内sockfd仍没有可读信息
{
    printf("超时");
}
else                                //未到超时时间,sockfd的状态发生了变化
{
    printf("ret = %d\n", ret);      //ret记录了发生状态变化的文件描述符的数目,这里一定是为1
}   

if (FD_ISSET(sockfd, &rfds))        //判断描述符是否真的可读,参数1就是你要判断的描述符
{
    recv(...);                      //读取sockfd文件描述符的数据
}

select()一方面受限于阻塞的文件描述符数量的限制,另一方面,该函数返回后需要遍历fd_set类型的变量,以获得状态发生改变的描述符,这就引进了epoll系统调用。

3. epoll()机制

在内核中的select实现中,它是采用轮询来处理的,轮询的fd数目越多,自然耗时越多。并且,在linux/posix_types.h头文件有这样的宏:

#define __FD_SETSIZE    1024

表示select最多同时监听1024个fd,当然,可以通过修改头文件再重编译内核来扩大这个数目,但这似乎并不治本,而且select()返回后,需要遍历fd_set类型的变量。epool也可以实现多路复用,epoll的强大之处在于它不会随着监听fd数目的增长而降低效率,不存在监测的描述符的上限,另一点就是获取完事件后,无须遍历整个被侦听的描述符集。

3.1 epoll机制的使用

epoll支持管道、FIFO、套接字和POSIX消息队列,终端、设备等,但是不支持普通文件(可执行程序、文本文件)。操作函数有:

int epoll_create(int size) / int epoll_create1(int flags)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)

具体说明如下:

3.1.1 创建epoll监听池
int epoll_create(int size);

创建一个epoll监听池,返回该池的句柄,size用来告诉内核监听的描述符的数目一共多大。这个参数与select()中的参数一不同,select()的是给出最大的监听的描述符加1。但是在2.6.8的Linux内核之后这个参数已经无效,内核已经不需要这个参数作为监听总数的设置值。
创建好epoll监听池后,该函数会返回一个epoll池的fd值(在/proc/进程id/fd可见)。所以使用完epoll后注意要close()掉该fd。
除此,

int epoll_create1(int flags);

当flag等于0时,这个函数的作用跟上面的一模一样。另外epoll_create1(EPOLLCLOEXEC)表示返回的epoll的fd具有“执行后关闭”特性。

3.1.2 添加要监听的事件到epoll池中或从中删除、修改事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

epfd: 表明epoll的fd;
op: 表明操作,可以是增加监听事件(EPOLL_CTL_ADD),或者修改监听事件(EPOLL_CTL_MOD)、删除监听事件(EPOLL_CTL_DEL)
fd: 监听的文件描述符
event: 监听的事件
epoll_event结构体原型为:

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

events的取值可为(可以是下面取值的集合,即通过位或合并):
EPOLLIN:触发该事件,表示对应的文件描述符上有可读数据。(包括对端SOCKET正常关闭);
EPOLLOUT:触发该事件,表示对应的文件描述符上可以写数据;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来讲的;
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
epoll_data_t是一个联合体,原型为:

typedef union epoll_data {
   void        *ptr;
   int          fd;
   uint32_t     u32;
   uint64_t     u64;
} epoll_data_t;

设置操作举例:

struct epoll_event ev;

//设置与要处理的事件相关的文件描述符
ev.data.fd=listenfd;
//设置要处理的事件类型
ev.events=EPOLLIN|EPOLLET;
//增加epoll事件
epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);
3.1.3 等待监听事件的发生

epoll_wait()用于等待事件的触发,类似于select()调用,函数原型为:

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)

epfd: epoll池的fd;
events: 从内核得到的触发事件的集合,调用者可以根据这个参数得到被触发的事件;
maxevents: 告诉内核events有多少个;
timeout: 等待超时时间,毫秒单位,设置为0表不等待立即返回,-1则是永久等待;
函数执行成功返回需要处理的事件的数目,被触发的事件存在events数组中,超时返回0,失败返回-1。
注意,epool_wait()等待注册的fd的事件发生,若发生了则将fd和相应事件类型放到参数events数组中,且将注册中epfd的事件类型清空,注意只是清空事件类型,所以下次若还需要监测这个fd的这个事件,需要用epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev)重新设置fd的事件类型。注意,这次不是使用EPOLL_CTL_ADD了。

3.2 epool使用示例代码

创建两个管道文件并且打开,通过epoll机制去监测这两个管道是否有数据可读,根据多路复用机制,只要任意一个管道有数据可供读写,那么epoll机制得以返回。这里要注意的是,打开管道时候,读端以只读方式打开,写端以只写方式打开,任意一端打开的时候必须发现对方一端必须存在,否则将会阻塞在打开操作中,例如,读端以O_RDONLY方式打开管道时若发现没有另外进程以O_WRONLY打开,那么就会阻塞在打开操作中,直至有进程以O_WRONLY打开为止。

#include 
#include 
#include 
#include 
#include 
#include 
#include 


int main(void)
{
    int fifo_fd1, fifo_fd2;
    int ep_fd;
    struct epoll_event event;
    struct epoll_event *ret_events = NULL;
    int cnt;
    int i;
    char c;

    mkfifo("./fifo1", 0666);
    mkfifo("./fifo2", 0666);

    fifo_fd1 = open("./fifo1", O_RDONLY);
    fifo_fd2 = open("./fifo2", O_RDONLY);

    /* 需要有进程以只写的方式打开fifo1、fifo2后才能执行于此  */
    printf("监测fifo1和fifo2中...\n");  

    /* 创建epoll池 */
    ep_fd = epoll_create1(0);

    /* 将检测事件加入epoll池中 */
    event.events = EPOLLIN | EPOLLET;   /* 监测fifo1可读,且以边沿方式触发 */
    event.data.fd = fifo_fd1;
    epoll_ctl(ep_fd, EPOLL_CTL_ADD, fifo_fd1, &event);

    event.events = EPOLLIN | EPOLLET;   /* 监测fifo2可读,且以边沿方式触发 */
    event.data.fd = fifo_fd2;
    epoll_ctl(ep_fd, EPOLL_CTL_ADD, fifo_fd2, &event);

    /* ret_events用于存放被触发的事件 */
    ret_events = malloc(sizeof(struct epoll_event) * 100);  

    /* 阻塞等待监测事件触发 */
    cnt = epoll_wait(ep_fd, ret_events, 100, -1);   
    printf("cnt = %d\n", cnt);

    /* 判断监测事件 */
    for (i = 0; i < cnt; i++)
    {
        if (ret_events[i].events & EPOLLIN)
        {
            read(ret_events[i].data.fd, &c, 1);
            printf("fd = %d, recv data = %c\n", ret_events[i].data.fd, c);
        }
    }

    free(ret_events);
    close(ep_fd);       /* 注意关闭epoll池的描述符 */
    close(fifo_fd2);
    close(fifo_fd1);

    return 0;
}

两个写管道的进程代码:
写fifo1,

#include 
#include 
#include 
#include 
#include 
#include 

int main(void)
{
    int fd;
    char c;

    fd = open("./fifo1", O_WRONLY);

    printf("w1: pls input char: \n");
    scanf("%c", &c);

    write(fd, &c, 1);

    close(fd);

    return 0;
}

写fifo2,


int main(void)
{
    int fd;
    char c;

    fd = open("./fifo2", O_WRONLY); 
    printf("w2: pls input char: \n");
    scanf("%c", &c);

    write(fd, &c, 1);

    close(fd);  
    return 0;
}

Makefile编写:

all :
    gcc epoll_test.c -o epoll_test
    gcc write_fifo1.c -o w1
    gcc write_fifo2.c -o w2

clean:
    rm epoll_test w1 w2 fifo1 fifo2

编译:
Linux IO多路复用之epoll机制_第1张图片
方便观察效果,将三个程序在三个窗口终端中运行:
Linux IO多路复用之epoll机制_第2张图片
向epoll监测下的fifo2写入字符后,epoll_wait得以返回:
Linux IO多路复用之epoll机制_第3张图片
epoll机制固然强大,但是它适用于并发性较高的服务器开发,一般的嵌入式Linux项目,适用较多的多路复用应该是select吧。

你可能感兴趣的:(Linux系统/网络编程,Linux编程,linux,epoll,select,多路复用)