9.1 I/O 模型
I/O 模型给我们提供一种机制,一个或多个 I/O 条件满足时就被通知是那种模型。
将一个输入操作分为两个不同的阶段:
- 等待数据准备好。
- 从内核到进程拷贝数据。
根据在这两个阶段的不同表现,I/O 模型分为五种不同的类型,即阻塞 I/O 、非阻塞 I/O 、I/O 复用、信号驱动 I/O 和异步 I/O。
现在我们来继续详细的介绍下这五个类型。
9.1.1 阻塞I/O模型
在以前的例子都是使用了这个模型,如图 9-1 所示。为了便于理解模型,考虑 UDP 数据报,因为 UDP 数据报比 TCP 数据报要简单一些,并且把 recvfrom 视为系统调用。
9.1.2 非阻塞I/O
当请求的 I/O 操作非得让进程睡眠不能完成时,不让进程睡眠,而返回一个错误。直到数据报准备好,将拷贝到应用缓冲区, recvfrom 返回成功指示,如图 9-2 所示。
9.1.3 I/O复用★
I/O 复用调用 select 或 poll ,并在该函数上阻塞,等待数据报套接口可读,当 select 返回可读条件时,调用 recvfrom 将数据报拷贝到应用程序缓冲区中,如图 9-3 所示。
9.1.4 信号驱动I/O
让内核在描述字准备好时用信号 SIGIO 通知,通过系统调用安装一个信号处理程序。此系统调用立即返回,进程继续工作,如图 9-4 所示。
9.1.5 异步I/O
这种模型没有被广泛应用,只作了解,如图9-5 所示。
9.1.6 各种模型的比较★
如图 9-6 所示为 上述五种不同 I/O 模型的比较。它表明;前四种模型的主要区别都在第一阶段,因为前四种模型的第二阶段基本相同:在数据从内核拷贝到调月者的缓冲区时,进程阻塞于 recvfrom 调用。然而,异步 I/O 模型处理的两个 阶段都不同于前四个模型。既是我们的前四个模型 阻塞 I/O 模型、非阻塞 I/O 模型、 I/O 复用模型和信号驱动 I/O 模型都是同步 I/O 模型,因为真正的 I/O 操作 recvfrom 阻塞进程,只有异步 I/O 模型与此异步 I/O 的定义相匹配。将这几个模型的区别列在如图 9 6 中。
9.2 select 函数★
下面来看一下 select 函数:它允许进程指示内核等待多个事件中的任意一个发生,并仅在一个或多个事件发生或进过指定的时间时才唤醒进程。
结构
这个函数的形式如下:
#include<sys/select.h>
#include<sys/time.h>
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *execepset, const struct timeval *timeout);
返回:准备好描述字的正数目, 0 超时, -1 出错。
timeout参数
在上面的参数中可以看到一个 timeval 结构,这个结构可以提供秒数和毫秒数成员,形式如下:
struct timeval
{
long tv_sec;/*second*/
long tv_usec; /*microsecond*/
}
这个 timeval 结构有三种可能:
- 永远等待下去:仅在有一个描述字准备好I/O时才返回,因此我们可以将参数timeout设置为空指针。
- 等待固定时间:在有一个描述字准备好I/O时返回,但不超过由timeout参数所指timeval结构中指定的妙数和微妙数。
- 根本不用等待:检查描述字后立即返回,这称为轮询(polling)。
在前两者情况的等待中,如果进程捕获了一个信号并从信号处理程序返回,那么等待一般被中断。
描述字参数 readset,writeset 和 execeptset
参数 readset,writeset 和 execeptset 指定让内核测试读、写、异常条件的描述字。如果我们对他们不感兴趣,可将其设为空指针。
select 函数使用描述字集为参数 readset(writeset 或 except)指定多个描述字,描述字集是一个整数数组,每个数中的每一个对应于一个描述字,例如 32 位整数,则数组的第一个元素对应于 0~31 描述字,第二个元素对应于 32~63 描述字等等。
操作描述字宏★
下面介绍操作这些描述字的几个宏:
void FD_ZERO(fd_set *fdset); /*将所有位设 0*/
void FD_SET(int fd, fd_set *fdset); /*将 fd 位设 1*/
void FD_CLR(int fd, fd_set *fdset); /*将 fd 位设为 0*/
int FD_ISSET(int fd, fd_set *fdset): /*检测 fd 位是否为 1*/
分配一个 fd_set 数据类型的描述字集,利用上面的四个宏来操作。例如将描述字 1,3 的设置如下:
fd_set fdset;
FD_ZERO (&fdset); /*初始化 fdset*/
FD_SET(1, &fdset); /*将 fd 为 1 的描述字设 1*/
FD_SET(3, &fdset); /*将 fd 为 3 的描述字设 1*/
FD_CLR(3, &fdset); /*将 fd 为 3 的描述字设 0*/
FD_ISSET(3, &fdset): /*检测 fd 为 3 的描述字是否为 1*/
参数 readset 、 writeset exceptset 为值-结果参数,调用 select 时,指定我们所关心的描述字,返回时结果指示那些描述字已准备好。
maxfdp1参数
参数 maxfdp1 指定被测试的描述字的个数,它是被测试的最大描述字加 1 。 如要测试 1,2,4 描述字,则必须测试 0,1,2,3,4 共 5 个描述字。
返回值
函数的返回值表示所有描述字集中的已准备好的描述字个数。如定时到则返回0 ,出错为 -1 。
描述字准备好的条件
当 select 函数调用阻塞时,当套接口读、写准备好或异常时 select 返回。那么什么时候认为套接口准备好呢?
1. 套接口准备好读的条件
a.套接口接收缓冲区中的数据字节数大于等于套接口接收缓冲区低潮限度的当前值。对这样的套接口的读操作将不阻塞并返回一个大于 0 的值,即准备好读入的数据量。可以用套接口选项 SO RCVLOWAT 来设置此低潮限 度,对于 TCP 和 UDP 套接口,其值 默认 为 1 。
b.连接的读这一半关闭,也就是说接收了 FIN 的 TCP 连接。对这样的套接口的读操作将不阻塞且返回 0 即文件结束符 。
c.套接口是一个监听套接口且已完成的连接数大于 0 。在正常情况下,这样的监听套接口上的 accept 不会阻塞。
d.有一个套接口错误待处理。对这样的套接口的读操作将不阻塞且返回一个错误一 1,error 则设置成明确的错误条件。这些待处理的错误 pending error 也可通过指定套接口选项 SO_ERROR 调用 getsockopt 来取得并清除。
2. 下列三个条件中的任一个满足时,套接口准备好写
a.套接口发送缓冲区中的可用空间字节数 大于 等于套接口发送缓冲区低潮限度的当前值,且套接口已连接或者套接口不要求连接 例如 UDP 套接口 。可以用套接口选项SO_SNDLOWAT 来设置套接口接收缓冲区低潮限度,对于 TCP 和 UDP 套接口缺省值一般为2048 。
b.连接的写这一半关闭。对这样的套接口的写操作将产生信号 SIGPIPE 。
c.有一个套接口错误待处理。对这样的套接口的写操作将不阻塞且返回一个错误一 1,error 则设置成明确的错误条件。这些待处理的错误也可通过指定套接 口选项 SO_ERROR 调用 getsockopt 来取得并清除。
3. 异常处理
如果一个套接口存在带外数据或者仍处于带外标记,那它有异常条件待处理。异常条件指的是:套接口带外数据的到达、控制状态信息的存在。
必须注意一点,当一个套接口出错时,它由select 标记为既可读又可写。
而在上面提到的接收和发送低潮限度的目的是:在select 返回可读或可写条件之前,应用进程可以对有多少数据可读或有多大空间可用于写进行控制。就是说,如果将接收低潮限度设置为 64 ,那么至少有 64 字节的数据可用,这样当遇到小于 64 个字节的数据准备好读时,select 函数就唤醒我们。
现在讨论完 select 函数后,来看另一个问题。
需要一种方法来关闭 TCP 连接的一半。也就是说,想给服务器发一个 FIN ,告诉它已完成了数据发送,但仍为读而开放套接口描述字。这由下一节描述的 shutdown 函数来完成。
9.3 shutdown 函数
以前终止连接的方法是调用 close 函数,该函数并不进行真正的四分组终止序列,而是将描述字的访问计数减 1 ,仅在此计数为 0 时才关闭套接口,发送 TCP 的正常连接终止序列,在此 close 有两个限制可由本节介绍的函数 shutdown 来避免。
- close将描述字的访问计数减1,仅在此计数为0时才关闭套接口。用shutdown可以激发TCP的正常连接终止序列,而不管访问计数。
- close终止了数据传送的两个方向:读和写。由于TCP连接是全双工的,有很多时候要通知另一端已完成了数据发送,即使那一端仍有许多数据要发送也是如此。shutdown函数可以仅仅关闭连接的读、写或两个方向都关闭。下图就是为该情况下的典型函数调用(如图9-7所示)。
结构
下面一起来看下 shutdown 函数的具体形式和参数等。
# include <sys/socket.h>
int shutdown (int sockfd, int howto);
返回值
返回:0 成功, -1 出错
参数
参数 sockfd 为要关闭的套接口描述字;
参数 howto 为以下常值:
- SHUT_RD:关闭连接的读这一半,不再接收套接口中的数据,而且留在套接口接收缓冲区中的数据都作废。进程不能再对套接口执行任何读函数。调用此函数后,TCP套接口接收的任何数据都被确认,但数据本身扔掉。
- SHUT_WD:关闭连接的写这一半,在TCP场合下,这种情况称为半关闭(half-close)(TCPvl的18.5节)。当前留在套接口发送缓冲区中的数据都被发送,后跟正常的TCP连接终止序列,进程不能再执行对套接口的任何写函数。
- SHUT_RDWR:连接的读这一半和写这一半都关闭,等同于调用函数shutdown两次,第一次调用时用SHUT_RD,第二次调用时用SHUT_WR。
9.4 poll 函数
poll 函数提供了与 select 函数相似的功能,但是当涉及到流设备时,它还提供一些附加的功能。
现在看下 poll 函数的形式:
# include <poll.h>
int poll (struct pollfd *fdarray, unsigned long nfds, int timeout);
返回:准备好描述宇的个数, 0 超时, -1 出错
参数
第一个参数是指向一个结构数组第一个元素的指针,每个数组元素都是一个pollfd 结构,它规定了为测试一给定描述字 fd 的一些条件。下面就是 pollfd 结构的源码。
struct pollfd
{
int fd;
short events;
short revent;
}
要测试的条件由成员 events 规定,函数在相应的 revents 成员中返回描述字的状态(每个描述字有两个变量,一个为调用值,另一个为结果,以此避免使用值 结果参数。回想一下,函数 select 的中间三个参数都是值-结果参数 )。 这两个成员中的每一个都由指定某个条件的一位或多位组成。
而第二个参数,结构数组中元素的个数是由参数 nfds 来规定的。
参数 timeout 同 select 中的 timeout 的功能一样,指定函数返回前等待多长时间,它是一个指定应等待的毫秒效的正值。有三种情况:大于 0 ,等待知道数目的时间;等于 0 ,立即返回,不阻塞; INFTIM ,永远等待。
返回值
poll 函数的返回值成功为准备好描述字的个数;出错返回 -1 ;若定时器到,还没有描述字准备好,则返回 0 。如果我们不关心某个描述字,可将其 pollfd 结构中的 fd 成员设为负数。
9.5 使用 select 函数的 TCP 例子★★★
使用 select 函数的 TCP 服务器端程序
下面来看一个完整的使用 select 函数的 TCP 服务器端程序,这是一个单线程并发服务器实例程序,函数的作用事并且我们会对此函数作出解释,该函数的源码如下:
由于程序比较长,请读者 看清楚并仔细学习,下面来对程序 进行说明 ,并且在程序中也有一部分对程序的 说明 ,也请理解。
第 1-6 行,定义了一个 client 结构这是用来存储客户信息的一个结构。它包含了套接口描述字 fd ,客户名 name ,客户 IPv4 地址 addr,以及客户数据data。
第 7 行, process_cli 函数处理客户信息的函数,它的参数为一个 client 的结构指针、接收缓冲区的大小和长度。
第 8 行, savedata函数处理数据的存储。
第28行,设置套接字的一些属性。具体在第六章中,在此不再赘述。
第44~50行,在这里为select函数初始套接字的一些属性。
第46行,宏FD_SETSIZE 声明在一个进程中 select 所能操作的文件描述符的最大数目它通常包含在 头文件 <sys /types.h> 中。
第 49 行,初始 allset 。
第 50 行,设置 listenfd套接字为1。
第56行,检测listenfd套接字是否为1。
第82行,recv函数将在下节具体介绍它。
第92行,调用process_cli 函数处理收到的客户的信息。
其余的代码相信大家都能看懂,这里就不多说了,并且这是一个综合例子,希望读者认真的去理解和消化,这对大家会很有提高的。
对于 UDP 的情况与此类似,这里不再赘述 。
pselect 函数
下面来看一个 pselect 函数,形式如下:
#include <sys/select.h>
#include <signal.h>
#include <sys/time.h>
int pselect (int maxfdp1, fd_set *readset, fd_set * writeset, fd_set *exceptset, const struct timespec * timeout, const sygset_t *sigmask);
返回:准备好的描述字个数,0 超时, -1 出错。
pselect 相对于正常的 select 有两个变化:
- pselect函数使用结构timespec,而不是timeval
struct timespec {
time_t tv_sec; /* seconds */
long tv_nsec; /* nanoseconds */
};
这两个结构的区别在第二个成员上:新结构的成员tv_nsec规定纳秒数,而老结构的成员tv_usec规定微秒数。
- 函数pselect增加了第六个参数
指向信号掩码的指针。这允许程序禁止递交某些信号,测试由这些当前禁止的信号的信号处理程序所设置的全局变量,然后调用pselect,告诉它临时重置信号掩码。
现在介绍pselec函数的例程:
第6~9行,这几个函数是查询或设置信号处理方式 ,这里不作介绍了 有兴趣的同学请查看《UNIX网络编程》的有关信号量的章节。
第 11 行,调用 pselect 函数,处理 等待rset中的描述字变为可读前,阻newsig中的信号。