6.2 死锁风险

应用程序协议必须设计得非常小心,以避免发生死锁—这种情况下,每个对等端都在阻塞等待其他端完成一些工作。例如,如果在连接建立后,客户端和服务器端都立即尝试接收数据,显然将导致死锁。死锁还可能发生在其他非即时的情况下。

SendQ和RecvQ缓冲区的容量在具体实现时会受一定的限制。虽然它们使用的实际内存大小会动态地增长和收缩,还是需要有一个硬性的限制,以防止行为异常的程序所控制的单独一个TCP连接将系统的内存全部耗尽。由于这些缓冲区的容量有限,它们可能被填满,事实也的确如此。如果与TCP的流量控制(flow control)机制结合使用,则可能导致另一种形式的死锁。

一旦RecvQ已满,TCP流控制机制就会产生作用。它将阻止传输发送端主机的SendQ中的任何数据,直到接收者调用输入流的read()方法后腾出了空间。(使用流控制机制的目的是为了保证发送者不会传输太多数据,而超出了接收系统的处理能力。)发送程序可以持续地写出数据,直到SendQ队列被填满,然而,如果在SendQ队列已满时调用out.write()方法,则将阻塞等待,直到有新的空间为止,也就是说直到一些字节传输到了接收到套接字的RecvQ队列中。如果此时RecvQ队列也已经被填满,所有操作都将停止,直到接收程序调用in.read()方法将一些字节传输到了Delivered队列中。

假设SendQ队列和RecvQ队列的大小分别为SQS和RQS。将一个大小为n的字节数组传递给write()方法调用,其中n>SQS,直到有至少n-SQS字节传递到接收端主机的RecvQ队列后,该方法才会返回。如果n的大小超过了(SQS+RQS),write()方法则将在接收程序从输入流读取了至少n-(SQS+RQS)字节后才会返回。如果接收程序没有调用read()方法,大数据量的send()调用则无法成功。特别是当连接的两端同时分别调用它们输出流的write()方法,而它们的缓冲区大小又大于SQS+RQS时,将发生死锁:两个write操作都不能完成,两个程序都将永远保持阻塞状态。

下面考虑一个具体的例子,即主机A上的程序和主机B上的程序之间的一个连接。假设A和B上的SQS和RQS都是500字节,图6-5展示了两个程序试图同时发送1500字节时的情况。主机A上的程序中的前500字节已经传输到了另一端,另外500字节已经复制到了主机A的SendQ队列中,余下的500字节则无法发送(因此out.write()方法将无法返回)直到主机B上的RecvQ队列有空间空出来。然而不幸的是主机B上的程序也遇到了同样的情况。因此两个程序的write()方法调用都永远无法完成。

figure_0168_0239

图 6-5 由于连接两端同时调用输出流的write()方法而导致了死锁

这个故事的寓意:要仔细设计协议,以避免在两个方向上传输大量数据时产生死锁。

这种情况真的会发生吗?让我们回顾一下第4.5节中的压缩协议示例。尝试运行该压缩客户端并传递给它一个大文件,该文件压缩后仍然很大。在此,“大”的精确定义取决于你的系统,不过压缩后依然超过2MB的文件应该就可以了。每次read/write操作,压缩客户端都向控制台打印一个“R”或“W”。如果该文件的未压缩版本和压缩版本都足够大,你的客户端将在打印出一堆W后停止,并且不会打印任何R,程序也不过终止。

为什么会发生这种情况呢?程序CompressClient.java在尝试从压缩流读取数据前,先要将所有未压缩的数据发送到压缩服务器。而另一方面,服务器只是简单地读取未压缩字节序列,并将压缩后的序列返回给客户端。(服务器在写回压缩数据前,其读取的字节数取决于所使用的压缩算法。)考虑这种情况:客户端和服务器端的SendQ队列和RecvQ队列中都有500字节的数据,而客户端发送了一个大小为10000字节(未压缩)的文件。同时假设对于这个文件,服务器读取1000字节并返回500字节,即压缩比为2:1。当客户端发送了2000字节后,服务器端将最终全部读取这些字节,并发回1000字节,此时客户端的RecvQ队列和服务器端的SendQ队列都将被填满。当客户端又发送了1000字节并且被服务器端全部读取后,服务器端后续的任何write操作尝试都将阻塞。当客户端又发送了另外1000字节后,客户端的SendQ队列和服务器端的RecvQ队列都将填满。后续的客户端write操作将阻塞,从而形成死锁。

如何解决这个问题?方案之一是在不同的线程中执行客户端的write循环和read循环。一个线程从文件中反复读取未压缩的字节并将其发送给服务器,直到到底文件的结尾,然后调用该套接字的shutdownOutput()方法。另一个线程从连接到服务器的输入流中反复读取压缩后的字节,并将其写入输出文件,直到到达了输入流的结尾(即服务器关闭了套接字)。如果一个线程阻塞了,另一个线程仍然可以独立执行。要实现这个功能,我们可以对客户端代码进行简单的修改,像下面这样将CompressClient.java中的SendBytes()方法调用放到一个线程中:

figure_0169_0240

CompressClientNoDeadlock.java的完整版本请参见本书的网站。

当然,解决这个问题也可以不使用多线程,而是使用第5章介绍的非阻塞Channel和Selector。