首页 > 代码库 > Socket底层实现
Socket底层实现
其实如果不理解套接字的具体实现所关联的数据结构和底层协议的工作细节,就很难抓住网络编程的精妙之处,对于TCP套接字(即Socket的实例)来说更是如此。这里我就对创建和使用Socket和ServerSocket实例的底层细节进行介绍。请注意,这些内容仅仅涵盖了一些普通的事件实例,略去了很多细节。尽管如此,我相信即使是这样的基础的理解也是有用的。如果希望了解更详尽的内容,可以参考TCP规范,或关于该方面的其他著作(例如TCP/IP详解)。
图1是一个Socket实例所关联的一些信息的简化视图。JVM或其运行的平台(即,主机操作系统中的“套接字层”)为这些类的支持提供了底层实现。Java对象上的操作则转换成了这种底层抽象上的操作。在这里,“Socket”指的是图1中的类之一,而“套接字(socket)”指的是底层抽象,这种抽象是有操作系统提供或由JVM自己实现(例如在嵌入式系统中)。有一点需要注意,即运行在统一主机上的其他程序可能也会通过底层套接字抽象来使用网络,因此会与Java Socket实例竞争系统资源,如端口等。
图1
在此,“套接字结构”是指底层实现(包括JVM和TCP/IP,但通常是后者)的数据结构集,这些数据结构包括了特定Socket实例所关联的信息。例如,套接字结构除其他信息外还包括:
l 该套接字说关联的本地和远程互联网地址和端口号。本地互联网地址(图中标记为“Local IP”)是赋值给本地主机的;本地端口号在Socket实例创建时设置的。远程地址和端口号标记了与本地套接字连接的远程套接字(如果没有连接的话)。不久,我们将对这些值确定的时间和方式做进一步介绍。
l 一个FIFO(先进先出,First In First Out)队列用于存放接收到的等待分配的数据,以及一个用于存放等待传输的数据的队列。
l 对于TCP套接字,还包括了与打开和关闭TCP握手相关的额外协议状态信息。图1中,状态是“关闭”;所有套接字的起始状态都是关闭的。
一些多用途操作系统为用户提供了获取底层数据结构“快照”的工具,netstat是其中之一,太在UNIX(Linux)和Windows平台上都可用。只要给定适当的选项,netstat就能显示和图1的那些信息:SendQ和RecvQ中的字节数,本地和远程IP地址和端口号,以及连接状态等。netstat的命令行选项有多种,但它输出看起来是这样的:
Active Internet connections(server and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:36045 0.0.0.0:* LISTEN
tcp 0 0 0.0.0.0:111 0.0.0.0:* LISTEN
tcp 0 0 0.0.0.0:53363 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:25 0.0.0.0:* LISTEN
tcp 0 0 128.133.190.219:34077 4.71.104.187:80 TIME_WAIT
tcp 0 0 128.133.190.219:43346 79.62.132.8:22 ESTABLISHED
tcp 0 0 128.133.190.219:875 128.133.190.43:2409 ESTABLISHED
tcp6 0 0 :::22 :::* LISTEN
前4行和最后一行描述了正在侦听连接的服务器套接字。第5行代表了到一个Web服务器(80端口)的连接,该服务器已经单方面关闭。倒数第2行是先有的TCP连接。如果系统支持的话,你可能想要尝试一下netstat,来检测下上文描述的场景的连接状态。然而要知道,这些图中描述的状态转换过程转瞬即逝,可能很难通过netstat提供的“快照”功能将其捕获。
了解这些数据结构,以及底层协议如何对其进行影响是非常有用的,因为它们控制了各种Socket对象行为的各个方面。例如,由于TCP提供了一种可信赖的字节流服务,任何写入Socket的OutputStream的数据副本都必须保留,直到其在连接的另一端被成功接收。向输出流写数据并不意味着数据实际上已经被发送,他们只是被复制到了本地缓冲区。就算在Socket的OutputStream上进行flush操作,也不能保证数据能够立即发送到信道。此外,字节流服务的自身属性决定了其无法保留输入流中消息的边界信息,这里的边界信息的意思就是上一个数据包和下一个数据包之间的区别信息。这使一些协议的接收和解析过程变得复杂。另一方面,对于DatagramSocket,数据包并没有为重传而进行缓存,任何时候调用send()方法返回后,数据就已经发送给了执行传输任务的网络子系统。如果网络子系统由于某种原因无法处理这些消息,该数据包将毫无提示地被丢弃(不过这种情况很少发生)。
1、缓冲区和TCP
作为程序员,在使用TCP套接字时需要记住的最重要一点是:
不能假设在连接的一端将数据写入输出流和在另一端从输入流读取数据之间有任何一致性。
尤其是在发送端由单个输出流的write()方法传输的数据,可能会通过另一端的多个输入流的read()方法来获取;而一个read()方法可能会返回多个write()方法传输的数据。
为了展示这种情况,考虑如下程序:
byte[] buf0 = new byte[1000];
byte[] buf1 = new byte[2000];
byte[] buf2 = new byte[5000];
…
Socket s = new Socket(destAddr, destPort);
OutputStream out = s.getOutputStream();
…
out.write(buf0);
…
out.write(buf1);
…
out.write(buf2);
…
s.close();
其中,圆点代表了设置缓冲区数据的代码,但不包括对out.write()方法的调用。在本节的讨论中,“in”代表接收端Socket的InputStream,“out”代表发送端Socket的OutputStream。
这个TCP连接想接收端传输8000字节。在连接的接收端,这8000字节的分组方式取决于连接两端out.write()方法和in.read()方法的调用时间差,以及提供给in.read()方法的缓冲区大小。
我们可以认为TCP连接上发送的所有字节序列在某一瞬间被分成了3个FIFO队列;
l SendQ:在发送端底层实现中缓存的字节,这些字节已经写入了输出流,但还没在接收端主机上成功接收。
l RecvQ:在接收端底层实现中缓存的字节,等待分配到接收程序,即从输入流中读取。
l Delivered:接收者从输入流已经读取到的字节。
调用out.write()方法将向SendQ追加字节。TCP协议负责将字节按顺序从SendQ移动到RecvQ。有重要的一点需要明确,这个转移过程无法由用户程序控制或直接观察到,并且在块中(chunk)发生,这些块的大小在一定程度上独立于传递给write()方法的缓冲区大小。
接收程序从Socket的InputStream读取数据时,字节就从RecvQ移动到Delivered中,而转移的块的大小依赖于RecvQ中的数据量和传递给read()方法缓冲区大小。
图2展示了上例中3次调用out.write()方法后,另一端调用in.read()方法前,以上3个队列的可能状态。不同的阴影效果分别代表了上文中3次调用write()方法传输的不同数据。
图2描述的发送端主机的netstat输出的瞬间状态中,会包含类似于下一行的内容:
在接收端主机,netstat会显示:
图2 3次调用write()方法后3个队列的状态
现在假设接收者调用read()方法时使用的缓冲区数组大小为2000字节,read()调用则将把等待分配队列(RecvQ)中的1500字节全部移动到数组中,返回值为1500。注意,这些数据包括了第一次和第二次调用write()方法时传输的字节。在过一段时间,但TCP连接传完更多数据后,这三部分的状态可能如图3所示。
图3 第一次调用read()方法后
如果接收者现在调用read()方法时使用4000字节的缓冲区数组,将有很多字节从等待分配队列(RecvQ)转移到已分配队列(Delivered)中。这包括第二次调用write()方法时剩下的1500字节加上第三次调用write()的前2500字节。此时队列的状态如图4所示。
图4 另一次调用read()后
下次调用read()方法返回的字节数,取决于缓冲区数组的大小,以及发送方套接字/TCP实现通过网络向接收方实现传输数据的时机。数据从SendQ到RecvQ缓冲区的移动过程对应用程序协议的设计有重要的指导性。我们已经遇到过需要对使用带内(in-band)分隔符,并通过Socket来接收的消息进行解析的情况。
Socket底层实现