UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程

UDP是无连接不可靠的数据报协议,不同于TCP提供的面向连接的可靠字节流。使用UDP编写的常见程序有:DNS、NFS、SNMP。

以下是典型的UDP客户/服务器程序的函数调用,客户不与服务器建立连接,而是只使用sendto函数给服务器发送数据报,发送时必须指定目的地的地址作为参数。服务器也不接受来自客户的连接,而是直接调用recvfrom函数,等待某个客户的数据到达,recvfrom函数将返回所接收的数据和客户的协议地址,从而服务器可以把响应发给正确的客户:
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第1张图片
这两个函数类似标准的read、write函数,但需要3个额外参数:
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第2张图片
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第3张图片
前三个参数sockfd、buff、nbytes等同于read和write函数的三个参数,分别是描述符、指向读入或写出缓冲区的指针和读写字节数。

flags参数后面介绍,在这之前该参数使用时总是置0。

sendto函数的to参数指向一个含有数据报接收者的协议地址(如IP地址、端口号)的套接字地址结构,其大小由addrlen参数指定。recvfrom函数的from参数指向由该函数在返回时填写的数据报发送者的协议地址的套接字地址结构,该套接字地址结构的字节数放在addrlen参数所指的整数中返回给调用者。

recvfrom函数的最后两个参数类似于accept函数的最后两个参数,返回时套接字地址结构中的内容告诉我们是谁发送了数据报(recvfrom函数)或是谁发起了连接(accept函数)。sendto函数的最后两个参数类似于connect函数的最后两个参数:调用时其中套接字地址结构内容含义是,被我们填入的数据报将发往该地址(sendto函数)或与之建立连接(connect函数)的协议地址。

这两个函数的返回值都是所读写的数据长度,recvfrom函数用于数据报协议时,返回值就是所接收数据报中的用户数据量。

可以写一个长度为0的数据报,在UDP情况下,这会形成一个只包含一个IP首部(IPv4通常为20字节,IPv6通常为40字节)和一个8字节UDP首部而没有数据的IP数据报。这意味着对于数据报协议,recvfrom函数返回0是可接受的,它不像TCP套接字上read函数返回0值表示对端关闭连接。

如果recvfrom函数的from函数是一个空指针,则相应的长度参数也必须是一个空指针,表示我们不关心发送者的协议地址。

recvfrom和sendto函数都可用于TCP,但通常没有理由这么做。
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第4张图片
以下是上图中的UDP服务器程序:

#include "unp.h"

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

    // 通过将第二个参数指定为SOCK_DGRAM(IPv4协议中的数据报套接字)创建一个UDP套接字
    sockfd = 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(sockfd, (SA *)&servaddr, sizeof(servaddr));

    dg_echo(sockfd, (SA *)&cliaddr, sizeof(cliaddr));
}

服务器调用dg_echo函数执行服务器的处理工作:

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

    for (; ; ) {
        len = clilen;
		n = Recvfrom(sockfd, mesg, MAXLINE, 0, pcliaddr, &len);

        // 此处最后一个参数使用len而非clilen
        // 因为如果协议使用的是变长套接字地址结构
        // 可能clilen会太大,应使用recvfrom函数返回的真正长度
		Sendto(sockfd, mesg, n, 0, pcliaddr, len);
    }
}

UDP是一个无连接协议,它没有像TCP中EOF之类的东西。

dg_echo函数是一个迭代服务器(iterative server),其中没有对fork的调用,因此单个服务器进程就得处理所有客户。一般来说,大多TCP服务器是并发的,大多UDP服务器是迭代的。

对于UDP套接字,UDP层中隐含有排队发生,事实上每个UDP套接字都有一个接收缓冲区,到达该套接字的每个数据报都进入这个套接字接收缓冲区,当进程调用recvfrom时,缓冲区中的下一个数据报以FIFO顺序返回给进程。但这个缓冲区大小是有限的,可用SO_RCVBUF套接字选项增大它。

下图是第5章中,TCP服务器在两个客户与之建立连接的情形:
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第5张图片
其中服务器主机上有两个已连接套接字,其中每个都有各自的套接字接收缓冲区。

两个客户发送数据报到UDP服务器的情形:
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第6张图片
其中只有一个服务器进程,它用单个套接字接收所有到达的数据报并发回所有响应。该套接字有一个接收缓冲区存放所有到达数据。

以上服务器代码中,main函数是协议相关的,它创建一个AF_INET协议套接字,分配并初始化一个IPv4套接字地址结构),而dg_echo函数是协议无关的,dg_echo函数不查看这个协议相关结构的内容,而是简单地把一个指向该套接字结构的指针传递给recvfrom和sendto函数,即,recvfrom函数返回时把客户的IP地址和端口号填入该结构,而随后该结构又作为目的地址传递给sendto函数。

UDP回射客户程序:

#include "unp.h"

// 协议无关的函数
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, strlen(sendline), 0, pservaddr, servlen);

        // 最后两个参数都是空指针,这告知内核我们不关心应答数据报由谁发送
        // 这存在一个风险,任何进程都可以向本客户的IP和端口发送数据报
        // 这些数据报被客户读入并被认为是服务器的应答
		n = Recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);
	
		recvline[n] = 0;    /* null terminate */
		Fputs(recvline, stdout);
    }
}

// 协议相关的函数
int main(int argc, char **argv) {
    int sockfd;
    struct sockaddr_in servaddr;

    if (argc != 2) {
        err_quit("usage: udpcli ");
    }

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(SERV_PORT);
    Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

    sockfd = Socket(AF_INET, SOCK_DGRAM, 0);

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

    exit(0);
}

以上客户没有请求内核给它的套接字指派一个临时端口,对于TCP客户,这发生在connect调用处,对一个UDP套接字,如果其进程首次调用sendto时没有绑定一个本地端口,内核在此时会为它选择一个临时端口并绑定。与TCP一样,UDP客户也可以显式地调用bind,但很少这么做。

以上UDP客户和服务器是不可靠的,如果一个客户数据报丢失(如被客户主机和服务器主机之间的某个路由器丢弃),客户将永远阻塞于recvfrom调用,等待一个永远不会到达的服务器应答。防止永久阻塞的方法是给客户的recvfrom调用设置一个超时,但这并不是完整的解决办法,如果确实超时了,我们无从判定超时原因是我们的数据报没有到达服务器,还是服务器的应答没有回到客户,如果客户请求是从账户A往账户B转一定数目的钱,而不是我们的简单回射,则请求丢失和应答丢失是不同的。

UDP客户验证接收到的数据报是否是服务器的回射:

void dg_cli1(FILE *fp, int sockfd, 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, pservaddr, &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);
    }
}

以上函数中,我们通知内核返回数据报发送者的地址,我们首先比较由recvfrom函数在值-结果参数中返回的长度,然后用memcpy函数比较套接字地址结构本身。

即使套接字地址结构包含一个长度字段,我们一般也不必设置或检查它,但以上函数中memcmp函数比较两个套接字地址结构中的每个数据字节,而内核返回套接字地址结构时,其中长度字段是设置的,因此对于上例,与之比较的另一个套接字地址结构也必须预先设置其长度,否则memcmp函数会比较一个值为0的字节(因为没有设置长度字段)和一个值为16的字节(sockaddr_in结构的长度),会不匹配。

如果服务器运行在一个只有单个IP地址的主机上,则以上代码可以正常工作,但如果服务器主机是多宿的,该客户就可能失败。我们在有两个接口和两个IP地址的主机freebsd4上运行以上客户:
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第7张图片
在这里插入图片描述
上图中,我们指定的服务器IP地址不与客户主机共享一个子网。

大多IP实现接受目的地址为本机任一IP地址的数据报,而不管数据报到达的接口,RFC 1122称之为弱端系统模型,如果一个系统实现的是强端系统模型,则它将只接受到达接口与目的地址一致的数据报。

上例中,服务器发送应答时,服务器选择的外出IP是172.24.37.94,这是服务器主机内核中的路由选择功能决定的,由于服务器没有在其套接字上绑定一个实际IP地址(服务器绑定的是一个通配IP地址,可通过在服务器上运行netstat来验证),因此服务器端内核将为封装这些应答的IP数据报选择源地址,选择的源地址会是外出接口的主IP地址,既然它是外出接口的主IP地址,如果我们在客户端指定发送数据报到该服务器接口的某个非主IP地址(即一个IP别名),也将导致上例失败。

上例的解决方法:
1.得到由recvfrom函数返回的IP地址后,客户通过DNS查找服务器主机的域名,然后验证域名而非IP地址。

2.UDP服务器为本机上配置的每个IP地址都创建一个套接字,用bind函数绑定每个IP地址到各自的套接字,然后在所有套接字上使用select函数,等待其中任何一个变为可读,再从可读的套接字上给出应答,这就保证了应答的源地址与请求的目的地址相同。

在多宿Solaris系统上,服务器应答的源IP地址就是客户请求的目的IP地址,但源自Berkeley的实现是以上情形(基于外出接口选择源IP地址)。

如果服务器进程未运行,启动客户后键入一行文本,那将什么也不发生,客户永远阻塞于它的recvfrom调用,等待一个永不出现的服务器应答。模拟以上情况,我们在主机macosx上启动tcpdump,然后在同一主机上启动客户,然后指定freebsd4为服务器主机(该主机上没有启动服务器进程),接着我们键入一行文本,该文本不会被回射:
在这里插入图片描述
以下是tcpdump的输出:
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第8张图片
客户主机在往服务器主机发UDP数据报前,需要一次ARP请求和应答的交换。

第三行客户数据报发出,长度为13,包含12个字符和1个换行符。第四行服务器主机响应的是一个端口不可达ICMP消息,但这个ICMP错误不返回给客户进程,客户将永远阻塞于recvfrom调用。ICMPv6也有端口不可达错误,因此此处的讨论对IPv6也类似。

我们称以上ICMP错误为异步错误,该错误由sendto函数引起,但sendto函数本身却成功返回。UDP输出操作成功返回仅仅表示在接口输出队列中有存放所形成的IP数据报的空间。

对于UDP套接字,由它引发的异步错误不会返回给它,除非它已连接(给UDP套接字调用connect)。

如果我们在单个UDP套接字上发送3个数据报给3个不同的服务器(3个不同的IP地址),该客户随后进入一个调用recvfrom读取应答的循环,其中有两个UDP数据报将被正确递送,但第三个主机上没有运行服务器,于是第三个主机响应一个ICMP端口不可达错误,这个ICMP出错消息包含引起错误的数据报的IP首部和UDP首部(ICMPv4和ICMPv6出错消息总是包含IP首部和所有UDP首部或部分TCP首部,以便接收者确定由哪个套接字引发该错误),但recvfrom函数可返回的信息仅有errno值,没有办法返回出错数据报的目的IP地址和目的UDP端口号,因此有以下规定:仅在进程已将其UDP套接字连接到唯一一个对端后,这些异步错误才返回给进程。

只要SO_BSDCOMPAT套接字选项没有开启,Linux甚至对未连接的UDP套接字也返回大多数ICMP目的地不可达错误。
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第9张图片
客户必须给sendto函数指定服务器的IP地址和端口号;而客户的IP地址和端口号都由内核自动选择,尽管客户也可以调用bind指定它们。客户的这两个值由内核选择时,客户的临时端口是在第一次调用sendto时选定的,不能改变,而客户的IP地址可以随着客户发送的每个UDP数据报而变动(假设客户没有捆绑一个具体的IP地址到套接字上)。

如果客户捆绑了一个IP地址到其套接字上,但内核决定将数据报从另一个数据链路发出,此时,IP数据报将包含一个不同于外出链路IP地址的源IP地址。我们在多宿主机freebsd上指定-u选项(使用UDP)和-l选项(指定本地IP地址和端口)运行sock程序:
在这里插入图片描述
上图中指定的本地IP地址不能到达目的地,必须从另一个接口出去,使用tcpdump查看:
在这里插入图片描述
可见源IP地址确实是由客户绑定的那个地址,而非外出接口的地址。
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第10张图片
对于UDP服务器,可能想从到达的IP数据报上取得至少四条信息:源IP地址、目的IP地址、源端口号、目的端口号。这些信息可从下图所示途径获取:
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第11张图片
TCP服务器总是能便捷地访问已连接套接字的这四条信息,且这四个值在连接的整个生命期内保持不变。对于UDP套接字,目的IP地址只能通过为IPv4设置IP_RECVDSTADDR套接字选项或为IPv6设置IPV6_PKTINFO套接字选项,然后调用recvmsg函数取得。由于UDP是无连接的,因此目的IP地址可随发送到服务器的每个数据报而改变。UDP服务器也可接收目的地址为服务器主机的某个广播地址或多播地址的数据报。

我们可以给UDP套接字调用connect,这样做不会导致三路握手过程,内核只检查是否存在立即可知的错误(如一个显然不可达的目的地),记录对端IP和端口号(取自传给connect函数的套接字地址结构),然后立即返回调用进程。

给connect函数重载UDP套接字容易让人混淆,如果约定sockname表示本地协议地址,peername表示外地协议地址,则对于UDP来说,connect函数更好的名字是setpeername,类似地,bind函数的更好名字是setsockname。

我们需要区分:
1.未连接UDP套接字,新创建UDP套接字默认如此。

2.已连接UDP套接字,对UDP套接字调用connect的结果。

已连接UDP套接字与未连接UDP套接字相比,有以下变化:
1.我们不能再给输出操作指定目的IP地址和端口号,即使用write、send函数代替sendto函数。写到已连接UDP套接字上的内容都自动发到由connect函数指定的协议地址。

其实我们可以给已连接套接字调用sendto,但不能指定目的地址,第5个参数(指向目的地址的套接字地址结构的指针)必须为空指针,第6个参数(该套接字地址结构的大小)应为0。POSIX指出,当第5个参数为空指针时,第6个参数的取值被忽略。

对于4.4 BSD的写操作:
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第12张图片
2.我们不必使用recvfrom函数获取数据报发送者,而改用read、recv、recvmsg函数,在一个已连接UDP套接字上,由内核为读操作返回的数据报只有那些来自先前connect函数所指定协议地址的数据报。目的地为该已连接UDP套接字的本地协议地址、发源地却不是该套接字早先调用connect的协议地址的数据报,不会投递到该套接字,这样就限制一个已连接UDP套接字能且仅能与一个对端交换数据报。确切地说,一个已连接UDP套接字能且仅能与一个IP地址交换数据报,因为可以connect到一个多播或广播地址。

3.由已连接UDP套接字引发的异步错误会返回给它们所在的进程,未连接UDP套接字不接收任何异步错误。
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第13张图片
如上图,应用进程先调用connect指定对端IP和端口号,然后使用read和write函数与对端交换数据。

来自其他IP地址或端口的数据报(上图中用???表示)不投递给这个已连接套接字,因为它们的源IP地址或源UDP端口不与该套接字connect到的协议地址相匹配。这些数据报可能投递给同一主机上的其他某UDP套接字,如果没有相匹配的套接字,UDP将丢弃它们,并返回ICMP端口不可达错误。

作为小结,我们可以说UDP客户进程或服务器进程只有在自己的UDP套接字与确定的唯一端进行通信时,才调用connect。调用connect的通常是UDP客户,但有些网络应用中UDP服务器会与单个客户长时间通信(如TFTP),此时客户和服务器都可能调用connect。
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第14张图片
通常通过在/etc/resolv.conf文件中列出DNS服务器主机的IP地址,一个DNS客户主机能被配置成使用一个或多个DNS服务器,如果列出的是单个服务器主机,客户进程就能调用connect,但如果列出的是多个服务器主机,客户进程就不能调用connect。DNS服务器通常可处理任意客户的请求,因此DNS服务器进程不能调用connet。

可对一个已连接UDP套接字再次调用connect,目的通常为:
1.指定新的IP地址和端口号。

2.断开套接字。

UDP套接字不同于TCP套接字,对于TCP套接字,connect函数只能调用一次。

为断开一个已连接UDP套接字的连接,可再次调用connect时把套接字地址结构的地址族成员(IPv4的sin_family、IPv6的sin6_family)设为AF_UNSPEC,这么做可能返回一个EAFNOSUPPORT错误,但这是可接受的,调用connect的过程仍会使UDP套接字断开。

各种UNIX变体断开UDP套接字上连接的方式存在差异,例如,以NULL作为套接字地址结构指针调用connect的方法仅仅适合某些系统,在另一些系统上,还要求第三个参数即套接字地址结构长度为非0。POSIX规范和BSD手册只是提到必须用一个NULL地址,而没有提到出错返回值(甚至没有提到成功返回值)。最便于移植的方法是清零一个地址结构,然后把它的地址族成员设为AF_UNSPEC,再把它传递给connect函数。

另一个存在差异的地方是断开连接前后套接字本地绑定的地址的取值,AIX保留被选中的本地IP和端口号,即使它们是隐式捆绑的。FreeBSD和Linux把本地IP地址设置回全0,即使早先调用过bind,但端口号保持不变。Solaris在隐式捆绑时把本地IP设置回全0,在显式调用过bind时保持IP地址不变。

应用进程在一个未连接UDP套接字上调用sendto时,源自Berkeley的内核暂时连接该套接字,发送数据报,然后断开该连接,因此在一个未连接的UDP套接字上用sendto函数发送两个数据报会使内核执行以下6个步骤:
1.连接套接字。

2.输出第一个数据报。

3.断开套接字连接。

4.连接套接字。

5.输出第二个数据报。

6.断开套接字连接。

对于搜索路由表的次数,第一次需为目的IP地址搜索路由表并高速缓存这条信息。假设第二次连接到相同的目的地址,此时会注意到目的地址等于已高速缓存的路由表信息中的其中一个目的地,于是就不必再次查找路由表。

当应用进程知道自己要给同一目的地址发送多个数据报时,显式连接效率更高,调用connect后调用两次write涉及内核执行以下步骤:
1.连接套接字。

2.输出第一个数据报。

3.输出第二个数据报。

此时内核只复制一次含有目的IP地址和端口号的套接字地址结构,而调用两次sendto时,需复制两次。

修改dg_cli函数,把它重写成调用connect的版本:

void dg_cli1(FILE *fp, int sockfd, SA *pservaddr, socklen_t servlen) {
    int n;
    char sendline[MAXLINE], recvline[MAXLINE + 1];

    Connect(sockfd, (SA *)pservaddr, servlen);

    while (Fgets(sendline, MAXLINE, fp) != NULL) {
        Write(sockfd, sendline, strlen(sendline));

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

所做的修改是调用connect,并用read和write函数代替sendto和recvfrom函数。该函数不查看传递给connect函数的套接字地址结构的内容,因此它仍然是协议无关的。

在主机macosx上运行该程序,并指定主机freebsd4的IP地址,但freebsd4上没有运行相应的服务器程序,有如下输出:
在这里插入图片描述
我们注意到,当启动客户进程时我们没有收到这个错误,该错误在我们发送第一个数据报给服务器后才发生,该数据报引发了来自服务器主机的ICMP错误。但当TCP客户进程调用connect,指定一个没有运行指定服务器的主机时,connect函数就会返回错误,因为调用connect会造成TCP三路握手,其中第一个分节会导致服务器主机TCP返回RST。

以下是上图的tcpdump输出:
在这里插入图片描述
该ICMP错误由内核映射成ECONNREFUSED错误,对应于err_sys函数输出的“Connection refused”。

并非所有内核都像上例一样把ICMP消息返回给已连接UDP套接字,一般源自Berkeley的内核返回这种错误,而System V不会。如果在Solaris 2.4主机上,客户的read调用永不返回(可用tcpdump观察到服务器主机返回了ICMP端口不可达错误),这个问题在Solaris 2.5中被修复。UnixWare不返回这种错误,而AIX、Digital Unix、HP-UX、Linux都返回这种错误。

现在我们查看无任何流量控制的UDP对数据报传输的影响,我们修改dg_cli函数为发送固定数目的数据报,且不从标准输入读,而是直接写2000个1400字节的UDP数据报给服务器:

#define NDG 2000   /* datagrams to send */
#define DGLEN 1400    /* length of each datagram */

void dg_cli1(FILE *fp, int sockfd, SA *pservaddr, socklen_t servlen) {
    int i;
    char sendline[DGLEN];

    for (i = 0; i < NDG; ++i) {
        Sendto(sockfd, sendline, DGLEN, 0, pservaddr, servlen);
    }
}

然后修改服务器程序,改为对其接收到的数据报计数,并不再把数据报回射给客户。以下是新的dg_echo函数,当我们用终端中断键终止服务器时(发送SIGINT信号),服务器会显示接收到的数据报数目并终止:

static int count;

static void recvfrom_int(int signo) {
    printf("\nreceived %d datagrams\n", count);
    exit(0);
}

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

    Signal(SIGINT, recvfrom_int);

    for (; ; ) {
        len = clilen;
		Recvfrom(sockfd, mesg, MAXLINE, 0, pcliaddr, &len);
	        
		++count;
    }
}

我们在主机freebsd上运行服务器,它是一个慢速的SPARC(它是Sun Microsystems于1980年代开发的产品)工作站;在RS/6000(它是IBM在1990年代初推出的产品,使用POSIX接口和AIX操作系统)上运行客户。两个主机间以100Mbit/s的以太网相连。另外,我们在服务器上运行netstat命令,在服务器启动前和结束后各运行一次,用来统计丢失了多少数据报。以下是服务器主机上的输出:
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第15张图片
客户发出了2000个数据报,但服务器只收到其中30个,丢失率为98%,但服务器进程和客户进程都没有给出任何指示来说明这些数据报已丢失。UDP没有流量控制且是不可靠的,UDP发送端淹没其接收端是轻而易举之事。

检查netstat的输出,可看到服务器主机接收到的数据报总数是2000(73208-71208)。“dropped due to full socket buffers”(因套接字缓冲区满而丢弃)的值表示已被UDP接收,但因为接收套接字的接收队列已满而被丢弃的数据报的数目,该值为1970(3491-1971),它加上由服务器进程输出的计数值(30)等于服务器主机接收到的2000个数据报。netstat的输出中,计数是全系统范围的值,没有办法确定具体影响到哪些进程。

上例中,由服务器进程接收的数据报数目是不确定的,它依赖于很多因素,如网络负载、客户主机的处理负载、服务器主机的处理负载。

如果我们再次运行相同的客户和服务器,但让客户运行在慢速的Sun主机上,让服务器运行在较快的RS/6000主机上,这次不会丢失数据报:
在这里插入图片描述
由UDP给某个特定套接字排队的UDP数据报数目受限于该套接字接收缓冲区的大小,我们可以使用SO_RCVBUF套接字选项修改该值,FreeBSD下UDP套接字接收缓冲区的默认大小为42080字节,即30个1400字节数据报的容纳空间,如果我们增大套接字接收缓冲区的大小,那么服务器有望接收更多数据报。修改dg_echo函数,把套接字接收缓冲区设置为220KB:

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

    Signal(SIGINT, recvfrom_int);

	// FreeBSD 5.1中,一个套接字接收缓冲区的最大大小默认为256*1024=262144字节
	// 但由于缓冲区分配策略,真实的限制是233016字节
	// 所以此处将套接字接收缓冲区大小设为220*1024=225280字节,略小于最大上限
    n = 220 * 1024;
    Setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &n, sizeof(n));

    for (; ; ) {
        len = clilen;
		Recvfrom(sockfd, mesg, MAXLINE, 0, pcliaddr, &len);
	        
		++count;
    }
}

然后再次在更慢的Sun主机上运行服务器程序,在更快的RS/6000主机上运行客户程序,这次接收到的数据报计数变为103,比之前使用默认套接字接收缓冲区大小的例子稍有改善,但不能根本解决问题。

如果上例中,我们在客户程序发送每个数据报前调用printf输出一下数据报的内容,则由于在每个数据报间引入了一个延迟,服务器会接收到更多数据报;在服务器程序接收到每个数据报后调用printf则会导致服务器丢失更多数据报。

已连接UDP套接字可用来确定某个特定目的IP地址的外出接口,这是connect函数应用到UDP套接字时的一个副作用造成的,内核会选择本地IP地址(假设进程没有使用bind函数显式指派它),这个本地IP地址是通过为目的IP地址搜索路由表得到的外出接口,然后选用该接口的主IP地址。

以下UDP程序connect到一个指定IP地址后调用getsockname得到本地IP和端口号并输出:

#include "unp.h"

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

    if (argc != 2) {
        err_quit("usage: udpcli ");
    }

    sockfd = Socket(AF_INET, SOCK_DGRAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(SERV_PORT);
    Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

    Connect(sockfd, (SA *)&servaddr, sizeof(servaddr));

    len = sizeof(cliaddr);
    Getsockname(sockfd, (SA *)&cliaddr, &len);
    printf("local address %s\n", Sock_ntop((SA *)&cliaddr, len));

    exit(0);
}

在多宿主机freebsd上运行以上程序:
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第16张图片
在UDP套接字上调用connect不给对端主机发送任何消息,它完全是一个本地操作,只是保存对端的IP地址和端口号。从上图还看到,在一个未绑定端口号的UDP套接字上调用connect会给该套接字指派一个临时端口。

以上操作并非对所有实现都有效,尤其是源自SVR 4的内核,举例来说,它对Solaris 2.5无效(Solaris仅指基于SVR 4的SunOS 5.0及其以后的版本);对以下实现有效:AIX、HP-UX 11、MacOS X、FreeBSD、Linux、Solaris 2.6及其以后的版本。

把第5章中的并发TCP回射服务器程序和本章中的迭代UDP回射服务器程序组合成一个使用select函数来复用TCP和UDP套接字的服务器程序:

#include "unp.h"

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);

    /* create listening TCP socket */
    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);

    // 设置SO_REUSEADDR,防止该端口上已有连接存在
    Setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
    Bind(listenfd, (SA *)&servaddr, sizeof(servaddr));

    Listen(listenfd, LISTENQ);

    /* create UDP socket */
    // 此处不用设置SO_REUSEADDR选项,因为UDP端口和TCP端口是相互独立的
    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));

    Signal(SIGCHLD, sig_chld);    /* must call waitpid */

    FD_ZERO(&rset);
    maxfdp1 = max(listenfd, udpfd) + 1;
    for (; ;) {
        FD_SET(listenfd, &rset);
		FD_SET(udpfd, &rset);
		if ((nready = select(maxfdp1, &rset, NULL, NULL, NULL)) < 0) {
		    // 对SIGCHLD信号的处理可能会中断select函数
		    if (errno == EINTR) {
		        continue;
		    } else {
		        err_sys("select error");
		    }
		}
	
		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);    /* parten closes connected socket */
		}
	
		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);
		}
    }
}

如果有两个应用程序,一个使用TCP,另一个使用UDP,TCP套接字的接收缓冲区中有4096字节数据,UDP套接字的接收缓冲区中有两个2048字节的数据报,TCP进程调用read,指定其第3个参数为4096,UDP进程调用recvfrom,指定其第3个参数也是4096,则read调用会返回4096字节的数据,而recvfrom调用返回2048字节(2个数据报中的第1个),不管应用读请求有多大,recvfrom函数不会返回多余1个数据报。

如果读一个大小为1024字节的UDP数据报时,提供的缓冲区大小只有100字节,则只能读到该数据报的前100字节,然后接收缓冲区中本UDP数据报就会被删除,后924字节永远都读不到了。

为了观察由服务器主机返回的端口不可达ICMP错误,我们这样运行ping程序:使用-i 60选项把分组发送频率由通常的1秒/次减低到60秒/次以减少输出量(有些系统上用-I而非-i);使用-v选项开启详细输出模式,以输出接收到的ICMP错误(但有些ping实现即使指定了-v选项,也不显示接收到的ICMP错误)。如果在主机aix上运行我们的UDP回射客户程序,所指定的服务器IP为192.168.42.1,但此服务器主机上没有运行相应服务器,同时在客户主机上运行ping,会得到以下输出:
UNIX网络编程卷一 学习笔记 第八章 基本UDP套接字编程_第17张图片
监听TCP套接字也有一个套接字缓冲区大小,但它不会接收数据,大多实现不会给套接字预先分配发送缓冲区或接收缓冲区的空间,SO_SNDBUF和SO_REVBUF套接字选项指定的套接字缓冲区大小仅仅是设定了一个上限。

IPv4数据报最大为65535字节,这是IP首部中16位的总长度字段限定的,IPv4首部需要20字节,UDP首部需要8字节,留给UDP用户数据最大为65507字节。对于没有jumbogram(巨型数据报)支持的IPv6,IPv6首部占40字节,留下了65487字节给用户数据。

以下是dg_cli函数的新版本,写一个大数据报给服务器:

#undef MAXLINE 
#define MAXLINE 65507

void dg_cli1(FILE *fp, int sockfd, SA *pservaddr, socklen_t servlen) {
    int size;
    char sendline[MAXLINE], recvline[MAXLINE + 1];
    ssize_t n;

    size = 70000;
    Setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size));
    Setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size));

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

    n = Recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);

    printf("received %d bytes\n", n);
}

如果以上代码中没有调用setsockopt函数预先设置发送缓冲区大小,源自Berkeley的内核会给sendto调用返回EMSGSIZE错误,因为其套接字发送缓冲区的默认大小不足以暂存最大的UDP数据报(65507字节)。如果我们运行以上程序时,服务器的套接字接收缓冲区小于我们发送的数据报大小,则服务器不返回任何数据报,我们可以运行tcpdump验证客户的数据报发送到了服务器上,但如果在服务器程序中放一个printf调用,就会发现它的recvfrom函数没有返回这个数据报,该数据报被直接丢弃了。在FreeBSD系统上我们可以运行netstat -s命令并查看接收这个大数据报前后“dropped due to full socket buffers”计数器值的变化加以验证,解决方法是修改服务器程序,预先设置它的套接字发送缓冲区与接收缓冲区的大小。

大多数网络上,65535字节的IP数据报需要分片,IP层必须支持的重组缓冲区大小只有576字节,因此可能目的主机无法接收最大大小的数据报(无法重组该IP数据报)。源自Berkeley的许多实现(包括4.4 BSD-Lite 2)有一个正负号bug,导致UDP不能接收大于32767字节的数据报。

你可能感兴趣的:(UNIX网络编程卷一(第三版),网络,unix,学习)