UNP_22_高级UDP套接字编程

Posted on Mar 22, 2020

22.1 概述

  首先确定某个外来 UDP 数据报的目的地址和接收端口,因为绑定某个 UDP 端口和通配地址的一个套接字能够在任何接口上接受单播,广播和组播数据报。
  TCP 是一个字节流协议,又使用滑动窗口,因此没有记录边界和接收端速度慢于发送端的情况。但是 UDP 却需要程序对这些进行处理。
  如果实现不支持 IP_RECVDSTADDR 套接字选项,那么确定外来 UDP 数据报目的地址的方法之一是捆绑所有接口地址并使用 select。

22.2 接收标志、目的 ip 地址和接口索引

过去 sendmsg 和 recvmsg 一直用于通过 Unix 域套接字传递描述符。但随着改变,使用方式也在变化:

  • 在 msghdr 结构加入 msg_flags 用来返回标志该进程
  • 辅助数据越来越多的被使用

我们会实现一个类似 recvmsg 的 recvfrom_flags 函数,区别是新的函数还返回:

  • 所返回的 msg_flags 值
  • 所收取数据报的目的地址(通过 IP_RECVDSTADDR 套接字选项获取)
  • 所收取数据报接受接口的索引(通过 IP_RECVIF 套接字选项获取)
// 该结构体用来返回最后两项
struct unp_in_pktinfo{
    struct in_addr ipi_addr;
    int            ipi_ifindex;
};
// 前三个参数类似于 recvfrom,后面的参数用来返回上面提到的项
ssize_t recvfrom_flags(int fd, void *ptr, size_t nbytes, int *flagsp,
			   SA *sa, socklen_t *salenptr, struct unp_in_pktinfo *pktp)
{
	struct msghdr	msg;
	struct iovec	iov[1];
	ssize_t			n;
// 不同的实现差异
#ifdef	HAVE_MSGHDR_MSG_CONTROL
	struct cmsghdr	*cmptr;
	union {
	  struct cmsghdr	cm;
	  char				control[CMSG_SPACE(sizeof(struct in_addr)) +
								CMSG_SPACE(sizeof(struct unp_in_pktinfo))];
	} control_un;

	msg.msg_control = control_un.control;
	msg.msg_controllen = sizeof(control_un.control);
	msg.msg_flags = 0;
#else
	bzero(&msg, sizeof(msg));	/* make certain msg_accrightslen = 0 */
#endif
	// 填写一个 msghdr 结构并用来调用 recvmsg
	msg.msg_name = sa;
	msg.msg_namelen = *salenptr;
	iov[0].iov_base = ptr;
	iov[0].iov_len = nbytes;
	msg.msg_iov = iov;
	msg.msg_iovlen = 1;

	if ( (n = recvmsg(fd, &msg, *flagsp)) < 0)
		return(n);

	*salenptr = msg.msg_namelen;	/* pass back results */
	if (pktp)
		bzero(pktp, sizeof(struct unp_in_pktinfo));	/* 0.0.0.0, i/f = 0 */

#ifndef	HAVE_MSGHDR_MSG_CONTROL
	*flagsp = 0;					/* pass back results */
	return(n);
#else
	// 返回 msg_flags 的成员值
	*flagsp = msg.msg_flags;
	if (msg.msg_controllen < sizeof(struct cmsghdr) ||
		(msg.msg_flags & MSG_CTRUNC) || pktp == NULL)
		return(n);
	// 处理辅助数据
	for (cmptr = CMSG_FIRSTHDR(&msg); cmptr != NULL;
		 cmptr = CMSG_NXTHDR(&msg, cmptr)) {

#ifdef	IP_RECVDSTADDR
		// 如果目的 IP 地址作为控制信息返回,就返回给调用者
		if (cmptr->cmsg_level == IPPROTO_IP &&
			cmptr->cmsg_type == IP_RECVDSTADDR) {

			memcpy(&pktp->ipi_addr, CMSG_DATA(cmptr),
				   sizeof(struct in_addr));
			continue;
		}
#endif

#ifdef	IP_RECVIF
		if (cmptr->cmsg_level == IPPROTO_IP &&
			cmptr->cmsg_type == IP_RECVIF) {
			struct sockaddr_dl	*sdl;

			sdl = (struct sockaddr_dl *) CMSG_DATA(cmptr);
			pktp->ipi_ifindex = sdl->sdl_index;
			continue;
		}
#endif
		err_quit("unknown ancillary data, len = %d, level = %d, type = %d",
				 cmptr->cmsg_len, cmptr->cmsg_level, cmptr->cmsg_type);
	}
	return(n);
#endif	/* HAVE_MSGHDR_MSG_CONTROL */
}

ssize_t Recvfrom_flags(int fd, void *ptr, size_t nbytes, int *flagsp,
			   SA *sa, socklen_t *salenptr, struct unp_in_pktinfo *pktp)
{
	ssize_t		n;

	n = recvfrom_flags(fd, ptr, nbytes, flagsp, sa, salenptr, pktp);
	if (n < 0)
		err_quit("recvfrom_flags error");

	return(n);
}

修改 dg_echo 来输出目的 IP 地址和数据报截断标志

#undef	MAXLINE
// 测试更大的数据报,用来测试截断标志
#define	MAXLINE	20		/* to see datagram truncation */

void
dg_echo(int sockfd, SA *pcliaddr, socklen_t clilen)
{
	int						flags;
	const int				on = 1;
	socklen_t				len;
	ssize_t					n;
	char					mesg[MAXLINE], str[INET6_ADDRSTRLEN],
							ifname[IFNAMSIZ];
	struct in_addr			in_zero;
	struct unp_in_pktinfo	pktinfo;

#ifdef	IP_RECVDSTADDR
	// 开启 IP_RECVDSTADDR 和 IP_RECVIF 套接字选项
	if (setsockopt(sockfd, IPPROTO_IP, IP_RECVDSTADDR, &on, sizeof(on)) < 0)
		err_ret("setsockopt of IP_RECVDSTADDR");
#endif
#ifdef	IP_RECVIF
	if (setsockopt(sockfd, IPPROTO_IP, IP_RECVIF, &on, sizeof(on)) < 0)
		err_ret("setsockopt of IP_RECVIF");
#endif
	bzero(&in_zero, sizeof(struct in_addr));	/* all 0 IPv4 address */

	for ( ; ; ) {
		len = clilen;
		flags = 0;
		n = Recvfrom_flags(sockfd, mesg, MAXLINE, &flags,
						   pcliaddr, &len, &pktinfo);
		printf("%d-byte datagram from %s", n, Sock_ntop(pcliaddr, len));
		// 返回的 IP 地址不为 0,调用 Inet_ntop 转换表达形式
		if (memcmp(&pktinfo.ipi_addr, &in_zero, sizeof(in_zero)) != 0)
			printf(", to %s", Inet_ntop(AF_INET, &pktinfo.ipi_addr,
										str, sizeof(str)));
		// 返回接口索引不为 0,调用 If_indextoname 获取接口名字并打印
		if (pktinfo.ipi_ifindex > 0)
			printf(", recv i/f = %s",
				   If_indextoname(pktinfo.ipi_ifindex, ifname));
		// 这四个标志位,分别测试是否打开,打开则打印
#ifdef	MSG_TRUNC
		if (flags & MSG_TRUNC)	printf(" (datagram truncated)");
#endif
#ifdef	MSG_CTRUNC
		if (flags & MSG_CTRUNC)	printf(" (control info truncated)");
#endif
#ifdef	MSG_BCAST
		if (flags & MSG_BCAST)	printf(" (broadcast)");
#endif
#ifdef	MSG_MCAST
		if (flags & MSG_MCAST)	printf(" (multicast)");
#endif
		printf("\n");

		Sendto(sockfd, mesg, n, 0, pcliaddr, len);
	}
}

22.3 数据报截断

  当到达的一个 UDP 数据报超过应用进程提供的缓冲区容量时,recvmsg 在其 msghdr 结构的 msg_flags 成员上设置 MSG_TRUNC 标志。不幸的是不是所有实现都支持这种方式来处理过长的 UDP 数据报,有三种情况:

  1. 丢弃超出部分的字节,并返回 MSG_TRUNC 标志
  2. 丢弃超出部分字节,但不告知应用进程
  3. 保留套接字进程并在同一套接字的读操作中进行返回

  解决判断困难的一个方法就是,设置接收缓冲区大小为数据包大小多一个字节,如果收到等于该缓冲区长度的数据报,就说明是一个过长的数据报。

22.4 何时用 UDP 代替 TCP

  1. UDP 支持广播和组播。应用进程使用广播和组播,就必须使用 UDP
  2. UDP 没有建立连接和断开。交换一个请求和一个应答,UDP 只需要 2 个分组,TCP 大约需要 20 个分组,并且最小事务处理时间 UDP 需要 RTT+SPT,TCP 为 2*RTT+SPT。

  但是并不是所有来连接都用来交换一个请求,在一个连接上处理多个请求,多出的一个 RTT 时间就显得不太关键了。尽管如此还是有这种情况会发生的。

并不是所有连接都需要 TCP 的所有特性:

  • 正面确认,丢失分组重传,重复分组检测,对接受的分组排序。
  • 窗口式流量控制。用来控制发送端的发送速度不会超过接收端的接受速度,速度实时控制并判断。
  • 慢启动和拥塞避免。

我们有如下建议:

  • 对于广播和多播必须使用 UDP。任何形式的错误控制必须加到客户和服务器程序之中,不过应用程序需要允许一定的错误,例如音频中几帧的丢失等。如果对于错误过于严格,就需要考虑两者的收益是否大于所带来的复杂性
  • 对于简单的请求-应答使用 UDP。一些控制手段需要加到应用程序中。但是如果交换的数据量较大,使用 TCP 也未尝不可
  • 对于海量数据传输,不应该使用 UDP,会大大增加程序的复杂性

但也存在例外:

  • TFTP 使用 UDP 传输海量数据,因为编码更加简单(使用 UDP 约 800 行,TCP 为 4500 行)
  • NFS 使用 UDP 传输海量数据,历史原因

※虽然 UDP 的使用频率在递减,但是预期接下来会增加,因为组播的使用会更加频繁

22.5 给 UDP 应用增加可靠性

  如果想让请求-应答式应用程序使用 UDP,必须在客户中增加以下两个特性。

  1. 超时和重传:用于处理丢失的数据报。
  2. 序列号:供客户验证一个应答是否匹配相应的请求

  增加序列号较为简单,只需要服务器进行记录并回射即可。
  处理超时重传的老方法是发送一个请求等待 N 秒。没有收到应答,就重新发送同一个请求并等待 N 秒,一定次数后放弃。这个方法存在的问题是不同的网络 RTT 差距较大,并会随时间变化。但可以节键 TCP 中相关的算法。

之后实现了一个例子用来实现上面的内容,参考书上相应的章节

22.6 捆绑接口地址

  get_ifi_info 函数常见用途之一是用于需要监视本地主机所有接口以便获悉某个数据报何时及那个接口上到达的 UDP 应用程序。这种用途允许接收程序获悉该 UDP 数据报的目的地址,因此决定一个数据报的传递套接字的正是它的目的地址。

void	mydg_echo(int, SA *, socklen_t, SA *);

int main(int argc, char **argv) {
	int					sockfd;
	const int			on = 1;
	pid_t				pid;
	struct ifi_info		*ifi, *ifihead;
	struct sockaddr_in	*sa, cliaddr, wildaddr;

	for (ifihead = ifi = Get_ifi_info(AF_INET, 1);
		 ifi != NULL; ifi = ifi->ifi_next) {

			/*4bind unicast address */
		sockfd = Socket(AF_INET, SOCK_DGRAM, 0);

		Setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

		sa = (struct sockaddr_in *) ifi->ifi_addr;
		sa->sin_family = AF_INET;
		sa->sin_port = htons(SERV_PORT);
		Bind(sockfd, (SA *) sa, sizeof(*sa));
		printf("bound %s\n", Sock_ntop((SA *) sa, sizeof(*sa)));

		if ( (pid = Fork()) == 0) {		/* child */
			mydg_echo(sockfd, (SA *) &cliaddr, sizeof(cliaddr), (SA *) sa);
			exit(0);		/* never executed */
		}

		if (ifi->ifi_flags & IFF_BROADCAST) {
				/* 4try to bind broadcast address */
			sockfd = Socket(AF_INET, SOCK_DGRAM, 0);
			Setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

			sa = (struct sockaddr_in *) ifi->ifi_brdaddr;
			sa->sin_family = AF_INET;
			sa->sin_port = htons(SERV_PORT);
			if (bind(sockfd, (SA *) sa, sizeof(*sa)) < 0) {
				if (errno == EADDRINUSE) {
					printf("EADDRINUSE: %s\n",
						   Sock_ntop((SA *) sa, sizeof(*sa)));
					Close(sockfd);
					continue;
				} else
					err_sys("bind error for %s",
							Sock_ntop((SA *) sa, sizeof(*sa)));
			}
			printf("bound %s\n", Sock_ntop((SA *) sa, sizeof(*sa)));

			if ( (pid = Fork()) == 0) {		/* child */
				mydg_echo(sockfd, (SA *) &cliaddr, sizeof(cliaddr),
						  (SA *) sa);
				exit(0);		/* never executed */
			}
		}
	}

		/* 4bind wildcard address */
	sockfd = Socket(AF_INET, SOCK_DGRAM, 0);
	Setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

	bzero(&wildaddr, sizeof(wildaddr));
	wildaddr.sin_family = AF_INET;
	wildaddr.sin_addr.s_addr = htonl(INADDR_ANY);
	wildaddr.sin_port = htons(SERV_PORT);
	Bind(sockfd, (SA *) &wildaddr, sizeof(wildaddr));
	printf("bound %s\n", Sock_ntop((SA *) &wildaddr, sizeof(wildaddr)));

	if ( (pid = Fork()) == 0) {		/* child */
		mydg_echo(sockfd, (SA *) &cliaddr, sizeof(cliaddr), (SA *) sa);
		exit(0);		/* never executed */
	}
	exit(0);
}

void
mydg_echo(int sockfd, SA *pcliaddr, socklen_t clilen, SA *myaddr)
{
	int			n;
	char		mesg[MAXLINE];
	socklen_t	len;

	for ( ; ; ) {
		len = clilen;
		n = Recvfrom(sockfd, mesg, MAXLINE, 0, pcliaddr, &len);
		printf("child %d, datagram from %s", getpid(),
			   Sock_ntop(pcliaddr, len));
		printf(", to %s\n", Sock_ntop(myaddr, clilen));

		Sendto(sockfd, mesg, n, 0, pcliaddr, len);
	}
}

22.7 并发 UDP 服务器

  大多数 UDP 服务器是迭代的,但是如果单个请求处理时间太长可能会造成后面的请求应答时间过长。当 UDP 服务器实现并发时并不能向 TCP 一样,因为 TCP 拥有唯一的链接可以采用简单的 fork 即可。UDP 有两类服务器,分别进行处:

  1. 读入一个客户请求发送一个应答,就不再相关,直接 fork 一个子进程进行处理,子进程直接回应客户。
  2. 第二种 UDP 与客户交换多个数据报,但是客户仅仅知道服务器一个总所周知的端口,解决办法是,给每个客户创建一个新的套接字,调用 bind 一个临时端口,使用该套接字发送这个客户的所有应答,这种操作需要客户端判断第一个应答中的就 port,并将之后的数据报都发送到这个 port。

※ TFTP 是第二种方式的一个例子

22.8 ipv6 分组信息

IPv6 允许应用进程为每个外出数据报指定最多 5 条信息:

  1. 源 IPv6 地址
  2. 外出接口索引
  3. 外出跳限
  4. 下一跳地址
  5. 外出流通类别

这些信息会作为辅助信息使用 sendmsg 进行发送

外出和到达接口

  IPv6 节点的接口由正值整数标识。任何接口都不会被赋予 0 值索引。

源和目的 IPv6 地址

  源 IPv6 地址通常通过调用 bind 指定。不过连同数据一起指定源地址可能并不需要多少开销。后者允许服务器确保所发送应答的源地址等于响应客户请求的目的地址,这是一个某些客户需要且 IPv4 难以提供的特性

指定和接收跳限

  外出跳限指定:
  单播数据报:通过 IPV6_UNICAST_HOPS 套接字进行指定
  组播数据报:通过 IPV6_MULTICAST_HOPS 套接字进行指定

  接收跳限指定:由 recvmsg 作为辅助数据返回的前提是进程开启 IPV6_RECVHOPLIMIT 套接字选项。包含本辅助数据的 cmsghdr 结构中,cmsg_level 成员将是 IPPROTO_IPV6,cmsg_type 成员将是 IPV6_HOPLIMIT,数据的第一个字节将是 4 字节整数跳限的第一个字节。
※ 跳限的正常值在 0 ~ 255 之间

指定下一跳地址

IPV6_NEXTHOP 辅助数据对象将数据报的下一跳指定为一个套接字地址结构。在包含本辅助数据的 cmsghdr 结构中,cmsg_level 成员是 IPPROTO_IPV6,cmsg_type 成员是 IPV6_NEXTHOP,数据的第一字节是套接字地址结构的第一个字节。

指定和接收流通类别

  IPV6_TCLASS 辅助数据对象指定数据包的流通类别。

22.9 ipv6 路径 mtu 控制

  IPv6 为应用程序提供若干路径 MTU 发现控制手段。默认设置对于绝大多数是正确的,不过特殊的程序想要更改 MTU 发现行为,IPv6 有 4 个套接字选项。

以最小 MTU 发送

  执行路径 MTU 发现时,一般按照外出接口的 MTU 或路径 MTU 二者中的较小者尽心分片。IPv6 定义了值为 1280 字节的最小 MTU,所有链路必须支持。使用这个方法可能会丢失较大 MTU 的发送机会,但是可以快速发送,避免了路径 MTU 发现的缺点

接收路径 MTU 变动提示

  应用程序可以开启 IPV6_RECVPATHMTU 套接字选项已接受路径 MTU 变动通知。本标志值使得任何时候路径 MTU 发生变动时作为互助数据由 recvmsg 返回变动后的 MTU

确定当前路径 MTU

  如果应用程序没有开启 IPV6_RECVPATHMTU 套接字选项,那么可以使用 IPV6_PATHMTU 确定一个已连接套接字路径的当前 MTU,没有的话返回外出接口的 MTU

避免分片

  默认情况下会对数据报按照路径 MTU 执行分片,但有些程序不希望执行分片。而是自行发现路径 MTU。IPV6_DONTFRAG 套接字选项用于关闭分片特性:其值为 0 表示允许自动分片,为 1 关闭自动分片。

小结

  有些程序想要知道某个 UDP 数据报的目的 IPv4 地址和接收接口。开启 IP_RECVDSTADDR 和 IP_RECVIF 套接字选项可以作为辅助数据随每个数据报返回这些信息。
  尽管 UDP 无法提供类似于 TCP 的众多特性,但是 UDP 使用的地方还是很多。广播和组播只能使用 UDP。简单的请求—应答也可以使用 UDP,不过必须增加程序级别的可靠性手段。UDP 不应用于海量数据的传输。