Linux内核之 I/O多路复用

今天这一篇,是该系列最后一篇。和《Linux内核之 文件I/O》密切相关,同样是站在系统编程这个层面上,也是极其重要的一篇!对于理解网络socket有很好的帮助。

 

一、I/O模型

1、前言

首先我们看下经典的《Unix网络编程第1卷》Chapter 6中作者介绍的五种I/O模型,分别为:

(1)blocking I/O 阻塞I/O型 

在这种模式下,若所调用的I/O函数没有完成相关的功能,则会使进程挂起直到相关数据到达才会返回

如常见对管道设备、终端设备和网络设备进行读写时经常会出现这种情况。

(2)nonblocking I/O 非阻塞I/O型

在这种模式下,当请求的I/O操作不能完成时,则不让进程睡眠,而是立即返回

非阻塞I/O使用户可以调用不会阻塞的I/O操作,如open()、write()和read()。

如果该操作不能完成,则会立即返回出错(如打不开文件)或者返回0(比如在缓冲区中没有数据可以读取或者没有空间可以写入数据)。

(3)I/O multiplexing (select and poll) I/O多路转接模型

在这种模式下,如果请求的I/O操作阻塞,且它不是真正的阻塞I/O,而是让其中的一个函数等待,在此期间,I/O还能进行其他操作

咱们接下来要说的select()函数、poll()函数和epoll()函数,就是属于这种类型。

(4)signal driven I/O (SIGIO) 信号驱动模型

在这种模式下,进程要定义一个信号处理程序,系统可以自动捕获特定信号的到来,从而启动I/O。这是由内核通知用户何时可以启动一个I/O操作决定的。

这种模型是非阻塞的,当有就绪的数据时,内核就向该进程发送SIGIO信号无论我们如何处理SIGIO信号,这种模型的好处就是当等待数据到达时,可以不阻塞。主程序继续执行,只有收到SIGIO信号时,才去处理数据即可。

(5)asynchronous I/O (the POSIX aio_functions) 异步I/O模型

在这种模型下,进程先让内核启动I/O操作,并在整个操作完成后通知该进程

种模型与信号驱动模型的主要区别在于:信号驱动I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O模型是由内核通知进程I/O操作何时完成的。

现在并不是所有的系统都支持这种模型。

2、什么是阻塞与非阻塞?

而在《Linux系统编程第2版》的书上,并没有上述这样的分类,对于I/O模型的概念定义比较零散,特别是阻塞与非阻塞;而对于同步和非同步在第4.5小节有专门的介绍。

我们综合两本书和我自己的理解整理下几个概念。

(a)阻塞

上面定义的已经非常明确。说通俗点,就是一直等,干等,其他也什么都做不了,而且还耗CPU

(b)非阻塞

上面定义也没毛病。但是到底能不能睡眠呢?

根据第二本书提到,非阻塞的这种马上返回非常低效,而改为睡眠,即等待的同时可以睡眠,CPU可以做点其他事

很多网上资料也是这么默认的。我们也可以这么认为。

那么阻塞非阻塞的区别在于 等待的方式或者说等待的行为,干等还是睡眠等待,耗不耗CPU。

3、什么是同步与异步?

还有另外两个概念,同步与异步。再加上述两个概念,就更容易混淆了。

对于同步,我们前面提到过不少,比如同步I/O就有好几种方式。

根据书上对于这两种的英文版可不是两个单词,有四个单词:"synchronized","nonsynchronized","synchronous","asynchronous"。

初看,觉得越来越复杂了。细看其实又可以分成两对;第一对过去式形式,“已同步的”与“非同步的”;第二对是形容词方式,“同步的”与“异步的”。 

书上继续介绍了,第一对比第二对操作要求更加严格,也更安全。

更复杂的是写操作,写操作对于这个四种都可能支持且交叉。

但常用的是"synchronous"且"nonsynchronized":写操作在数据保存入内核缓冲区后返回。这不是我们认为的“异步”写吗,但是这里翻译对应的是“非同步”。

而对于读操作稍微简单,只有两种组合,而常见的是"synchronized"且"synchronous":读操作直到最新数据保存到提供的缓冲区后才返回。符合我们前面提到的读总是同步的

细心的你有没有发现,同步和异步是相对数据而言的,就是数据一致性,异步不等数据强一致了,而同步一定等到数据一致。

事实上,在实际应用中,写只是写缓存,而读一定要读最新的。否则违背了I/O操作的目的。

所以,到这里,我们再给同步和异步下定义就不难了。

(c)同步,一直等待(读写)操作完成后再返回结果。

(d)异步,只要(读写)操作提交了就会返回,至于真正意义上数据的同步留给后台。

所以同步和异步的区别在于(操作)事件返回的时机,也可以说是消息通知的行为(是不是马上通知)。 

同时我们也看出,异步的后面还是需要同步的,最后还是要实现数据的一致性。所以我们也就理解了同步I/O为什么这么多方式、为什么这么重要。

所以,我们结合各种知识(数据库、分布式)异步是达到(数据)最终一致,同步应该算是强同步的方式。所以我们也可以理解为什么英文版的同步和异步分成这么多单词,实质还是要求(一致性)的强弱不同。

那么阻塞、非阻塞与同步、异步之间又有什么关系或者是不是2*2的组合呢?

我们再从后往前看它们的定义。

异步,马上返回;阻塞还有意义吗?所以异步一般是非阻塞。你考虑下回写机制就很清楚了。

同步可以分为阻塞和非阻塞,因为消息要到最后才返回,在这期间完全可以选择怎么等待的问题。

 

 4、I/O 多路复用的定义

从上面定义我们看出,同步阻塞方式,只能是一个进程或者一个线程在一个文件描述符上进行操作。

相反,要实现程序在多个文件描述符上同时进行操作就是I/O多路复用

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

但select(),poll(),epoll()本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

二、I/O 多路复用

与多线程和多进程相比,I/O多路复用的最大优势是系统开销小,系统不需要建立新的进程或者线程,也不必维护这些线程和进程。

1、select

原型:

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

功能:

监视并等待多个文件描述符的属性变化(可读、可写或错误异常)。

select()函数监视的文件描述符3类,分别是:writefdsreadfds exceptfds

调用后select()函数会阻塞,直到有描述符就绪(有数据可读、可写、或者有错误异常),或者超时(timeout 指定等待时间),函数才返回。当select()函数返回后,可以通过遍历fd_set,来找到就绪的描述符。

参数:

1、nfds: 要监视的文件描述符的范围,一般取监视的描述符数的最大值+1,如这里写 10, 这样的话,描述符 0,1, 2 …… 9 都会被监视,在 Linux 上最大值一般为1024。

2、readfd: 监视的可读描述符集合,只要有文件描述符即将进行读操作,这个文件描述符就存储到这。

3、writefds: 监视的可写描述符集合。

4、exceptfds: 监视的错误异常描述符集合。

三个参数 readfds、writefds 和 exceptfds,如果不需要使用某一个的条件,就可以把它设为空指针( NULL )。

集合fd_set中存放的是文件描述符,可通过以下四个宏进行设置:

void FD_ZERO(fd_set *fdset);          //清空集合
void FD_SET(int fd, fd_set *fdset); //将一个给定的文件描述符加入集合之中 void FD_CLR(int fd, fd_set *fdset); //将一个给定的文件描述符从集合中删除 int FD_ISSET(int fd, fd_set *fdset); // 检查集合中指定的文件描述符是否可以读写

5、timeout: 超时时间,它告知内核等待所指定描述字中的任何一个就绪可花多少时间。其 timeval 结构用于指定这段时间的秒数和微秒数。

struct timeval {
    long    tv_sec;         /**/
    long    tv_usec;        /* 微妙 */
};

这个参数有三种可能:

1)永远等待下去:仅在有一个描述字准备好 I/O 时才返回。为此,把该参数设置为空指针 NULL。

2)等待固定时间:在指定的固定时间(timeval 结构中指定的秒数和微秒数)内,在有一个描述字准备好 I/O 时返回,如果时间到了,就算没有文件描述符发生变化,这个函数会返回 0。

3)根本不等待(不阻塞):检查描述字后立即返回,这称为轮询。为此,struct timeval变量的时间值指定为 0 秒 0 微秒,文件描述符属性无变化返回 0有变化返回准备好的描述符数量

返回值: 

成功:就绪描述符的数目,超时返回 0;

出错:-1

select实例

同时循环读取标准输入的内容,读取有名管道的内容。默认的情况下,标准输入没有内容,read()时会阻塞;同样的,有名管道如果没有内容,read()也会阻塞。

我们如何实现循环读取这两者的内容呢?

最简单的方法是,开两个线程,一个线程循环读标准输入的内容,一个线程循环读有名管道的内容。

#include <sys/select.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    fd_set rfds;
    struct timeval tv;
    int ret;
    int fd;
    
    ret = mkfifo("test_fifo", 0666); // 创建有名管道
    if(ret != 0){
        perror("mkfifo:");
    }
    
    fd = open("test_fifo", O_RDWR); // 读写方式打开管道
    if(fd < 0){
        perror("open fifo");
        return -1;
    }
    
    ret = 0;
    
    while(1){
        // 这部分内容,要放在while(1)里面
        FD_ZERO(&rfds);     // 清空
        FD_SET(0, &rfds);   // 标准输入描述符 0 加入集合
        FD_SET(fd, &rfds);  // 有名管道描述符 fd 加入集合
        
        // 超时设置
        tv.tv_sec = 1;
        tv.tv_usec = 0;
        
        // 监视并等待多个文件(标准输入,有名管道)描述符的属性变化(是否可读)
        // 没有属性变化,这个函数会阻塞,直到有变化才往下执行,这里没有设置超时
        // FD_SETSIZE 为 <sys/select.h> 的宏定义,值为 1024
        ret = select(FD_SETSIZE, &rfds, NULL, NULL, NULL);
        //ret = select(FD_SETSIZE, &rfds, NULL, NULL, &tv);
        
        if(ret == -1){ // 出错
            perror("select()");
        }else if(ret > 0){ // 准备就绪的文件描述符
            
            char buf[100] = {0};
            if( FD_ISSET(0, &rfds) ){ // 标准输入
                read(0, buf, sizeof(buf));
                printf("stdin buf = %s
", buf);
                
            }else if( FD_ISSET(fd, &rfds) ){ // 有名管道
                read(fd, buf, sizeof(buf));
                printf("fifo buf = %s
", buf);
            }
            
        }else if(0 == ret){ // 超时
            printf("time out
");
        }
       
    }
    
    return 0;
}

当前终端运行此程序,另一终端运行一个往有名管道写内容的程序,运行结果如下:

另一个终端往有名管道写的程序代码:

#include <sys/select.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
 
int main(int argc, char *argv[])
{
    //select_demo(8);
    
    fd_set rfds;
    struct timeval tv;
    int ret;
    int fd;
    
    ret = mkfifo("test_fifo", 0666); // 创建有名管道
    if(ret != 0){
        perror("mkfifo:");
    }
    
    fd = open("test_fifo", O_RDWR); // 读写方式打开管道
    if(fd < 0){
        perror("open fifo");
        return -1;
    }
    
    while(1){
        char *str = "this is for test";
        write(fd, str, strlen(str)); // 往管道里写内容
        printf("after write to fifo
");
        sleep(5);
    }
    
    return 0;
}

运行结果如下:

select()目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点

select()的缺点在于:

1)每次调用select(),都需要把fd集合从用户态拷贝到内核态,同时每次调用 select()都需要在内核遍历传递进来的所有fd,总之这两者在fd很多时开销很大

2)单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。

2、poll

select()和poll()系统调用的本质一样,前者在BSD UNIX中引入的,后者在System V中引入的。

poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll()没有最大文件描述符数量的限制(但是数量过大后性能也是会下降)。

poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大

原型:

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

功能:

监视并等待多个文件描述符的属性变化。

参数:

1、fds: 不同与 select() 使用三个位图来表示三个 fdset 的方式,poll() 使用一个 pollfd 的指针实现。

一个 pollfd 结构体数组,其中包括了你想测试的文件描述符和事件, 事件由结构中事件域 events 来确定,调用后实际发生的事件将被填写在结构体的 revents 域。

struct pollfd {
    int   fd;         /* 文件描述符 */
    short events;     /* 等待的事件 */
    short revents;    /* 实际发生了的事件 */
};

  fd:每一个 pollfd 结构体指定了一个被监视的文件描述符,可以传递多个结构体,指示 poll() 监视多个文件描述符。

  events:每个结构体的 events 域是监视该文件描述符的事件掩码,由用户来设置这个域。

  events 等待事件的掩码取值如下:

1)处理输入
POLLIN      普通或优先级带数据可读
POLLRDNORM  普通数据可读
POLLRDBAND  优先级带数据可读
POLLPRI     高优先级数据可读

2)处理输出:
POLLOUT     普通或优先级带数据可写
POLLWRNORM  普通数据可写
POLLWRBAND  优先级带数据可写

3)处理错误:
POLLERR     发生错误
POLLHUP     发生挂起
POLLVAL     描述字不是一个打开的文件

  poll() 处理三个级别的数据,普通 normal,优先级带 priority band,高优先级 high priority,这些都是出于流的实现。

  POLLIN | POLLPRI 等价于 select() 的读事件

  POLLOUT | POLLWRBAND 等价于 select() 的写事件

  POLLIN 等价于 POLLRDNORM | POLLRDBAND

  POLLOUT 则等价于 POLLWRNORM 。

  例如,要同时监视一个文件描述符是否可读和可写,我们可以设置 events 为 POLLIN | POLLOUT

  revents:revents 域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域。events 域中请求的任何事件都可能在 revents 域中返回。

  每个结构体的 events 域是由用户来设置,告诉内核我们关注的是什么,而 revents 域是返回时内核设置的,以说明对该描述符发生了什么事件。

2、nfds: 用来指定第一个参数数组元素个数。

3、timeout: 指定等待的毫秒数,无论 I/O 是否准备好,poll() 都会返回。当等待时间为 0 时,poll() 函数立即返回;为 -1 则使 poll() 一直阻塞直到一个指定事件发生。

返回值:

成功时,poll() 返回结构体中 revents 域不为 0 的文件描述符个数;如果在超时前没有任何事件发生,poll()返回 0;

失败时,poll() 返回 -1。

修改之前的例子,改为用 poll() 实现:

#include <poll.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main(int argc, char *argv[])
{    
    int ret;
    int fd;
    struct pollfd fds[2]; // 监视文件描述符结构体,2 个元素
    
    ret = mkfifo("test_fifo", 0666); // 创建有名管道
    if(ret != 0){
        perror("mkfifo:");
    }
    
    fd = open("test_fifo", O_RDWR); // 读写方式打开管道
    if(fd < 0){
        perror("open fifo");
        return -1;
    }
    
    ret = 0;
    
    fds[0].fd = 0;   // 标准输入
    fds[1].fd = fd;  // 有名管道
    
    fds[0].events = POLLIN; // 普通或优先级带数据可读
    fds[1].events = POLLIN; // 普通或优先级带数据可读
    
    while(1){        
        // 监视并等待多个文件(标准输入,有名管道)描述符的属性变化(是否可读)
        // 没有属性变化,这个函数会阻塞,直到有变化才往下执行,这里没有设置超时
        ret = poll(fds, 2, -1);
        //ret = poll(&fd, 2, 1000);
        
        if(ret == -1){ // 出错
            perror("poll()");
        }else if(ret > 0){ // 准备就绪的文件描述符
            
            char buf[100] = {0};
            if( ( fds[0].revents & POLLIN ) ==  POLLIN ){ // 标准输入
                read(0, buf, sizeof(buf));
                printf("stdin buf = %s
", buf);
                
            }else if( ( fds[1].revents & POLLIN ) ==  POLLIN ){ // 有名管道
                read(fd, buf, sizeof(buf));
                printf("fifo buf = %s
", buf);
            }
            
        }else if(0 == ret){ // 超时
            printf("time out
");
        }
        
    }
    
    return 0;
}

poll() 的实现和 select() 非常相似只是描述 fd 集合的方式不同,poll() 使用 pollfd 结构而不是 select() 的 fd_set 结构,其他的都差不多。

3、epoll

epoll 是在2.6内核中提出的,是之前的 select() 和 poll() 的增强版本

相对于 select() 和 poll()来说,epoll更加灵活,没有描述符限制

epoll 使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的 copy 只需一次

epoll 操作过程需要三个接口,分别如下:

#include <sys/epoll.h>
int epoll_create(int size);
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);

(1)epoll_create

功能:

该函数生成一个 epoll 专用的文件描述符(创建一个 epoll 的句柄)。

内核还会创建所需要的红黑树,以及就绪链表。

参数:

size: 用来告诉内核这个监听的数目一共有多大,参数 size 并不是限制了 epoll 所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议

自从 linux 2.6.8 之后,size 参数是被忽略的,也就是说可以填只有大于 0 的任意值。

需要注意的是,当创建好 epoll 句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd 的,所以在使用完epoll后,必须调用 close() 关闭,否则可能导致 fd 被耗尽。

返回值:

成功:epoll 专用的文件描述符

失败:-1

(2)epoll_ctl

功能:

epoll 的事件注册函数,它不同于 select() 是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。

参数:

1、epfd: epoll 专用的文件描述符,epoll_create()的返回值

2、op: 表示动作,用三个宏来表示:

EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从 epfd 中删除一个 fd;

3、fd: 需要监听的文件描述符

4、event: 告诉内核要监听什么事件,struct epoll_event 结构如下:

// 保存触发事件的某个文件描述符相关的数据(与具体使用方式有关)
typedef union epoll_data {
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;

// 感兴趣的事件和被触发的事件
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 队列里

返回值:成功:0,失败:-1

(3)epoll_wait

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

功能:

等待事件的产生,收集在 epoll 监控的事件中已经发送的事件类似于 select() 调用

参数:

1、epfd: epoll 专用的文件描述符,epoll_create()的返回值

2、events: 分配好的 epoll_event 结构体数组,epoll 将会把发生的事件赋值到events 数组中(events 不可以是空指针,内核只负责把数据复制到这个 events 数组中,不会去帮助我们在用户态中分配内存)。

3、maxevents: maxevents 告之内核这个 events 有多大 。

4、timeout: 超时时间,单位为毫秒;为 -1 时,函数为阻塞

返回值:

成功:返回需要处理的事件数目,如返回 0 表示已超时。

失败:-1

epoll 对文件描述符的操作有两种模式:条件触发 LT(level trigger)边缘触发 ET(edge trigger)LT 模式是默认模式,LT 模式与 ET 模式的区别如下:

LT 模式:当 epoll_wait 检测到描述符事件发生,立即通知应用程序,应用程序可以不立即处理该事件。下次调用 epoll_wait 时,会再次响应应用程序并通知此事件。

ET 模式:当 epoll_wait 检测到描述符事件发生,并且等事件已变化(比如写数据完成)时才通知应用程序,应用程序必须立即处理该事件。通知有且只有一次

ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死

 所以边缘触发需要一次性的把缓冲区的数据读完为止,也就是一直读,直到读到EGAIN为止,EGAIN说明缓冲区已经空了,因为这一点,边缘触发需要设置文件句柄为非阻塞

    //水平触发
    ret = read(fd, buf, sizeof(buf));
    
    //边缘触发
    while(true) {
        ret = read(fd, buf, sizeof(buf);
        if (ret == EAGAIN) break;
    }

再次修改之前的例子,改为用 epoll 实现:

#include <sys/epoll.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
    int ret;
    int fd;
    
    ret = mkfifo("test_fifo", 0666); // 创建有名管道
    if(ret != 0){
        perror("mkfifo:");
    }
    
    fd = open("test_fifo", O_RDWR); // 读写方式打开管道
    if(fd < 0){
        perror("open fifo");
        return -1;
    }
    
    ret = 0;
    struct epoll_event event;   // 告诉内核要监听什么事件
    struct epoll_event wait_event;
    
    
    int epfd = epoll_create(10); // 创建一个 epoll 的句柄,参数要大于 0, 没有太大意义
    if( -1 == epfd ){
        perror ("epoll_create");
        return -1;
    }
    
    event.data.fd = 0;     // 标准输入
    event.events = EPOLLIN; // 表示对应的文件描述符可以读
    
    // 事件注册函数,将标准输入描述符 0 加入监听事件
    ret = epoll_ctl(epfd, EPOLL_CTL_ADD, 0, &event);
    if(-1 == ret){
        perror("epoll_ctl");
        return -1;
    }
    
    event.data.fd = fd;     // 有名管道
    event.events = EPOLLIN; // 表示对应的文件描述符可以读
    
    // 事件注册函数,将有名管道描述符 fd 加入监听事件
    ret = epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
    if(-1 == ret){
        perror("epoll_ctl");
        return -1;
    }
    
    ret = 0;
    
    while(1){             
        // 监视并等待多个文件(标准输入,有名管道)描述符的属性变化(是否可读)
        // 没有属性变化,这个函数会阻塞,直到有变化才往下执行,这里没有设置超时
        ret = epoll_wait(epfd, &wait_event, 2, -1);
        //ret = epoll_wait(epfd, &wait_event, 2, 1000);
        
        if(ret == -1){ // 出错
            close(epfd);
            perror("epoll");
        }else if(ret > 0){ // 准备就绪的文件描述符
            
            char buf[100] = {0};
            
            if( ( 0 == wait_event.data.fd ) 
                    && ( EPOLLIN == wait_event.events & EPOLLIN ) ){ // 标准输入
                
                read(0, buf, sizeof(buf));
                printf("stdin buf = %s
", buf);
                
            }else if( ( fd == wait_event.data.fd ) 
                      && ( EPOLLIN == wait_event.events & EPOLLIN ) ){ // 有名管道
                
                read(fd, buf, sizeof(buf));
                printf("fifo buf = %s
", buf);
                
            }
            
        }else if(0 == ret){ // 超时
            printf("time out
");
        }
        
    }
   
    close(epfd);
    
    return 0;
}

select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描

而 epoll() 事先通过 epoll_ctl() 来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似 callback 的回调机制(软件中断),迅速激活这个文件描述符,当进程调用 epoll_wait() 时便得到通知

epoll 的优点主要是一下几个方面:

1)监视的描述符数量不受限制。

它所支持的 FD 上限是最大可以打开文件的数目,这个数字一般远大于 2048,举个例子,在 1GB 内存的机器上大约是 10 万左右,具体数目可以 cat /proc/sys/fs/file-max 察看,一般来说这个数目和系统内存关系很大。

2)I/O 的效率不会随着监视 fd 的数量的增长而下降。

select()、poll() 实现需要自己不断轮询所有 fd 集合,直到设备就绪,期间可能要睡眠和唤醒多次交替

而 epoll 其实也需要调用 epoll_wait() 不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪 fd 放入就绪链表中,并唤醒在 epoll_wait() 中进入睡眠的进程

虽然都要睡眠和交替,

但是 select() 和 poll() 在“醒着”的时候要遍历整个 fd 集合

而 epoll 在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的 CPU 时间。这就是回调机制带来的性能提升。

3)select(),poll() 每次调用都要把 fd 集合从用户态往内核态拷贝一次,而 epoll 只要一次拷贝,这也能节省不少的开销。

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

  补充

实例中反复用到了一个函数perror。

errnoperror

1)errno就是error number,意思就是错误号码。linux系统中对各种常见错误做了个编号,当函数执行错误时,函数会返回一个特定的errno编号来告诉我们这个函数到底哪里错了。

2)errno是由操作系统来维护的一个全局变量,操作系统内部函数都可以通过设置errno来告诉上层调用者究竟刚才发生了一个什么错误。

3)errno本身实质是一个int类型的数字,每个数字编号对应一种错误。当我们只看errno时只能得到一个错误编号数字,并不知道具体错在哪里;

所以:linux系统提供了一个函数perror(意思print error),perror函数内部会读取errno并且将这个不好认的数字直接给转成对应的错误信息字符串,然后打印出来。

这两者在调试时也很有用。

参考资料:

《linux系统编程》第2版

https://www.jianshu.com/p/c1ae21d36c00

原文地址:https://www.cnblogs.com/orange-CC/p/13571016.html