IP IP之间通信。
TCP 进程之间通信
Socket TCP层的封装,通过Socket,可以进行TCP通信。
TCP/IP 协议简介
IP
IP(Internet Protocol)协议。IP 协议提供了主机和主机间的通信。
为了完成不同主机的通信,我们需要某种方式来唯一标识一台主机,这个标识,就是著名的IP地址。通过IP地址,IP 协议就能够帮我们把一个数据包发送给对方。
TCP
IP 协议提供了主机和主机间的通信。TCP 协议在 IP 协议提供的主机间通信功能的基础上,完成这两个主机上进程对进程的通信。
TCP 的作用就在于,让我们能够知道这个数据属于哪个进程,从而完成进程间的通信。
为了标识数据属于哪个进程,我们给需要进行 TCP 通信的进程分配一个唯一的数字来标识它。这个数字,就是我们常说的端口号。
TCP 的全称是 Transmission Control Protocol,大家对它说得最多的,大概就是面向连接的特性了。之所以说它是有连接的,是说在进行通信前,通信双方需要先经过一个 三次握手 的过程。三次握手完成后,连接便建立了。这时候我们才可以开始发送/接收数据。(与之相对的是 UDP,不需要经过握手,就可以直接发送数据)。
下面我们简单了解一下三次握手的过程。
-
首先,客户向服务端发送一个
SYN
,假设此时 sequence number 为x
。这个x
是由操作系统根据一定的规则生成的,不妨认为它是一个随机数。 -
服务端收到
SYN
后,会向客户端再发送一个SYN
,此时服务器的seq number = y
。与此同时,会ACK x+1
,告诉客户端“已经收到了SYN
,可以发送数据了”。 -
客户端收到服务器的
SYN
后,回复一个ACK y+1
,这个ACK
则是告诉服务器,SYN
已经收到,服务器可以发送数据了。
经过这 3 步,TCP 连接就建立了。这里需要注意的有三点:
-
连接是由客户端主动发起的
-
在第 3 步客户端向服务器回复
ACK
的时候,TCP 协议是允许我们携带数据的。之所以做不到,是 API 的限制导致的。 -
TCP 协议还允许 “四次握手” 的发生,同样的,由于 API 的限制,这个极端的情况并不会发生。
Socket 基本用法
Socket 是 TCP 层的封装,通过 socket,我们就能进行 TCP 通信。
在 Java 的 SDK 中,socket 的共有两个接口:用于监听客户连接的 ServerSocket
和用于通信的 Socket
。使用 socket 的步骤如下:
-
创建
ServerSocket
并监听客户连接 -
使用
Socket
连接服务端 -
通过
Socket
获取输入输出流进行通信
Socket、ServerSocket 傻傻分不清楚
在一个客户端连接的情况下,其实有 3 个 socket。
在 Java 的 SDK 中,socket 的共有两个接口:用于监听客户连接的
ServerSocket
和用于通信的Socket
。
注意, ServerSocket
是用于监听客户连接,而没有说它也可以用来通信。下面我们来详细了解一下他们的区别。
注:以下描述使用的是 UNIX/Linux 系统的 API
首先,我们创建 ServerSocket
后,内核会创建一个 socket。这个 socket 既可以拿来监听客户连接,也可以连接远端的服务。由于 ServerSocket
是用来监听客户连接的,紧接着它就会对内核创建的这个 socket 调用 listen
函数。这样一来,这个 socket 就成了所谓的 listening socket,它开始监听客户的连接。
接下来,我们的客户端创建一个 Socket
,同样的,内核也创建一个 socket 实例。内核创建的这个 socket 跟 ServerSocket
一开始创建的那个没有什么区别。不同的是,接下来 Socket
会对它执行 connect
,发起对服务端的连接。前面我们说过,socket API 其实是 TCP 层的封装,所以 connect
后,内核会发送一个 SYN
给服务端。
现在,我们切换角色到服务端。服务端的主机在收到这个 SYN
后,会创建一个新的 socket,这个新创建的 socket 跟客户端继续执行三次握手过程。
三次握手完成后,我们执行的 serverSocket.accept()
会返回一个 Socket
实例,这个 socket 就是上一步内核自动帮我们创建的。
所以说,在一个客户端连接的情况下,其实有 3 个 socket。
前面我说的TCP 通过端口号来区分数据属于哪个进程的说法,在 socket 的实现里需要改一改。Socket 并不仅仅使用端口号来区别不同的 socket 实例,而是使用 <peer addr:peer port, local addr:local port>
这个四元组。
在上面的例子中,我们的 ServerSocket
长这样:<*:*, *:9877>
。意思是,可以接受任何的客户端,和本地任何 IP。
accept
返回的 Socket
则是这样:<127.0.0.1:xxxx, 127.0.0.1:9877>
,其中xxxx
是客户端的端口号。
如果数据是发送给一个已连接的 socket,内核会找到一个完全匹配的实例,所以数据准确发送给了对端。
如果是客户端要发起连接,这时候只有 <*:*, *:9877>
会匹配成功,所以 SYN
也准确发送给了监听套接字。
Socket 长连接的实现
背景知识
Socket 长连接,指的是在客户和服务端之间保持一个 socket 连接长时间不断开。
比较熟悉 Socket
的读者,可能知道有这样一个 API:
socket.setKeepAlive(true);
这个应该就是让 TCP 不断开的意思。那么,我们要实现一个 socket 的长连接,只需要这一个调用即可。
遗憾的是,对于 4.4BSD 的实现来说,Socket 的这个 keep alive 选项如果打开并且两个小时内没有通信,那么底层会发一个心跳,看看对方是不是还活着。
注意,两个小时才会发一次。也就是说,在没有实际数据通信的时候,我把网线拔了,你的应用程序要经过两个小时才会知道。
在说明如果实现长连接前,我们先来理一理我们面临的问题。假定现在有一对已经连接的 socket,在以下情况发生时候,socket 将不再可用:
-
某一端关闭是 socket(这不是废话吗)。主动关闭的一方会发送
FIN
,通知对方要关闭 TCP 连接。在这种情况下,另一端如果去读 socket,将会读到EoF
(End of File)。于是我们知道对方关闭了 socket。 -
应用程序奔溃。此时 socket 会由内核关闭,结果跟情况1一样。
-
系统奔溃。这时候系统是来不及发送
FIN
的,因为它已经跪了。此时对方无法得知这一情况。对方在尝试读取数据时,最后会返回 read time out。如果写数据,则是 host unreachable 之类的错误。 -
电缆被挖断、网线被拔。跟情况3差不多,如果没有对 socket 进行读写,两边都不知道发生了事故。跟情况3不同的是,如果我们把网线接回去,socket 依旧可以正常使用。
在上面的几种情形中,有一个共同点就是,只要去读、写 socket,只要 socket 连接不正常,我们就能够知道。基于这一点,要实现一个 socket 长连接,我们需要做的就是不断地给对方写数据,然后读取对方的数据,也就是所谓的心跳。只要心还在跳,socket 就是活的。写数据的间隔,需要根据实际的应用需求来决定。
跟 TCP/IP 学协议设计
协议版本如何升级?
答案可以在 IP 协议找到。
IP 协议的第一个字段叫 version,目前使用的是 4 或 6,分别表示 IPv4 和 IPv6。由于这个字段在协议的开头,接收端收到数据后,只要根据第一个字段的值就能够判断这个数据包是 IPv4 还是 IPv6。
如何发送不定长数据的数据包
IP 的头部有个 header length 和 data length 两个字段。通过添加一个 len 域,我们就能够把数据根据应用逻辑分开。
跟这个相对的,还有另一个方案,那就是在数据的末尾放置终止符。比方说,想 C 语言的字符串那样,我们在每个数据的末尾放一个 \0
作为终止符,用以标识一条消息的尾部。这个方法带来的问题是,用户的数据也可能存在 \0
。此时,我们就需要对用户的数据进行转义。比方说,把用户数据的所有 \0
都变成 \0\0
。读消息的过程总,如果遇到 \0\0
,那它就代表 \0
,如果只有一个 \0
,那就是消息尾部。
使用 len 字段的好处是,我们不需要对数据进行转义。读取数据的时候,只要根据 len 字段,一次性把数据都读进来就好,效率会更高一些。
终止符的方案虽然要求我们对数据进行扫描,但是如果我们可能从任意地方开始读取数据,就需要这个终止符来确定哪里才是消息的开头了。
当然,这两个方法不是互斥的,可以一起使用。
上传多个文件,只有所有文件都上传成功时才算成功
IP 在数据报过大的时候,会把一个数据报拆分成多个,并设置一个 MF (more fragments)位,表示这个包只是被拆分后的数据的一部分。
好,我们也学一学 IP。这里,我们可以给每个文件从 0 开始编号。上传文件的同时,也携带这个编号,并额外附带一个 MF 标志。除了编号最大的文件,所有文件的 MF 标志都置位。因为 MF 没有置位的是最后一个文件,服务器就可以根据这个得出总共有多少个文件。
另一种不使用 MF 标志的方法是,我们在上传文件前,就告诉服务器总共有多少个文件。
如果读者对数据库比较熟悉,学数据库用事务来处理,也是可以的。这里就不展开讨论了。
如何保证数据的有序性
我们看看 TCP/IP 是怎么处理的。IP 在发送数据的时候,不同数据报到达对端的时间是不确定的,后面发送的数据有可能较先到达。TCP 为了解决这个问题,给所发送数据的每个字节都赋了一个序列号,通过这个序列号,TCP 就能够把数据按原顺序重新组装。
方法是,我们维护多一个结果队列的缓冲,这个缓冲里面的数据按序列号从小到大排序。工作线程要将结果放入,有两种可能:
-
刚刚完成的任务刚好是下一个,将这个结果放入队列。然后从缓冲的头部开始,将所有可以放入结果队列的数据都放进去。
-
所完成的任务不能放入结果队列,这个时候就插入结果队列。然后,跟上一种情况一样,需要检查缓冲。
如果测试表明,这个结果缓冲的数据不多,那么使用普通的链表就可以。如果数据比较多,可以使用一个最小堆。
如何保证对方收到了消息
我们说,TCP 提供了可靠的传输。这样不就能够保证对方收到消息了吗?
很遗憾,其实不能。在我们往 socket 写入的数据,只要对端的内核收到后,就会返回 ACK
,此时,socket 就认为数据已经写入成功。然而要注意的是,这里只是对方所运行的系统的内核成功收到了数据,并不表示应用程序已经成功处理了数据。
解决办法还是一样,我们学 TCP
,添加一个应用层的 APP ACK
。应用接收到消息并处理成功后,发送一个 APP ACK
给对方。
有了 APP ACK
,我们需要处理的另一个问题是,如果对方真的没有收到,需要怎么做?
TCP 发送数据的时候,消息一样可能丢失。TCP 发送数据后,如果长时间没有收到对方的 ACK
,就假设数据已经丢失,并重新发送。
我们也一样,如果长时间没有收到 APP ACK
,就假设数据丢失,重新发送一个。
参考文章: