6.4.2 关闭TCP连接
TCP协议有一个优雅的关闭(graceful close)机制,以保证应用程序在关闭连接时不必担心正在传输的数据会丢失。如第4.5节的压缩示例程序所示,这个机制还设计为允许两个方向的数据传输相互独立地终止。关闭机制的工作流程是:应用程序通过调用连接套接字的close()方法或shutdownOutput()方法表明数据已经发送完毕。此刻,底层的TCP实现首先将留存在SendQ队列中的数据传输出去(还要依赖于另一端RecvQ队列的剩余空间),然后向另一端发送一个关闭TCP连接的握手消息。该关闭握手消息可以看作是流终止标志:它告诉接收端TCP不会再有新的数据传入RecvQ队列了。(注意,关闭握手消息本身并没有传递给接收端应用程序,而是通过read()方法返回-1来指示其在字节流中的位置。)正在关闭的TCP将等待其关闭握手消息的确认信息,该确认信息表明在连接上传输的所有数据已经安全地传输到了RecvQ中。只要收到了确认消息,该连接就变成“半关闭(Half closed)”状态。直到连接的另一个方向上收到了对称的握手消息后,连接才完全关闭—也就是说,连接的两端都表明它们再没有数据要发送了。
TCP连接的关闭事件序列可能以两种方式发生:一种方式是先由一个应用程序调用close()方法(或shutdownOutput()方法),并在另一端调用close()方法之前完成其关闭握手消息;另一种方式是两端同时调用close()方法,它们的关闭握手消息在网络上交叉传输。图6-10展示了以第一种方式关闭连接时,底层实现中的事件序列。关闭握手消息已经发送,套接字数据结构的状态也已经设置为“Closing”(专业术语称为“FIN_WAIT_1”),然后close()调用返回。完成这些工作后,将禁止在该Socket上的任何读写操作(会抛出异常)。当收到关闭握手确认消息后,套接字数据结构的状态则改变为“半关闭”(专业术语称为“FIN_WAIT_2”),这种状态将一直持续,直到接收到另一端的关闭握手消息。此时,客户端netstat的输出内容将展示连接的状态为:
(在首先发起关闭的主机上,FIN_WAIT_2是“半关闭”状态的专业术语。图中由“Closing”指示的状态的专业术语是FIN_WAIT_1,不过该状态转瞬即逝,很难被netstat捕获到。)
注意,如果连接处于半关闭状态时,远程终端已经离开,那么本地底层数据结构则将无限期地保持在该状态。当另一端的关闭握手消息到达后,则发回一条确认消息并将状态改变成“Time-Wait”。虽然应用程序中相应的Socket实例可能早已消失,与之关联的底层数据结构还将在底层实现中继续存留几分钟。出现这种情况的原因见第6.4.2节的讨论。
图 6-10 首先关闭一端的TCP连接
在图6-10的右端时,netstat的输出内容包括:
图6-11简单展示了没有首先发起关闭的终端上的事件序列。关闭握手消息到达后,它立即发回一个确认消息,并将连接状态改变为“Close-Wait”。该主机上netstat的输出内容显示:
此时,只需要等待应用程序调用Socket的close()方法。调用该方法后,将发起最终的关闭握手消息,并释放底层套接字数据结构,虽然对原始Socket实例的引用仍然留存在Java程序中。
注意这样一个事实:close()方法和shutdownOutput()方法都没有等待关闭握手的完成,而是调用后立即返回。你可能会问,发送者怎样能保证已发送的数据能够真正到底接收程序呢(即Delivered)?实际上,当应用程序调用close()或shutdownOutput()方法并成功关闭连接时,的确可能还有数据留存在SendQ队列中。如果连接的任何一端在数据传输到RecvQ队列之前崩溃,数据将丢失,而发送端应用程序却不会知道。
最好的解决方案是设计一种应用程序协议,以使首先调用close()方法的一方在接收到了应用程序层的数据已接收保证后,才真正执行关闭操作。例如,当我们的TCPEchoClient程序接收到了它所发送的数据的完全副本后,它就能够知道此时在连接两个方向上都没有数据在传输,因此可以安全地关闭连接。
图 6-11 在另一端关闭后关闭
Java的确提供了一种能够修改Socket的close()行为的方法,即setSoLinger()方法。setSoLinger()用于控制close()方法在返回前是否等待关闭握手的完成。它有两个参数:一个布尔变量用来指示是否等待;一个整型变量用来指定放弃之前等待的时间(单位为秒)。也就是说,使用setSoLinger()设置了超时时间后,close()方法将阻塞等待,直到关闭握手完成或指定时间超时。然而,在本书的写作期间,即使在setSoLinger()设置的时间限制已经超过时,close()方法也没有提供任何信息来指示关闭握手的失败。换句话说,setSoLinger()方法没有为当前实现的应用程序提供任何额外担保。
关闭TCP连接的最后微妙之处在于对Time-Wait状态的需要。TCP规范要求在终止连接时,两端的关闭握手都完成后,至少要有一个套接字在Time-Wait状态保持一段时间。这个要求的提出是由于消息在网络中传输时可能延迟。如果在连接两端都完成了关闭握手后,它们都移除了其底层数据结构,而此时在同样一对套接字地址之间又立即建立了新的连接,那么前一个连接在网络上传输时延迟的消息就可能在新连接建立后到达。由于其包含了相同的源地址和目的地址,旧消息就会被错误地认为是属于新连接的,其包含的数据就可能被错误地分配到应用程序中。
虽然这种情形可能很少发生,TCP还是使用了包括Time-Wait状态在内的多种机制对其进行防范。Time-Wait状态用于保证每个TCP连接都在一段平静时间内结束,这期间不会有数据发送。平静时间的长度应该等于分组报文在网络上存留的最长时间的两倍。因此,当一个连接完全结束(即套接字数据结构离开Time-Wait状态并被删除),并为同样一对地址上的新连接清理道路后,就不会再有旧实例发送的消息还存留在网络中。实际上,平静时间的长度要依赖于具体实现,因为没有机制能真正限制分组报文在网络上能够延迟的时间。通常使用的时间范围是30秒到4分钟,或更短。
Time-Wait状态最重要的作用是,只要底层套接字数据结构还存在,就不允许在相同的本地端口上关联其他套接字。尤其是试图使用该端口创建新的Socket实例时,将抛出IOException异常。