第63章 其他备选的 I/O模型

        除了常规文件I/O外,本章我们将讨论其他3种 I/O模型。

  • I/O多路复用(select()以及poll()系统调用)。
  • 信号驱动I/O.
  • Linux 专有的epoll编程接口

63.1 整体概览

  •         目前为止,本书中大部分程序使用的I/O模型都是单个进程每次只在一个文件描述符上执行I/O操作,每次I/O系统调用都会i阻塞直到完成数据传输。比如,当从一个管道中读取数据时,如果管道中恰好没有数据,那么通常read()会阻塞。而如果管道中没有足够的空间保存待写入的数据时,write()也会阻塞。挡在其他类型的文件如FIFO和套接字上执行I/O操作时,也会出现类似行为。

磁盘文件是个特例。例如第23章中所描述的,内核采用缓冲区cache来加速磁盘I/O请求。因而一旦请求的数据传输到内核的缓冲cache,对磁盘的write()操作将立刻返回,而不用等到将数据实际写入磁盘后长才返回(除非在打开文件时指定了O_SYNC标志)。与之对应的是,read(),read()调用将数据从内核缓冲区 cache 移动到用户的缓冲区中,如果请求的数据
不在内核缓冲区 cache,那么内核就会让进程休眠,同时执行对磁盘的读操作。

        对于许多应用来说,传统的阻塞式I/O 模型已经足够了,但这不代表所有的应用都能得到
满足。特别的,有些应用需要处理以下某项任务,或者两者都需要兼顾。

  • 如果可能的话,以非阻塞的方式检查文件描述符上是否可进行I/O 操作。
  • 同时检查多个文件描述符,看它们中的任何一个是否可以执行I/O 操作

        我们已经遇到了两种可以部分满足这些需求的技术:非阻塞式I/O 和多进程或多线程
技术。

        我们在5.9 节和44.9 节中对非阻塞式I/O 做了详细的说明。如果在打开文件时设定了O_NONBLOCK 标志,会以非阻塞方式打开文件。如果I/O 系统调用不能立刻完成,则会返回
错误而不是阻塞进程。非阻塞式I/O 可以运用到管道、FIFO、套接字、终端、伪终端以及其
他一些类型的设备上。

        非阻塞式I/O 可以让我们周期性地检查(“轮询”)某个文件描述符上是否可执行I/O 操作。
比如,我们可以让一个输入文件描述符成为非阻塞式的,然后周期性地执行非阻塞式的读操
作。如果我们需要同时检查多个文件描述符,那么就需要将它们都设为非阻塞,然后依次对
它们轮询。但是,这种轮询通常是我们不希望看到的。如果轮询的频率不高,那么应用程序
响应I/O 事件的延时可能会达到无法接受的程度。换句话说,在一个紧凑的循环中做轮询就是
在浪费CPU

        如果不希望进程在对文件描述符执行 I/O 操作时被阻塞,我们可以创建一个新的进
程来执行 I/O。此时父进程就可以去处理其他的任务了,而子进程将阻塞直到 I/O 操作完
成。如果我们需要处理多个文件描述符上的 I/O,此时可以为每个文件描述符创建一个子
进程。这种方法的问题在于开销昂贵且复杂。创建及维护进程对系统来说都有开销,而
且一般来说子进程需要使用某种 IPC 机制来通知父进程有关 I/O 操作的状态

        使用多线程而不是多进程,这将占用较少的资源。但线程之间仍然需要通信,以告知其
他线程有关I/O 操作的状态,这将使编程工作变得复杂。尤其是如果我们使用线程池技术来最
小化需要处理大量并发客户的线程数量时。(多线程特别有用的一个地方是如果应用程序需要调
用一个会执行阻塞式 I/O 操作的第三方库,那么可以通过在分离的线程中调用这个库从而避免
应用被阻塞
。)

        由于非阻塞式I/O 和多进(线)程都有各自的局限性,下列备选方案往往更可取。

  • I/O 多路复用允许进程同时检查多个文件描述符以找出它们中的任何一个是否可执行I/O 操作。系统调用select()和poll()用来执行I/O 多路复用
  • 信号驱动I/O 是指当有输入或者数据可以写到指定的文件描述符上时,内核向请求数据的进程发送一个信号。进程可以处理其他的任务,当I/O 操作可执行时通过接收信号来获得通知。当同时检查大量的文件描述符时,信号驱动I/O 相比select()和poll()有显著的性能提升。
  • epoll API 是Linux 专有的特性,首次出现是在Linux 2.6 版中。同I/O 多路复用API 一样,epoll API 允许进程同时检查多个文件描述符,看其中任意一个是否能执行I/O 操作。同信号驱动I/O 一样,当同时检查大量文件描述符时,epoll 能提供更好的性能

        实际上 I/O 多路复用、信号驱动 I/O 以及epoll 都是用来实现同一个目标的技术—同时检查多个文件描述符,看它们是否准备好了执行 I/O 操作(准确地说,是看 I/O 系统调用是否可以非阻塞地执行)。文件描述符就绪状态的转化是通过一些I/O 事件来触发的,比如输入数据到达,套接字连接建立完成,或者是之前满载的套接字发送缓冲区在TCP 将队列中的数据传送到对端之后有了剩余空间。同时检查多个文件描述符在类似网络服务器的应用中很有用处,或者是那些必须同时检查终端以及管道或套接字输入的应用程序。

        需要注意的是这些技术都不会执行实际的I/O 操作。它们只是告诉我们某个文件描述符已经处于就绪状态了。这时需要调用其他的系统调用来完成实际的I/O 操作。

选择哪种技术

        在本章中,我们将思考为何要选择其中的某种技术,为什么其他技术不适用,其理由是什么。同时我们会总结出一些要点。

  • 系统调用select()和poll()在UNIX 系统中已经存在了很长的时间。同其他技术相比,它们主要的优势在于可移植性,主要缺点在于当同时检查大量的(数百或数千个)文件描述符时性能延展性不佳。
  • epoll API 的关键优势在于它能让应用程序高效地检查大量的文件描述符。其主要缺点在于它是专属于Linux 系统的API。
  • 同epoll 一样,信号驱动I/O 可以让应用程序高效地检查大量的文件描述符。但是epoll有一些信号驱动I/O 所没有的优点。

        --避免了处理信号的复杂性。
        — 我们可以指定想要检查的事件类型(即,读就绪或者写就绪)。
        — 我们可以选择以水平触发或边缘触发的形式来通知进程(在63.1.1 节中详述)。
        另外,要完全利用信号 I/O 的优点需要用到不可移植的 Linux 专有的特性,而如果我们这
么做了,那么信号驱动 I/O 的可移植性也不会比epoll 更好。

        Libevent 库就是这样一个软件层,它提供了检查文件描述符 I/O 事件的抽象,已经移植到了多个UNIX 系统中。Libevent 的底层机制能够(以透明的方式)应用本章所描述的任意一种技术:select()、poll()、信号驱动I/O 或者epoll。

63.1.1 水平出发和边缘触发

         在深入讨论多种可选的I/O 机制之前,我们需要先区分两种文件描述符准备就绪的通知模式

  • 水平触发通知:如果文件描述符上可以非阻塞地执行 I/O 系统调用,此时认为它已经就绪。
  • 边缘触发通知:如果文件描述符自上次状态检查以来有了新的I/O 活动(比如新的输入),此时需要触发通知。

        表63-1 总结了 I/O 多路复用、信号驱动I/O 以及epoll 所采用的通知模型。epoll API 同其他两种I/O 模型的区别在于它对水平触发(默认)和边缘触发都支持。

         当采用水平触发通知时,我们可以在任意时刻检查文件描述符的就绪状态。这表示当我们确定了文件描述符处于就绪态时(比如存在有输入数据),就可以对其执行一些I/O 操作,然后重复检查文件描述符,看看是否仍然处于就绪态(比如还有更多的输入数据),此时我们就能执行更多的I/O,以此类准。换句话说,由于水平触发模式允许我们在任意时刻重复检查 I/O 状态,没有必要每次当文件描述符就绪后需要尽可能多地执行 I/O(也就是尽可能多地读取字节,亦或是根本不去执行任何I/O)。

        与之相反的是,当我们采用边缘触发时,只有当I/O 事件发生时我们才会收到通知。在另一个I/O 事件到来前我们不会收到任何新的通知。另外,当文件描述符收到I/O 事件通知时,通常我们并不知道要处理多少I/O(例如有多少字节可读)。因此,采用边缘触发通知的程序通常要按照如下规则来设计。

  • 在接收到一个I/O 事件通知后,程序在某个时刻应该在相应的文件描述符上尽可能多地执行I/O(比如尽可能多地读取字节)。如果程序没这么做,那么就可能失去执行I/O的机会。因为直到产生另一个I/O 事件为止,在此之前程序都不会再接收到通知了,因此也就不知道此时应该执行I/O 操作。这将导致数据丢失或者程序中出现阻塞。前面我们说“在某个时刻”,是因为有时候当我们确定了文件描述符是就绪态时,此时可能并不适合马上执行所有的I/O 操作。问题的原因在于如果我们仅对一个文件描述符执行大量的I/O 操作,可能会让其他文件描述符处于饥饿状态。在63.4.6 节中,我们对epoll API 的边缘触发通知做介绍时再深入讨论这个问题。
  • 如果程序采用循环来对文件描述符执行尽可能多的I/O,而文件描述符又被置为可阻塞的,那么最终当没有更多的I/O 可执行时,I/O 系统调用就会阻塞。基于这个原因,每个被检查的文件描述符通常都应该置为非阻塞模式,在得到I/O 事件通知后重复执行I/O 操作,直到相应的系统调用(比如read() ,write())以错误码EAGAIN 或EWOULDBLOCK 的形式失败。

63.1.2 备选的I/O模型中采用非阻塞I/O

        非阻塞I/O(O_NONBLOCK 标志)常和本章中所描述的I/O 模型一起使用。下面列出了一些例子,以说明为什么这么做会很有用。

  • 如同上一节所述,非阻塞I/O 通常和提供有边缘触发通知机制的I/O 模型一起使用。
  • 如果多个进程(或线程)在同一个打开的文件描述符上执行I/O 操作,那么从某个特定进程的角度来看,文件描述符的就绪状态可能会在通知就绪和执行后续I/O 调用之间发生改变。结果就是一个阻塞式的I/O 调用将阻塞,从而防止进程检查其他的文件描述符。(这种情况会发生在本章所描述的所有I/O 模型上,无论它们采用的是水平触发还是边缘触发。)
  • 尽管水平触发模式的API 比如select()或poll()通知我们流式套接字的文件描述符已经写就绪了,如果我们在单个write()或send()调用中写入足够大块的数据,那么该调用将阻塞。
  • 在非常罕见的情况下,水平触发型的 API 比如 select()和 poll(),会返回虚假的就绪通知—它们会错误地通知我们文件描述符已经就绪了。这可能是由内核bug 造成的,或非普通情况下的设计方案所期望的行为。

63.2 I/O多路复用

        I/O 多路复用允许我们同时检查多个文件描述符,看其中任意一个是否可执行 I/O 操作。我们可以采用两个功能几乎相同的系统调用来执行 I/O 多路复用操作。第一个是select(),它首次出现在BSD 系统的套接字API 中。在这两个系统调用中,历史上select()的应用更广泛。另一个系统调用是poll(),它出现在System V 中。select()和poll()现在都是SUSv3 中规定的标准接口。

        我们可以在普通文件、终端、伪终端、管道、FIFO、套接字以及一些其他类型的字符型设备上使用select()和poll()来检查文件描述符。这两个系统调用都允许进程要么一直等待文件描述符成为就绪态,要么在调用中指定一个超时时间。

63.2.1 selset()系统调用

        系统调用select()会一直阻塞,直到一个或多个文件描述符集合成为就绪态。

#include <sys/time.h>
#include <sys/select.h>

int select(int mfds,fd_set *readsfd,fd_set_set *writefds,fd_set 
*exceptfds, struct timeval *timeout);

        Returns number of ready file descriptors, 0 on timeout,or -1 on err

        参数nfds、readfds、writefds 和exceptfds 指定了select()要检查的文件描述符集合。参数timeout 可用来设定select()阻塞的时间上限。我们接下来详细描述这些参数的意义。

文件描述符集合

        参数readfds、writefds 以及exceptfds 都是指向文件描述符集合的指针,所指向的数据类型是fd_set。这些参数按照如下方式使用。

  •  readfds 是用来检测输入是否就绪的文件描述符集合。
  • writefds 是用来检测输出是否就绪的文件描述符集合。
  • exceptfds 是用来检测异常情况是否发生的文件描述符集合。

        术语“异常情况”常常被误解为在文件描述符上出现了一些错误,这并不正确。在 Linux 上,一个异常情况只会在下面两种情况下发生(其他的UNIX 实现也类似)。

  • 连接到处于信包模式下的伪终端主设备上的从设备状态发生了改变(见64.5 节)。
  • 流式套接字上接收到了带外数据(见61.13.1 节)

        通常,数据类型fd_set 以位掩码的形式来实现。但是,我们并不需要知道这些细节,因为所有关于文件描述符集合的操作都是通过四个宏来完成的:FD_ZERO(),FD_SET(),FD_CLR()以及FD_ISSET()。

#include <sys/select.h>

void FD_ZERO(fd_set *fdset);
void FD_SET(int fd,fd_set *fdset);
void FD_CLR(int fd,fd_set *fdset);

void FD_ISSET(int fd,fd_set *fdset);
             Returns true(1) if fd is in fdset,or false(0) oheriwise

这些宏按如下方式工作。

  • FD_ZERO()将fdset 所指向的集合初始化为空
  • FD_SET()将文件描述符fd 添加到由fdset 所指向的集合中。
  • FD_CLR()将文件描述符fd 从fdset 所指向的集合中移除。
  • 如果文件描述符fd 是fdset 所指向的集合中的成员,FD_ISSET()返回true。

文件描述符集合有一个最大容量限制,由常量FD_SETSIZE 来决定。在Linux 上,该常量的值为1024。(其他UNIX 实现对于该限制也有类似的常量值来限定。)

        尽管 FD_*宏操作的是用户空间数据结构,select()的内核实现却能处理更大的文件描述符集合。在glibc 中没有什么简单的方法可以修改FD_SETSIZE 的定义。如果我们想修改这个限制,必须修改 glibc 头文件中的定义。但是,基于本章稍后提到的原因,如果我们需要检查大量的文件描述符,那么使用 epoll 可能比 select()更加可取。

        参数 readfds、writefds 和 exceptfds 所指向的结构体都是保存结果值的地方。在调用select()
之前,这些参数指向的结构体必须初始化(通过FD_ZERO()和FD_SET()),以包含我们感兴趣的文件描述符集合。之后select()调用会修改这些结构体,当select()返回时,它们包含的就是已处于就绪态的文件描述符集合了。(由于这些结构体会在调用中被修改,如果要在循环中重复调用 select(),我们必须保证每次都要重新初始化它们。)之后这些结构体可以通过FD_ISSET()来检查

        如果我们对某一类型的事件不感兴趣,那么相应的fd_set 参数可以指定为NULL。我们将在63.2.3 节中对这三种事件类型做更准确的解释。

        参数nfds 必须设为比3 个文件描述符集合中所包含的最大文件描述符号还要大1。该参数让select()变得更有效率,因为此时内核就不用去检查大于这个值的文件描述符号是否属于这些文件描述符集合。

timeout 参数

        参数timeout 控制着select()的阻塞行为。该参数可指定为NULL,此时select()会一直阻塞。
又或者指向一个timeval 结构体。

struct timeval{
    time_t tv_sec;   /*Seconds*/
    suseconds tv_usec;  /*Microsecnds (long int)*/
}

        如果结构体timeval 的两个域都为0 的话,此时 select()不会阻塞,它只是简单地轮询指定
的文件描述符集合,看看其中是否有就绪的文件描述符并立刻返回
。否则,timeout 将为select()
指定一个等待时间的上限值。

        尽管结构体timeval 能支持微秒级的精度,该调用的准确度仍受软件时钟粒度的限制(见10.6 节)。SUSv3 规定,当timeout 不是该粒度的整数倍时将向上取整。

        当timeout 设为NULL,或其指向的结构体字段非零时,select()将阻塞直到有下列事件发生:

  • readfds、writefds 或exceptfds 中指定的文件描述符中至少有一个成为就绪态;
  • 该调用被信号处理例程中断;
  • timeout 中指定的时间上限已超时。

        在Linux 上,如果select()因为有一个或多个文件描述符成为就绪态而返回,且如果参数timeout 非空,那么select()会更新timeout 所指向的结构体以此来表示剩余的超时时间。但是,这种行为是与具体实现相关的。SUSv3 中还允许系统不去修改timeout 所指向的结构体,且大多数UNIX 实现都不会修改这个结构体。在循环中使用了select()的可移植的应用程序应该总是确保timeout 所指向的结构体在每次调用select()之前都要得到初始化,而且在调用完成后应该忽略该结构体中返回的信息。

        SUSv3 中规定由timeout 所指向的结构体只有在select()调用成功返回后才有可能被修改
但是,在 Linux 上如果select()被一个信号处理例程中断的话(因此select()会产生EINTR 错误码),那么该结构体也会被修改以表示剩余的超时时间(其作用相当于 select()成功返回)。

select()的返回值

        作为函数的返回值,select()会返回如下几种情况中的一种。

  • 返回−1 表示有错误发生。可能的错误码包括 EBADF 和 EINTR。EBADF 表示readfds、
    writefds 或者exceptfds 中有一个文件描述符是非法的(例如当前并没有打开)。EINTR
    表示该调用被信号处理例程中断了。(如21.5 节所述,如果被信号处理例程中断,select()
    是不会自动恢复的。
  • 返回0 表示在任何文件描述符成为就绪态之前select()调用已经超时。在这种情况下,每个返回的文件描述符集合将被清空。
  • 返回一个正整数表示有1 个或多个文件描述符已达到就绪态。返回值表示处于就绪态的文件描述符个数。在这种情况下,每个返回的文件描述符集合都需要检查(通过 FD_ISSET()),以此找出发生的 I/O 事件是什么。如果同一个文件描述符在readfds、writefds 和exceptfds 中同时被指定,且它对于多个I/O 事件都处于就绪态的话,那么就会被统计多次。换句话说,select()返回所有在 3 个集合中被标记为就绪态的文件描述符总数。

示例程序

        程序清单63-1 中的程序说明了select()的用法。通过命令行参数,我们可以指定超时时间以及我们希望检查的文件描述符。第一个命令行参数指定了select()中的timeout 参数,以秒为单位。如果这里指定了连字符(-),那么select()的timeout 参数就设为NULL,表示会一直阻塞。剩下的命令行参数用来指定需要检查的文件描述符个数,跟着的字符表示需要被检查的事件类型。我们这里可以指定的是r(读就绪)和w(写就绪)。

程序清单63-1 使用select()来检查多个文件描述符

#include <sys/time.h>
#include <sys/select.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
//#include "tlpi_hdr.h"

static void
usageError(const char *progName)
{
    fprintf(stderr, "Usage: %s {timeout|-} fd-num[rw]...\n", progName);
    fprintf(stderr, "    - means infinite timeout; \n");
    fprintf(stderr, "    r = monitor for read\n");
    fprintf(stderr, "    w = monitor for write\n\n");
    fprintf(stderr, "    e.g.: %s - 0rw 1w\n", progName);
    exit(EXIT_FAILURE);
}

int main(int argc,char *argv[])
{
    fd_set readfds,writefds;
    int ready,nfds,fd,numRead,j;
    struct timeval timeout;
    struct timeval *pto;
    char buf[10];  /*Large enough to hold "rw\0"*/

    if (argc < 2 || strcmp(argv[1], "--help") == 0)
        usageError(argv[0]);

    /* Timeout for select() is specified in argv[1] */

    if(strcmp(argv[1],"-") == 0){
        pto = NULL;
    }else{
        pto = &timeout;
        timeout.tv_sec = atol(argv[1]);
        timeout.tv_usec = 0;  /*No microseconds*/
    }

    /* Process remaining arguments to build file descriptor sets */

    nfds = 0;
    FD_ZERO(&readfds);
    FD_ZERO(&writefds);
    for(j=2;j<argc;j++){
        numRead = sscanf(argv[j],"%d%2[rw]",&fd,buf);
        if(numRead !=2)
            usageError(argv[0]);
        if(fd>=FD_SETSIZE)
        {
            printf("file descriptor exceeds limit (%d)\n", FD_SETSIZE);
            return -1;
        }

        if(fd >= nfds)
            nfds = fd+1;
        if(strchr(buf,'r') != NULL)
            FD_SET(fd,&readfds);
        if(strchr(buf,'w') != NULL)
            FD_SET(fd,&writefds);
    }

    /* We've built all of the arguments; now call select() */
    ready = select(nfds,&readfds,&writefds,NULL,pto);
                                /*Ignore exceptional events*/
    if(ready == -1)
    {
        perror("select");
        return -1;
    }

    /* Display results of select() */

    printf("ready = %d\n", ready);

    for(fd = 0;fd<nfds;fd++)
        printf("%d:%s%s\n",fd,FD_ISSET(fd,&readfds)? "r":"",
                FD_ISSET(fd,&writefds)?"w":"");
    
    if(pto !=NULL)
        printf("timeout after select(): %ld.%03ld\n",
                (long)timeout.tv_sec,(long)timeout.tv_usec / 1000);
    
    exit(EXIT_SUCCESS);
}

        在下面的shell 会话日志中,我们说明了程序清单63-1 的用法。在第一个例子中,我们请
求检查文件描述符0 上的输入,超时时间定为10 秒。

         上面的输出告诉我们select()确定了有一个文件描述符已处于就绪态。文件描述符0 已经准备好读取数据了。我们也可以看到timeout 已经被修改了。最后一行输出只有shell 提示符$,这是因为t_select 程序并没有读取让文件描述符0 处于读就绪态的换行符,因此这个字符由shell 读取,结果就是打印出了另一个shell 提示符。

        在下一个示例中,我们再次检查文件描述符 0 的输入状态,但这一次将超时时间设为0 秒。

        select()调用立刻返回,且发现没有文件描述符处于就绪态。

         下一个示例中,我们检查文件描述符0 上是否有输入,以及文件描述符1 上是否有输出。
在这种情况下,我们将参数timeout 设为NULL(第一个命令行参数为连字符-),表示一直阻
塞下去。

63.2.2 poll()系统调用

        系统调用poll()执行的任务同select()很相似。两者间主要的区别在于我们要如何指定待检查的文件描述符。在select()中,我们提供三个集合,在每个集合中标明我们感兴趣的文件描述符。而在poll()中我们提供一列文件描述符,并在每个文件描述符上标明我们感兴趣的事件。

#include <poll.h>

int poll(struct pollfd fds[], nfds_t nfds,int timeout);
 Return number of ready file descriptors,0 on timeout, or -1 on error

        参数fds 列出了我们需要poll()来检查的文件描述符。该参数为pollfd 结构体数组,其定
义如下。

struct pollfd{
    int fd;           /*File descriptor*/
    short events;    /*Requested events bit mask*/
    short reevents;  /*Returned events bit mask*/
}

        参数nfds 指定了数组fds 中元素的个数。数据类型nfds_t 实际为无符号整形。

        pollfd 结构体中的events 和revents 字段都是位掩码。调用者初始化events 来指定需要为描述符fd 做检查的事件。当poll()返回时,revents 被设定以此来表示该文件描述符上实际发生的事件。

        表63-2 列出了可能会出现在events 和revents 字段中的位掩码。该表中第一组位掩码(POLLIN、POLLRDNORM、POLLRDBAND、POLLPRI 以及POLLRDHUP)同输入事件相关。下一组位掩码(POLLOUT、POLLWRNORM 以及POLLWRBAND)同输出事件相关。第三组位掩码(POLLERR、POLLHUP 以及POLLNVAL)是设定在revents 字段中用来返回有关文件描述符的附加信息。如果在events 字段中指定了这些位掩码,则这三位将被忽略。在Linux 系统中,poll()不会用到最后一个位掩码POLLMSG。

         如果我们对某个特定的文件描述符上的事件不感兴趣,可以将events 设为0。另外,给fd字段指定一个负值(例如,如果值为非零,取它的相反数)将导致对应的events 字段被忽略,且events 字段将总是返回0。这两种方法都可以用来(也许只是暂时的)关闭对单个文件描述符的检查,而不需要重新建立整个fds 列表。

        注意,下面进一步列出的要点主要是关于poll()的Linux 实现.

  • 尽管被定义为不同的位掩码,POLLIN 和POLLRDNORM 是同义词。
  • 尽管被定义为不同的位掩码,POLLOUT 和 POLLWRNORM 是同义词。
  • 一般来说POLLRDBAND 是不被使用的,也就是说它在events 字段中被忽略,也不会
    设定到revents 中去。
  • 尽管在特定情形下可用于对套接字的设定,POLLWRBAND 并不会传达任何有用的信息。
    (不会出现当 POLLOUT 和 POLLWRNORM 没有设定,而设定了POLLWRBAND 的情况。)

        POLLRDBAND 和POLLWRBAND 对于提供有System V STREAMS 实现的系统来说是
有意义的(Linux 没有实现STREAMS)。在STREAMS 下,消息可以附上一个非零的优先
级,这样的消息在接收端排队时按照优先级递减的方式排列,会排在普通消息(优先级为0)
的前面。

  • 必须定义_XOPEN_SOURCE 测试宏, 这样才能在头文件<poll.h> 中得到常量
    POLLRDNORM、POLLRDBAND、POLLWRNORM 以及POLLWRBAND 的定义。
  • POLLRDHUP 是Linux 专有的标志位,从2.6.17 版内核以来就一直存在。要在头文件
    <poll.h>中得到它的定义,必须定义_GNU_SOURCE 测试宏。
  • 如果指定的文件描述符在调用poll()时关闭了,则返回POLLNVAL。

        总结以上要点,poll()真正关心的标志位就是POLLIN、POLLOUT、POLLPRI、POLLRDHUP、POLLHUP 以及POLLERR。我们在63.2.3 节中以更详尽的方式讨论这些标志位的意义。

timeout参数

        参数timeout 决定了 poll()的阻塞行为,具体如下。

  • 如果timeout 等于−1,poll()会一直阻塞直到 fds 数组中列出的文件描述符有一个达到
    就绪态(定义在对应的events 字段中)或者捕获到一个信号。
  • 如果timeout 等于0,poll()不会阻塞—只是执行一次检查看看哪个文件描述符处于
    就绪态。
  • 如果timeout 大于0,poll()至多阻塞timeout 毫秒,直到fds 列出的文件描述符中有一
    个达到就绪态,或者直到捕获到一个信号为止。

        同select()一样,timeout 的精度受软件时钟粒度的限制(见10.6 节),而SUSv3 中规定,
如果timeout 的值不是时钟粒度的整数倍,将总是向上取整。

poll()返回值

        作为函数的返回值,poll()会返回如下几种情况中的一种。

  • 返回−1 表示有错误发生。一种可能的错误是EINTR,表示该调用被一个信号处理例程中断。(如21.5 节中所注明的,如果被信号处理例程中断,poll()绝不会自动恢复。)
  • 返回0 表示该调用在任意一个文件描述符成为就绪态之前就超时了。
  • 返回正整数表示有1 个或多个文件描述符处于就绪态了。返回值表示数组fds 中拥有非零revents 字段的pollfd 结构体数量。

        注意select()同poll()返回正整数值时的细小差别。如果一个文件描述符在返回的描述符集合中出现了不止一次,系统调用select()会将同一个文件描述符计数多次。而系统调用poll()返回的是就绪态的文件描述符个数,且一个文件描述符只会统计一次,就算在相应的revents 字段中设定了多个位掩码也是如此。

示例程序

        程序清单63-2 给出了一个使用poll()的简单演示。这个程序创建了一些管道(每个管道使
用一对连续的文件描述符),将字节写到随机选择的管道写端,然后通过poll()来检查看哪个管道中有数据可进行读取。

        下面的 shell 会话展示了当我们运行该程序时会看到什么结果。程序的命令行参数指定了应
该创建10 个管道,而写操作应该随机选择其中的3 个管道。

程序清单63-2:使用poll()来检查多个文件描述符---poll_pipes.c

        

#include <time.h>
#include <poll.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>

int
main(int argc, char *argv[])
{
    int numPipes, ready, randPipe, numWrites, j;
    struct pollfd *pollFd;
    int (*pfds)[2];                     /* File descriptors for all pipes */

    if (argc < 2 || strcmp(argv[1], "--help") == 0)
    {
	printf("%s num-pipes [num-writes]\n", argv[0]);
	return -1;
    }
        

    /* Allocate the arrays that we use. The arrays are sized according
       to the number of pipes specified on command line */
    
    numPipes = atoi(argv[1]);
    pfds = calloc(numPipes,sizeof(int [2]));
    if(pfds == NULL)
    {
        perror("calloc");
        return -1;
    }
    pollFd = calloc(numPipes,sizeof(struct pollfd));
    if(pollFd == NULL)
    {
        perror("calloc");
        return -1;
    }

    /* Create the number of pipes specified on command line */

    for(j=0;j < numPipes;j++)
    {
        if(pipe(pfds[j]) == -1)
        {
            perror("pipe");
            return -1;
        }
    }

    /* Perform specified number of writes to random pipes */

    numWrites = (argc >2)?atoi(argv[2]):1;
    srandom((int) time(NULL));
    for(j=0;j<numWrites;j++){
        randPipe = random()% numPipes;
        printf("Writing to fd:%3d (read fd: %3d)\n",
                pfds[randPipe][1],pfds[randPipe][0]);
        if(write(pfds[randPipe][1],"a",1) == -1)
        {
            perror("pipe");
            return -1;
        }
    }
    /* Build the file descriptor list to be supplied to poll(). This list
       is set to contain the file descriptors for the read ends of all of
       the pipes. */

    for(j=0;j<numPipes;j++)
    {
        pollFd[j].fd = pfds[j][0];
        pollFd[j].events = POLLIN;
    }

    ready = poll(pollFd,numPipes,0);  //Nonblocking
    if(ready == -1)
    {
        perror("poll:");
        return -1;
    }

    printf("poll() returned: %d\n", ready);

    /* Check which pipes have data available for reading */
    for (j = 0; j < numPipes; j++)
        if (pollFd[j].revents & POLLIN)
            printf("Readable: %3d\n", pollFd[j].fd);

    exit(EXIT_SUCCESS);

        
}

63.2.3 文件描述符何时就绪

        正确使用select()和poll()需要理解在什么情况下文件描述符会表示为就绪态。SUSv3中说:如果对I/O 函数的调用不会阻塞,而不论该函数是否能够实际传输数据,此时文件描述符(未指定O_NONBLOCK 标志)被认为是就绪的。select()和poll()只会告诉我们I/O操作是否会阻塞,而不是告诉我们到底能否成功传输数据。按照这个思路,让我们考虑一下这些系统调用在不同类型的文件描述符上所做的操作。我们将这些信息在表格中以两列来显示。

  • select()这一列表示文件描述符是否被标记为可读(r),可写(w)还是有异常情况
    (x)
  • poll()这一列表示在revents 字段中返回的位掩码。在这些表格中,我们忽略
    POLLRDNORM、POLLWRNORM、POLLRDBAND 以及 POLLWRBAND。尽管在很
    多情况下这些标志会在revents 中返回(如果在events 字段中指定过这些标志),但它
    们相对于 POLLIN、POLLOUT、POLLHUP 以及 POLLERR 来说,并没有提供更多有
    用的信息。

普通文件

        代表普通文件的文件描述符总是被 select()标记为可读和可写。对于 poll()来说,则会在
revents 字段中返回POLLIN 和POLLOUT 标志。原因如下。

  • read()总是会立刻返回数据、文件结尾符或者错误(例如,文件并没有因为读操作而打开)。
  • write()总是会立刻传输数据或者因出现某些错误而失败。

终端和伪终端

      表63-3 总结了在终端和伪终端上(见第64 章)select()和poll()的行为表现。

        当伪终端对的其中一端处于关闭状态时,另一端由 poll()返回的 revents 将取决于具体实现。在 Linux 上至少会设置 POLLHUP 标志。但是,在其他实现上将返回各种不同的标志来表示这个事件—比如,POLLHUP、POLLERR 或者 POLLIN。此外,在一些实现中,设定什么样的标志取决于被检查的是伪终端主设备还是从设备。

管道和FIFO

        表63-4 中总结了管道或FIFO 的读端细节。“管道中有数据?”这一列表示管道中是否至少有
1 字节数据可读。在这个表格中,我们假设已经在events 字段中指定了POLLIN 标志

        在其他一些 UNIX 实现中,如果管道的写端是关闭状态,那么 poll()不会返回POLLHUP,
而会返回POLLIN 标志(因为read()遇到文件结尾符会立刻返回)。可移植性高的程序应该检
查这两个标志从而得知read()是否会阻塞。

        表63-5 总结了管道写端的细节。在这个表格中,我们假设已经在 events 字段中指定了POLLOUT 标志。“有PIPE_BUF 个字节的空间吗?”这一列表示管道中是否有足够剩余空间能够以原子方式写入 PIPE_BUF 个字节而不会阻塞。这是 Linux 判定管道是否写就绪的标准方法。其他一些UNIX 实现也采用相同的标准;还有一些实现中认为只要可以写入1 个字节,那么管道就是写就绪的。(在Linux 2.6.10 版之前,管道的负载能力就是PIPE_BUF 个字节。这表示如果管道只包含1 字节数据,那么就认为它是不可写的。

        在其他一些UNIX 实现中,如果管道的读端关闭,那么poll()并不会返回POLLERR 标志,相反,要么会返回POLLOUT,要么返回POLLHUP。可移植的程序需要检查这些标志,以此来判断write()是否会阻塞。

套接字

      表63-6 总结了 select()和 poll()在套接字上的行为表现。对于 poll()这一列,我们假设events字段已经指定了(POLLIN | POLLOUT | POLLPRI)标志位。对于select()这一列,我们假设需要检查文件描述符的输入、输出以及异常情况是否发生。(即,文件描述符在所有传递给select()的 3 个集合中都有指定)。该表只涵盖了常见的情况,并不包含所有可能出现的情况。  

        

                Linux 专有的POLLRDHUP 标志(从Linux 2.6.17 以来就一直存在)需要做进一步的解释。其实,这个标志的实际形式是EPOLLRDHUP—主要被设计用于epoll API 的边缘触发模式
下(见63.4 节)。当流式套接字连接的远端关闭了写连接时会返回该标志。使用这个标志能让
采用了 epoll 边缘触发模式的应用程序使用更简洁的代码来判断远端是否已经关闭。(另一种可
选的方法是,在应用程序中设定POLLIN 标志,然后执行一次read(),如果返回0 则表示远端
已经关闭了。)

63.2.4 比较select()和poll()

        本节中,我们讨论一些select()和poll()之间的异同点。

实现细节

        在Linux 内核层面,select()和poll()都使用了相同的内核poll 例程集合。这些poll 例程有别
于系统调用 poll()本身。每个例程都返回有关单个文件描述符就绪的信息。这个就绪信息以位掩码的形式返回,其值同poll()系统调用中返回的revents 字段中的比特值相关(见表63-2)。poll()
系统调用的实现包括为每个文件描述符调用内核poll 例程,并将结果信息填到对应的revents字段中去。

        为了实现select(),我们使用一组宏将内核poll 例程返回的信息转化为由select()返回的与
之对应的事件类型。

#define POLLIN_SET (POLLRDNORM | POLLRDBAND |POLLHUP|POLLERR)
                            /*Ready for reading*/
#define POLLOUT (POLWRBAND | POLLWRNORM | POLLOUT|POLLERR)
                    /*Ready for writing*/
#define POLLEX_SET (POLLPRI) /*Exceptional condition*/

         这些宏定义展现了 select()和 poll()所返回的信息之间的语义关系。(观察63.2.3 节的表
格中select()和poll()这两列,可以发现每个系统调用提供的信息都同上述宏保持一致。)唯
一点我们需要额外增加的是,如果被检查的文件描述符当中有一个关闭了,poll()会
在 revents 字段中返回 POLLNVAL,而 select()会返回−1 且将错误码设为EBADF

API之间的区别

         以下是系统调用select()和poll()之间的一些区别。

  • select()所使用的数据类型fd_set 对于被检查的文件描述符数量有一个上限限制(FD_SETSIZE)。在Linux 下,这个上限值默认为1024,修改这个上限需要重新编译应用程序。与之相反,poll()对于被检查的文件描述符数量本质上是没有限制的。
  • 由于select()的参数fd_set 同时也是保存调用结果的地方,如果要在循环中重复调用
    select()的话,我们必须每次都要重新初始化fd_set。而 poll()通过独立的两个字段events
    (针对输入)和revents(针对输出)来处理,从而避免每次都要重新初始化参数。
  • select()提供的超时精度(微秒)比poll()提供的超时精度(毫秒)高。(这两个系统调
    用的超时精度都受软件时钟粒度的限制。)
  • 如果其中一个被检查的文件描述符关闭了,通过在对应的 revents 字段中设定
    POLLNVAL 标记,poll()会准确告诉我们是哪一个文件描述符关闭了。与之相反,select()
    只会返回−1,并设错误码为 EBADF。通过在描述符上执行 I/O 系统调用并检查错误
    码,让我们自己来判断哪个文件描述符关闭了。通常这些区别都不重要,因为应用程
    序一般都会自己跟踪已经关闭的文件描述符。

可移植性

        历史上,select()比poll()使用得更加广泛。如今这两个接口都在SUSv3 中标准化了,且都广泛存在于现代的UNIX 实现中。但是如63.2.3 节中提到的,poll()在不同的实现中行为上会有一些差别。

性能

    当如满足如下两条中任意一条时,poll()和select()将具有相似的性能表现。

  • 待检查的文件描述符范围较小(即,最大的文件描述符号较低)。
  • 有大量的文件描述符待检查,但是它们分布得很密集。(即,大部分或所有的文件描
    述符号都在0 到某个上限之间)。

        然而,如果被检查的文件描述符集合很稀疏的话,select()和 poll()的性能差异将变得非常明显。比如,最大文件描述符号 N 是个很大的整数,但在 0 到 N 之间只有 1 个或几个文件描述符
要被检查。在这种情况下,poll()的性能表现将优于select()。我们可以通过传递给这两个系统
调用的参数来理解这其中的原因。在select()中,我们传递一个或多个文件描述符集合,以及
比待检查的集合中最大的文件描述符号还要大1 的nfds。不管我们是否要检查范围0 到nfds−1
之间的所有文件描述符,nfds 的值都不变。无论哪种情况,内核都必须在每个集合中检查nfds
个元素,以此来查明到底需要检查哪个文件描述符。与之相反,当使用 poll()时,只需要指定
我们感兴趣的文件描述符即可,内核只会去检查这些指定的文件描述符。

        Linux 2.4 版中poll()和select()在稀疏的描述符集合中性能表现差异很大。在2.6 版内核中通过一些优化手段,这个性能差异已经被极大地缩小了。

        我们将在63.4.5 节中进一步讨论select()和poll()的性能,在那一节中我们将比较这两个系
统调用同epoll 之间的性能差异。

63.2.5 select()和poll()存在的问题

        系统调用select()和poll()是用来同时检查多个文件描述符就绪状态的方法,它们是可移植的、
长期存在且被广泛使用的。但是当检查大量的文件描述符时,这两个 API 都会遇到一些问题。

  • 每次调用select()或poll(),内核都必须检查所有被指定的文件描述符,看它们是否处
    于就绪态。当检查大量处于密集范围内的文件描述符时,该操作耗费的时间将大大超
    过接下来的操作。
  • 每次调用select()或poll()时,程序都必须传递一个表示所有需要被检查的文件描述符
    的数据结构到内核,内核检查过描述符后,修改这个数据结构并返回给程序。(此外,
    对于select()来说,我们还必须在每次调用前初始化这个数据结构。)对于poll()来说,
    随着待检查的文件描述符数量的增加,传递给内核的数据结构大小也会随之增加。当检
    查大量文件描述符时,从用户空间到内核空间来回拷贝这个数据结构将占用大量
    的 CPU 时间。对于 select()来说,这个数据结构的大小固定为FD_ SETSIZE,与待检
    查的文件描述符数量无关。
  • select()或poll()调用完成后,程序必须检查返回的数据结构中的每个元素,以此查明
    哪个文件描述符处于就绪态了

        上述要点产生的结果就是随着待检查的文件描述符数量的增加,select()和 poll()所占用的
CPU 时间也会随之增加(更多细节请参见63.4.5 节)。对于需要检查大量文件描述符的程序来
说,这就产生了问题。

        select()和poll()糟糕的性能延展性源自这些 API 的局限性:通常,程序重复调用这些系统
调用所检查的文件描述符集合都是相同的,可是内核并不会在每次调用成功后就记录下它们。

        我们接下来要讨论的信号驱动I/O 以及epoll 都可以使内核记录下进程中感兴趣的文件描
述符,通过这种机制消除了select()和poll()的性能延展问题。这种解决方案可根据发生的I/O
事件来延展,而与被检查的文件描述符个数无关。结果就是,当需要检查大量的文件描述符

  1. 时,信号驱动I/O 和epoll 能提供更好的性能表现。

63.3 信号驱动I/O

        在I/O 多路复用中,进程是通过系统调用(select()或poll())来检查文件描述符上是否可以执行I/O 操作。而在信号驱动I/O 中,当文件描述符上可执行I/O 操作时,进程请求内核为自己发送一个信号。之后进程就可以执行任何其他的任务直到I/O 就绪为止,此时内核会发送信号给进程。要使用信号驱动I/O,程序需要按照如下步骤来执行。

  1. 为内核发送的通知信号安装一个信号处理例程。默认情况下,这个通知信号为SIGIO。
  2. 设定文件描述符的属主,也就是当文件描述符上可执行I/O 时会接收到通知信号的进程或
    进程组。通常我们让调用进程成为属主。设定属主可通过fcntl()的F_SETOWN 操作来完
    成:
fcntl(fd,F_SETOWN,pud)

3.通过设定O_NONBLOCK 标志使能非阻塞I/O。

4.通过打开O_ASYNC 标志使能信号驱动I/O。这可以和上一步合并为一个操作,因为它们
都需要用到fcntl()的F_SETFL 操作(见5.3 节)。

flags = fcntl(fd,F_GETFL); /*Get current flags*/
fcntl(fd,F_SETFL,flags | O_ASYNC | O_NONBLOCK);

5,调用进程现在可以执行其他的任务了。当I/O 操作就绪时,内核为进程发送一个信号,然后调用在第1 步中安装好的信号处理例程。

6,信号驱动I/O 提供的是边缘触发通知(见63.1.1 节)。这表示一旦进程被通知I/O 就绪,它就应该尽可能多地执行I/O(例如尽可能多地读取字节)。假设文件描述符是非阻塞式的,这表示需要在循环中执行I/O 系统调用直到失败为止,此时错误码为EAGAIN 或EWOULDBLOCK。

        在Linux 2.4 版及更早的时候,信号驱动I/O 能应用于套接字、终端、伪终端以及其他特定类型的设备上。Linux 2.6 版上信号驱动I/O 还可以应用到管道和FIFO 上。自Linux 2.6.25版以来,inotify 文件描述符上也可以使用信号驱动I/O 了。

            在下面几页中,我们先给出一个使用信号驱动 I/O 的例子,然后详细解释上述这些
步骤。

    示例程序

        程序清单63-3提供了一个使用信号驱动I/O 的简单例子。该程序执行前文描述的步骤,在
标准输入上使能信号驱动I/O,之后将终端置为cbreak 模式(见62.6.3 节),这样每次输入只会有一个字符。之后程序进入无限循环,所做的工作就是递增变量cnt,同时等待输入就绪。当有输入存在时,SIGIO 信号处理例程就设定一个标志gotSigio,该标志由主程序监控。当主程序看到该标志被设定后,就读取所有存在的输入字符并将它们连同变量cnt 的当前值一起打印出来。如果输入中读取到了井字符(#),程序就退出。

        下面是当我们运行该程序时会看到的输出,我们输入字符x 多次,最后跟着一个井字
符(#)。

程序清单63-3:在终端上使用信号驱动I/O --demo_sigio.c

#include <signal.h>
#include <ctype.h>
#include <fcntl.h>
#include <termios.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
//#include "tty_functions.h"      /* Declaration of ttySetCbreak() */
typedef enum { FALSE, TRUE } Boolean;

int
ttySetCbreak(int fd, struct termios *prevTermios)
{
    struct termios t;

    if (tcgetattr(fd, &t) == -1)
        return -1;

    if (prevTermios != NULL)
        *prevTermios = t;

    t.c_lflag &= ~(ICANON | ECHO);
    t.c_lflag |= ISIG;

    t.c_iflag &= ~ICRNL;

    t.c_cc[VMIN] = 1;                   /* Character-at-a-time input */
    t.c_cc[VTIME] = 0;                  /* with blocking */

    if (tcsetattr(fd, TCSAFLUSH, &t) == -1)
        return -1;

    return 0;
}
static volatile sig_atomic_t gotSigio = 0;
                                /* Set nonzero on receipt of SIGIO */

static void
sigioHandler(int sig)
{
    gotSigio = 1;
}

int
main(int argc, char *argv[])
{
    int flags, j, cnt;
    struct termios origTermios;
    char ch;
    struct sigaction sa;
    Boolean done;

    /* Establish handler for "I/O possible" signal */

    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sa.sa_handler = sigioHandler;
    if (sigaction(SIGIO, &sa, NULL) == -1)
    {
        perror("sigaction");
        return -1;
    }

    /* Set owner process that is to receive "I/O possible" signal */

    if (fcntl(STDIN_FILENO, F_SETOWN, getpid()) == -1)
    {
        perror("fcntl(F_SETOWN)");
        return -1;
    }

    /* Enable "I/O possible" signaling and make I/O nonblocking
       for file descriptor */

    flags = fcntl(STDIN_FILENO, F_GETFL);
    if (fcntl(STDIN_FILENO, F_SETFL, flags | O_ASYNC | O_NONBLOCK) == -1)
    {
        perror("fcntl(F_SETFL)");
        return -1;
    }
    /* Place terminal in cbreak mode */

    if (ttySetCbreak(STDIN_FILENO, &origTermios) == -1)
    {
        perror("ttySetCbreak:");
        return -1;
    }


    for (done = FALSE, cnt = 0; !done ; cnt++) {
        for (j = 0; j < 100000000; j++)
            continue;                   /* Slow main loop down a little */

        if (gotSigio) {                 /* Is input available? */
            gotSigio = 0;

            /* Read all available input until error (probably EAGAIN)
               or EOF (not actually possible in cbreak mode) or a
               hash (#) character is read */

            while (read(STDIN_FILENO, &ch, 1) > 0 && !done) {
                printf("cnt=%d; read %c\n", cnt, ch);
                done = ch == '#';
            }
        }
    }

    /* Restore original terminal settings */

    if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &origTermios) == -1)
    {
        perror("tcsetattr:");
        return -1;
    }

    exit(EXIT_SUCCESS);
}

在启动信号驱动I/O前安装信号处理例程

        由于接收到SIGIO 信号的默认行为是终止进程运行,因此我们应该在启动信号驱动I/O 前先为SIGIO 信号安装处理例程。如果我们在安装SIGIO 信号处理例程之前先启动了信号驱动I/O,那么会存在一个时间间隙,此时如果I/O 就绪的话内核发送过来的SIGIO 信号就会使进程终止运行。

设定文件描述符属主

        我们使用fcntl()来设定文件描述符的属主,方式如下。

fcntl(fd,F_SETOWN,pid);

        我们可以指定一个单独的进程或者是进程组中的所有进程在文件描述符I/O 就绪时收到
信号通知。如果参数pid 为正整数,就解释为进程ID 号。如果参数pid 是负数,它的绝对值就指定了进程组ID 号。

        通常会在pid 中指定调用进程的进程ID 号(这样信号就会发送给打开这个文件描述符的进程)。但是,也可以将其指定为另一个进程或进程组(例如,调用者进程组),而信号会发送给这个目标,取决于如20.5 节中所述的权限检查,这里发送进程会作为完成F_SETOWN操作的进程。

        当指定的文件描述符上可执行I/O 时,fcntl()的F_GETOWN 操作会返回接收到信号的进
程或进程组ID 号。

id = fcntl(fd,F_GETOWN);
if(id == -1)
{
    perror("fcntl:");
    return -1;
}

        进程组ID 号以负数的形式由该调用返回。

        系统调用约定在某些Linux 所支持的架构上(值得注意的是x86 架构)有一些限制。
这意味着如果文件描述符由一个进程组ID 小于 4096 的进程所持有,那么 fcntl()的
G_GETOWN 操作不会以负数形式返回这个 ID 号,glibc 会错误地认为这是一个系统调用
错误。结果就是,fcntl()的包装函数会返回−1,同时errno 中会包含该进程组ID(正数形
式)。这是因为内核系统调用接口返回负数形式的 errno 值作为函数返回值,以此来表示
出现了错误。而在一些情况下,有必要将这样的结果同成功调用后返回的合法的负数值
区分开来。要做到区分,glibc 将系统调用返回的−1 到−4095 之间的负数解释为出现错误,
将它们的值(以绝对值的形式)拷贝到errno 中,然后返回−1 作为函数结果。这种技术
足以应对那些可以合法返回负数值的系统调用服务了。fcntl()的F_GETOWN 操作是唯一
会出现这种失败情况的例子。这个限制意味着使用进程组来接收“I/O 就绪”信号(并
不常见)的应用程序无法可靠地通过F_GETOWN 来获知该进程组是否拥有一个文件描
述符。

63.3.1 何时发送 “I/O就绪信号”

        现在我们针对多种文件类型考虑何时会发送“I/O 就绪”信号。

终端和伪终端        

     对于终端和伪终端,当产生新的输入时会生成一个信号,即使之前的输入还没有被读取也是如此。如果终端上出现文件结尾的情况,此时也会发送“输入就绪”的信号(但伪终端上不会)。
        对于终端来说没有“输出就绪”的信号。当终端断开连接时也不会发出信号。
        从 2.4.19 版内核开始,Linux 对伪终端的从设备端提供了“输出就绪”的信号。当伪终端主设备侧读取了输入后就会产生这个信号。   

管道和FIFO

        对于管道或FIFO 的读端,信号会在下列情况中产生。

  • 数据写入到管道中(即使已经有未读取的输入存在)。
  • 管道的写端关闭。

        对于管道或FIFO 的写端,信号会在下列情况中产生。

  • 对管道的读
  • 操作增加了管道中的空余空间大小,因此现在可以写入PIPE_BUF 个字节
    而不被阻塞。
  • 管道的读端关闭。

套接字

        信号驱动I/O 可适用于UNIX 和Internet 域下的数据报套接字。信号会在下列情况中
产生。

  • 一个输入数据报到达套接字(即使已经有未读取的数据报正等待读取)。
  • 套接字上发生了异步错误。

        信号驱动I/O 可适用于UNIX 和Internet 域下的流式套接字。信号会在下列情况中产生。

  • 监听套接字上接收到了新的连接。
  • TCP connect()请求完成,也就是TCP 连接的主动端进入ESTABLISHED 状态,如图
    61-5 所示。对于UNIX 域套接字,类似情况下是不会发出信号的。
  • 套接字上接收到了新的输入(即使已经有未读取的输入存在)。
  • 套接字对端使用shutdown()关闭了写连接(半关闭),或者通过close()完全关闭。
  • 套接字上输出就绪(例如套接字发送缓冲区中有了空间)。
  • 套接字上发生了异步错误。

inotify 文件描述符

        当inotify 文件描述符成为可读状态时会产生一个信号—也就是由inotify 文件描述符监
视的其中一个文件上有事件发生时。

63.3.2 优化信号驱动I/O的使用

    在需要同时检查大量文件描述符(比如数千个)的应用程序中—例如某种类型的网络服务端程序—同select()和poll()相比,信号驱动I/O 能提供显著的性能优势。信号驱动I/O能达到这么高的性能是因为内核可以“记住”要检查的文件描述符,且仅当I/O 事件实际发生在这些文件描述符上时才会向程序发送信号。结果就是采用信号驱动I/O 的程序性能可以根据发生的I/O 事件的数量来扩展,而与被检查的文件描述符的数量无关。    

要想全部利用信号驱动I/O 的优点,我们必须执行下面两个步骤。

  • 通过专属于Linux 的fcntl() F_SETSIG 操作来指定一个实时信号,当文件描述符上的
    I/O 就绪时,这个实时信号应该取代SIGIO 被发送。
  • 使用sigaction()安装信号处理例程时,为前一步中使用的实时信号指定SA_ SIGINFO
    标记(见21.4 节)。

        fcntl()的F_SETSIG 操作指定了一个可选的信号,当文件描述符上的I/O 就绪时会取代
SIGIO 信号被发送。

if(fcntl(fd,F_SETSIG,sig) == -1)
{
    perror("fcntl:");
}

        F_GETSIG 操作完成的任务同F_SETSIG 相反,它取回当前为文件描述符指定的信号。

sig = fcntl(fd,F_GETSIG);
if(sig == -1)
{
    perror("fcntl:");
    return -1;
}

        (为了在头文件<fcntl.h>中得到F_SETSIG 和 F_GETSIG 的定义,我们必须定义测试宏
_GNU_SOURCE。)
        使用F_SETSIG 来改变用于通知“I/O 就绪”的信号有两个理由,如果我们需要在多个文件描述符上检查大量的I/O 事件,这两个理由都是必须的。

  • 默认的“I/O 就绪”信号SIGIO 是标准的非排队信号之一。如果有多个 I/O 事件发
    送了信号,而SIGIO 被阻塞了—也许是因为SIGIO 信号的处理例程已经被调用
    了—除了第一个通知外,其他后序的通知都会丢失。如果我们通过F_SETSIG 来指
    定一个实时信号作为“I/O 就绪”的通知信号,那么多个通知就能排队处理。
  • 如果信号处理例程是通过 sigaction()来安装,且在 sa.sa_flags 字段中指定了SA_
    SIGINFO 标志,那么结构体 siginfo_t 会作为第二个参数传递给信号处理例程(见
    21.4 节)。这个结构体包含的字段标识出了在哪个文件描述符上发生了事件,以及事
    件的类型。

        注意,需要同时使用F_SETSIG 以及SA_SIGINFO 才能将一个合法的siginfo_t 结构体传
递到信号处理例程中去。

        如果我们做F_SETSIG 操作时将参数sig 指定为0,那么将导致退回到默认的行为:发送的
信号仍然是SIGIO,而且结构体siginfo_t 将不会传递给信号处理例程。

        对于“I/O 就绪”事件,传递给信号处理例程的结构体siginfo_t 中与之相关的字段如下。

  • si_signo:引发信号处理例程得到调用的信号值。这个值同信号处理例程的第一个参数
    一致。
  • si_fd:发生I/O 事件的文件描述符。
  • si_code:表示发生事件类型的代码。该字段中可出现的值以及它们的描述参见表63-7。
  • si_band:一个位掩码。其中包含的位和系统调用 poll()中返回的 revents 字段中的位相
    同。如表63-7 所示,si_code 中可出现的值同si_band 中的位掩码有着一一对应的关系。

        在一个纯输入驱动的应用程序中,我们可以进一步优化使用F_SETSIG。我们可以阻塞待发出的“I/O 就绪”信号,然后通过sigwaitinfo()或sigtimedwait()(见22.10 节)来接收排队中的信号。这些系统调用返回的siginfo_t 结构体所包含的信息同传递给信号处理例程的siginfo_t结构体一样。以这种方式接收信号,我们实际是以同步的方式在处理事件,但同select()和poll()相比,这种方法能够高效地获知文件描述符上发生的I/O 事件。

信号队列溢出的处理

         我们在22.8 节中已经知道,可以排队的实时信号的数量是有限的。如果达到这个上限,内核对于“I/O 就绪”的通知将恢复为默认的SIGIO 信号。出现这种现象表示信号队列溢出了。当出现这种情况时,我们将失去有关文件描述符上发生I/O 事件的信息,因为SIGIO 信号是不会排队的。(此外,SIGIO 的信号处理例程不接受siginfo_t 结构体参数,这意味着信号处理例程不能确定是哪一个文件描述符上产生了信号。)

       根据 22.8 节中所述,我们可以通过增加可排队的实时信号数量的限制来减小信号队列溢出的可能性。但是这并不能完全消除溢出的可能。一个设计良好的采用F_SETSIG 来建立实时信号
作为“I/O 就绪”通知的程序必须也要为信号SIGIO 安装处理例程。如果发送了SIGIO 信号,
那么应用程序可以先通过sigwaitinfo()将队列中的实时信号全部获取,然后临时切换到select()
或poll(),通过它们获取剩余的发生I/O 事件的文件描述符列表。

在多线程程序中使用信号驱动I/O

        从2.6.32 版内核开始,Linux 提供了两个新的非标准的fcntl()操作,可用于设定接收“I/O 就
绪”信号的目标,它们是F_SETOWN_EX 和F_GETOWN_EX。

        F_SETOWN_EX 操作类似于F_SETOWN,但除了允许指定进程或进程组作为接收信号的
目标外,它还可以指定一个线程作为“I/O 就绪”信号的目标。对于这个操作,fcntl()的第三个参数为指向如下结构体的指针。

struct f_owner_ex {
    int type;
    pid_t pid;
};

        结构体中type 字段定义了pid 的类型,它可以有如下几种值。

F_OWNER_PGRP
        字段pid 指定了作为接收“I/O 就绪”信号的进程组ID。与F_SETOWN 不同的是,这里进程组ID 指定为一个正整数。
F_OWNER_PID
        字段pid 指定了作为接收“I/O 就绪”信号的进程ID。
F_OWNER_TID
        字段pid 指定了作为接收“I/O 就绪”信号的线程ID。这里pid 的值为clone()或getpid()的返回值。
        F_GETOWN_EX 为F_SETOWN_EX 的逆操作。它使用fcntl()的第三个参数所指向的结构
体f_owner_ex 来返回之前由F_SETOWN_EX 操作所定义的设置。

63.4 epoll 编程接口

        同I/O 多路复用和信号驱动I/O 一样,Linux 的epoll(event poll)API 可以检查多个文件描述符上的I/O 就绪状态。epoll API 的主要优点如下。

  • 当检查大量的文件描述符时,epoll 的性能延展性比select()和poll()高很多。
  • epoll API 既支持水平触发也支持边缘触发。与之相反,select()和poll()只支持水平触发,而信号驱动I/O 只支持边缘触发。

        性能表现上,epoll 同信号驱动I/O 相似。但是,epoll 有一些胜过信号驱动I/O 的优点。

  • 可以避免复杂的信号处理流程(比如信号队列溢出时的处理)。
  • 灵活性高,可以指定我们希望检查的事件类型(例如,检查套接字文件描述符的读就
    绪、写就绪或者两者同时指定)。

        epoll API 是 Linux 系统专有的,在2.6 版中新增。

        epoll API 的核心数据结构称作 epoll 实例,它和一个打开的文件描述符相关联。这个文件
描述符不是用来做 I/O 操作的,相反,它是内核数据结构的句柄,这些内核数据结构实现了两
个目的。

  • 记录了在进程中声明过的感兴趣的文件描述符列表—interest list(兴趣列表)。
  • 维护了处于I/O 就绪态的文件描述符列表—ready list(就绪列表)。

        ready list 中的成员是interest list 的子集。

        对于由 epoll 检查的每一个文件描述符,我们可以指定一个位掩码来表示我们感兴趣的事
件。这些位掩码同poll()所使用的位掩码有着紧密的关联。

  • 系统调用epoll_create()创建一个epoll 实例,返回代表该实例的文件描述符。
  • 系统调用epoll_ctl()操作同epoll 实例相关联的兴趣列表。通过epoll_ctl(),我们可以增
    加新的描述符到列表中,将已有的文件描述符从该列表中移除,以及修改代表文件描述
    符上事件类型的位掩码。
  • 系统调用epoll_wait()返回与epoll 实例相关联的就绪列表中的成员。

63.4.1 创建epoll 实例:epoll_create()

        系统调用epoll_create()创建了一个新的epoll 实例,其对应的兴趣列表初始化为空。

#include <sys/epoll.h>
int epoll_creat(int size);
    Returns file descriptor on success, or -1 on error

         参数size 指定了我们想要通过epoll 实例来检查的文件描述符个数。该参数并不是一个上限,而是告诉内核应该如何为内部数据结构划分初始大小。(从Linux 2.6.8 版以来,size 参数被忽略不用,因为内核实现做了修改意味着该参数之前提供的信息已经不再需要了。)

        作为函数返回值,epoll_create()返回了代表新创建的epoll 实例的文件描述符。这个文件描述符在其他几个epoll 系统调用中用来表示epoll 实例。当这个文件描述符不再需要时,应该通过close()来关闭。当所有与epoll 实例相关的文件描述符都被关闭时,实例被销毁,相关的资源都返还给系统。(多个文件描述符可能引用到相同的epoll 实例,这是由于调用了fork()或者dup()这样类似的函数所致。)

63.4.2 修改epoll 的兴趣列表:epoll_ctl()

        系统调用epoll_ctl()能够修改由文件描述符epfd 所代表的epoll 实例中的兴趣列表。

#include <sys/poll.h>
int epoll_ctl(int epfd,int op,int fd,struct epoll_event *ev);
                        Returns 0 on success,or -1 on error    

        参数fd 指明了要修改兴趣列表中的哪一个文件描述符的设定。该参数可以是代表管道、
FIFO、套接字、POSIX 消息队列、inotify 实例、终端、设备,甚至是另一个epoll 实例的文件
描述符(例如,我们可以为受检查的描述符建立起一种层次关系)。但是,这里fd 不能作为普
通文件或目录的文件描述符(会出现EPERM 错误)。

        参数op 用来指定需要执行的操作,它可以是如下几种值。

EPOLL_CTL_ADD
        将描述符fd 添加到epoll 实例epfd 中的兴趣列表中去。对于fd 上我们感兴趣的事件,都指定在ev 所指向的结构体中,下面会详细介绍。如果我们试图向兴趣列表中添加一个已存在的文件描述符,epoll_ctl()将出现EEXIST 错误。
EPOLL_CTL_MOD
        修改描述符fd 上设定的事件,需要用到由ev 所指向的结构体中的信息。如果我们试图修改不在兴趣列表中的文件描述符,epoll_ctl()将出现ENOENT 错误。
EPOLL_CTL_DEL
        将文件描述符fd 从epfd 的兴趣列表中移除。该操作忽略参数ev。如果我们试图移除一个不在epfd 的兴趣列表中的文件描述符,epoll_ctl()将出现ENOENT 错误。关闭一个文件描述符会自动将其从所有的epoll 实例的兴趣列表中移除。

        参数ev 是指向结构体epoll_event 的指针,结构体的定义如下。

struct epoll_event{
    uint32_t events;  /*epoll events (bit mask)*/
    epoll_data_t data;  /*User data*/
};

        结构体epoll_event 中的data 字段的类型为:

typedef union qpoll_data{
    void *ptr;  /*Pointer to user-defined data*/
    int fd;     /*File descriptor*/
    uint32_t u32;  /*32-bit integer*/
    uint64_t u64;  /*64-bit integer*/
}epoll_data_t;

参数ev 为文件描述符fd 所做的设置如下。

  • 结构体 epoll_event 中的 events 字段是一个位掩码,它指定了我们为待检查的描述符 fd 上所感兴趣的事件集合。我们将在下一节中说明该字段可使用的掩码值。
  • data 字段是一个联合体,当描述符fd 稍后成为就绪态时,联合体的成员可用来指定传回给调用进程的信息。

程序清单63-4 展示了一个使用epoll_create()和epoll_ctl()的例子。

程序清单63-4:使用epoll_create()和epoll_ctl()

int epfd;
struct epoll_event ev;
epfd = epoll_create(5);
if(epfd == -1)
{
    perror("epoll_create:");
    return -1;
}

ev.data.fd = fd;
ev.events = EPOLLIN;
if(epoll_ctl(epfd,EPOLL_CTL_ADD,fd,ev) == -1)
{
    perror("epoll_create:");
    return -1;
}

max_user_watches 上限

        因为每个注册到 epoll 实例上的文件描述符需要占用一小段不能被交换的内核内存空间,
因此内核提供了一个接口用来定义每个用户可以注册到 epoll 实例上的文件描述符总数。这个
上限值可以通过 max_user_watches 来查看和修改。max_user_watches 是专属于Linux 系统的
/proc/sys/fd/epoll 目录下的一个文件。默认的上限值根据可用的系统内存来计算得出(参见
epoll(7)的用户手册页)。

63.4.3 事件等待:epoll_wait()

        系统调用epoll_wait()返回epoll 实例中处于就绪态的文件描述符信息。单个 epoll_wait()调
用能返回多个就绪态文件描述符的信息。

#include <sys/poll.h>
int epoll_wait(int epfd,struct epoll_event *evlist,int maxevents,
                int timeout);
    Returns number of ready file descriptors,0 on timeout,or -1 on error

       参数evlist 所指向的结构体数组中返回的是有关就绪态文件描述符的信息。(结构体
epoll_event 已经在上一节中描述。)数组evlist 的空间由调用者负责申请,所包含的元素个数
在参数maxevents 中指定。 

        在数组evlist 中,每个元素返回的都是单个就绪态文件描述符的信息。events 字段返回了在该描述符上已经发生的事件掩码。Data 字段返回的是我们在描述符上使用 cpoll_ctl()注册感兴趣的事件时在ev.data 中所指定的值。注意,data 字段是唯一可获知同这个事件相关的文件描述符号的途径。因此,当我们调用epoll_ctl()将文件描述符添加到兴趣列表中时,应该要么将ev.data.fd 设为文件描述符号(如程序清单63-4 中所示),要么将ev.data.ptr 设为指向包含文件描述符号的结构体。

        参数timeout 用来确定epoll_wait()的阻塞行为,有如下几种。

  • 如果timeout 等于−1,调用将一直阻塞,直到兴趣列表中的文件描述符上有事件产生,或者直到捕获到一个信号为止。
  • 如果timeout 等于0,执行一次非阻塞式的检查,看兴趣列表中的文件描述符上产生了哪个事件。
  • 如果timeout 大于0,调用将阻塞至多timeout 毫秒,直到文件描述符上有事件发生,或者直到捕获到一个信号为止。

        调用成功后,epoll_wait()返回数组evlist 中的元素个数。如果在timeout 超时间隔内没有任何文件描述符处于就绪态的话,返回0。出错时返回−1,并在errno 中设定错误码以表示错误原因。

        在多线程程序中,可以在一个线程中使用epoll_ctl()将文件描述符添加到另一个线程中由epoll_wait()所监视的epoll 实例的兴趣列表中去。这些对兴趣列表的修改将立刻得到处理,而epoll_wait()调用将返回有关新添加的文件描述符的就绪信息。

epoll 事件        

        当我们调用epoll_ctl()时可以在ev.events 中指定的位掩码以及由 epoll_wait()返回的evlist[].events 中的值在表63-8 中给出。除了有一个额外的前缀E 外,大多数这些位掩码的名称同poll()中对应的事件掩码名称相同。(例外情况是EPOLLET 和 EPOLLONESHOT,下面我们会给出更详细的说明。)这种名称上有着对应关系的原因是当我们在 epoll_ctl()中指定输入,或通过epoll_wait()得到输出时,这些比特位表达的意思同对应的 poll()的事件掩码所表达的意思一样。

EPOLLONESHOT 标志

       默认情况下,一旦通过epoll_ctl()的EPOLL_CTL_ADD 操作将文件描述符添加到epoll 实例的兴趣列表中后,它会保持激活状态(即,之后对epoll_wait()的调用会在描述符处于就绪态时通知我们)直到我们显式地通过 epoll_ctl()的EPOLL_CTL_DEL 操作将其从列表中移除。如果我们希望在某个特定的文件描述符上只得到一次通知,那么可以在传给epoll_ctl()的ev.events 中指定EPOLLONESHOT(从 Linux 2.6.2 版开始支持)标志。如果指定了这个标志,那么在下一个epoll_wait()调用通知我们对应的文件描述符处于就绪态之后,这个描述符就会在兴趣列表中被标记为非激活态,之后的epoll_wait()调用都不会再通知我们有关这个描述符的状态了。如果需要,我们可以稍后通过 epoll_ctl()的EPOLL_CTL_ MOD 操作重新激活对这个文件描述符的检查。(这种情况下不能用EPOLL_CTL_ADD 操作,因为非激活态的文件描述符仍然还在epoll 实例的兴趣列表中。  

  程序示例 

     程序清单63-5 展示了应该如何使用epoll API。命令行参数表示该程序期望得到一个或多个
终端或者FIFO 的路径名。该程序执行如下步骤。

  •    创建一个epoll 实例
  • 打开由命令行参数指定的每个文件,以此作为输入,并将得到的文件描述符添加到epoll 实例的兴趣列表中。将需要检查的事件集合设定为EPOLLIN。
  • 执行一个循环,在循环中调用epoll_wait()来检查epoll 实例的兴趣列表中的文件描述符,并处理每个调用返回的事件。对于这个循环,请注意以下几点.

        — 在epoll_wait()调用之后,程序检查是否返回了 EINTR 错误码⑥。如果在 epoll_wait()调用执行期间程序被一个信号打断,之后又通过SIGCONT 信号恢复执行,此时就可能出现这个错误(见21.5 节)。如果出现这种情况,程序会重新调用epoll_wait()。
        — 如果epoll_wait()调用成功,程序就再执行一个内层循环检查evlist 中每个已就绪的元素。对于evlist 中的每个元素,程序不只是检查 events 字段中的EPOLLIN 标记⑧,EPOLLHUP 和EPOLLERR⑨标记也要检查。后两种事件会在FIFO 的对端关闭,或者当终端挂起时出现。如果返回的是EPOLLIN,程序从对应的文件描述符中读取一些输入并在标准输出上打印出来。否则,如果返回的是EPOLLHUP 或EPOLLERR,程序就关闭对应的文件描述符⑩并递减打开文件数的统计量(numOpenFds)。
        — 当所有打开的文件描述符都被关闭后,循环终止(当numOpenFds 等于0 时)。

        下面的 shell 会话演示了程序清单 63-5 中所示程序的使用。我们用到了两个终端窗口,在其中一个窗口上用该程序来检查两个FIFO 文件的输入。(如44.7 节中描述的,程序打开的每个FIFO 文件,其读操作只会在另一个进程打开FIFO 文件做写操作后才能完成)在另外一个窗口上,我们运行cat(1)程序将数据写到这些FIFO 中去。

 

 程序清单63-5:使用epoll API-----epoll_input.c

#include <sys/epoll.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>

#define MAX_BUF     1000        /* Maximum bytes fetched by a single read() */
#define MAX_EVENTS     5        /* Maximum number of events to be returned from
                                   a single epoll_wait() call */

int
main(int argc, char *argv[])
{
    int epfd, ready, fd, s, j, numOpenFds;
    struct epoll_event ev;
    struct epoll_event evlist[MAX_EVENTS];
    char buf[MAX_BUF];

    if (argc < 2 || strcmp(argv[1], "--help") == 0)
    {
        printf("%s file...\n", argv[0]);
        return -1;
    }

    epfd = epoll_create(argc - 1);
    if (epfd == -1)
    {
        perror("epoll_create");
        return 1;
    }

    /* Open each file on command line, and add it to the "interest
       list" for the epoll instance */

    for (j = 1; j < argc; j++) {
        fd = open(argv[j], O_RDONLY);
        if (fd == -1)
        {
            perror("open");
            return 1;
        }   
        printf("Opened \"%s\" on fd %d\n", argv[j], fd);

        ev.events = EPOLLIN;            /* Only interested in input events */
        ev.data.fd = fd;
        if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == -1)
        {
            perror("epoll_ctl");
            return 1;
        }   
    }

    numOpenFds = argc - 1;

    while (numOpenFds > 0) {

        /* Fetch up to MAX_EVENTS items from the ready list of the
           epoll instance */

        printf("About to epoll_wait()\n");
        ready = epoll_wait(epfd, evlist, MAX_EVENTS, -1);
        if (ready == -1) {
            if (errno == EINTR)
                continue;               /* Restart if interrupted by signal */
            else
            {
                perror("epoll_wait");
                return 1;
            }  
        }

        printf("Ready: %d\n", ready);

        /* Deal with returned list of events */

        for (j = 0; j < ready; j++) {
            printf("  fd=%d; events: %s%s%s\n", evlist[j].data.fd,
                    (evlist[j].events & EPOLLIN)  ? "EPOLLIN "  : "",
                    (evlist[j].events & EPOLLHUP) ? "EPOLLHUP " : "",
                    (evlist[j].events & EPOLLERR) ? "EPOLLERR " : "");

            if (evlist[j].events & EPOLLIN) {
                s = read(evlist[j].data.fd, buf, MAX_BUF);
                if (s == -1)
                {
                    perror("read");
                    return 1;
                }  
                printf("    read %d bytes: %.*s\n", s, s, buf);

            } else if (evlist[j].events & (EPOLLHUP | EPOLLERR)) {

                /* After the epoll_wait(), EPOLLIN and EPOLLHUP may both have
                   been set. But we'll only get here, and thus close the file
                   descriptor, if EPOLLIN was not set. This ensures that all
                   outstanding input (possibly more than MAX_BUF bytes) is
                   consumed (by further loop iterations) before the file
                   descriptor is closed. */

                printf("    closing fd %d\n", evlist[j].data.fd);
                if (close(evlist[j].data.fd) == -1)
                {
                    perror("close:");
                    return 1;
                }  
                numOpenFds--;
            }
        }
    }

    printf("All file descriptors closed; bye\n");
    exit(EXIT_SUCCESS);
}

63.4.4 深入探究epoll的语义

        现在我们来看看打开的文件同文件描述符以及 epoll 之间交互的一些细微之处。基于本次讨论的目的,回顾一下图5-2 中展示的文件描述符,打开的文件描述(file description),以及整个系统的文件i-node 表之间的关系。

        当我们通过epoll_create()创建一个epoll 实例时,内核在内存中创建了一个新的i-node 并打开文件描述,随后在调用进程中为打开的这个文件描述分配一个新的文件描述符。同epoll 实例的兴趣列表相关联的是打开的文件描述,而不是 epoll 文件描述符。这将产生下列结果。

  • 如果我们使用dup()(或类似的函数)复制一个epoll 文件描述符,那么被复制的描述符所指代的epoll 兴趣列表和就绪列表同原始的epoll 文件描述符相同。若要修改兴趣列表,在epoll_ctl()的参数epfd 上设定文件描述符可以是原始的也可以是复制的。
  • 上一条观点同样也适用于 fork()调用之后的情况。此时子进程通过继承复制了父进程的epoll 文件描述符,而这个复制的文件描述符所指向的epoll 数据结构同原始的描述符相同。

        当我们执行 epoll_ctl()的EPOLL_CTL_ADD 操作时,内核在 epoll 兴趣列表中添加了一个元素,这个元素同时记录了需要检查的文件描述符数量以及对应的打开文件描述的引用。epoll_wait()调用的目的就是让内核负责监视打开的文件描述。这表示我们必须对之前的观点做改进:如果一个文件描述符是epoll 兴趣列表中的成员,当关闭它后会自动从列表中移除。改进版应该是这样的:一旦所有指向打开的文件描述的文件描述符都被关闭后,这个打开的文件描述将从epoll 的兴趣列表中移除。这表示如果我们通过 dup()(或类似的函数)或者fork()为打开的文件创建了描述符副本,那么这个打开的文件只会在原始的描述符以及所有其他的副本都被关闭时才会移除。

        这些语义可导致出现某些令人惊讶的行为。假设我们执行程序清单63-6 中所示的代码。即使文件描述符 fd1 已经被关闭了,这段代码中的epoll_wait()调用也会告诉我们fd1已就绪(换句话说,evlist[0].data.fd 的值等于fd1)。这是因为还有一个打开的文件描述符fd2 存在,它所指向的文件描述信息仍包含在epoll 的兴趣列表中。当两个进程持有对同一个打开文件的文件描述符副本时(一般是由于 fork()调用),也会出现相似的场景。执行 epoll_wait()操作的进程已经关闭了文件描述符,但另一个进程仍然持有打开的文件描述符副本。

程序清单63-6:epoll 在文件描述符副本下的语义

int epfd,fd1,fd2
struct epoll_event ev;
sruct epoll_event evlist[MAX_EVENTS];

/*Omitted:code to open 'fd1' and create epoll file descriptor 
'epfd'...*/

ev.data.fd = fd1;
ev.events = EPOLLIN;
if(epoll_ctl(epfd,EPOLL_CTL_ADD,fd1,ev) == -1)
{
    perror("epoll_ctrl:");
    return -1;
}

/*Suppose that 'fd1' now happens to become ready for input*/

fd2 = dup(fd1);
close(fd1);
ready = epoll_wait(epfd,evlist,MAX_EVENTS,-1);
if(ready == -1)
{
    perror("epoll_wait:");
    return -1;
}

63.4.5 epoll 同I/O 多路复用的性能对比

        表63-9 展示了当我们使用poll()、select()以及epoll 监视 0 到N−1 的N 个连续文件描述符时的结果(在2.6.25 版内核上)。(该测试设定为在每次监视中,只有一个随机选择的文件描述符处于就绪态。)从这个表格中,我们发现随着被监视的文件描述符数量的上升,poll()和select()的性能表现越来越差。与之相反,当N 增长到很大的值时,epoll 的性能表现几乎不会降低。(当N 值上升时,微小的性能下降可能是由于测试系统上的CPU cache达到了上限。)

         在63.2.5 节中我们知道了为什么 select()和 poll()在监视大量的文件描述符时性能表现很
差。现在我们看看为什么epoll 的性能表现会更好。

  • 每次调用select()和poll()时,内核必须检查所有在调用中指定的文件描述符。与之相反,当通过epoll_ctl()指定了需要监视的文件描述符时,内核会在与打开的文件描述上下文相关联的列表中记录该描述符。之后每当执行 I/O 操作使得文件描述符成为就绪态时,内核就在epoll 描述符的就绪列表中添加一个元素。(单个打开的文件描述上下文中的一次I/O 事件可能导致与之相关的多个文件描述符成为就绪态。)之后的epoll_wait()调用从就绪列表中简单地取出这些元素。
  • 每次调用 select()或 poll()时,我们传递一个标记了所有待监视的文件描述符的数据结构给内核,调用返回时,内核将所有标记为就绪态的文件描述符的数据结构再传回给我们。与之相反,在 epoll 中我们使用 epoll_ctl()在内核空间中建立一个数据结构,该数据结构会将待监视的文件描述符都记录下来。一旦这个数据结构建立完成,稍后每次调用 epoll_wait()时就不需要再传递任何与文件描述符有关的信息给内核了,而调用返回的信息中只包含那些已经处于就绪态的描述符。

        除了以上几点外,对于select()来说,我们必须在每次调用之前先初始化输入数据。而无论是select()还是poll(),我们必须对返回的数据结构做检查,以此找出N 个文件描述符中有哪些是处于就绪态的。但是,通过一些测试得出的结果表明,这些额外的步骤所花费的时间同系统调用监视N 个文件描述符所花费的时间相比就显得微不足道了。表63-9 并没有包含这些检查步骤所用的时间。

        粗略来看,我们可以认为当N(被监视的文件描述符数量)取值很大时,select()和poll()的性能会随着N 的增大而线性下降。这可以从表63-9 中N=100 和N=1000 时的情况得到。而当N=10000 时,性能伸缩性实际上比线性还要差。

        与之相反的是,epoll 的性能会根据发生I/O 事件的数量而扩展(呈线性)。因此常见的能
够高效使用epoll API 的应用场景就是需要同时处理许多客户端的服务器:需要监视大量的文
件描述符,但大部分处于空闲状态,只有少数文件描述符处于就绪态。

        要使用边缘触发通知,我们在调用epoll_ctl()时在ev.events 字段中指定EPOLLET
标志。

struct epoll_event ev;
ev.daata.fd = fd;
ev.events = EPOLLIN | EPOLLET;
if(epoll_ctrl(epfd,EPOLL_CTL_ADD,fd.ev) == -1)
{
    perror("epoll_ctrl:");
    return -1;
}

        我们通过一个例子来说明epoll 的水平触发和边缘触发通知之间的区别。假设我们使用
epoll 来监视一个套接字上的输入(EPOLLIN),接下来会发生如下的事件。

  1. 套接字上有输入到来。
  2. 我们调用一次epoll_wait()。无论我们采用的是水平触发还是边缘触发通知,该调用都会告诉我们套接字已经处于就绪态了。
  3. 再次调用epoll_wait()。

        如果我们采用的是水平触发通知,那么第二个epoll_wait()调用将告诉我们套接字处于就绪态。而如果我们采用边缘触发通知,那么第二个epoll_wait()调用将阻塞,因为自从上一次调用epoll_wait()以来并没有新的输入到来。

        正如我们在 63.1.1 节中提到的,边缘触发通知通常和非阻塞的文件描述符结合使用。因而,采用epoll 的边缘触发通知机制的程序基本框架如下。

  1. 让所有待监视的文件描述符都成为非阻塞的。
  2. 通过epoll_ctl()构建epoll 的兴趣列表。
  3. 通过如下的循环处理I/O 事件。

              (a)通过epoll_wait()取得处于就绪态的描述符列表。
                (b)针对每一个处于就绪态的文件描述符,不断进行I/O 处理直到相关的系统调用(例
                如read()、write()、recv()、send()或accept())返回EAGAIN 或EWOULDBLOCK
                错误。  

当采用边缘触发通知时避免出现文件描述符饥饿现象

        假设我们采用边缘触发通知监视多个文件描述符,其中一个处于就绪态的文件描述符
上有着大量的输入存在(可能是一个不间断的输入流)。如果在检测到该文件描述符处于
就绪态后,我们将尝试通过非阻塞式的读操作将所有的输入都读取,那么此时就会有使其
他的文件描述符处于饥饿状态的风险存在(即,在我们再次检查这些文件描述符是否处于
就绪态并执行I/O 操作前会有很长的一段处理时间)。该问题的一种解决方案是让应用程序
维护一个列表,列表中存放着已经被通知为就绪态的文件描述符。通过一个循环按照如下
方式不断处理。

  1. 调用 epoll_wait()监视文件描述符,并将处于就绪态的描述符添加到应用程序维护的
    列表中。如果这个文件描述符已经注册到应用程序维护的列表中了,那么这次监视
    操作的超时时间应该设为较小的值或者是 0。这样如果没有新的文件描述符成为就
    绪态,应用程序就可以迅速进行到下一步,去处理那些已经处于就绪态的文件描述
    符了。
  2. 在应用程序维护的列表中,只在那些已经注册为就绪态的文件描述符上进行一定限度
    的 I/O 操作(可能是以轮转调度(round-robin)方式循环处理,而不是每次epoll_wait()
    调用后都从列表头开始处理)。当相关的非阻塞 I/O 系统调用出现 EAGAIN 或
    EWOULDBLOCK 错误时,文件描述符就可以从应用程序维护的列表中移除了。

        尽管采用这种方法需要做些额外的编程工作,但是除了能避免出现文件描述符饥饿现象
外,我们还能获得其他益处。比如,我们可以在上述循环中加入其他的步骤,比如处理定时器以及用sigwaitinfo()(或其他类似的机制)来接收信号。

        因为信号驱动 I/O 也是采用的边缘触发通知机制,因此也需要考虑文件描述符饥饿的情况。与之相反,在采用水平触发通知机制的应用程序中,考虑文件描述符饥饿的情况并不是必须的。这是因为我们可以采用水平触发通知在非阻塞式的文件描述符上通过循环连续地检查描述符的就绪状态,然后在下一次检查文件描述符的状态前在处于就绪态的描述符上做一些I/O 处理就可以了。

63.5 在信号和文件描述符上等待

        有时候,进程既要在一组文件描述符上等待I/O 就绪,也要等待待发送的信号。我们可以
尝试通过select()来执行这样的操作,如程序清单63-7 所示。

程序清单63-7:非阻塞信号和select()调用的错误用法

sig_atomic_t gotSig = 0;
void handler(int sig){
    gotSig = 1;
}

int main(int argc,char *argv[]){
    struct sigaction sa;
    /*.....*/

    sa.sa sigaction = handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    if (sigaction(SIGUSR1,&sa,NULL) == -1)
    {
        perror("sigaction:");
        return -1;
    }
    /* What if the signal is delivered now? */
    ready = select(nfds,&readfds,NULL,NULL,NULL);
    if (ready > 0)
        printf("%d file descriptors ready\n",ready);
     else if (ready == -1 && errno == EINTR){
         if (gotsig)
         printf("Got signal\n");
     }
     else {
        /* Some other error */
     }

}

        这段代码的问题在于,如果信号(本例中是SIGUSR1)到来的时机刚好是在安装信号处
理例程之后且在select()调用之前,那么select()依然会阻塞。(这是竞态条件的一种形式。)现
在我们来看看对于这个问题有什么解决方案。

63.5.1 pselect() 系统调用

        系统调用pselect()执行的任务同select()相似。它们语义上的主要区别在于一个附加的参数—sigmask。该参数指定了当调用被阻塞时有哪些信号可以不被过滤掉。

        

#define _XOPEN_SOURCE 600
#include <sys/select.h>

int pselect (int nfds,fd_set *reads,fd_set *writefds,
fd_set *exceptfds, struct timespec *time,const sigset_t *sigmask);
Returns number of ready file descriptors,0 on timeout ,or -1 on error

        更准确地说,假设我们这样调用pselect():

ready = pselect(nfds,&readfs,&writefds,&exceptfds,timeout,&sigmask);

        这个调用等同于以原子方式执行下列步骤:

sigset_t origmask;

sigprocmask(SIG_SETMASK,&sigmask,&origmask);
ready = select(nfds,&readfds,&writefds,&exceptfds,timeout);
sigprocmask(SIG_SETMASK,&origmask,NULL);  //Restore signal mask

        使用pselect()我们可以将程序清单63-7中main()函数的第一部分替换为程序清单63-8中的代码。

        除了参数sigmask外,select()和select()还有如下区别。

  • pselect()中的timeout 参数是一个timespec 结构体(见23.4.2 节),允许将超时时间精
    度指定为纳秒级(select()为毫秒级)。
  • SUSv3 中明确说明pselect()在返回时不会修改timeout 参数。

        如果我们将pselect()的sigmask 参数指定为NULL,那么除了上述区别外pselect()就等同
于select()(即pselect()不会操作进程的信号掩码)。

        pselect()接口定义在POSIX.1g 中,现在已经加入到SUSv3 规范。并不是所有的UNIX 实
现都支持这一接口,Linux 中也只是在2.6.16 版内核后才加入。

程序清单63-8:使用pselect()

sigset_t emptyset,blockset;
struct sigaction sa;

sigemptyset(&blockset);
sigaddset(&blockset,SIGUSR1);

if(sigprocmask(SIG_BLOCK,&blockset,NULL) == -1)
{
    perror("sigprocmask:");
    return -1;
}
sa.sa_sigaction = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
if(sigaction(SIGUSR1,&sa,NULL) == -1){
    perror("sigaction:");
    return -1;
}

sigemptyset(&emptyset);
ready = pselect(nfds,&readfds,NULL,NULL,NULL,&emptyset);
if(ready == -1)
{
    perror("pselect:");
    return -1;
}

ppoll()和epoll_pwait()系统调用

        在Linux 2.6.16 版中还新增了一个非标准的系统调用ppoll(),它同poll()之间的关系类似于
pselect()同select()。同样的,从2.6.19 版内核开始,Linux 也新增了epoll_pwait(),这是对 epoll_wait()的扩展。对于这些新增系统调用的细节可以参见 ppoll(2)和epoll_pwait()的用户手册页。

63.5.2 self-pipe 技巧

        由于pselect()并没有被广泛实现,可移植的应用程序必须采用其他手段来避免当等待信号
并同时调用select()时出现的竞态条件。通常会用到如下方法。

  1. 创建一个管道,将读端和写端都设为非阻塞的。
  2. 在监视感兴趣的文件描述符时,将管道的读端也包含在参数readfds 中传给select()。
  3. 为感兴趣的信号安装一个信号处理例程。当这个信号处理例程被调用时,写一个字节的数
    据到管道中。关于这个信号处理例程,有以下几点需要注意。

        — 在第一步中已经将管道的写端设为了非阻塞态,这是为了防止出现由于信号到来的太
快,重复调用信号处理例程会填满管道空间,结果造成信号处理例程的 write()操作阻
塞(因而进程本身也就阻塞了)。(对于空间已满的管道,写操作失败并没有关系,因
为上一次写操作已经表明了信号的传递。)
        — 信号处理例程是在创建管道之后安装的,这是为了防止在管道创建前就发送了信号从
而产生竞态条件。
        — 在信号处理例程中使用 write()是安全的,因为 write()是异步信号安全函数之一,参见
表21-1。

        在循环中调用select(),这样如果被信号处理例程中断的话,select()还可以重新得到调用。(严格来说在这种方式下重新调用 select()并不是必须的。这只是表示我们可以通过监视readfds
来检查是否有信号到来,而不是通过检查返回的EINTR 错误码。)

        select()调用成功后,我们可以通过检查代表管道读端的文件描述符是否被置于 readfds 中
来判断信号是否到来。

        当信号到来时,读取管道中的所有字节。由于可能会有多个信号到来,我们需要用一个循
环来读取字节直到 read()(非阻塞式)返回 EAGAIN 错误码。将管道中的数据全部读取完
毕后,接下来就执行必要的操作以作为对发送的信号的回应。

        这项技术通常被称为是self-pipe,程序清单63-9 中的代码展示了这种技术的用法。
同样可以采用poll()和epoll_wait()来作为这种技术的变种。

程序清单 6-9:采用self-pipe技巧  -----self_pipe.c

        



#include <sys/time.h>
#include <sys/select.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>

static int pfd[2];                      /* File descriptors for pipe */

static void
handler(int sig)
{
    int savedErrno;                     /* In case we change 'errno' */

    savedErrno = errno;
    if (write(pfd[1], "x", 1) == -1 && errno != EAGAIN)
    {
	perror("write:");
	return ;
    }
    errno = savedErrno;
}
#define max(m,n) ((m) > (n) ? (m) : (n))

int
main(int argc, char *argv[])
{
    fd_set readfds;
    int ready, nfds, flags;
    struct timeval timeout;
    struct timeval *pto;
    struct sigaction sa;
    char ch;
    int fd, j;

    if (argc < 2 || strcmp(argv[1], "--help") == 0){
        printf("%s {timeout|-} fd...\n"
                "\t\t('-' means infinite timeout)\n", argv[0]);
	return -1;
    }

    /* Initialize 'timeout', 'readfds', and 'nfds' for select() */

    if (strcmp(argv[1], "-") == 0) {
        pto = NULL;                     /* Infinite timeout */
    } else {
        pto = &timeout;
        timeout.tv_sec = atol(argv[1]);
        timeout.tv_usec = 0;            /* No microseconds */
    }

    nfds = 0;

    /* Build the 'readfds' from the fd numbers given in command line */

    FD_ZERO(&readfds);
    for (j = 2; j < argc; j++) {
        fd = atoi(argv[j]);
        if (fd >= FD_SETSIZE)
            printf("file descriptor exceeds limit (%d)\n", FD_SETSIZE);

        if (fd >= nfds)
            nfds = fd + 1;              /* Record maximum fd + 1 */
        FD_SET(fd, &readfds);
    }

    if (pipe(pfd) == -1)
    {
	perror("pipe:");
	return -1;
    }

    FD_SET(pfd[0], &readfds);           /* Add read end of pipe to 'readfds' */
    nfds = max(nfds, pfd[0] + 1);       /* And adjust 'nfds' if required */

    flags = fcntl(pfd[0], F_GETFL);
    if (flags == -1)
    {
	perror("fcntl:");
	return -1;
    }
    flags |= O_NONBLOCK;                /* Make read end nonblocking */
    if (fcntl(pfd[0], F_SETFL, flags) == -1)
    {
	perror("fcntl2:");
	return -1;
    }

    flags = fcntl(pfd[1], F_GETFL);
    if (flags == -1)
    {
	perror("fcntl3:");
	return -1;
    }
    flags |= O_NONBLOCK;                /* Make write end nonblocking */
    if (fcntl(pfd[1], F_SETFL, flags) == -1)
    {
	perror("fcntl4:");
	return -1;
    }

    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;           /* Restart interrupted reads()s */
    sa.sa_handler = handler;
    if (sigaction(SIGINT, &sa, NULL) == -1)
    {
	perror("sigaction:");
	return -1;
    }

    while ((ready = select(nfds, &readfds, NULL, NULL, pto)) == -1 &&
            errno == EINTR)
        continue;                       /* Restart if interrupted by signal */
    if (ready == -1)                    /* Unexpected error */
    {
	perror("select:");
	return -1;
    }

    if (FD_ISSET(pfd[0], &readfds)) {   /* Handler was called */
        printf("A signal was caught\n");

        for (;;) {                      /* Consume bytes from pipe */
            if (read(pfd[0], &ch, 1) == -1) {
                if (errno == EAGAIN)
                    break;              /* No more bytes */
                else
                {
		    perror("select:");
		    return -1;
    		}      /* Some other error */
            }

            /* Perform any actions that should be taken in response to signal */
        }
    }

    /* Examine file descriptor sets returned by select() to see
       which other file descriptors are ready */

    printf("ready = %d\n", ready);
    for (j = 2; j < argc; j++) {
        fd = atoi(argv[j]);
        printf("%d: %s\n", fd, FD_ISSET(fd, &readfds) ? "r" : "");
    }

    /* And check if read end of pipe is ready */

    printf("%d: %s   (read end of pipe)\n", pfd[0],
            FD_ISSET(pfd[0], &readfds) ? "r" : "");

    if (pto != NULL)
        printf("timeout after select(): %ld.%03ld\n",
               (long) timeout.tv_sec, (long) timeout.tv_usec / 1000);

    exit(EXIT_SUCCESS);
}

63.6总结

        本章我们探究了针对标准I/O 模型之外的其他几种可选的I/O 模型。它们是:I/O 多路复用(select()和poll())、信号驱动I/O 以及Linux 专有的epoll API。所有这些机制都允许我们监视多个文件描述符,以查看哪个文件描述符上可执行I/O 操作。需要注意的是,所有这些机制并不实际执行I/O 操作。相反,一旦发现某个文件描述符处于就绪态,我们仍然采用传统的I/O 系统调用来完成实际的I/O 操作。

        I/O 多路复用机制中的select()和poll()能够同时监视多个文件描述符,以查看哪个文件描述符上可执行 I/O 操作。在这两个系统调用中,我们传递一个待监视的文件描述符列表给内核,之后内核返回一个修改过的列表以表明哪些文件描述符处于就绪态了。在每一次调用中都要传递完整的文件描述符列表,并且在调用返回后还要检查它们,这个事实表明当需要监视大量的文件描述符时,select()和 poll()的性能表现将变得很差。

        信号驱动I/O 允许一个进程在文件描述符处于I/O 就绪态时接收到一个信号。要使用信号驱动I/O,我们必须为SIGIO 信号安装一个信号处理例程,设定接收信号的属主进程,并在打开文件时设定O_ASYNC 标志使得信号可以生成。相比I/O 多路复用,当监视大量的文件描述符时信号驱动I/O 有着显著的性能优势。Linux 允许我们修改用来通知的信号,而如果我们采用实时信号的话,那么多个信号通知就可以排队处理。信号处理例程可以使用siginfo_t 参数来确定产生信号的文件描述符以及发生事件的类型。

        同信号驱动I/O 一样,当监视大量的文件描述符时epoll 也能提供高效的性能。epoll(以及信号驱动I/O)的性能优势源自内核能够“记住”进程正在监视的文件描述符列表这一事实(与之相反的是,select()和poll()都必须反复告诉内核哪些文件描述符需要监视)。相比于信号驱动I/O,epoll API 还有些值得一提的优点:我们可以避免处理信号时的复杂流程,而且可以指定需要监视的I/O 事件类型(例如输入或输出事件)。

        本章中我们在水平触发通知和边缘触发通知之间做了严格区分。在水平触发通知模型下,只要当前文件描述符上可以进行I/O 操作,我们就能得到通知。与之相反,在边缘触发通知模型下,只有自上一次监视以来,文件描述符上有发生I/O 事件时才会通知我们。I/O 多路复用采用的是水平触发通知模型;信号驱动I/O 基本上是边缘触发通知模型;而epoll 能够以任意一种方式工作(默认情况下是水平触发)。边缘触发通知通常都和非阻塞式I/O 结合起来使用。

        本章结尾部分我们探讨了一个经常会遇到的问题。那就是如何在监视多个文件描述符的同时等待信号的发送?对于这个问题,通常的解决方案是采用一种称为self-pipe 的技巧,即信号处理例程写一个字节数据到管道中,代表管道读端的文件描述符包含在被监视的文件描述符集合中。SUSv3中定义了pselect(),这是select()的变种,它提供了解决这个问题的另一种方法。但是pselect()并没有包含在所有的UNIX 实现中。Linux 也提供了类似(但非标准)的ppoll()和epoll_pwait()接口。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值