网络编程学习——I/O复用(一)

1 概述

  前面的例子我们可以看到TCP客户同时处理两个输入:标准输入和TCP套接字。我们遇到的问题是就在客户阻塞于(标准输入上的)fgets调用期间,服务器进程就会被杀死。服务器TCP虽然正确地给客户TCP发送了一个FIN,但是既然客户进程正阻塞于从标准输入读入的过程,它将看不到这个EOF,直到套接字读时为止(可能已经过了很长的时间)。这样的进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪(也就是说输入已准备好被读取,或者描述符已承接更多的输出),它就通知进程。这个能力称为I/O复用(multiplexing),是由select和poll这两个函数支持的。

  I/O复用典型使用在下列网络应用场合。

  • 当客户处理多个描述符(通常是交互式输入和网络套接字)时,必须使用I/O复用。这是我们早先讲述过的场合。

  • 一个客户同时处理多个套接字是可能的,不过比较少见。可以结合Web客户的上下文给出这种场合使用select的例子。

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

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

  • 如果一个服务器要处理多个服务或者多个协议(例如inetd守护进程),一般就要使用I/O复用。

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

 

2 I/O模型

  下面查看Unix下可用的5种I/O模型的基本区别:

  • 阻塞式I/O;

  • 非阻塞式I/O;

  • I/O复用(select和poll);

  • 信号驱动式I/O(SIGIO);

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

 一个输入操作通常包括两个不同的阶段:

(1)等待数据准备好;

(2)从内核向进程复制数据;

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

2.1 阻塞式I/O模型

  最流行的I/O模型是阻塞式I/O(blocking I/O)模型。默认情况下,所有的套接字都使阻塞的。以数据报套接字为例子,我们有如下

163752_Wo5G_2537915.jpg

图1-1 阻塞式I/O模型

  我们使用UDP而不是TCP作为例子的原因在于就UDP而言,数据准备好读取的概念比较简单:要么整个数据报已经收到,要么还没收到。然而对于TCP而言,诸如套接字低水位标记(low-water mark)等额外变量开始起作用,导致这个概念变得复杂。

  图1-1中,这里我们把recvfrom函数视为系统调用。进程调用recvfrom,其系统调用直到数据报到达且被复制到应用进程的缓冲区中或者发生错误才返回。最常见的错误是系统调用被信号中断。我们说进程在从调用recvfrom开始返回的整段时间内是被阻塞的。recvfrom成功返回后,应用进程开始处理数据报。

2.2 非阻塞式I/O模型

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

 

171635_XDq9_2537915.jpg

图1-2 非阻塞式I/O模型

  前三次调用recvfrom时没有数据可返回,因此内核转而立即返回一个EWOULDBLOCK错误。第四次调用recvfrom时已有一个数据报准备好,它被复制到应用进程缓冲区,于是recvfrom成功返回。我们接着处理数据。

  当一个应用进程像这样对一个非阻塞描述符循环调用recvfrom时,我们称之为轮询(polling)。应用进程持续轮询内核,以查看某个操作是否就绪。这么做往往消耗大量CPU时间,不过这种模型偶尔也会遇到,通常是在专门提供某一种功能的系统中才有。

 

2.3 I/O复用模型

  有了I/O复用(I/O multiplexing),我们就可以调用select或poll,阻塞在这个系统调用的中的某一个之上,而不是阻塞在真正的I/O系统调用上。图1-3展示了I/O复用模型。

173330_JIHY_2537915.jpg

图1-3 I/O复用模型

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

  比较图1-1和1-3,I/O复用并不显得有什么优势,事实上由于使用select需要两个而不是单个系统调用,I/O复用还稍显有劣势。不过我们将看到,select的优势在于我们可以等待多个描述符就绪。

 

 

2.4 信号驱动式I/O模型

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

174336_6v9z_2537915.jpg

图1-4 信号驱动式I/O模型

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

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

2.5 异步I/O模型

  异步I/O(asynchronous I/O)由POSIX规范定义。演变成当前POSIX规范的各种早期标准所定义的实时函数中存在的差异已经取得一致。一般地说,这些函数的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。这种模型与前一节介绍的信号驱动模型的主要区别在于:信号驱动式I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。

180456_IxKr_2537915.jpg

图1-5 异步I/O模型

  我们调用aio_read函数(POSIX异步I/O函数以aio_或lio_开头),给内核传递描述符、缓冲区指针、缓冲区大小(与rad相同的三个参数)和文件偏移(与lseek类似),并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,而且在等待I/O完成期间,我们的进程不被阻塞。本例中我们假设要求内核在操作完成时产生某个信号。该信号直到数据已复制到相应进程缓冲区才产生,这一点不同于信号驱动I/O模型。

205841_lysf_2537915.jpg

图1-6 5种I/O模型的比较

  前四种都使同步I/O模型,因为其中真正的I/O操作(recvfrom)将被阻塞进程。

 

3 select函数

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

  我们调用select告知内核对哪些描述符(就读、写或异常条件)感兴趣以及等待多长时间。我们感兴趣的描述符不局限于套接字,任何描述符都可以使用select来测。

#include <sys/select.h>
/* Check the first NFDS descriptors each in READFDS (if not NULL) for read
   readiness, in WRITEFDS (if not NULL) for write readiness, and in EXCEPTFDS
   (if not NULL) for exceptional conditions.  If TIMEOUT is not NULL, time out
   after waiting the interval specified therein.  Returns the number of ready
   descriptors, or -1 for errors.

   This function is a cancellation point and therefore not marked with
   __THROW.  */
extern int select (int __nfds, fd_set *__restrict __readfds,
           fd_set *__restrict __writefds,
           fd_set *__restrict __exceptfds,
           struct timeval *__restrict __timeout);
// __restrict,C语言中的一种类型限定符(Type Qualifiers),用于告诉编译器,对象已经被指针所引用,不能通过
除该指针外所有其他直接或间接的方式修改该对象的内容

  最后一个参数timeout告知内核等待所指描述符中的任何一个就绪可花多长时间。其timeval结构用于指定这段时间的秒数和微妙数。

#include <bits/time.h>
/* A time value that is accurate to the nearest
   microsecond but also has a range of years.  */
struct timeval
  {
    __time_t tv_sec;        /* Seconds.  */
    __suseconds_t tv_usec;    /* Microseconds.  */
  };

  这个参数有以下三种可能。

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

  2. 等待一段固定时间:在有一个描述符准备I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数。

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

  前两种情形的等待通常会被进程在等待期间捕获的信号中断,并从信号处理函数返回。

  尽管timeval结构允许我们指定了一个微秒级的分辨率,然而内核支持的真实分辨率往往粗糙得多。timeout参数的const限定词表示它在函数返回时不会被select修改。

  中间的三个参数_readfd、_writefd和_exceptfd指定我们要让内核测读、写和异常条件的描述符。目前支持的异常条件只有两个:

  1. 某个套接字的带外数据的到达。

  2. 某个已置为分组模式的伪终端存在可从主端读取的控制状态信息。

  select使用描述符集,通常是一个整数数组,其中每个整数中的每一位对应一个描述符。

#include <sys/select.h>
/* The fd_set member is required to be an array of longs.  */
typedef long int __fd_mask;

/* fd_set for select and pselect.  */
typedef struct
  {
    /* XPG4.2 requires this member name.  Otherwise avoid the name
       from the global namespace.  */
#ifdef __USE_XOPEN
    __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->fds_bits)
#else
    __fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif
  } fd_set;

  举例来说,假设使用32位整数,那么该数组的第一个元素对应于描述符0~31,第二个元素对应于描述符32~63,依次类推。所有这些实现细节都与应用程序无关,它们隐藏在名为fd_set的数据类型和以下四个宏中:

/* Access macros for `fd_set'.  */
#define    FD_SET(fd, fdsetp)    __FD_SET (fd, fdsetp)
#define    FD_CLR(fd, fdsetp)    __FD_CLR (fd, fdsetp)
#define    FD_ISSET(fd, fdsetp)    __FD_ISSET (fd, fdsetp)
#define    FD_ZERO(fdsetp)        __FD_ZERO (fdsetp)

// clear all bits in fd_set.
# define __FD_ZERO(fdsp) \
  do {                                          \
    int __d0, __d1;                                  \
    __asm__ __volatile__ ("cld; rep; " __FD_ZERO_STOS                  \
              : "=c" (__d0), "=D" (__d1)                  \
              : "a" (0), "0" (sizeof (fd_set)              \
                      / sizeof (__fd_mask)),          \
                "1" (&__FDS_BITS (fdsp)[0])                  \
              : "memory");                          \
  } while (0)

// turn on the bit for d in set.
#define __FD_SET(d, set) \
  ((void) (__FDS_BITS (set)[__FD_ELT (d)] |= __FD_MASK (d)))
  
// turn off the bit fo d in set.
#define __FD_CLR(d, set) \
  ((void) (__FDS_BITS (set)[__FD_ELT (d)] &= ~__FD_MASK (d)))

// is the bit for d on the set?  
#define __FD_ISSET(d, set) \
  ((__FDS_BITS (set)[__FD_ELT (d)] & __FD_MASK (d)) != 0)

  描述符集的初始化非常重要,因为作为自动变量分配的一个描述符集如果没有初始化,那么可能发生不可预期的后果。

  __nfds参数指定待测的描述符个数,它的值是带测的最大描述符加1,描述符0,1,2…一直到__nfds-1均被测。头文件<sys/select.h>中定义的FD_SETSIZE常值是数据类型fd_set中的描述符总数,其值通常是1024,不过很少有程序用到那么多描述符。

<bits/typesizes.h>
/* Number of descriptors that can fit in an `fd_set'.  */
#define __FD_SETSIZE        1024

#include <sys/select.h>
/* Maximum number of file descriptors in `fd_set'.  */
#define    FD_SETSIZE        __FD_SETSIZE

  __nfds参数强迫使我们计算出所关心的最大描述符并告知内核该值。如打开描述符1、4和5的代码为例,其__nfds值就是6(是6不是5是因为描述符是从0开始)。select修改由指针_readfd、_writefd和_exceptfd所指向的描述符集,因而三个参数都使值-结果参数。调用该函数时,我们指定所关心的描述符的值,该函数返回时,结果将指示哪些描述符已就绪。该函数返回后,我们使用FD_ISSET宏来测fd_set数据类型中的描述符。描述符集内任何与未就绪描述符对应的位返回时均清0。为此,每次重新调用select函数时,我们都得再次把所有描述符集内所关心的位均置1。该函数的返回值表示跨所有描述符集的已就绪的总位数。如果在任何描述符就绪之前定时器到时,那么就返回0。返回-1表示出错。

 

3.1 描述符就绪条件

  满足下面四个条件之一的任何一个时,一个套接字准备好读:

  1. 该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小。对这样的套接字执行读行读操作不会阻塞并将返回一个大于0的值(也就是返回准备好读入的数据)。我们可以使用SO_RCVLOWAT套接字选项设置该套接字的低水位标记。对于TCP和UDP套接字而言,其默认值为1。

  2. 该连接的读半部关闭(也就是收到FIN的TCP连接)。对这样的套接字的读操作将不阻塞并返回(也就是返回EOF)。

  3. 该套接字是一个监听套接字且已完成的连接数不为0。对这样的套接字的accept通常不会阻塞。

  4. 其上有一个套接字错误待处理。对这样的套接字的读操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置成确切的错误条件。这些带处理错误(pending error)也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除。

  满足下面四个条件之一的任何一个时,一个套接字准备好写:

  1. 该套接字发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位的当前大小,并且或者该套接字已连接,或者该套接字不需要连接(如UDP套接字)。这意味着如果我们把这样的套接字设置成非阻塞,写操作将不阻塞并返回一个正值(例如由传输层接受的字节数)。我们可以使用SO_SNDLOWAT套接字选项来设置该套接字的低水位标记。对于TCP和UDP套接字而言,其默认值通常为2048。

  2. 该连接的写半部关闭。对这样的套接字的写操作将产生SIGPIPE信号。

  3. 使用非阻塞式connect的套接字已建立连接,或者connect已经以失败告终。

  4. 其上有一个套接字错误待处理。对这样的套接字的写操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置成确切的错误条件。这些待处理的错误也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除。

  如果一个套接字存在带外数据或者仍出于带外标记,那么它有异常条件待处理。注意,当某个套接字发生错误时,它将由select标记为既可读又可写。

  接收低水位标记和发送低水位标记的目的在于:允许应用进程控制在select返回可读或可写条件之前有多少数据可读或者有多大空间可用于写。

  任何UDP套接字只要其发送低水位标记小于等于发送缓冲区大小(默认应该总是这种关系)就总是可以写的。这是因为UDP套接字不需要连接。图1-7汇总了上述导致select返回某个套接字就绪的条件。

233622_WjOI_2537915.jpg

图1-7 select返回某个套接字就绪的条件小结

 

4 客户端处理函数(修订版)

  早先版本的问题在于:当套接字上发生某些事件时,客户可能阻塞于fgets调用。新版本改为阻塞于select调用,或是等待标准输入可读,或是等待套接字可读。图1-8展示了调用select所处理的各种条件。

085039_Fiq3_2537915.jpg

图1-8 客户端处理函数由select处理各种条件

  客户的套接字上的三个条件处理如下。

(1)如果对端TCP发送数据,那么该套接字变为可读,并且read返回一个大于0的值(即可读入数据的字节数)。

(2)如果对端TCP发送一个FIN(对端进程终止),那么该套接字变为可读,并且read返回0(EOF)。

(3)如果对端TCP发送一个RST(对端主机崩溃并重启),那么该套接字变为可读,并且read返回-1,而errno中含有确切错误码。

  // 完成剩余部分的客户处理工作。
  char sendbuff[ MAX_MESG_SIZE ];
  char recvbuf[ MAX_MESG_SIZE ];
  int nfds;  // 描述符总数
  fd_set rset; // 描述符集
  
  FD_ZERO( &rset );
  for( ; ; )
  {
    int n;
    FD_SET( fileno( stdin ), &rset );
    FD_SET( sockfd, &rset );
    nfds = max( fileno( stdin ), sockfd ) + 1;
    select( nfds, &rset, NULL, NULL, NULL );
    
    if( FD_ISSET( sockfd, &rset ) ) // socket is readable
    {
      if( ( n = recv( sockfd, recvbuf, MAX_MESG_SIZE, 0 ) ) == 0 )
      {
        cout << " mimiasd:server terminated prematurely " << endl;
        exit( 1 );
      }
      recvbuf[ n ] = 0;
      fputs( recvbuf, stdout );
    }
    
    if( FD_ISSET( fileno( stdin ), &rset ) ) // input is readable
    {
      if( fgets( sendbuff, MAX_MESG_SIZE, stdin ) == NULL )
        return; // all done
      send( sockfd, sendbuff,strlen( sendbuff ), 0 );
    }
  }

 

5 批量输入

  不幸的是,上面的程序仍然不正确。最初的版本以停等方式工作,这对交互式使用是合适的:发送一行文本给服务器,然后等待应答。这段时间是往返时间(round-trip,RTT)加上服务器的处理时间(对于简单的回射服务器而言,处理时间几乎为0)。如果知道了客户与服务器之间的RTT,我们便可以估计出回射固定数目的行所需花多少时间。

  ping程序是测量RTT的一个简单方法。如果我们把客户与服务器之间的网络作为全双工管道来看待,请求是从客户向服务器发送,应答从服务器向客户发送,那么图1-9展示了这样的停-等方式。

102223_hNfW_2537915.jpg

图1-9 停-等方式的时间线,交互式输

 

 

 

 

 

 

 

转载于:https://my.oschina.net/u/2537915/blog/657399

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值