Unix网络编程(7):高级I/O函数
1. 套接字超时
在涉及套接字的I/O
操作上设置超时的办法有以下三种:
- 调用 $alarm$ ,它在指定超时期满时产生 $SIGALRM$ 信号。这个办法涉及信号处理,而信号处理在不同的实现上存储差异,而且可能干扰进程中现有的 $alarm$ 调用;
- 在 $select$ 中阻塞等待
I/O
,以此代替直接阻塞在 $read$ 或 $write$ 调用上; - 使用较新的 $SO_-RCVTIMEO$ 和 $SO_-SNDTIMEO$ 套接字选项。这个办法的问题在于并非所有实现都支持这两个套接字选项。
以上三种技术都适用于输入和输出操作,不过我们依然期待可用于 $connect$ 技术,因为TCP
内置的 $connect$ 超时时间相当长 ( 典型为 $75$ 秒 )。$select$ 可用来在 $connect$ 上设置超时的先决条件是相应套接字处于非阻塞模式,而那两个套接字选项对 $connect$ 并不适用。还要注意,前两个技术适用于任何描述符,而最后一个只适用于套接字描述符。
#include "unp.h"
static void connect_alarm(int);
int connect_timeo(int sockfd, const struct sockaddr *saptr, socklen_t salen, int nsec) {
Sigfunc *sigfunc;
int n;
sigfunc = Signal(SIGALRM, connect_alarm);
if (alarm(nsec) != 0)
err_msg("connect_timeo: alarm was already set");
if ((n = connect(sockfd, saptr, salen)) < 0) {
close(sockfd);
if (errno == EINTR) errno = ETIMEDOUT;
}
alarm(0); // turn off the alarm
Signal(SIGALRM, sigfunc) // restore previous signal handler
return n;
}
static void connect_alarm(int signo) {
return; // just interrupt the connect()
}
上述代码使用第一种方法,通过 $alarm$ 设置秒数,再通过信号处理函数中断 $connect$ 调用,同时关闭套接字。自定义的 $Signal$ 函数也会阻塞 $SA_-RESTART$ 信号。要注意,如果指定 $nsec$ 为一个大于 $75$ 的值,那么函数仍然会在 $75$ 秒时超时并返回。还要注意,虽然通过这种方式实现简单,但是在多线程程序中,正确使用信号是很困难的,所以建议只在未线程化或单线程程序中适用该技术。
#include "unp.h"
int readable_timeo(int fd, int sec) {
fd_set rset;
struct timeval tv;
FD_ZERO(&rset);
FD_SET(fd, &rset);
tv.tv_sec = sec;
tc.tc_usec = 0;
return (select(fd + 1, &rset, NULL, NULL, &tv));
上述代码使用第二种方法,通过 $select$ 等待描述符可读,或发生超时。该函数不执行读操作,只是等待描述符变为可读,因此既适用于TCP
也适用于UDP
。
#include "unp.h"
void dg_cli(FILE *fp, int sockfd, const struct sockaddr *pservaddr, socklen_t servlen) {
int n;
char sendline[MAXLINE], recvline[MAXLINE + 1];
struct timeval tv;
tv.tv_sec = 5;
tc.tc_usec = 0;
Setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
while (Fgets(sendline, MAXLINE, fp) != NULL) {
Sendto(sockfd, sendline, strlen(sendline), 0, psevaddr, sevlen);
n = recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);
if (n < 0) {
if (errno == EWOULDBLOCK) {
fprintf(stderr, "socket timeout\n");
continue;
} else
err_sys("recvfrom error");
}
recvline[n] = 0;
Fputs(recvline, stdout);
}
}
上述代码使用第三种办法,通过 $SO_-RECTIMEO$ 套接字选项设置超时。本选项一旦设置到某个描述符 ( 包括指定超时值 ),超时设置将会应用在所有对于该描述符的读操作上面。类似的,$SO_-SNDTIMEO$ 选项则应用于写操作。
2. recv
和send
函数
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buff, size_t nbytes, int flags);
ssize_t send(int sockfd, const void *buff, size_t nbytes, int flags);
$recv$ 和 $send$ 的前 $3$ 个参数等同于 $read$ 和 $write$ 参数,$flags$ 参数的值或为 $0$ ,或为以下一个或多个的逻辑或:
$flags$ | 说明 | $recv$ | $send$ |
---|---|---|---|
$MSG_-DONTROUTE$ | 绕过路由表查找 | $\checkmark$ | |
$MSG_-DONTWAIT$ | 仅本操作非阻塞 | $\checkmark$ | $\checkmark$ |
$MSG_-OOB$ | 发送或接收带外数据 | $\checkmark$ | $\checkmark$ |
$MSG_-PEEK$ | 窥看外来消息 | $\checkmark$ | |
$MSG_-WAITALL$ | 等待所有数据 | $\checkmark$ |
- $MSG_-DONTROUTE$ :告知内核目的主机在某个直接连接的本地网络上,无需执行路由表查找。既可以使用 $MSG_-DONTROUTE$ 标志针对单个输出操作开启,也可以使用 $SO_-DONTROUTE$ 套接字选项针对某个套接字上的所有输出操作开启;
- $MSG_-DONTWAIT$ :在无需打开相应套接字的非阻塞标志的前提下,把单个
I/O
操作临时指定为非阻塞,接着执行I/O
操作,然后关闭非阻塞标志; - $MSG_-OOB$ :对于 $send$ ,指定发送带外数据。对于 $recv$ ,指明即将读入的是带外数据;
- $MSG_-PEEK$ :适用于 $recv$ 和 $recvfrom$ ,允许查看已经可以读取的数据,并且系统不会在返回后丢弃这些数据;
- $MSG_-WAITALL$ :告知内核不要在尚未读入请求数目的字节之前让一个读操作返回。要注意,如果(a)捕获信号,(b)连接终止,(c)套接字发生错误时,相应的读函数仍有可能提前返回。
3. readv
和writev
函数
#include <sys/uio.h>
ssize_t readv(int filedes, const struct iovec *iov, int iovcnt);
ssize_t writev(int filedes, const struct iovec *iov, int iovcnt);
struct iovec {
void *iov_base; // 缓冲区开始地址
size_t iov_len; // 缓冲区大小
};
这两个函数类似于 $read$ 和 $write$ ,不过允许单个系统调用读入到或写出自一个或多个缓冲区,称为分散读 ( $scatter$ $read$ ) 和集中写 ( $gather$ $write$ ),因为来自读操作的输入数据被分散到多个应用缓冲区中,而来自多个应用缓冲区的输出数据则被集中提供给单个写操作。这两个函数可用于任何描述符,不仅限于套接字,另外 $writev$ 还是一个原子操作,从而一次调用只会产生一个UDP
数据报。
$iovec$ 结构数组中元素的数目存在某个限制,具体取决于实现,例如4.3BSD
和Linux
中均最多允许 $1024$ 个。POSIX
要求在头文件 <$sys/uio.h$> 中定义IOV_-MAX
常值,而且其值至少为 $16$ 。
4. recvmsg
和sendmsg
函数
#include <sys/socket.h>
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
ssize_t sendmsg(int sockfd, struct msghdr *msg, int flags);
struct msghdr {
void *msg_name; // 协议地址
socklen_t msg_namelen; // 协议地址长度
struct iovec *msg_iov; // scatter/gather数组
int msg_iovlen; // 数组长度
void *msg_control; // 辅助数组,struct cmsghdr
socklen_t msg_controllen; // 辅助数组长度
int msg_flags; // recvmsg()返回的flags
};
$msg_-name$ 和 $msg_-namelen$ 用于套接字未连接的场合,譬如未 $connect$ 的UDP
套接字。如果无需指明协议地址,$msg_-name$ 应为空指针。对于 $recvmsg$ 函数,$msg_-name$ 参数是一个值-结果参数。$msg_-control$ 和 $msg_-controllen$ 这两个成员指定可选的辅助数据的位置和大小,$msg_-controllen$ 对于 $recvmsg$ 是一个值-结果参数。$recvmsg$ 和 $sendmsg$ 内部有两个 $flags$ ,一个是传递值的 $flags$ 参数,另一个是 $msg_-flags$ 成员,传递的是引用。只有 $recvmsg$ 使用 $msg_-flags$ 成员,$recvmsg$ 被调用时,$flags$ 参数被复制到 $msg_-flags$ 成员,并由内核使用其值驱动接收处理过程,内核还依据 $recvmsg$ 的结果更新 $msg_-flags$ 成员的值。$sendmsg$ 则忽略 $msg_-flags$ 成员,直接使用 $flags$ 参数驱动发送处理过程。
标志 | $send$ 、$sendto$ 或 $sendmsg$ 函数的 $flags$ 参数 | $recv$ 、$recvfrom$ 或 $recvmsg$ 函数的 $flags$ 参数 | $recvmsg$ 函数的 $msg_-flags$ 结构成员参数返回 |
---|---|---|---|
$MSG_-DONTROUTE$ | $\checkmark$ | ||
$MSG_-DONTWAIT$ | $\checkmark$ | $\checkmark$ | |
$MSG_-PEEK$ | $\checkmark$ | ||
$MSG_-WAITALL$ | $\checkmark$ | ||
$MSG_-EOR$ | $\checkmark$ | $\checkmark$ | |
$MSG_-OOB$ | $\checkmark$ | $\checkmark$ | $\checkmark$ |
$MSG_-BCAST$ | $\checkmark$ | ||
$MSG_-MCAST$ | $\checkmark$ | ||
$MSG_-TRUNC$ | $\checkmark$ | ||
$MSG_-CTRUNC$ | $\checkmark$ | ||
$MSG_-NOTIFICATION$ | $\checkmark$ |
- $MSG_-BCAST$ :返回条件是本数据报作为链路层广播收取或其目的
IP
地址是一个广播地址。与 $IP_-RECVDSTADDR$ 套接字选项相比,本标志是用于判断一个UDP
数据报是否发往某个广播地址的更好办法; - $MSG_-MCAST$ :返回条件是本数据报作为链路层多播收取;
- $MSG_-TRUNC$ :返回条件是本数据报被截断,即内核预备返回的数据超过进程事先分配的空间;
- $MSG_-CTRUNC$ :返回条件是本数据报的辅助数据被截断,即内核预备返回的辅助数据超过进程事先分配的空间;
- $MSG_-EOR$ :返回条件是返回数据结束一个逻辑记录。
TCP
不使用该标志,因为它是一个字节流协议; - $MSG_-OOB$ :不为
TCP
带外数据返回,适用于其他协议族; - $MSG_-NOTIFICATION$ :由
SCTP
接收者返回,指示读入的消息是一个事件通知,而不是数据消息。
5. 辅助数据
辅助数据 ( $ancillary$ $data$ ) ,另一个名称是控制信息 ( $control$ $information$ ),可通过调用 $sendmsg$ 和 $recvmsg$ 这两个函数,使用 $msghdr$ 结构中的 $msg_-control$ 和 $msg_-controllen$ 这两个成员发送和接收。
协议 | $cmsg_-level$ | $cmsg_-type$ | 说明 |
---|---|---|---|
IPv4 |
$IPPROTO_-IP$ | $IP_-RECVDSTADDR$ | 随UDP 数据报接收目的地址 |
$IP_-RECVIF$ | 随UDP 数据报接收接口索引 |
||
IPv6 |
$IPPROTO_-IPV6$ | $IPV6_-DSTOPTS$ | 指定/接收目的地选项 |
$IPV6_-HOPLIMIT$ | 指定/接收跳限 | ||
$IPV6_-HOPOPTS$ | 指定/接收步跳选项 | ||
$IPV6_-NEXTHOP$ | 指定下一跳地址 | ||
$IPV6_-PKTINFO$ | 指定/接收分组信息 | ||
$IPV6_-RTHDR$ | 指定/接收路由首部 | ||
$IPV6_-TCLASS$ | 指定/接收分组流通类别 | ||
Unix 域 |
$SOL_-SOCKET$ | $SCM_-RIGHTS$ | 发送/接收描述符 |
$SCM_-CREDS$ | 发送/接收用户凭证 |
辅助数据由一个或多个辅助数据对象组成,每个对象以一个定义在头文件 <$sys/socket.h$> 中的 $cmsghdr$ 结构开头。
#include <sys/socket.h>
struct cmsghdr {
socklen_t cmsg_len; // 数据长度,包含当前结构
int cmsg_level; // 原始协议
int cmsg_type; // 协议确切类型
// cmsg数据在当前结构之后,由cmsg_len可以计算出长度
};
在每个$cmsg_-type$ 成员和实际数据之间可以有填充字节,从数据结尾处到下一个辅助数据对象之前也可以有填充字节。为了对应用程序屏蔽填充字节,定义了以下几个宏:
#include <sys/socket.h>
#include <sys/param.h> // for ALIGN macro
// 返回指向第一个cmsghdr结构的指针,若不存在则返回NULL
struct cmsghdr *CMSG_FIRSTHDR(struct msghdr *mhdrptr);
// 返回指向下一个cmsghdr结构的指针,若不存在则返回NULL
struct cmsghdr *CMSG_NXTHDR(struct msghdr *mhdrptr, struct cmsghdr *cmsgptr);
// 指向与cmsghdr结构关联的数据的第一个字节的指针
unsigned char *CMSG_DATA(struct cmsghdr *cmsgptr);
// 给定数据量下存放到cmsg_len中的值
unsigned int CMSG_LEN(unsigned int length);
// 给定数据量下下一个辅助数据对象总大小
unsigned int CMSG_SPACE(unsigned int length);
$CMSG_-LEN$ 和 $CMSG_-SPACE$ 的区别在于,前者不计辅助数据对象中数据部分之后可能的填充字节,返回的是 $cmsg_-len$ 的值,后者则会计算填充字节,返回的是为辅助数据对象动态分配的空间大小。
6. 排队的数据量
- 如果获知已排队数据量的目的在于避免读操作阻塞在内核中,那么可以使用非阻塞式
I/O
; - 如果既想查看数据,又想数据仍然留存在接收队列中,那么可以使用 $MSG_-PEEK$ 标志。但是在没有数据可读的情况下,可能会阻塞,可以结合使用非阻塞式套接字避免阻塞,或者组合使用 $MSG_-DONTWAIT$ 和 $MSG_-PEEK$ ;
- 一些实现支持 $ioctl$ 的 $FIONREAD$ 命令,该命令的第三个 $ioctl$ 参数是指向某个整数的一个指针,内核通过该整数返回的值就是套接字接收队列的当前字节数。
7. 标准I/O
到目前为止的所有例子中,我们一直使用Unix I/O
,这些函数围绕描述符工作,通常作为Unix
内核中的系统调用实现。执行I/O
的另一个方法是使用标准I/O
,由ANSI C
标准规范,意在便于移植到支持ANSI C
的非Unix
系统上。标准I/O
函数库处理我们直接使用Unix I/O
函数时必须考虑的一些细节,譬如自动缓冲输入流和输出流。不幸的是,它对于流的缓冲处理可能导致我们同样必须考虑的一组新问题。标准I/O
函数库可用于套接字,不过需要考虑以下几点:
- 调用 $fdopen$ ,可以从任何一个描述符创建出一个标准
I/O
流。类似地,通过调用 $fileno$ ,可以获取一个给定标准I/O
流对应的描述符; TCP
和UDP
套接字是全双工的,标准I/O
流也可以是全双工的,只要以 $r+$ 类型打开流即可。然而在这样的流上,我们必须在调用一个输出函数之后插入一个 $fflush$ 、$fseek$ 、$fsetpos$ 或 $rewind$ 调用才能接着调用一个输入函数。类似的,调用一个输入函数后也必须调用这些系统调用,除非遇到EOF
。$fseek$ 、$fsetpos$ 和 $rewind$ 这 $3$ 个函数的问题是它们都调用 $lseek$ ,而 $lseek$ 调用在套接字上只会失败;- 解决上述读写问题的简单办法是为一个给定套接字打开两个标准
I/O
流,一个用于写,一个用于读。
标准I/O
函数库执行以下三类缓冲:
- 完全缓冲 ( $fully$ $buffering$ ),意味着只在下列情况发生时才进行
I/O
:缓冲区满、进程显式调用 $fflush$ 或进程调用 $exit$ 终止。标准I/O
缓冲区的大小通常为 $8192$ 字节; - 行缓冲 ( $line$ $buffering$ ),意味着只在下列情况发生时才进行
I/O
:遇到换行符、进程调用 $fflush$ 或进程调用 $exit$ 终止; - 不缓冲 ( $unbuffering$ ),意味着每次调用标准
I/O
输出函数都发生I/O
。
标准I/O
函数库的大多数Unix
实现使用如下原则:
- 标准错误输出不缓冲;
- 标准输入和标准输出完全缓冲,除非它们指代终端设备 ( 这种情况下行缓冲 );
- 所有其他
I/O
流都是完全缓冲,除非它们指代终端设备 ( 这种情况下行缓冲 )。
有两种方法可以避免完全缓冲:第一种是通过 $setvbuf$ 迫使输出流变为行缓冲,第二种是在每次调用 $fputs$ 之后通过调用 $fflush$ 强制输出。然而实际上,这两种方法都容易出错,所以在大多数情况下,最好的办法是避免在套接字上使用标准I/O
函数库。
8. T/TCP
:事务目的的TCP
T/TCP
是对TCP
进行过修改的一个版本,能够避免近来彼此通信过的主机之间的三次握手。T/TCP
能够把SYN
、FIN
和数据组合到单个分节中,前提是数据的大小小于 $MSS$ 。最小T/TCP
事务的时间线由三个分节组成,第一个分节是由客户的单个 $sendto$ 调用产生SYN
、FIN
和数据,该分节组合了 $connect$ 、$write$ 和 $shutdown$ 共三个调用的功能。服务器执行通常的套接字函数调用步骤:$socket$ 、$bind$ 、$listen$ 和 $accept$ ,其中后者在客户的分节到达时返回。服务器用 $send$ 返回应答并关闭套接字,从而使得服务器在同一个分节中向客户发出SYN
、FIN
和ACK
。通过T/TCP
技术,客户从初始化到发送一个请求的再到读取相应应答的所花费的时间少了一个 $RTT$ 。
T/TCP
的优势在于TCP
的所有可靠性 ( 序列号、超时、重传等 ) 得以保留,而不像UDP
那样把可靠性推给应用程序。为了处理T/TCP
,套接字API
需要做些变动:
- 客户端调用 $sendto$ ,以便把数据的发送结合到连接的建立中,服务器的协议地址改为传递给 $sendto$ 而不是 $connect$ ;
- 新增一个输出标志 $MSG_-EOF$ ,用于指示本套接字上不再有数据待发送。该标志允许我们把 $shutdown$ 调用结合到输出操作之中;
- 新定义一个级别为
IPPROTO_-TCP
的套接字选项 $TCP_-NOPUSH$ ,防止TCP
只为腾空套接字发送缓冲区而发送分节。当某个客户准备以单个 $sendto$ 发送一个请求时,如果该请求大小超过 $MSS$ ,它就为相应的套接字设置该选项,以减少所发送的分节数目; - 想跟一个服务器建立连接并且使用
T/TCP
发送一个请求的客户应该调用 $socket$ 、$setsockopt$ ( 开启 $TCP_-NOPUSH$ 选项 ) 和 $sendto$ ( 若只有一个请求待发送则指定 $MSG_-EOF$ 标志 )。如果 $setsockopt$ 返回 $ENOPROTOOPT$ 错误或者 $sendto$ 返回 $ENOTCONN$ 错误,那么该主机不支持T/TCP
; - 服务器如果想要随
ACK
一起发送FIN
,那么就应该调用 $send$ 发送,并指定 $MSG_-EOF$ 标志; T/TCP
的编译时测试可以使用伪代码#ifdef MSG_-EOF
。