第6章 I/O 复用:select 和 poll 函数

概述

I/O 复用:进程指定了一个或多个 I/O ,当进程具有这样的能力,就是预先告知内核,使得内核一旦发现进程指定的一个或多个 I/O 条件就绪时,内核就通知进程。这个能力就是 I/O 复用。是由 select 和 poll 这两个函数支持的。

网络应用场合:

(1)当客户处理多个描述符(通常是交互式输入和网络套接字)时,必须使用 I/O 复用。

(2)一个客户同时处理多个套接字是可能的,不过比较少见。

(3)如果一个 TCP 服务器既要处理监听套接字,又要处理已连接套接字,一般就要使用 I/O 复用。

(4)如果一个服务器既要处理 TCP,又要处理 UDP,一般就要使用 I/O 复用。

(5)如果一个服务器要处理多个服务或者多个协议,一般就要使用 I/O 复用。

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


I/O 模型

  • 阻塞式 I/O

  • 非阻塞式 I/O

  • I/O 复用(select 和 poll)

  • 信号驱动式 I/O(SIGO)

  • 异步 I/O(POSIX 的 aio_ 系列函数)

对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。

1. 阻塞式 I/O 模型

进程在从调用 recvfrom 开始到它返回的整段时间内是被阻塞的。

2. 非阻塞式 I/O 模型

进程把一个套接字设置成非阻塞是在通知内核:当所请求的 I/O 操作非得把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。

3. I/O 复用模型

有了 I/O 复用,我们就可以调用 select 或 poll 系统调用,让进程阻塞在这两个系统调用中的某个之上,而不是阻塞在真正的 I/O 系统调用上。

我们阻塞于 select 调用,等待数据报套接字变为可读。当 select 返回套接字可读这一条件时,我们调用 recvfrom 把所读数据报复制到应用进程缓冲区。

与 I/O 复用密切相关的另一种 I/O 模型是在多线程中使用阻塞式 I/O。这种模型与上述模型极为相似,但它没有使用 select 阻塞在多个文件描述符上,而是使用多个线程,这样每个线程都可以自由地调用诸如 recvfrom 之类的阻塞式 I/O 系统调用了。

4. 信号驱动式 I/O 模型

我们也可以使用信号,让内核在描述符就绪时发送 SIGIO 信号通知我们。我们称这种模型为信号驱动式 I/O。

我们首先开启套接字的信号驱动式 I/O 功能,并通过 sigaction 系统调用安装一个信号处理函数。该系统调用立即返回,我们的进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个 SIGIO 信号。我们随后既可以在信号处理函数中调用 recvfrom 读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它读取数据报。

无论如何处理 SIGIO 信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好处理,也可以是数据报已准备好被读取。

5. 异步 I/O 模型

异步 I/O 由 POSIX 规范定义。这些函数的工作机制是:告知内核启动某个操作。并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。这种模型与前一节介绍的信号驱动模型的主要区别在于:信号驱动式 I/O 是由内核通知我们何时可以启动一个 I/O 操作,而异步 I/O 模型是由内核通知我们 I/O 操作何时完成。

5 种 I/O 模式的比较


select 函数

该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒它。

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

timeout 告知内核等待所指定描述符中的任何一个就绪可花多长时间。该参数有以下可能:

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

(2)等待一段固定时间:在有一个描述符准备好 I/O 时返回,但是不超过由该参数所指向的时间。

(3)根本不等待:检查描述符后立即返回,这称为轮询(polling)。该参数必须指向一个 timeval 结构,而且其中的秒数和微秒数数必须为 0 。

前两种情形的等待通常会被进程在等待期间捕获的信号中断,并从信号处理函数返回。这导致我们可能需要重启 select,所以我们必须做好 select 返回 EINTR 错误的准备。

timeout 参数在 Unix 中被定义为 const,表示不被修改。举例来说,如果我们指定一个 10s 的超时值,不过在定时器到时之前 select 就返回了(结果可能是有一个或多个描述符就绪,也可能是得到 EINTR 错误),那么 timeout 参数所指向的 timeval 结构不会被更新成该函数返回时剩余的秒数。如果我们需要知道这个值,那么必须在调用 select 之前取得系统时间,它返回后再取得系统时间,两者相减就是改值(任何健壮的程序都得考虑到系统时间可能在这段时间内偶尔会被管理员或 ntpd 之类守护进程调整)。

timeout 在有些 Linux 版本会修改这个 timeval 结构。如我这是 Ubuntu,上面的确没有使用 const 做限定。

中间三个参数 readfds、writefds、exceptfds 指定我们要让内核测试读、写和异常条件的描述符。如果我们对某一个的条件不感兴趣,就可以把它设为空指针。事实上,如果这三个指针均为空,我们就有了一个比 Unix 的 sleep 函数更为精确的定时器。

select 使用的描述符集通常是一个整数数组,其中每个整数中的每一位对应一个描述符。比如一个 32 整数,那么该数组的第一个元素对应于描述符 0~31,第二个元素对应于描述符32~63,依此类推。如下:

0

1 2 ...... 30 31 32 33 ...... 62 63 64 ......

第一个整数

第二个整数

      ......

所有这些实现细节都与应用程序无关,它们隐藏在名为 fd_set 的数据类型和以下四个宏中:

    void FD_CLR(int fd, fd_set *set);    /*关闭 set 的 fd 位*/
    int  FD_ISSET(int fd, fd_set *set);    /*fd 是否在 set 中*/
    void FD_SET(int fd, fd_set *set);    /*开启 set 的 fd 位*/
    void FD_ZERO(fd_set *set);    /*初始化,清空 set 的所有位*/

我们打开 <bits/typesizes.h>、<sys/select.h> 可以看到:

    /* Number of descriptors that can fit in an `fd_set'.  */
    #define __FD_SETSIZE		1024

    /* Maximum number of file descriptors in `fd_set'.  */
    #define	FD_SETSIZE		__FD_SETSIZE

nfds 参数指定待测试的描述符个数,它的值是待测试的最大描述符加 1,描述符 0,1,2 ...... 一直到 nfds - 1 均将被测试。如打开描述符 1、4、5,其 nfds 的值就是 6 。这是为效率考虑而设计的,表示进程与内核之间不复制描述符集中不必要的部分,只复制前 6 个,从而不测试总为 0 的那些位。

返回值:若有就绪描述符则为其数目,若超时则为 0,若出错则为 -1。

该函数返回后,我们使用 FD_ISSET 宏来测试 fd_set 数据类型中的描述符。描述符集内任何与未就绪描述符对应的位返回时均清成 0。为此,每次重新调用 select 函数时,我们都得再次把所有描述符集内所关心的位均置为 1。

描述符就绪条件

(1)满足下列四个条件之一,一个套接字准备好读:

  • 该套接字接受缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记(SO_RCVLOWAT 套接字选项来设置)的当前大小。对于 TCP 和 UDP 套接字而言,低水位标记其默认值为 1。
  • 该连接的读半部关闭(也就是接收了 FIN 的 TCP 连接)。对这样的套接字的读操作将不阻塞并返回 0(也就是 EOF)。
  • 该套接字是一个监听套接字且已完成的连接数不为 0。对这样的套接字的 accept 通常不会阻塞。
  • 其上有一个套接字错误待处理。对这样的套接字读操作立即返回 -1,同时把 errno 置成错误条件。这些待处理的错误也可以通过指定 SO_ERROR 套接字选项调用 getsockopt 获取并清除。

(2)下列四条件之一满足,套接字准备好写:

  • 该套接字发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的当前大小,并且或者该套接字已连接,或者该套接字不需要连接(如 UDP)。我们可以使用 SO_SNDLOWAT 选项来设置该套接字的低水位标记,对于 TCP、UDP 而言,其默认值为 2048。
  • 该连接的写半部关闭。对这样的套接字写操作将产生 SIGPIPE 信号。
  • 使用非阻塞式 connect 的套接字已建立连接,或 connect 已经以失败告终。
  • 其上有一个套接字错误待处理。对这样的套接字写操作立即返回 -1,同时把 errno 置成错误条件。这些待处理的错误也可以通过指定 SO_ERROR 套接字选项调用 getsockopt 获取并清除。

(3)如果一个套接字存在带外数据或者仍处于带外标记,那么它有异常条件待处理。

注意:接收低水位标记和发送低水位标记的目的在于允许进程控制在 select 返回之前,有多少数据可读或者有多大空间可写。比如,如果我们知道除非至少存在 64 个字节的数据,否则我们的应用程序没有任何有效工作可做,那么可以把接收低水位标记设置为 64,以防少于 64 个字节的数据准备好读时 select 唤醒我们。


使用 select 的 str_cli 用例

功能:接受标准 I/O 的输入,并通过套接字发送接受的输入

void str_cli(FILE *fp, int sockfd)
{
    int maxfdp1;
    fd_set rset;
    char sendline[MAXLINE], recvline[MAXLINE];

    FD_ZERO(&rset);
    for(;;){
        FD_SET(fileno(fp), &rset);
        FD_SET(sockfd, &rset);
        maxfdp1 = max(fileno(fp), sockfd) + 1;    /* max fd + 1*/
        select(maxfdp1, &rset, NULL, NULL, NULL);
        
        if(FD_ISSET(sockfd, &rset)){    /* socket is readable */
            if(read(sockfd, recvline, MAXLINE) == 0)
            {
                err_quit("str_cli: server terminated prematurely");
            }
        }

        if(FD_ISSET(fileno(fd), &rset)) {    /* input is readable */
            if(fgets(sendline, MAXLINE, fd) == NULL)
                return;
            write(sockfd, sendline, strlen(sendline));
        }
    }
}

shutdown 函数

该函数用来终止网络连接。

(1)终止网络连接的通常做法是调用 close,然而 close 是把描述符引用计数变为 0 时才关闭套接字。使用 shutdown 可以不管引用计数就激发 TCP 的正常连接终止序列。

(2)close 终止读和写两个方向的数据传送。然而我们有时并不希望这样。比如 TCP 连接是全双工的,有时候我们需要告知对端我们已经完成数据的发送,即使对端仍有数据要发送给我们,这时我们就不能把读也关闭掉。

客户调用 shutdown 关闭写,表示完成写操作,发送 FIN。服务器接收到 FIN,返回数据和 FIN 确认,这时服务器可能还有数据要发送给客户,等所有数据发送完毕后,服务端应调用 close 关闭连接(会引发服务端的读写关闭,以及客户端的读关闭)。

       #include <sys/socket.h>
       int shutdown(int sockfd, int how);

该函数的行为依赖 how 参数值:

SHUT_RD:关闭连接的读这一半,套接字中不再有数据可接收,而且接受缓冲区中现有的数据都被丢弃。对一个 TCP 套接字这样调用 shutdown 后,该套接字接收的来自对端的任何数据都被确认,然后悄然丢弃。

SHUT_WR:关闭连接的写这一半,当前留在套接字发送缓冲区中的数据将被发送掉,后跟 TCP 的正常连接终止序列。进程不能再对这样的套接字调用任何写函数。

SHUT_RDWR:连接的读和写都关闭。


pselect 函数

该函数是 POSIX 发明的,如今有许多 Unix 变种支持它。

       #include <sys/select.h>
       int pselect(int nfds, fd_set *readfds, fd_set *writefds,
                   fd_set *exceptfds, const struct timespec *timeout,
                   const sigset_t *sigmask);

返回:若有就绪描述符则为其数目,若超时则为 0,若出错则为 -1。

相较于 select 有两个变化:

(1)使用 timespec 结构,它能指定纳秒级。

(2)增加了第六个参数:一个指向信号掩码的指针。该参数允许程序先禁止递交某些信号,再测试由这些当前被禁止信号的信号处理函数设置的全局变量,然后调用 pselect,告诉它重新设置信号掩码。

我们先来思考一下这种情况:一般情况,我们为了避免 select 调用阻塞时被信号中断,往往会写成这样

void sig_intr(int signo)
{
    intr_flag |= signo;
}

void main()
{
    ...
    
    if( intr_flag )
       handle_intr(); --------------------------------- 1
 
    if( (nready = select(...)) < 0 ) ------------------ 2
    {
       if( errno == EINTR )
       {
          if( intr_flag )
             handle_intr();
       }
       ...
    }
    
    ...
}

如果在 1 和 2 之间信号到达,接着执行 select ,这时 select 并不会出错,如果 select 一直阻塞着,那么虽然 intr_flag 已改变,但这个 handle_intr() 却得不到处理。也就是这个信号将丢失。

所以正确的写法是使用 sigprocmask 来阻塞信号:

void sig_intr(int signo)
{
    intr_flag |= signo;
}

void main()
{
    ...
    sigset_t newmask, oldmask;
    sigemptyset(&newmask);
    sigaddset(&newmask, SIGINT);
    sigprocmask(SIG_BLOCK, &newmask, &oldmask);
    if( intr_flag )
       handle_intr(); --------------------------------- 1
 
    //sigprocmask(SIG_BLOCK, &oldmask, NULL);
    if( (nready = select(...)) < 0 ) ------------------ 2
    {
       if( errno == EINTR )
       {
          if( intr_flag )
             handle_intr();
       }
       ...
    }
    
    ...
}

但是这样有一个问题,select 也不会因为这个信号而返回错误,因为信号被阻塞了。如果在调用 select 之前恢复屏蔽呢,这又变成了上面一种情况。使用 pselect 可以解决这样的问题。

void sig_intr(int signo)
{
    intr_flag |= signo;
}

void main()
{
    ...
    sigset_t zeromask, newmask, oldmask;
    sigemptyset(&zeromask);
    sigemptyset(&newmask);
    sigaddset(&newmask, SIGINT);
    sigprocmask(SIG_BLOCK, &newmask, &oldmask);
    if( intr_flag )
       handle_intr(); --------------------------------- 1
 
    if( (nready = pselect(..., &zeromask)) < 0 ) ------------------ 2
    {
       if( errno == EINTR )
       {
          if( intr_flag )
             handle_intr();
       }
       ...
    }
    
    ...
}

pselect(...,&zeromask) 与 pselect(...,NULl) 意义完全不同,前者是在调用期间不阻塞任何新信号,后者和调用 select 相同,保持原来的信号 mask。这时,pselect 使用 zeromask 替代原来的 mask,等返回时又还原为原来的 mask。所以,当阻塞过程中有 INT 信号到来时,就会引起错误。当然,只要我们在 pselect 的 mask 中添加要屏蔽的信号,那么 pselect 也会屏蔽这些信号。


poll 函数

poll 函数提供的功能与 select 类似,不过在处理流设备时,能够提供额外的信息。

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

       #define _GNU_SOURCE         /* See feature_test_macros(7) */
       #include <signal.h>
       #include <poll.h>
       int ppoll(struct pollfd *fds, nfds_t nfds,
               const struct timespec *tmo_p, const sigset_t *sigmask);

第一个参数是指向一个结构数组第一个元素的指针。每个数组元素都是一个 pollfd 结构,用于指定测试某个给定描述符 fd 的条件。

    struct pollfd {
        int fd;        /*descriptor to check*/
        short events;    /*events of interest on fd*/
        short revents;    /*events that occurred on fd*/
    }

要测试的条件由 events 成员指定,函数在相应的 revents 成员中返回该描述符的状态。下面列出指定 events 和测试 revents 的一些常值。

poll 识别三类数据:普通、优先级带、高优先级。这些术语均出自基于流的实现。

就 TCP 和 UDP 套接字而言,以下条件引起 poll 返回特定的 revent。

  • 所有正规 TCP 数据和所有 UDP 数据都被认为是普通数据。
  • TCP 的带外数据被认为是优先级带数据。
  • 当 TCP 连接的读半部关闭时(譬如收到一个来自对端的 FIN),也被认为是普通数据,随后的读操作将返回 0。
  • TCP 连接存在错误既可认为是普通数据,也可认为是错误。无论哪种情况,随后的读操作将返回 -1,并把 errno 设置成合适的值。这可用于处理诸如接收到 RST 或发生超时等条件。
  • 在监听套接字上有新的连接可用既可认为是普通数据,也可认为是优先级数据。大多数实现视之为普通数据。
  • 非阻塞 connect 的完成被认为是使相应套接字可写。

结构数组中元素的个数是由 nfds 参数指定的。

猜你喜欢

转载自blog.csdn.net/lc250123/article/details/81295564