当一个进程需要从多个文件描述中读,并写入多个文件描述符中(例如TCP服务器)。

  1. 如果采用阻塞I/O,那么前面的描述符中没有数据时就会阻塞,这样即使后面的描述符有数据也无法读取,写描述符同理。
  2. 如果采用非阻I/O,那么就需要不断轮询所有描述符(浪费CPU时间)。

当一个进程需要对一个文件描述符同时进行读写,两者并没有前后关系。如果采用阻塞I/O,那么没有数据读就会阻塞,进而导致进程无法处理写入,写阻塞时同理。

上述问题的一个解决方法是采用多线程,每个线程中对一个描述符进行阻塞I/O,缺点是线程实现复杂,同时进程支持的线程数量有限。

I/O多路转接

一句话来描述,“I/O多路转接就是调用进程将所有的阻塞I/O描述符注册到内核,由内核判断哪个描述符可以进行I/O,然后通知调用进程”。

系统提供了3个接口来支持I/O多路转接,分别是select, poll, epoll。这些系统调用允许调用进程注册一组文件描述符,并且在这些描述符上等待事件的发生(例如有数据可读或可写等)。当事件发生时,调用进程就可以做相应的动作。

在网络编程中,I/O多路转接通常同时监听多个套接字,以实现高效的并发通信。相比于多个线程来处理阻塞I/O或者单线程中非阻塞I/O不停的轮询,I/O多路转接可以在一个线程中的处理多个套接字,并减少系统调用的次数。

I/O多路转接是一种高效I/O处理机制,可用于并发网络通信或者其他需要同时监视多个文件描述符的场景。

select函数

通过select函数应用程序可以将其关心的描述符、对每个描述符关心的事件(读、写或异常条件)、愿意等待的时长告诉内核。

当从select函数返回时,内核会通知应用程序两件事:

  1. 已经准备好的描述符的数量
  2. 对于读、写以及异常条件中哪些描述符已经准备好(这需要重新检查传入的三种条件的文件描述符集)。

此时就可以在这些描述符上调用相应的I/O函数(readwrite等)并确知其不会阻塞。

#include <sys/select.h>

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

// 返回值:成功返回就绪的文件描述符数目;超时返回0;出错返回-1

timeout参数说明如下:

  1. timeout参数为NULL时,表示永远阻塞。只有当所指定的文件描述符准备好或者被信号中断才返回。捕捉到信号返回时,返回值为-1,并且errno被设置为EINTR
  2. timeout->tv_sec == 0 && timeout->tv_usec == 0时,不阻塞。测试完所有指定的文件描述符后立即返回,等同于非阻塞轮询所有文件描述符。
  3. timeout->tv_sec != 0 || timeout->tv_usec != 0时,等待指定的秒数和微秒数。当指定的文件描述符有准备好指定的时间值已经超过则立即返回。如果在超时到期时还没有文件描述符准备好则返回0(在等待期间可能会被信号中断并返回-1)。

POSIX.1允许select实现修改timeout的值,所以在select函数返回时,不能确保timeout值还是传入时的值

readfdswritefdsexceptfds这三个参数是指向描述符集(fd_set)的指针,表示应用进程关心对应文件描述符集上的可读、可写以及异常条件。这三个参数中的一个或全部为空时,表示对不关注相应条件。全部为空时等同于sleep函数,但精度更高。

描述符集存储在fd_set数据类型中。

nfds参数指的是最大文件描述符值加1。要在3个文件描述符集中找出最大的文件描述符,然后对其值加1。也可以将该参数设置为常量FD_SETSIZE,该常量指定最大描述数(可能是1024)。

// <sys/select.h>
/* Maximum number of file descriptors in `fd_set'.  */
#define FD_SETSIZE              __FD_SETSIZE
/* Number of descriptors that can fit in an `fd_set'.  */
#define __FD_SETSIZE            1024

返回值有以下三种:

  1. 返回值-1表示出错。当所指定的描述符一个都没有准备好时却捕捉到一个信号,返回-1。此时,传入的文件描述符集都不修改。
  2. 返回值0表示没有文件描述符准备好。当已经超时但却没有一个文件描述符准备好时,返回0。此时,所有传入的文件描述符集都会被置0,即使是之前设置过关心的文件描述符的位。
  3. 大于0的返回值表示已经准备好的文件描述符数。该值是3个描述符集中已准备好的文件描述符的总和,所以如果一个文件描述符准备好读和写,那么会被统计两次。此时3个描述符中仍旧打开的位对应于已经准备好(读写不阻塞或有未决异常条件)的描述符

传入select函数的文件描述符是否以阻塞或非阻塞方式打开对select函数(其作用是检测传入的文件描述符集中哪些文件描述符状态发生了变化)均无影响,有影响的地方是当select函数返回并指出某个文件描述符就绪之后,之后对该文件描述符进行读/写操作时的行为。

  1. 如果是以阻塞方式打开的文件描述符,那么在尝试读/写时,如果操作不能立即完成(例如,没有数据可读或者缓冲区已满),那么调用线程或进程将会阻塞,直到条件满足(例如,有数据可读或有足够大的缓冲区可写)。
  2. 如果文件描述符是以非阻塞方式打开,那么在尝试读/写时,如果操作不能立即完成,调用会立即返回一个错误,而不是阻塞调用线程或进程。

fd_set数据类型

fd_set实质上是一个位向量,每一位都代表一个文件描述符。

其内部结构通常是一个固定大小的数组,每个数组元素能够表示多个文件描述符的状态(long型数组则表示64个文件描述符状态)。这种方式以空间高效的方式存储大量文件描述。

源码定义如下:

#define __FD_SETSIZE 1024

typedef struct
{
    unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))]; // 16 long 8字节 每字节8位
} __kernel_fd_set;

typedef __kernel_fd_set fd_set;

操作方式:变量赋值和下面的操作函数。

#include <sys/select.h>

int FD_ISSET(int fd, fd_set* fdset);
// 返回值:fd在描述符集中返回非0,否则返回0

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

FD_ZERO表示将文件描述符集fdset变量的所有位置为0。要开启描述符集中的某一位,可以调用FD_SET。调用FD_CLR用来清除一位。最后用FD_ISSET检测某一位是否开启。

通常在创建一个描述符集变量之后,必须先用FD_ZERO将该描述符集置0,然后再用FD_SET设置应用程序中关心的各个描述符的位。

测试示例如下:

#include <stdio.h>
#include <sys/select.h>
#include <unistd.h>

int main(int argc, char* argv[])
{
    fd_set rset;                  // 创建文件描述符集
    FD_ZERO(&rset);               // 全置0
    FD_SET(STDIN_FILENO, &rset);  // 设置标准输入

    // 检测标准输入是否已经设置
    if (FD_ISSET(STDIN_FILENO, &rset))
        printf("enable STDIN_FILENO\n");

    FD_CLR(STDIN_FILENO, &rset);
    if (FD_ISSET(STDIN_FILENO, &rset) == 0)
        printf("disable STDIN_FILENO\n");
}
// enable STDIN_FILENO
// disable STDIN_FILENO

示例

该示例中使用select函数监控标准输入文件描述符的状态是否就绪(即是否有数据可读 ),并设置超时时间。

如果超时时间到期,则会自动进入下一次等待期内,直到非信号导致的异常出错或者有数据读取到才返回。

具体代码如下:

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>
#include <unistd.h>

// 该示例使用select函数检测标准输入是否就绪,即是否可以从标准输入中读取到数据
int main()
{
    // 创建并初始化读文件描述符集
    fd_set rset;
    FD_ZERO(&rset);
    // 添加标准输入文件描述符
    FD_SET(STDIN_FILENO, &rset);

    // 设置超时时间5s,如果5s没有输入,select函数返回0,文件描述符集被置为0
    struct timeval timeout;
    timeout.tv_sec = 5;
    timeout.tv_usec = 0;

    // 最大文件描述符值+1,即从该值起不在监控文件描述符的变化
    int maxfd = 1;

    while (1)
    {
        // 每次进入前需要重置时间,因为select函数会修改该参数
        timeout.tv_sec = 5;
        timeout.tv_usec = 0;

        // select函数也会修改该参数
        FD_ZERO(&rset);
        FD_SET(STDIN_FILENO, &rset);

        int retval = select(maxfd, &rset, NULL, NULL, &timeout);
        if (retval == -1)
        {
            if (errno == EINTR)
                printf("caught signal and continue");
            else
            {
                perror("select");
                // 调用出错或捕捉到信号
                exit(-1);
            }
        }
        else if (retval == 0)
        {
            // 超时并进行下一次等待
            // 返回0是会将传入的文件描述符集置0
            printf("timeout and again\n");
        }
        else
        {
            // 标准输入已准备好
            // 接下来要检测是哪个文件描述符,这里需要保存之前关注的文件描述符
            // 否则传入的文件描述符集被select函数修改之后就无法判断了
            printf("ready\n");
            if (FD_ISSET(STDIN_FILENO, &rset))
            {
                char buf[256] = {0};
                read(STDIN_FILENO, buf, sizeof(buf) - 1);
                printf("readdata: %s\n", buf);
                break;
            }
            else
                printf("other fd ready");
        }
    }
}
// 执行结果如下:
// $ ./select_demo
// timeout and again
// timeout and again
// 1234
// ready
// readdata: 1234

pselect函数

pselect函数基本上与select函数相同,pselect函数原型如下:

#include <sys/select.h>

int pselect(int nfds, fd_set* restrict readfds, fd_set* restrict writefds, fd_set* restrict exceptfds,
    const struct timespec* restrict timeout, const sigset_t* restrict sigmask);
// 返回值:成功返回就绪的描述符数目;超时返回0;失败返回-1

select函数差异点如下:

  1. timeout参数类型,select函数是struct timeval类型,精度为微秒,pselect函数的超时时间精度为纳秒。并且声明为const表示不会被修改。
  2. 存在sigmask参数,表示可使用信号屏蔽字。取值为NULL时与select函数没有区别。取值不为空,表示原子性的设置信号屏蔽字并阻塞等待I/O事件sigsuspend函数类似。

有一点需要注意的是pselect函数屏蔽的信号在pselect阻塞期间如果产生,那么将不会被递送,直到pselect函数返回才会被递送,并按照设置的信号处理方式执行。

下面的例子将说明这一点,该示例是对上面的select示例做了简单修改,同时增加了对SIGINT信号的屏蔽。

pselect函数阻塞期间,通过在终端上键入Ctrl+C触发SIGINT信号并不会使进程立即终止(SIGINT信号的默认行为是终止进程),而是要等到pselect函数返回之后,才会递送信号,进而执行该信号的处理动作(终止该进程)。

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

// 该示例使用select函数检测标准输入是否就绪,即是否可以从标准输入中读取到数据
int main()
{
    // 创建并初始化读文件描述符集
    fd_set rset;
    FD_ZERO(&rset);
    // 添加标准输入文件描述符
    FD_SET(STDIN_FILENO, &rset);

    // 设置超时时间5s,如果5s没有输入,select函数返回0,文件描述符集被置为0
    struct timespec timeout;
    timeout.tv_sec = 5;
    timeout.tv_nsec = 0;

    // 屏蔽中断信号
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGINT);

    // 最大文件描述符值+1,即从该值起不在监控文件描述符的变化
    int maxfd = 1;

    while (1)
    {
        // 每次进入前需要重置时间,因为select函数会修改该参数
        timeout.tv_sec = 5;
        timeout.tv_nsec = 0;

        // select函数也会修改该参数
        FD_ZERO(&rset);
        FD_SET(STDIN_FILENO, &rset);

        int retval = pselect(maxfd, &rset, NULL, NULL, &timeout, &mask);
        if (retval == -1)
        {
            if (errno == EINTR)
                printf("caught signal and continue");
            else
            {
                perror("select");
                // 调用出错或捕捉到信号
                exit(-1);
            }
        }
        else if (retval == 0)
        {
            // 超时并进行下一次等待
            printf("timeout and again\n");
        }
        else
        {
            // 标准输入已准备好
            // 接下来要检测是哪个文件描述符,这里需要保存之前关注的文件描述符
            // 否则传入的文件描述符集被select函数修改之后就无法判断了
            printf("ready\n");
            if (FD_ISSET(STDIN_FILENO, &rset))
            {
                char buf[256] = {0};
                read(STDIN_FILENO, buf, sizeof(buf) - 1);
                printf("readdata: %s\n", buf);
                break;
            }
            else
                printf("other fd ready");
        }
    }
}
// 执行结果如下:
// $ ./pselect_demo
// timeout and again
// ^C 这里信号不会立即终止,而是要等到pselect函数超时返回