Unix网络编程(7):高级I/O函数

1. 套接字超时

        在涉及套接字的I/O操作上设置超时的办法有以下三种:

  1. 调用 $alarm$ ,它在指定超时期满时产生 $SIGALRM$ 信号。这个办法涉及信号处理,而信号处理在不同的实现上存储差异,而且可能干扰进程中现有的 $alarm$ 调用;
  2. 在 $select$ 中阻塞等待I/O,以此代替直接阻塞在 $read$ 或 $write$ 调用上;
  3. 使用较新的 $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. recvsend函数

#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$

3. readvwritev函数

#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.3BSDLinux中均最多允许 $1024$ 个。POSIX要求在头文件 <$sys/uio.h$> 中定义IOV_-MAX常值,而且其值至少为 $16$ 。

4. recvmsgsendmsg函数

#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$

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. 排队的数据量

  1. 如果获知已排队数据量的目的在于避免读操作阻塞在内核中,那么可以使用非阻塞式I/O
  2. 如果既想查看数据,又想数据仍然留存在接收队列中,那么可以使用 $MSG_-PEEK$ 标志。但是在没有数据可读的情况下,可能会阻塞,可以结合使用非阻塞式套接字避免阻塞,或者组合使用 $MSG_-DONTWAIT$ 和 $MSG_-PEEK$ ;
  3. 一些实现支持 $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函数库可用于套接字,不过需要考虑以下几点:

        标准I/O函数库执行以下三类缓冲:

        标准I/O函数库的大多数Unix实现使用如下原则:

        有两种方法可以避免完全缓冲:第一种是通过 $setvbuf$ 迫使输出流变为行缓冲,第二种是在每次调用 $fputs$ 之后通过调用 $fflush$ 强制输出。然而实际上,这两种方法都容易出错,所以在大多数情况下,最好的办法是避免在套接字上使用标准I/O函数库。

8. T/TCP:事务目的的TCP

        T/TCP是对TCP进行过修改的一个版本,能够避免近来彼此通信过的主机之间的三次握手。T/TCP能够把SYNFIN和数据组合到单个分节中,前提是数据的大小小于 $MSS$ 。最小T/TCP事务的时间线由三个分节组成,第一个分节是由客户的单个 $sendto$ 调用产生SYNFIN和数据,该分节组合了 $connect$ 、$write$ 和 $shutdown$ 共三个调用的功能。服务器执行通常的套接字函数调用步骤:$socket$ 、$bind$ 、$listen$ 和 $accept$ ,其中后者在客户的分节到达时返回。服务器用 $send$ 返回应答并关闭套接字,从而使得服务器在同一个分节中向客户发出SYNFINACK。通过T/TCP技术,客户从初始化到发送一个请求的再到读取相应应答的所花费的时间少了一个 $RTT$ 。
        T/TCP的优势在于TCP的所有可靠性 ( 序列号、超时、重传等 ) 得以保留,而不像UDP那样把可靠性推给应用程序。为了处理T/TCP,套接字API需要做些变动:

Unix网络编程(7):高级I/O函数