本书迄今为止的所有例子都是单播:一个进程与另一个进程通信。TCP只支持单播寻址,而UDP和原始IP还支持其他寻址类型,下图比较了不同的寻址方式:
IPv6往寻址体系中增加了任播(anycasting)方式。RFC 1546讲述了一个IPv4任播版本,但它从未广泛部署过。IPv6任播定义在RFC 3513中。任播允许从一组通常提供相同服务的主机中选择一个(一般是选择按某种测度而言离源主机最近的)。通过适当的路由配置,主机可以在IPv4或IPv6中通过在多个位置向路由协议注入相同的地址(指的是将相同的地址信息配置到网络中的多个路由器中)来提供任播服务。RFC 3513的任播只允许路由器拥有任播地址,主机可能无法提供任播服务。编写本书时还没有使用任播地址的API可用。细化IPv6任播体系结构的工作仍在进展中,将来的主机也许能动态地提供任播服务。
上图的要点是:
1.多播支持在IPv4中是可选的,在IPv6中是必选的。
2.IPv6不支持广播。使用广播的IPv4应用程序一旦移植到IPv6就需要改用多播重新编写。
3.广播和多播要求用于UDP或原始IP,不能用于TCP。
广播的用途之一是在本地子网定位一个服务器主机,前提是已知或认定这个服务器主机位于本地子网,但不知道它的单播IP地址,这种操作也称为资源发现。另一个用途是在有多个客户主机与单个服务器主机通信的局域网环境中尽量减少分组流通。
以下功能使用广播:
1.ARP(Address Resolution Protocol,地址解析协议):ARP不是一个用户应用,而是IPv4的基本组成部分之一。ARP在本地子网上广播一个请求,目的是获取某个IP地址的系统的硬件地址。ARP使用链路层广播而非IP层广播。
2.DHCP(Dynamic Host Configration Protocol,动态主机配置协议):在认定本地子网上有一个DHCP服务器主机或中继主机的前提下,DHCP客户主机向广播地址(通常是255.255.255.255,因为客户还不知道自己的IP地址、子网掩码、本子网的受限广播地址,假如某个子网的IP为15.65.56,子网掩码为24,那么该子网的受限广播地址应为15.65.56.255)发送自己的请求。
3.NTP(Network Time Protocol,网络时间协议):NTP的一种常见使用情形是客户主机会配置待使用的一个或多个服务器主机的IP,然后以某个频度(每隔64秒或更长时间)轮询这些服务器主机。客户主机使用一些算法根据服务器返回的时间和到达服务器主机的往返时间(RTT)来更新本地时钟。但在一个广播局域网上,服务器主机可以为本地子网上所有客户主机每隔64秒广播一次当前时间,免得每隔客户主机各自轮询这个服务器主机,从而减少网络分组流通量。
4.路由守护进程:routed是最早实现且最常用的路由守护进程之一,它在一个局域网上广播自己的路由表,这样连接到该局域网上的其他所有路由器都可以接收这些路由通告,而无须事先为每个路由器配置其邻居路由器的IP地址。该局域网上的主机也能监听这些路由通告,并相应地更新各自的路由表。RIP第2版允许多播,也允许广播。
多播可以代替以上两个广播的用途(资源发现和减少网络分组流通)。
我们可以使用记法{子网ID,主机ID}表示一个IPv4地址,其中子网ID表示由子网掩码(或CIDR前缀,CIDR前缀是一个由IP地址和斜杠组成的标记,用于指示一个子网的范围;而子网掩码是一个用于将IP地址划分为网络部分和主机部分的二进制掩码,它由一串连续的1和0组成,其中1表示网络部分,0表示主机部分。)覆盖的IP地址的连续位,主机ID标识以外的位。我们用-1表示所有位均为1的字段,广播地址由以下两种:
1.子网定向广播地址:{子网ID,-1}。它是指定子网上所有接口的广播地址。例如,我们有一个192.168.42/24子网,那么192.168.42.255就是该子网上所有接口的子网定向广播地址。
通常路由器不转发子网定向广播地址。下图是连接子网192.168.42/24和192.168.123/24的一个路由器:
如上图,路由器在子网192.168.123/24上收到一个目的地址为192.168.42.255(另一个接口的子网定向广播地址)的一个单播IP数据报,路由器通常不会把这个数据报转发到子网192.168.42/24。有些系统提供一个允许转发子网定向广播数据报的配置选项。
转发子网定向广播分组会促成称为放大攻击的一类拒绝服务攻击,如往一个子网定向广播地址发送ICMP echo请求将造成多个应答发往受害系统,再加上一个伪造的源地址,会导致针对该源地址的带宽利用攻击,所以最好将允许转发子网定向广播数据报的选项关闭。
由于这个原因,最好不要设计依赖子网定向广播数据报的转发的应用程序,除非是在可安全开启该选项的受控环境中。
2.受限广播地址:{-1,-1}或255.255.255.255。路由器从不转发目的地址为255.255.255.255的IP数据报。
诸如BOOTP和DHCP等应用在自举过程中把255.255.255.255用作目的地址,因为此时客户还不知道服务器主机的IP地址。
当应用进程发送一个目的地址为255.255.255.255的UDP数据报时,大多主机允许发送这种广播数据报(假设进程已经设置了SO_BROADCAST套接字选项),并把该目的地址转换成外出接口的子网定向广播地址。通常需要直接访问数据链路才能向255.255.255.255发送数据包。
当应用发送一个目的地址为255.255.255.255的UDP数据报且发送主机是一个多接口主机时,有些系统只在主接口(第一个被配置的接口)上发送单个广播分组,其中的目的地址被置为该接口的子网定向广播地址;另一些系统会在每个具备广播能力的接口上发送一个对应接口的子网定向广播。RPC 1122对本问题的描述是no stand(未做规定),为了便于移植,如果应用进程需要向每个具备广播能力的接口发送对应接口的子网定向广播数据报,它就应该首先获取各个接口的配置,然后对每个具备广播能力的接口执行一个目的地址为该接口的子网定向广播地址的sendto函数。
下图是向一个单播地址发送一个UDP数据报时发生的步骤:
上图中以太网子网地址为192.168.42/24,其中24位作为子网ID,剩下8位作为主机ID。左侧的应用进程在一个UDP套接字上调用sendto往IP地址192.168.42.3、端口7433发送一个数据报。UDP层对它冠以一个UDP首部后把UDP数据报传递到IP层。IP层对它冠以一个IPv4首部,确定其外出接口,在以太网情况下还激活ARP把目的IP地址映射成相应的以太网地址(00:0a:95:79:bc:b4)。该分组然后作为一个目的地址为该48位以太网地址的以太网帧发送出去。该以太网帧的帧类型字段值为表示IPv4的0x0800(IPv6分组的帧类型字段值为0x86dd)。
中间主机的以太网接口看到该帧后把该帧的目的以太网地址与自己的以太网地址(00:04:ac:17:bf:38)进行比较,它们不一致,该接口于是忽略这个帧。可见单播帧不会对该主机造成任何额外开销,因为忽略帧的是接口而非主机。
右侧主机的以太网接口也看到该帧,当它比较该帧的目的以太网地址和自己的以太网地址时,会发现它们相同,该接口于是读入帧,读入完毕后可能产生一个硬件中断,致使相应设备驱动程序从接口内存中读取该帧。该帧类型为0x0800,该帧承载的分组于是被置于IP的输入队列。
当IP层处理该分组时,它首先比较该分组的目的IP地址(192.168.42.3)和自己所有的IP地址(主机可以多宿),既然这个目的地址是本主机自己的IP地址之一,该分组于是被接受。
IP层接着查看该分组IPv4首部中的协议字段,其值为表示UDP的17,该分组承载的UDP数据报于是被传递到UDP层。
UDP层检查该UDP数据报的目的端口(如果其UDP套接字已经连接,还要检查源端口),接着把数据报置于相应套接字的接收队列。必要的话UDP层作为内核一部分唤醒阻塞在相应输入操作上的进程,由该进程读取这个新收取的数据报。
上例的关键点是单播IP数据报仅由目的IP地址指定的单个主机接收,子网上其他主机不受影响。
接着考虑另一个例子,同样的子网,但发送进程发送的是目的地址为子网定向广播地址192.168.42.255的数据报:
当左侧主机发送该数据报时,它注意到目的IP地址是所在以太网的子网定向广播地址,于是把它映射为48位全1的以太网地址ff:ff:ff:ff:ff:ff,这个地址使得该子网上每个以太网接口都接收该帧,上图中右侧两个运行IPv4的主机都接收该帧。既然以太网帧类型为0x0800,这两个主机于是都把该帧承载的分组传递到IP层,既然该分组的目的IP地址匹配两者的广播地址,且协议字段为17(UDP),这两个主机于是把该分组承载的UDP数据报传递到UDP。
右侧那个主机把该UDP数据报传递给绑定端口520的应用进程。一个进程无需为接收广播UDP数据报而进行任何处理,它只需要创建一个UDP套接字,并把应用的端口号捆绑到其上(我们假设捆绑的IP地址是典型的INADDR_ANY)。
但中间的那个主机没有任何应用进程绑定UDP端口520,该主机的UDP代码于是丢弃这个已收取的数据报。该主机不能发送一个ICMP端口不可达消息,因为这么做可能产生广播风暴,即自网上大量主机几乎同时产生一个响应,导致网络在一段时间内不可用。且发送该数据报的主机如何处理这些ICMP出错消息也成问题:有的主机可能会报告该错误,但有一些主机不会报告该错误。
上图还表示出由左侧主机发送的数据报也被递送给自己,这是广播的一个属性,根据定义,广播分组去往子网上所有主机,包括发送主机自身。如果发送应用进程还绑定了自己要发送到的端口(520),那它将收到自己发送的每个广播数据报的一个副本(但一般发送UDP广播数据报的应用进程不需要捆绑这些数据报的目的端口)。
上图左侧主机还展示了由IP层或数据链路层执行的一个逻辑回馈,通过这个回馈,每个数据报备复制一份并沿协议栈向上传送。也可以使用物理回馈,但这样在网络存在故障的情况下会导致问题。
本例展示了广播存在的根本问题,子网上未参加相应广播应用的主机也不得不沿协议栈一路向上完整地处理收取的UDP广播数据报,直到该数据报在UDP层被丢弃。另外,子网上所有非IP的主机(如运行Novell IPX的主机,它是Novell公司开发的一种网络协议,用于在局域网(LAN)中进行数据通信的协议,常用于Novell NetWare操作系统。)也不得不在数据链路层接收完整的帧,然后再丢弃它(假设这些主机不支持该帧的帧类型,如IPv4的帧类型0x0800)。如果主机上运行着以较高速率产生IP数据报的应用(如音频、视频应用),这些非必要的处理可能严重影响子网上这些主机的工作。多播可在一定程度上解决此问题。
上例中选择UDP端口520是有意的,该端口由routed守护进程用于交换RIP分组,一个子网上使用RIP版本1的所有路由器每隔30秒发送一个UDP广播数据报,如果该子网上有200个系统(包括2个使用RIP的路由器),那么作为主机的其余198个系统将不得不每隔30秒就处理并丢弃这些广播数据报(假设这198个主机上无一运行routed)。RIP第二版使用多播解决这个问题。
再次修改回射客户程序的dg_cli函数,这次允许它向UDP标准daytime服务器广播发送请求,然后显示所有应答,我们对main函数所做的唯一改动是把目的端口号改为13:
servaddr.sin_port = htons(13);
我们先随未修改的dg_cli函数编译修改后的main函数,并在主机freebsd上运行它:
上图中命令行参数是该freebsd主机第二个以太网接口的子网定向广播地址。我们键入一行文本,程序调用sendto,结果返回EACCESS错误,错误原因在于,除非显式告诉内核我们准备发送广播数据报,否则系统不允许我们这么做,我们可以通过设置SO_BROADCAST套接字选项做到这一点。
源自Berkeley的实现实施这种健全性检查,但对于Solaris 2.5,即使不指定SO_BROADCAST套接字选项也能发送目的地址为广播地址的数据报。POSIX规范要求发送广播数据报必须设置该套接字选项。
对于不存在SO_BROADCAST套接字选项的4.2 BSD来说,广播是一个特权操作,该选项增设到4.3 BSD后,所有进程都允许设置该套接字选项以执行广播操作。
修改后的dg_cli函数,这个版本设置SO_BROADCAST套接字选项并显示5秒内收到的所有应答:
#include "unp.h"
static void recvfrom_alarm(int);
void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen) {
int n;
const int on = 1;
char sendline[MAXLINE], recvline[MAXLINE + 1];
socklen_t len;
struct sockaddr *preply_addr;
preply_addr = Malloc(servlen);
Setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on));
Signal(SIGALRM, recvfrom_alarm);
while (Fgets(sendline, MAXLINE, fp) != NULL) {
Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);
alarm(5);
for (; ; ) {
len = servlen;
n = recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);
if (n < 0) {
// 5秒到,SIGALRM信号的处理函数被调用,导致recvfrom函数返回EINTR错误
if (errno == EINTR) {
break; /* waited long enough for replies */
} else {
err_sys("recvfrom error");
}
} else {
recvline[n] = 0; /* null terminate */
// 对每个收到的应答都调用sock_ntop_host,该函数以点分十进制格式
// 返回服务器IP地址(假设IPv4情形)
printf("from %s: %s",
Sock_ntop_host(preply_addr, len), recvline);
}
}
}
free(preply_addr);
}
static void recvfrom_alarm(int signo) {
return;
}
指定192.168.42.255这个子网定向广播地址运行以上程序:
我们必须每次键入一行文本以产生UDP数据报输出,我们每次收到3个应答,其中有一个来自发送主机本身。所有应答数据报都是单播,因为作为目的地址的请求数据报的源地址是一个单播地址。
上例中所有系统都报告相同的系统时间,因为它们都运行NTP。
源自Berkeley的内核不允许对广播数据报执行分片,对于目的地址是广播地址的IP数据报,如果其大小超过外出接口的MTU,发送它的系统调用将返回EMSGSIZE错误,这是一个自BSD 4.2以来就存在的决策。内核实际可以分割广播数据报文,没有技术上的问题,只是感觉上广播已经给网络带来了很大的负载,再因分片造成这个负担乘以分片数就更不应该。
运行使用广播版dg_cli函数的回射客户程序,我们将标准输入重定向到一个文件,这个文件中有一行2000字节的数据,它将导致以太网上发生分片:
AIX、FreeBSD、MacOS都实施了不允许广播数据报分片的限制。Linux、Solaris、HP-UX都允许对目的地址为广播地址的数据报进行分片。为了便于移植,需要广播的应用应使用SIOCGIFMTU调用ioctl确定外出接口的MTU,从中扣除IP首部和UDP首部的长度得到最大净荷大小。如果在局域网上,可把广播数据报大小限制为1472字节以内(由1500字节的以太网MTU得出),因为局域网中以太网的MTU通常是最小的。
当有多个进程访问共享数据,而正确结果取决于进程的执行顺序时,我们称这些进程处于竞争状态。由于在典型的UNIX系统中进程的执行顺序取决于每次都会发生变化的众多因素,因此处于竞争状态的进程有时产生正确的结果,有时产生不正确的结果。最难调试的一类竞争状态是通常情况下结果正确,偶尔才发生结果不正确现象的那些。竞争状态对于线程化编程是一个关注点,因为在线程之间共享着很多数据(如所有全局变量)。
当涉及信号处理时,往往会出现另一种竞争状态,发生问题的原因在于信号会在程序执行过程中由内核随时递交。POSIX允许我们临时阻塞某些信号的递交,但进行IO操作时这一点用处不大(如果在执行I/O操作期间阻塞信号,可能会错过某些重要的信号,这可能会导致程序无法及时响应某些关键事件或无法正确处理信号)。
以上广播版dg_cli函数中存在一个竞争状态,可以按如下做法强行产生该竞争状态:把alarm函数的参数从5改为1,在printf函数前加上sleep(1)
。
对以上dg_cli函数做了这些修改后我们键入第一个输入文本行,它作为一个广播数据报发送出去,1秒钟的alarm报警时钟也同时启动,我们随后阻塞在recvfrom调用中,第一个应答可能在数毫秒内到达我们的套接字,该应答由recvfrom函数返回后,我们进入1秒钟的睡眠期,其他应答可能陆续到达,然后被置于我们的套接字接收缓冲区,但在我们睡眠期间,alarm定时器到时,从而产生SIGALRM信号,之后我们的信号处理函数被调用,但该信号处理函数只是返回并中断让我们阻塞在其中的sleep函数,我们接着循环回去,每读入一个已经在套接字接收缓冲区中排队的应答就先暂停1秒再显示其内容,当处理完所有应答时我们再次阻塞在recvfrom函数,而此时定时器已不再运转,于是我们会永远阻塞在recvfrom函数。这里的问题是,我们的意图是让信号处理函数中断某个阻塞中的recvfrom函数,但信号可以在任何时刻被递交,当它被递交时,我们可能在无限for循环中的任何地方执行。
我们首先讨论一个不正确的解决办法,它在执行for循环的其他部分时阻塞信号的递交来减小出错的窗口:
#include "unp.h"
static void recvfrom_alarm(int);
void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen) {
int n;
const int on = 1;
char sendline[MAXLINE], recvline[MAXLINE + 1];
sigset_t sigset_alrm;
socklen_t len;
struct sockaddr *preply_addr;
preply_addr = Malloc(servlen);
Setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on));
Sigemptyset(&sigset_alrm);
Sigaddset(&sigset_alrm, SIGALRM);
Signal(SIGALRM, recvfrom_alarm);
while (Fgets(sendline, MAXLINE, fp) != NULL) {
Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);
alarm(5);
for (; ; ) {
len = servlen;
// 调用recvfrom前,先解阻塞SIGALRM信号
// 以便阻塞在recvfrom函数时该信号能被递交
Sigprocmask(SIG_UNBLOCK, &sigset_alrm, NULL);
n = recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);
// 在recvfrom函数返回后,我们立即阻塞该信号
// 如果信号被阻塞时定时器时间到,那么内核会将其记录下来,但不递交该信号
// 直到该信号被解阻塞
Sigprocmask(SIG_BLOCK, &sigset_alrm, NULL);
if (n < 0) {
if (errno == EINTR) {
break; /* waited long enough for replies */
} else {
err_sys("recvfrom error");
}
} else {
recvline[n] = 0; /* null terminate */
printf("from %s: %s",
Sock_ntop_host(preply_addr, len), recvline);
}
}
}
free(preply_addr);
}
static void recvfrom_alarm(int signo) {
return;
}
编译运行以上程序,它看起来工作正常,然而存在竞争状态的大多程序在很多状态下照样工作正常。该程序存在的问题是,解阻塞信号、调用recvfrom、阻塞信号三者都是相互独立的系统调用,如果SIGALRM信号恰好在这三个系统调用之间的两个窗口递交,那么最终(收到最后一个应答数据报之后,再次调用recvfrom时)还是会永远阻塞在recvfrom函数。
这种方法的一个变体是在信号被递交后让信号处理函数设置一个全局标志:
static void recvfrom_alarm(int signo) {
had_alarm = 1;
return;
}
每次调用alarm前把该标志初始化为0,然后在recvfrom调用前检查这个标志,如果其值不为0就不再调用recvfrom:
for (; ; ) {
len = servlen;
Sigprocmask(SIG_UNBLOCK, &sigset_alrm, NULL);
if (had_alarm = 1) {
break;
}
n = recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);
}
但以上方法在测试标志和调用recvfrom之间仍存在较小的时间窗口,期间SIGALRM信号可能产生并递交,如果收不到应答(即已经收到了所有应答),recvfrom函数将永远阻塞。
正确的方法之一是使用pselect函数:
#include "unp.h"
static void recvfrom_alarm(int);
void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen) {
int n;
const int on = 1;
char sendline[MAXLINE], recvline[MAXLINE + 1];
fd_set rset;
sigset_t sigset_alrm, sigset_empty;
socklen_t len;
struct sockaddr *preply_addr;
preply_addr = Malloc(servlen);
Setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on));
FD_ZERO(&rset);
Sigemptyset(&sigset_empty);
Sigemptyset(&sigset_alrm);
Sigaddset(&sigset_alrm, SIGALRM);
Signal(SIGALRM, recvfrom_alarm);
while (Fgets(sendline, MAXLINE, fp) != NULL) {
Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);
Sigprocmask(SIG_BLOCK, &sigset_alrm, NULL);
alarm(5);
for (; ; ) {
FD_SET(sockfd, &rset);
n = pselect(sockfd + 1, &rset, NULL, NULL, NULL, &sigset_empty);
if (n < 0) {
if (errno == EINTR) {
break;
} else {
err_sys("pselect error");
}
} else {
err_sys("pselect error: returned %d", n);
}
len = servlen;
n = Recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);
recvline[n] = 0; /* null terminate */
printf("from %s: %s",
Sock_ntop_host(preply_addr, len), recvline);
}
}
free(preply_addr);
}
static void recvfrom_alarm(int signo) {
return; /* just interrupt the recvfrom() */
}
pselect函数的最后一个参数是指向sigset_empty变量的指针,sigset_empty是一个没有任何信号的信号集,pselect函数会先保存当前信号掩码(只有SIGALRM信号被阻塞),测试指定的描述符,如果描述符都没准备好就把信号掩码设为空集再阻塞进程,在pselect函数返回前,会把信号掩码恢复成刚被调用时的值。pselect函数的关键点在于,设置信号掩码、测试描述符、恢复信号掩码这3个操作在调用进程看来是原子操作。
pselect函数是一个较新的POSIX函数,有些平台可能不支持它,下面是它的一个不正确但简单的实现,给出这个不正确的实现的原因在于展示pselect函数涉及的3个步骤:
1.保存当前信号掩码,并把信号掩码设置为由调用者指定的值。
2.测试描述符。
3.恢复信号掩码。
#include "unp.h"
int pselect(int nfds, fd_set *rset, fd_set *wset, fd_set *xset,
const struct timespec *ts, const sigset_t *sigmask) {
int n;
struct timeval tv;
sigset_t savemask;
if (ts != NULL) {
tv.tv_sec = ts->tv_sec;
tv.tv_usec = ts->tv_nsec / 1000;
}
sigprocmask(SIG_SETMASK, sigmask, &savemask); /* caller's mask */
n = select(nfds, rset, wset, xset, (ts == NULL) ? NULL : &tv);
sigprocmask(SIG_SETMASK, &savemask, NULL); /* restore mask */
return n;
}
解决竞争状态的另一个正确方法是从信号处理函数中调用siglongjmp,我们称siglongjmp函数为非局部跳转,使用它可以从一个函数跳转回另一个函数:
#include "unp.h"
#include <setjmp.h>
static void recvfrom_alarm(int);
static sigjmp_buf jmpbuf;
void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen) {
int n;
const int on = 1;
char sendline[MAXLINE], recvline[MAXLINE + 1];
socklen_t len;
struct sockaddr *preply_addr;
preply_addr = Malloc(servlen);
Setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on));
Signal(SIGALRM, recvfrom_alarm);
while (Fgets(sendline, MAXLINE, fp) != NULL) {
Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);
alarm(5);
for (; ; ) {
// 建立跳转缓冲区后会返回0,然后调用recvfrom
// 建立跳转缓冲区时第二个参数非0,意味着会在jmpbuf参数中保存当前信号屏蔽字
// 从信号处理函数中跳转回来时,会返回2,即信号处理函数中
// 调用sigsetjmp时的第2个参数
if (sigsetjmp(jmpbuf, 1) != 0) {
break;
}
len = servlen;
n = Recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);
recvline[n] = 0; /* null terminate */
printf("from %s: %s",
Sock_ntop_host(preply_addr, len), recvline);
}
}
free(preply_addr);
}
static void recvfrom_alarm(int signo) {
// 此处的调用会跳转到dg_cli函数中的siglongjmp函数
// 这种情况下dg_cli函数中的siglongjmp函数的返回值为以下调用的第二个参数(即2)
siglongjmp(jmpbuf, 2);
}
以上程序中唯一会发生的问题是信号在printf函数处理输出的过程中被递交,此时printf函数被中断,并在信号处理函数中调用sigsetjmp跳转,这可能会使printf函数的私有数据结构前后不一致,为防止这种情况,我们可以把错误的dg_cli函数中的信号阻塞和解阻塞方法结合非局部跳转一起使用,但这会使这种解决方案变得不便,因为必须在可能由于被中断而导致不良行为的任何函数周围进行信号阻塞。
以上程序中存在两个时序问题:
1.如果信号是在recvfrom函数返回和把返回值存入n之间被递交,则该数据报将被认为已丢失(尽管它已被recvfrom函数收取),但UDP应用应该能处理数据报的丢失,但如果是TCP数据应用程序,数据就永远丢失了(因为TCP已确认了这个数据并把它递送给了应用进程)。
2.alarm调用和首次sigsetjmp调用之间的时间无法保证小于alarm时间(上例中为5秒),解决办法之一是在调用sigsetjmp后再设置一个标志,并在信号处理函数中测试该标志,如果该标志还未设置,那么就不调用siglongjmp跳出信号处理函数,而是仅仅重置alarm就行。
还可使用从信号处理函数到主控函数的IPC来解决竞争问题,本方法不是让信号处理函数简单地返回并期望该返回能中断阻塞中的recvfrom函数,而是让信号处理函数使用IPC通知主控函数dg_cli定时器已到时。这跟让信号处理函数在定时器时间到时设置全局变量的建议有些类似,因为该全局变量被用作一种形式的IPC(dg_cli函数和信号处理函数之间的共享内存区),使用全局变量办法的问题在于主控函数必须测试该变量,如果信号的递交和变量的测试几乎同时发生,竞争状态的时序问题就会发生(即测试完变量后,如果变量没有被设置,到调用recvfrom之间会有一个时间窗口,如果在该时间窗口之间信号递交,且已经读完了所有响应的UDP数据报,那么recvfrom函数还是会无限阻塞)。
以下使用进程内部的管道,当定时器时间到时,信号处理函数将向该管道中写一个字节,dg_cli函数读入该字节以决定何时终止for循环。本方法通过select函数来同时检测套接字和管道的可读性:
#include "unp.h"
static void recvfrom_alarm(int);
static int pipefd[2];
void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen) {
int n, maxfdp1;
const int on = 1;
char sendline[MAXLINE], recvline[MAXLINE + 1];
fd_set rset;
socklen_t len;
struct sockaddr *preply_addr;
preply_addr = Malloc(servlen);
Setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on));
// 创建一个普通的Unix管道,pipe[0]是读端,pipe[1]是写端
// 我们也可以调用socketpair创建一个全双工管道,某些系统上普通Unix管道也是全双工的
Pipe(pipefd);
maxfdp1 = max(sockfd, pipefd[0]) + 1;
FD_ZERO(&rset);
Signal(SIGALRM, recvfrom_alarm);
while (Fgets(sendline, MAXLINE, fp) != NULL) {
Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);
alarm(5);
for (; ; ) {
FD_SET(sockfd, &rset);
FD_SET(pipefd[0], &rset);
if ((n = select(maxfdp1, &rset, NULL, NULL, NULL)) < 0) {
// SIGALRM信号可能中断select调用,当select函数返回EINTR错误时,忽略该错误
// 因为我们知道管道的读入端将最终变为可读,然后终止for循环
if (errno == EINTR) {
continue;
} else {
err_sys("select error");
}
}
if (FD_ISSET(sockfd, &rset)) {
len = servlen;
n = Recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr,
&len);
recvline[n] = 0; /* null terminate */
printf("from %s: %s",
Sock_ntop_host(preply_addr, len), recvline);
}
if (FD_ISSET(pipefd[0], &rset)) {
Read(pipefd[0], &n, 1); /* timer expired */
break;
}
}
}
free(preply_addr);
}
static void recvfrom_alarm(int signo) {
Write(pipefd[1], "", 1); /* write one null byte to pipe */
return;
}
以上程序中,我们可以在select函数返回后插入几条printf语句,以便查看当alarm时间到时,select函数究竟是返回一个错误还是返回描述符的可读条件。在FreeBSD上,selelct函数会返回EINTR,然后再次调用select时返回管道的可读条件。
广播发送的数据报由发送主机某个所在子网上的所有主机接收。广播的劣势在于同一子网上所有主机都必须处理数据报,需一直沿协议栈向上处理到UDP层,即使不参与广播应用的主机也不能幸免。如果某主机上运行着音频、视频等以较高数据速率工作的应用,这些非必要的处理会给主机带来过度的处理负担,多播可解决本问题,因为多播发送的数据报只会由对相应多播应用感兴趣的主机接收。
如果运行dg_cli函数的广播版本的UDP回射客户程序,可能会收到多个应答,多次运行时它们每次到达的先后顺序可能不同,但发送主机本身的应答通常是第一个,因为该数据报不会出现在真正网络上。