本节书摘来自异步社区《UNIX网络编程 卷1:套接字联网API(第3版)》一书中的第8章,第8.11节,作者:【美】W. Richard Stevens , Bill Fenner , Andrew M. Rudoff著,更多章节内容可以访问云栖社区“异步社区”公众号查看
8.11 UDP的connect函数
在8.9节结尾我们提到,除非套接字已连接,否则异步错误是不会返回到UDP套接字的。我们确实可以给UDP套接字调用connect(4.3节),然而这样做的结果却与TCP连接大相径庭:没有三路握手过程。内核只是检查是否存在立即可知的错误(例如一个显然不可达的目的地),记录对端的IP地址和端口号(取自传递给connect的套接字地址结构),然后立即返回到调用进程。
给connect函数重载(overload)UDP套接字的这种能力容易让人混淆。如果使用约定,令sockname是本地协议地址,peername是外地协议地址,那么更好的名字本该是setpeername。类似地,bind函数更好的名字本该是setsockname。
有了这个能力后,我们必须区分:
未连接UDP套接字(unconnected UDP socket),新创建UDP套接字默认如此;
已连接UDP套接字(connected UDP socket),对UDP套接字调用connect的结果。
对于已连接UDP套接字,与默认的未连接UDP套接字相比,发生了三个变化。
(1)我们再也不能给输出操作指定目的IP地址和端口号。也就是说,我们不使用sendto,而改用write或send。写到已连接UDP套接字上的任何内容都自动发送到由connect指定的协议地址(例如IP地址和端口号)。
其实我们可以给已连接UDP套接字调用sendto,但是不能指定目的地址。sendto的第五个参数(指向指明目的地址的套接字地址结构的指针)必须为空指针,第六个参数(该套接字地址结构的大小)应该为0。POSIX规范指出当第五个参数是空指针时,第六个参数的取值就不再考虑。
(2)我们不必使用recvfrom以获悉数据报的发送者,而改用read、recv或recvmsg。在一个已连接UDP套接字上,由内核为输入操作返回的数据报只有那些来自connect所指定协议地址的数据报。目的地为这个已连接UDP套接字的本地协议地址(例如IP地址和端口号),发源地却不是该套接字早先connect到的协议地址的数据报,不会投递到该套接字。这样就限制一个已连接UDP套接字能且仅能与一个对端交换数据报。
确切地说,一个已连接UDP套接字仅仅与一个IP地址交换数据报,因为connect到多播或广播地址是可能的。
(3)由已连接UDP套接字引发的异步错误会返回给它们所在的进程,而未连接UDP套接字不接收任何异步错误。
图8-14就4.4BSD总结了上列第一点。
POSIX规范指出,在未连接UDP套接字上不指定目的地址的输出操作应该返回ENOTCONN,而不是EDESTADDRREQ。
图8-15总结了我们给已连接UDP套接字归纳的三点。
应用进程首先调用connect指定对端的IP地址和端口号,然后使用read和write与对端进程交换数据。
来自任何其他IP地址或端口的数据报(图8-15中我们用“???”表示)不投递给这个已连接套接字,因为它们要么源IP地址要么源UDP端口不与该套接字connect到的协议地址相匹配。这些数据报可能投递给同一个主机上的其他某个UDP套接字。如果没有相匹配的其他套接字,UDP将丢弃它们并生成相应的ICMP端口不可达错误。
作为小结,我们可以说UDP客户进程或服务器进程只在使用自己的UDP套接字与确定的唯一对端进行通信时,才可以调用connect。调用connect的通常是UDP客户,不过有些网络应用中的UDP服务器会与单个客户长时间通信(如TFTP),这种情况下,客户和服务器都可能调用connect。
DNS提供了另一个例子,如图8-16所示。
通常通过在/etc/resolv.conf文件中列出服务器主机的IP地址,一个DNS客户主机就能被配置成使用一个或多个DNS服务器。如果列出的是单个服务器主机(图中最左边的方框),客户进程就可以调用connect,但是如果列出的是多个服务器主机(图中从右边数第二个方框),客户进程就不能调用connect。另外DNS服务器进程通常是处理客户请求的,因此服务器进程不能调用connect。
8.11.1 给一个UDP套接字多次调用connect
拥有一个已连接UDP套接字的进程可出于下列两个目的之一再次调用connect:
指定新的IP地址和端口号;
断开套接字。
第一个目的(即给一个已连接UDP套接字指定新的对端)不同于TCP套接字中connect的使用:对于TCP套接字,connect只能调用一次。
为了断开一个已UDP套接字连接,我们再次调用connect时把套接字地址结构的地址族成员(对于IPv4为sin_family,对于IPv6为sin6_family)设置为AF_UNSPEC。这么做可能会返回一个EAFNOSUPPORT错误(TCPv2第736页),不过没有关系。使套接字断开连接的是在已连接UDP套接字上调用connect的进程(TCPv2第787~788页)。
各种Unix变体断开套接字上连接的方式存在差异,同样的方法可能适合某些系统而不适合其他系统。举例来说,以空的套接字地址结构指针调用connect的方法仅仅适合某些系统(而在另一些系统上,要求第三个参数即套接字地址结构长度为非0)。POSIX规范和BSD手册页面在此帮助不大,只是提到必须使用一个空地址(null address),而根本没有提到出错返回值(甚至成功返回值也没有提到)。最便于移植的解决办法就是清零一个地址结构后把它的地址族成员设置为AF_UNSPEC,再把它传递给connect。
另一个存在差异的地方是断开连接前后套接字本地绑定地址的取值。AIX保留被选中的本地IP地址和端口号,即使它们起源于隐式捆绑。FreeBSD和Linux把本地IP地址设置回全0,即使早先调用过bind,端口号也保持不变。Solaris在隐式捆绑时把本地IP地址设置回全0,在显式调用过bind时保持IP地址不变。
8.11.2 性能
当应用进程在一个未连接的UDP套接字上调用sendto时,源自Berkeley的内核暂时连接该套接字,发送数据报,然后断开该连接(TCPv2第762~763页)。在一个未连接的UDP套接字上给两个数据报调用sendto函数于是涉及内核执行下列6个步骤:
连接套接字;
输出第一个数据报;
断开套接字连接;
连接套接字;
输出第二个数据报;
断开套接字连接。
另一个考虑是搜索路由表的次数。第一次临时连接需为目的IP地址搜索路由表并高速缓存这条信息。第二次临时连接注意到目的地址等于已高速缓存的路由表信息的目的地(我们假设这两个sendto调用有相同的目的地址),于是就不必再次查找路由表(TCPv2第737~738页)。
当应用进程知道自己要给同一目的地址发送多个数据报时,显式连接套接字效率更高。调用connect后调用两次write涉及内核执行如下步骤:
连接套接字;
输出第一个数据报;
输出第二个数据报。
在这种情况下,内核只复制一次含有目的IP地址和端口号的套接字地址结构,相反当调用两次sendto时,需复制两次。[Partridge和Pink 1993]指出,临时连接未连接的UDP套接字大约会耗费每个UDP传输三分之一的开销。