UNP_8_基本UDP套接字编程

Posted on Mar 8, 2020

8.1 概述

  在使用 TCP 编写的程序和 UDP 编写的程序之间存在本质的差别,这两种协议的传输层存在差别:UDP 是无连接不可靠的数据报协议,不同于 TCP 提供的面向连接的可靠字节流。相比 TCP 有些场合更加适合使用 UDP,例如 DNS,NFS,SNMP系统等等

  客户和服务器之间的数据传输可以使用 sendto,recvfrom 两个函数来进行通讯,其中,sendto 必须指定目的地址,同理 recvfrom 会一并收到客户端的协议地址,所以服务器可以正确的对客户端进行响应

  本章还会介绍在 UDP 协议中使用 connect 的影响

8.2 recvfrom 和 sendto 函数

类似于标准的 read 和 write 函数,但是需要额外的三个参数

#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buff, size_t nbytes, 
                 int flags, struct sockaddr *from, socklen_t *addrlen);

ssize_t sendto(int sockfd, const void *buff, size_t nbytes, 
                 int flags, const struct sockaddr *to, socklen_t addrlen);
                 // 若成功均返回读或写的字节数,出错返回 -1
                 // 与标准 read,write 不同,可以返回 0 代表只读 0 字节
                 // 但是在 TCP 中 read 返回 0 代表对端关闭连接

参数解析:

  • sockfd:描述符
  • buff:指向读入或输出的缓冲区指针
  • nbytes:读写字节数
  • flags:后面进行讨论
  • to/from:指向一个协议地址(例如 IP 地址和端口号)的套接字地址结构,大小由 addrlen 参数决定

注意:sendto 最后一个参数是一个值类型,而 recvfrom 是一个指针类型

如果 recvfrom 的地址指针是一个空指针,那么长度指针也必须是一个空指针,代表并不关心是哪里发来的数据

8.3 UDP 回射服务器程序:main 函数

int main(int argc, char ** argv){
    int sockfd;
    struct sockaddr_in servaddr, chiladdr;
    // 创建 UDP 套接字指定 SOCK_DGRAM
    sockfd = Socket(AF_INET, SOCK_DGRAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    aervaddr.sin_family = AF_INET;
    aervaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    aervaddr.sin_port = htons(SERV_PORT);
    // 绑定本机地址
    Bind(sockfd, (SA*)&servaddr, sizeof(servaddr));
    // 执行服务器回射逻辑
    dg_echo(sockfd, (SA*) &cliaddr, sizeof(cliaddr));
}

8.4 UDP 回射服务器程序:dg_echo 函数

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

	for ( ; ; ) {
		len = clilen;
        // 使用 recvfrom 读取下一个到达端口的数据报,在使用 sendto 发送回发送者
		n = Recvfrom(sockfd, mesg, MAXLINE, 0, pcliaddr, &len);

		Sendto(sockfd, mesg, n, 0, pcliaddr, len);
	}
}
  • 这个函数永远不会终止,因为 UDP 是一个无连接的协议,没有像 TCP 中 EOF 之类的东西
  • 该服务器是一个迭代服务器,并没有使用 fork,所以一个服务器进程就可以处理所有的客户。一般来说大多数 TCP 服务器是并发的,大多数 UDP 服务器是迭代的
  • 每一个 UDP 套接字均有一个接收缓冲区,当进程调用 recvfrom 时,缓冲区下一个数据报以 FIFO 方式返回给进程。使用 SO_RCVBUF 可以修改接收缓冲区

  上面的两个函数,main 函数是协议相关的(创建 AF_INET 套接字,分配并初始化一个 Ipv4 套接字地址结构),dg_echo 是协议无关的,因为 dg_echo 并不查看传入的地址只进行使用。

8.5 UDP 回射客户程序:main 函数

UDP 客户端的 main 函数

int main(int argc, char **argv)
{
	int					sockfd;
	struct sockaddr_in	servaddr;

	if (argc != 2)
		err_quit("usage: udpcli <IPaddress>");
    // 把服务器的 IP + port 填入一个地址结构,将该结构发送给 dg_cli,用来表示数据发送到哪里
	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	servaddr.sin_port = htons(SERV_PORT);
	Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
    // 创建一个 UDP 套接字,调用 dg_cli
	sockfd = Socket(AF_INET, SOCK_DGRAM, 0);

	dg_cli(stdin, sockfd, (SA *) &servaddr, sizeof(servaddr));

	exit(0);
}

8.6 UDP 回射客户程序:dg_cli 函数

void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
	int	n;
	char	sendline[MAXLINE], recvline[MAXLINE + 1];
    // 从标准输入接收数据
	while (Fgets(sendline, MAXLINE, fp) != NULL) {
        // 发送给服务器
		Sendto(sockfd, sendline, strle n(sendline), 0, pservaddr, servlen);
        // 从服务器获取回射的数据
		n = Recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);
        // 把数据的末尾置为 \0
		recvline[n] = 0;	/* null terminate */
        // 打印出来
		Fputs(recvline, stdout);
	}
}
  • 我们的客户端并没有指定 port,对于 UDP 套接字,在进程首次调用 sendto 的时候会由内核默认选择一个临时端口。和 TCP 一样,客户可以显示的调用 bind,几乎不会这么使用
  • dg_cli 也是协议无关的函数,客户的 main 函数是协议相关的,main 函数初始化一个某种协议类型的地址结构,并将其发送给 dg_cli

8.7 数据报的丢失

    这个 UDP 客户端和服务器不可靠,如果一个客户端的数据丢失,客户端将永远阻塞在 recvfrom 调用上,防止这种情况一般是给 recvfrom 设置一个超时  
    但是超时并不能很好的解决问题,我们不能判断超时原因是数据没有到达服务器,还是服务器的应答没有回到客户。

8.8 验证接收到的响应

    因为客户端发送的数据是随机端口的,所以我们收到的数据报可能是多个进程互相混乱。暂时的方法是记录发送时的 IP 和 port,这样接收到数据时,仅需要进行判断,相同的地址则保留,忽略任何其他数据报。

  我们修改 servaddr.sin_port = htons(SERV_PORT) 转换为 servaddr.sin_port = htons(7)
  重写 dg_cli 函数以分配另一个套接字地址结构用来存放 recvfrom 返回的结构

void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
	int				n;
	char			sendline[MAXLINE], recvline[MAXLINE + 1];
	socklen_t		len;
	struct sockaddr	*preply_addr;
    // 分配一个地址结构
	preply_addr = Malloc(servlen);

	while (Fgets(sendline, MAXLINE, fp) != NULL) {

		Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);

		len = servlen;
		n = Recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);
        // 判断目标地址和回来的数据报的发送地址是否一致,不一致的话就忽略
		if (len != servlen || memcmp(pservaddr, preply_addr, len) != 0) {
			printf("reply from %s (ignored)\n",
					Sock_ntop(preply_addr, len));
			continue;
		}

		recvline[n] = 0;	/* null terminate */
		Fputs(recvline, stdout);
	}
}

  如果这个程序仅运行在一个只有单个 IP 地址的服务器上,工作正常。但是服务器是多个 IP 的话,可能返回时的地址和发送的目的地址不一样,但都是一个服务器

  • 解决办法一:得到 recvfrom 返回的地址之后,使用 DNS 查找服务器主机的名字来验证主机的域名(而不是 IP 地址)
  • 解决办法二:UDP 服务器个体每个 IP 地址创建一个套接字服务器,用 bind 进行捆绑。然后使用 select 进行等待,之后根据不同的 IP 地址,进行发送即可,这就保证了应答的源地址和请求的目的地址相同

8.9 服务器进程未运行

    在不启动服务器的情况下启动客户的话,因为是 UDP 所以并不能直到服务器的状态,所以当客户端发送一个数据报时,会阻塞在 recvfrom 处,有超时会等待超时  
    当客户主机发送 UDP 数据报之前,需要一次 ARP 请求和应答的交换。  
    如果服务器没有启动,ARP 请求会返回一个 客户端不可达 的 ICMP 报错,但是并不会返回给客户端进程  
    这种错误是 异步错误,该错误由 sendto 引起,但是 sendto 本身却成功返回。我们知道 UDP 输出操作成功返回仅仅表示接口输出队列中具有存放所形成 IP 数据包的空间。但是错误在一段时间后返回,这就是异步的原因  

一个基本规则:对于一个 UDP 套接字,它引发的异步错误并不返回给它,除非它已连接。

8.10 UDP 程序例子小结

    客户需要给 sendto 指定服务器的 IP + port,由内核自动选择客户端的 IP + port,客户端也可以使用 bind 来指定它们。客户的临时端口是在第一次调用 sendto 时由内核指定的,并且不可以进行改变;但是客户端的IP 地址可以每次调用 sendto 时进行随机选择。如果客户端时多宿的,那么客户端可以在多个 IP 地址之间反复横跳  
    如果客户捆绑一个 IP 地址到其套接字上,但是内核决定外出数据报必须从另一个数据链路发出,这种情况下, IP 数据报将包含一个不同于外出链路 IP 地址的源 IP 地址。  
    TCP 服务器,可以很容易的获取 源 IP + port 和 目的 IP + port。并且保持不变。对于 UDP 要想获取需要使用 IP_RECVDSTADDR 套接字并调用 recvmsg 函数来进行获取  
来自客户的 IP 数据报 TCP 服务器 UDP 服务器
源 IP 地址 accept recvfrom
源 port accept recvfrom
目的 IP 地址 getsockname recvmsg
目的 port getsockname getsockname

  服务器可从到达的 IP 数据报中获取的信息

8.11 UDP 的 connect 函数

  对 UDP 调用 connect 函数的效果和 TCP 存在极大的差别。没有三路握手的过程,内核仅仅检查是否存在立即可知的错误(例如存在一个明显不可以到达的目的地),记录对端的 IP 地址和端口号(取自传递给 connect 的套接字地址结构),之后便返回。

  • 未连接 UDP 套接字:新创建的 UDP 套接字默认状态
  • 已连接 UDP 套接字:对 UDP 套接字调用 connect 的结果 对于已经连接的 UDP 套接字和未连接的相比不同点如下:
  1. 我们不能给输出操作指定目的地址,即不可以使用 sendto,应该使用 write 或 send。写到已连接套接字上的任何内容均自动发送给 connect 连接的目的地址,非要使用 sendto 也可以,但是目的地址参数必须是空指针。
  2. 不必使用 recvfrom 用来获取数据报的发送者,而改用 read,recv 或 recvmsg。在一个已经连接的 UDP 套接字上,由内核为输入操作的数据报只来自那些由 connect 所指定的协议地址数据报。
  3. 由已连接 UDP 套接字引发的异步错误会返回给他们所在的进程,而未连接 UDP 套接字不接受任何异步错误

我们说 UDP 客户端或者服务器仅在使用自己的 UDP 套接字与确定的唯一对端进行通信时,才可以调用 connect,一般通常是客户端进行 connect 调用。

1. 给一个 UDP 套接字多次调用 connect

由以下两个目的会多次使用 connect

  • 指定新的套接字地址(IP + port)
  • 断开套接字

注意对 TCP 只能调用一次 connect

为了断开一个已连接 UDP 套接字,下一次调用 connect 时需要指定套接字地址参数为 AF_UNSPEC,可能会返回 EAFNOSUPPORT 错误,但是不用处理

2. 性能

在一个未连接 UDP 套接字使用 sendto 发送数据步骤如下:

  • 连接套接字
  • 输出第一个数据报
  • 断开套接字连接
  • 连接套接字
  • 输出第二个数据报
  • 断开套接字连接

当应用给一个唯一的地址发送数据时,显示连接地质结构效率更高,已连接 UDP 套接字发送数据如下:

  • 连接套接字
  • 输出第一个数据报
  • 输出第二个数据报

内核仅复制一次目的地质结构,未连接的需要复制多次,已连接 UDP 套接字所需开销仅占未连接 UDP 套接字的三分之一

8.12 dg_cli 函数(修订版)

使用 connect 函数来重写 dg_cli 函数

void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
	int		n;
	char	sendline[MAXLINE], recvline[MAXLINE + 1];
    // 对 UDP 套接字使用 connect
	Connect(sockfd, (SA *) pservaddr, servlen);

	while (Fgets(sendline, MAXLINE, fp) != NULL) {
        // 把传递函数换为 write 和 read
		Write(sockfd, sendline, strlen(sendline));

		n = Read(sockfd, recvline, MAXLINE);

		recvline[n] = 0;	/* null terminate */
		Fputs(recvline, stdout);
	}
}

有些内核会将 ICMP 返回的错误立即发送给已连接的 UDP 套接字,可惜的是有的内核并不会

8.13 UDP 缺乏流量控制

  UDP 的客户端可以通过大量发送数据轻易的淹没一个接收缓慢的服务端,并且无法知道丢失的是那些进程的数据

UDP 套接字接收缓冲区

  UDP 连接的传输受接收缓冲区的影响,所以可以使用 SO_RCVBUF 套接字选项修改接收缓冲区的大小,可以一定程度的改善接受状况,但是并不能从根本上解决问题,治标不治本

	n = 220 * 1024;
    // 设置新的缓冲区大小为 n
	Setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &n, sizeof(n));

8.14 UDP 中的外出接口的确定

  已连接 UDP 套接字可以确定某个特定目的地的外出接口。这是 connect 的副作用:内核选择本 IP 地址(假设进程没有使用 bind 显式指定)。这个本地 IP 地址通过目的地 IP 地址搜索路由表得到外出接口,然后选用该接口的主 IP 地址而选定(不太懂。。。)

8.15 使用 select 函数的 TCP 和 UDP 回射服务器程序

int main(int argc, char **argv)
{
	int					listenfd, connfd, udpfd, nready, maxfdp1;
	char				mesg[MAXLINE];
	pid_t				childpid;
	fd_set				rset;
	ssize_t				n;
	socklen_t			len;
	const int			on = 1;
	struct sockaddr_in	cliaddr, servaddr;
	void				sig_chld(int);

		/* 4create listening TCP socket */
    // 创建一个监听 TCP 套接字
	listenfd = Socket(AF_INET, SOCK_STREAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family      = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
	servaddr.sin_port        = htons(SERV_PORT);

	Setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
	Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

	Listen(listenfd, LISTENQ);

		/* 4create UDP socket */
    // 创建一个 UDP 套接字并绑定和 TCP 相同的端口,并不需要设置端口重用套接字选项,因为 TCP 和 UDP 端口是重用的
	udpfd = Socket(AF_INET, SOCK_DGRAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family      = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
	servaddr.sin_port        = htons(SERV_PORT);

	Bind(udpfd, (SA *) &servaddr, sizeof(servaddr));
/* end udpservselect01 */

/* include udpservselect02 */
    // TCP 部分创建的子进程结束的信号处理部分
	Signal(SIGCHLD, sig_chld);	/* must call waitpid() */
    // 为 select 初始化一个描述符集,取最大的描述符值加一
	FD_ZERO(&rset);
	maxfdp1 = max(listenfd, udpfd) + 1;
	for ( ; ; ) {
		FD_SET(listenfd, &rset);
		FD_SET(udpfd, &rset);
        // 使用 select 判断 TCP 和 UDP 套接字是否可读
		if ( (nready = select(maxfdp1, &rset, NULL, NULL, NULL)) < 0) {
			if (errno == EINTR)
            // sig_chil 信号处理发送的错误,处理一下即可
				continue;		/* back to for() */
			else
				err_sys("select error");
		}
        // TCP 可读时,accept + fork + str_echo
		if (FD_ISSET(listenfd, &rset)) {
			len = sizeof(cliaddr);
			connfd = Accept(listenfd, (SA *) &cliaddr, &len);
	
			if ( (childpid = Fork()) == 0) {	/* child process */
				Close(listenfd);	/* close listening socket */
				str_echo(connfd);	/* process the request */
				exit(0);
			}
			Close(connfd);			/* parent closes connected socket */
		}
        // 如果 UDP 套接字可读,那么就有一个数据报到达,使用 recvfrom 读取,在使用 sendto 发回给客户
		if (FD_ISSET(udpfd, &rset)) {
			len = sizeof(cliaddr);
			n = Recvfrom(udpfd, mesg, MAXLINE, 0, (SA *) &cliaddr, &len);

			Sendto(udpfd, mesg, n, 0, (SA *) &cliaddr, len);
		}
	}
}

小结

  • UDP 连接相比 TCP 连接缺少很多特性,比如检测丢失分组的重传,验证响应是否来自正确的对端等等
  • UDP 套接字可能产生异步错误,它们是在分组发送完一段时间才报告的错误。TCP 套接字总是将错误发送给相应的进程,但是 UDP 套接字必须已连接上才会进行报错
  • UDP 没有流量控制,但是在 UDP 的应用场景中,这并不是大问题