Unix网络编程(3):I/O复用
1. I/O
模型
Unix
下有 $5$ 种可用的I/O
模型:阻塞式I/O
、非阻塞式I/O
、I/O
复用、信号驱动式I/O
和异步I/O
。
1.1 阻塞式I/O
最流行的I/O
模型是阻塞式I/O
( $blocking$ $I/O$ ) 模型。默认情形下,所有的套接字都是阻塞的。
1.2 非阻塞式I/O
进程把一个套接字设置成非阻塞式是在通知内核:当所请求的I/O
操作非得把本进程置于休眠状态才能完成时,不要把本进程置于休眠状态,而是返回一个错误。
当一个应用进程像这样对一个非阻塞描述符循环调用 $recvfrom$ 时,我们称之为轮询 ( $polling$ )。应用进程持续轮询内核,以查看某个操作是否就绪,这么做往往耗费大量CPU
时间。不过这种模型偶尔也会遇到,通常是在专门提供某一种功能的系统中才有。
1.3 I/O
复用
通过I/O
复用,我们可以调用 $select$ 或 $poll$ ,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的I/O
系统调用上。
1.4 信号驱动式I/O
我们也可以使用信号,让内核在描述符就绪时发送 $SIGIO$ 信号通知我们。
我们首先开启套接字的信号驱动I/O
功能,并通过 $sigaction$ 系统调用安装一个信号处理函数。该系统调用立即返回,我们的进程继续工作。当数据报准备好被读取时,内核就为该进程产生一个 $SIGIO$ 信号。我们随后既可以在信号处理函数中调用 $recvfrom$ 读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它读取数据报。
1.5 异步I/O
异步I/O
( $asynchronous$ $I/O$ ) 由POSIX
规范定义。一般地说,这些函数的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。这种模型与前一节介绍的信号驱动模型的主要区别在于:信号驱动式I/O
是由内核通知我们何时可以启动一个I/O
操作,异步I/O
是由内核通知我们I/O
操作何时完成。
我们调用 $aio_-read$ 函数,给内核传递描述符、缓冲区指针、缓冲区大小和文件偏移,并告诉内核当整个操作完成时如何通知我们。该系统调用会立即返回,而且在等待I/O
完成期间,我们的进程不被阻塞。
2. select
函数
$select$ 函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定时间后才唤醒。
#include <sys/select.h>
#include <sys/time.h>
// 存在就绪描述符则返回其数目,超时返回0,出错返回-1
int select(int maxfdp1, fd_set *readset, fd_set *writeset,
fd_set *exceptset, const struct timeval *timeout);
strcut timeval {
long tv_sec; // seconds
long tv_usec; // microseconds
$timeval$ 参数有以下三种可能:
- 永远等待下去:仅在有一个描述准备好
I/O
时才返回,此时该参数设为 $NULL$ ; - 等待一段固定时间:在有一个描述符准备好
I/O
时返回,但是不超过由该参数所指定的时间; - 不等待:检查描述符后立即返回,此时该参数指定一个 $timeval$ 结构,但是成员都为 $0$ 。
尽管 $timeval$ 结构允许我们指定一个微妙级的时间,但是内核支持的真实分辨率往往会更粗糙。例如,许多Unix
内核把超时值向上舍入为 $10ms$ 的倍数。另外还涉及调度延迟,也就是说定时器时间到后,内核还需要花一点时间调度相应进程运行。
$select$ 函数中的三个参数 $readset$ 、$writeset$ 和 $exceptset$ 中,如果我们对某个条件不感兴趣,就可以把它设为空指针。事实上,如果这三个指针均为空,我们就有了一个比Unix
的 $sleep$ 函数更为精确的定时器。$maxfdp1$ 参数指定待测试的描述符个数,它的值是待测试的最大描述符加 $1$ 。头文件 <$sys/select.h$> 中定义的 $FD_-SETSIZE$ 常值是数据类型 $fd_-set$ 中的描述符总数,通常为 $1024$ 。
$select$ 使用描述符集,通常是一个整数数组,其中每个整数中的每一位对应一个描述符。例如,假设使用 $32$ 位整数,那么该数组的第一个元素对应于描述符 $0 \sim 31$ ,第二个元素对应于描述符 $32 \sim 63$ ,以此类推。所有这些实现细节都与应用程序无关,隐藏在名为 $fd_-set$ 的数据类型和以下四个宏中:
// 清零
void FD_ZERO(fd_set *fdset);
// 打开
void FD_SET(int fd, fd_set *fdset);
// 关闭
void FD_SET(int fd, fd_set *fdset);
// 检查
void FD_ISSET(int fd, fd_set *fdset);
$select$ 函数修改由指针 $readset$ 、$writeset$ 和 $exceptset$ 指向的描述符集,因而这三个参数都是值-结果参数。调用该函数时,我们指定关心的描述符值,函数返回时,结果将指示哪写描述符已经就绪。我们通过 $FD_-ISSET$ 宏测试 $fd_-set$ 数据类型中的描述符,描述符集内任何与未就绪描述符对应的位返回时均清 $0$ 。为此,每次重新调用 $select$ 时,我们都得再把所有描述符集内关心的位置 $1$ 。
满足下列四个条件中的任何一个时,套接字准备好读:
- 该套接字接受缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小,对这样的套接字执行读操作不会阻塞并将返回一个大于 $0$ 的值。可以使用 $SO_-RCVLOWAT$ 套接字选项设置该套接字的低水位标记,对
TCP
和UDP
套接字而言,默认值为 $1$ ; - 该连接的读半关闭 ( 也就是接收了
FIN
的TCP
连接 ),对这样的套接字执行读操作不会阻塞并将返回 $0$ ( 即EOF
); - 该套接字是一个监听套接字并且已经完成的连接数不为 $0$ ,对这样的套接字的 $accept$ 通常不会阻塞;
- 有一个套接字错误待处理,对这样的套接字的读操作将不阻塞并返回 $-1$ ,同时把 $errno$ 设置成确切的错误条件。这些待处理错误 ( $pending$ $error$ ) 也可以通过指定 $SO_-ERROR$ 套接字选项调用 $getsockopt$ 获取并清除。
满足下列四个条件中的任何一个时,套接字准备好写:
- 该套接字发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的当前大小,对于基于连接的协议,还要求该套接字已连接。如果我们把这样的套接字设置为阻塞,写操作将不阻塞并返回一个正值。我们可以使用 $SO_-SNDLOWAT$ 套接字选项来设置该套接字的低水位标记,对于
TCP
和UDP
套接字而言,默认值通常为 $2048$ ; - 该连接的写半关闭,对这样的套接字的写操作将产生 $SIGPIPE$ 信号;
- 使用非阻塞式 $connect$ 的套接字已建立连接,或者 $connect$ 已经失败;
- 有一个套接字错误待处理,对这样的套接字的写操作将不阻塞并返回 $-1$ ,同时把 $errno$ 设置为确切的错误条件。这些待处理错误也可以通过指定 $SO_-ERROR$ 套接字选项调用 $getsockopt$ 获取并清除。
如果一个套接字存在带外数据或者仍处于带外标记,那么它有异常条件待处理。当某个套接字上发生错误时,它将由 $select$ 标记为既可读又可写。
条件 | 可读 | 可写 | 异常 |
---|---|---|---|
有数据可读 | $\checkmark$ | ||
读半关闭 | $\checkmark$ | ||
监听套接字准备好新连接 | $\checkmark$ | ||
有空间可写 | $\checkmark$ | ||
写半关闭 | $\checkmark$ | ||
待处理错误 | $\checkmark$ | $\checkmark$ | |
TCP 带外数据 |
$\checkmark$ |
3. shutdown
函数
终止网络连接的通常方法是调用 $close$ 函数。不过 $close$ 有两个限制,可以通过 $shutdown$ 来避免:
- $close$ 把描述符的引用计数减一,仅在该计数为 $0$ 时才关闭套接字。$shutdown$ 则可以直接关闭套接字;
- $close$ 终止读和写两个方向的数据传输,$shutdown$ 则只关闭一半的连接。
#include <sys/socket.h>
// 成功返回0,出错返回-1
int shutdown(int sockfd, int howto);
该函数的行为依赖于 $howto$ 参数:
- $SHUT_-RD$ :读半关闭,套接字中不再有数据可以接收,而且现有接收缓冲区中的数据全部丢弃。此时对端的后续数据都会被确认,然后丢弃;
- $SHUT_-WR$ :写半关闭,当前套接字发送缓冲区中的数据全部丢弃;
- $SHUT_-RDWR$ :读写半关闭。
4. poll
函数
$poll$ 提供的功能和 $select$ 类似,不过在处理流设备时,他能够提供额外的信息。
#include <poll.h>
// 若存在就绪描述符则返回其数目,超时返回0,出错返回-1
int poll(struct pollfd *fdarray, unsigned long nfds, int timeout);
struct pollfd {
int fd;
short events; // fd关注的事件
short revents; // fd发生的事件
};
第一个参数是指向一个结构数首元素的指针,要测试的条件由 $events$ 指定,函数在相应的 $revents$ 中返回该描述符的状态。
常值 | $events$ | $revents$ | 说明 |
---|---|---|---|
$POLLIN$ | $\checkmark$ | $\checkmark$ | 普通或优先级带数据可读 |
$POLLRDNORM$ | $\checkmark$ | $\checkmark$ | 普通数据可读 |
$POLLRDBAND$ | $\checkmark$ | $\checkmark$ | 优先级带数据可读 |
$POLLPRI$ | $\checkmark$ | $\checkmark$ | 高优先级数据可读 |
$POLLOUT$ | $\checkmark$ | $\checkmark$ | 普通数据可写 |
$POLLWRNORM$ | $\checkmark$ | $\checkmark$ | 普通数据可写 |
$POLLWRBAND$ | $\checkmark$ | $\checkmark$ | 优先级带数据可写 |
$POLLERR$ | $\checkmark$ | 发生错误 | |
$POLLHUP$ | $\checkmark$ | 发生挂起 | |
$POLLNVAL$ | $\checkmark$ | 描述符不是一个打开的文件 |
$poll$ 识别三类数据:普通、优先级带和高优先级。就TCP
和UDP
套接字而言,以下条件引起 $poll$ 返回特定的 $revents$ :
- 所有正规
TCP
数据和所有UDP
数据都被认为是普通数据; TCP
的带外数据是优先级带数据;- 当
TCP
连接读半关闭时,也被认为是普通数据,并且后续读都会返回 $0$ ; TCP
连接存在错误既可以认为是普通数据,也可以认为是错误。无论哪种情况,随后的读都会返回 $-1$ ,并设置 $errno$ ;- 在监听套接字上有新连接可用既可以认为是普通数据,也可以认为是优先级数据。大多数实现认为是普通数据;
- 非阻塞式 $connect$ 的完成被认为是使相应套接字可写。
$timeout$ 指定 $poll$ 函数返回前等待多久,它是一个指定应等待毫秒数的正值。
$timeout$ 值 | 说明 |
---|---|
$INFTIM$ | 永远等待 |
$0$ | 立即返回,不阻塞 |
$>0$ | 等待指定数目的毫秒数 |