前言
很久之后,你开始学习计算机网络,不是为了面试,只是为了解决工作中遇到的问题,在遇到工作中遇到网络问题之前,你的想法是工作中作为一个CRUD仔,网络是通顺的,不要担心网络问题,但是这次碰到的网络问题很让你头疼,为了解决这个问题你用尽了浑身解数,最终问题被解决,这次问题解决之后,你打算彻底学习一下计算机网络。聊一下三次握手吧,我在实习准备面试题的时候,三次握手和四次握手就是高频面试题,我选择性的放弃了这个经典的面试题,因为大学的时候学的计算机网络并不好,其实刚开始我还是蛮有兴趣的,但是后面发现越听越听不懂,索性就开始刷知乎,看数学书,当时听不懂这门课的原因,我想一大部分原因在于当时没有多少代码量,程序是认知计算机世界的桥梁,然后老师显然没有意识到这一点,当然学校也没认识到,其实还有其他课程,也面临这样的问题。但是有的时候工作中会碰到,所以只好花时间重修,但是也不后悔大学将时间奉献给了数学,我很怀念那段时光,突然懂得某一个数学定理,我当时记得花了一个月还是多久才解出了高等代数的一道课后习题,当时开心了很久。
我们来回忆一下《TCP学习笔记(二) 初遇篇》的内容:
TCP是面向连接的运输层协议,这就是说应用程序在使用TCP协议之前,必须先建立TCP连接,在数据释放完之后,必须释放已经建立的TCP连接。
TCP提供可靠交付的服务。通过TCP连接传送的数据。无差错、不丢失、不重复,并且按序到达。
为了确保不丢失,在报文在网络环境中丢失之后,TCP建立了重传机制,在网络拥堵的情况,报文丢失的频率比较高,在这种情况下,进行重传会进一步加深网络拥堵的程度,所以TCP引入了拥塞控制。
TCP提供全双工通信。TCP允许通信双方的应用进程在任何时候都能够发送数据
TCP是面向连接的协议,连接可以理解为通信双方之间的一条道路,通过连接进行运输报文。在TCP中传送报文分为三个阶段:
- 连接建立
- 数据传送
- 连接释放
TCP连接的建立主要是为了解决以下三个问题:
- 要使得通信的双方能够确知对方的存在
- 要允许双方协商一些参数
- 能够对运输实体资源(缓存大小、连接表中的项目)进行分配。
TCP连接的建立采取客户和服务器模式。主动发起连接建立的应用进程称之为客户,而被动等待连接建立的应用进程叫做服务端。我在写TCP的时候想找两个客户端的通信的代码示例,因为Java里面使用TCP协议大致上有ServerSocket和Socket这两个类,ServerSocket是服务端,Socket是客户端。看到这里才发现人家TCP本身就是客户和服务器模式。
我们本篇主要讲连接建立和连接释放,也就是为广大程序员耳熟能详的三次握手和四次握手。
连接建立-三报文握手
其实是三报文握手,不是三次握手,其实是一次握手中交换了三个报文,而并不是握了三次手,参见RFC 793(谢希仁的那本教材说是RFC 973,我在百度上找了好久没找到,搜索RFC 793就找到了,想来是不是作者手滑打错了)。在RFC 793三次握手对应的描述如下:
The procedures to establish connections utilize the synchronize (SYN) control flag and involves an exchange of three messages. This exchange has been termed a three-way hand shake [3]
程序在建立连接使用SYN控制标志并进行三次消息交换,这种交换也被称之为三报文握手。
教材中认为叫应该三报文握手的原因在于,三次握手从字面上推断为握手握了三次,其实是一次握手交换了三个报文,像是初次见面握手上下摇晃了三次。
我同意这个观点,早期我看到三次握手就以为是握了三次手,我们先大致介绍三报文的握手来体会为什么,客户端和服务端交换三个报文之后就能够确知对方的存在和协商后续的报文传输的相关问题。
TCP建立连接的过程叫做握手,握手需要在客户和服务器之间交换是三个TCP报文段,为了讨论问题,我们现在首先要引入两台通信的计算机A和B,A和B的通信进程使用TCP协议,A为客户端,B为服务端。一开始B的服务器进程首先创建运输控制块TCB(Transmission Control Block), 然后服务器进程就开始处理Listen状态,等待客户端的连接请求。写到这里Java程序员有没有想到Java中的Socket编程,在BIO中也是先new ServerSocket, 然后监听某个端口,然后调用accept方法侦听客户端进程,该方法会阻塞到直到有客户端请求进来,会返回一个Socket对象:
public void serverSocketDemo() throws Exception{ // 声明该Socket占用的进程 ServerSocket serverSocket = new ServerSocket(8080); // 监听哪个端口 serverSocket.bind(new InetSocketAddress(9090)); // 开始监听,有客户端请求进来,会返回给一个Socket对象 // Socket中有输入输出流 // 可以用来读写数据 Socket socket = serverSocket.accept(); InputStream inputStream = socket.getInputStream(); OutputStream outPutStream = socket.getOutputStream(); }
基本上高级语言Socket编程都是这个步骤,先创建ServerSocket,然后占用端口,只不过侦听方法有的叫accept,有的叫listen,其实都是一样的原理。
A的TCP进程也是首先创建传输控制模块TCP。然后,在打算建立TCP连接时,向B发出连接请求报文段,这是首部中的同部位SYN=1,同时选择一个初始序号seq = x。TCP规定,SYN报文段(即SYN = 1 的报文段)不能携带任何数据,但要消耗一个序号。这时TCP客户进程进入SYN-SENT(同步已发送状态)。
B收到连接请求报文段后,如同意建立连接,则向A发送确认。在确认报文段中应该把SYN位和ACK位置都置1,确认好是ack = x + 1,同时也会为自己选择一个初始序号seq=y。请注意,这个报文段也不能携带数据,但同样要消耗掉一个序号。这是TCP服务器进程进入SYN-RCVD(同步收到)状态。
TCP客户端进程收到B的确认后,还要向B给出确认。确认报文段的ACK置为1,确认号ack = y +1,而自己的序号seq = x + 1。TCP标准规定,ACK的报文段可以携带数据。但如果不携带数据则不消耗序号,在这种情况下,下一个数据报文段的序号仍然是seq = x + 1。这时,TCP连接已经建立,A进入ESTABLISHED状态。
上面给出的连接建立过程我们称之为三报文握手,建立过程如下图所示:
那么对于这个过程来说,第一个问题就是客户端在收到服务端的确认报文之后,为什么还要在向服务端发送一个确认报文,这个答案在RFC-793中回答了这个问题:
The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion.
三报文握手的原因是为了防止旧的重复连接初始化造成混乱。
我们下面来通过一个场景来解释上面这句话,假设A发送的第一个请求报文在某些网络结点中滞留了很久,A认为此报文已经遗失,于是又再次发送了一个请求建立连接的报文,如果在这种情况下旧的报文比新的早到达B,如果只有两次握手,那么B就会认为和A建立了两个连接,但是旧的连接中,A并不会再向B发送数据,这样服务端B的资源就白白被浪费了。如果是三报文握手,B收到了旧的报文之后,返回A一个SYN+ACK报文给客户端。客户端收到后就可以根据自身的上下文,判断这是一个历史连接(序列号过期或超时),那么客户端就会发送请求中止连接报文给服务端,表示中止这一次的连接,可以避免服务端的资源被浪费。
再有在网络拥堵的情况下,如果只是两报文交换就建立了TCP连接,在网络拥堵下,客户端会发送很多请求建立连接的报文,服务端收到一个请求连接建立的报文,就建立一个连接会造成大量服务端资源的浪费。
TCP提供可靠传输,发送发送的消息也需要按序进行交付,在建立连接的开始,客户端的报文中有一个序列号的字段,由双方共同维护,序列号是可靠传输的一个关键因素,它的作用为:
- 接收方可以去除重复的数据。
- 接收方可以根据数据包的序列号按序接收
- 可以标识发送出去的数据包中,哪些是被对方收到的。
其实B给A发送的确认报文也可以被拆成两次发送,可以先发送一个确认报文端(ACK = 1, ack = x +1), 然后再发送一个同步报文端(SYN = 1,seq = y),这样的过程就变成了四报文握手,也能实现一样的效果,但是主流的实现是将这两步合成一步。
连接释放-四报文挥手
其实按照我最初的想法是,连接建立需要经历三次报文握手是比较合理的,但是释放连接要经历四报文挥手我就有点不理解了,直接一次性挂掉不行吗?你可以联想到生活中这样的一个场景就是,俩人打电话,有一方话还没说话,另一方就挂了,通常过早挂电话还会再给另一方打一次电话。我们还是先介绍连接释放的过程,再去提出问题。
首先还是上面两个已经建立连接的两台计算机,或者说是两个计算机进程,然后此时A需要关闭连接了, A的应用进程首先向其TCP发出连接释放报文段,并停止发送数据,主动关闭TCP连接。A把连接释放报文段首部的终止位FIN置1,其序号seq = u,它等于前面已传送的数据的最后一个字节的序号加1.这时A进入FIN-WAIT-1(终止等待1)状态,等待B的确认。请注意,TCP规定FIN报文段即使不携带数据,它也会消耗掉一个序号。
B收到连接释放请求后发出确认,确认好是ack = u + 1, 而这个报文段自己的序号是v,等于B前面已经传送的数据的最后一个字节的序号+1.然后B就进入CLOSE-WAIT状态(等待关闭状态),但此时的TCP连接初遇半关闭状态,即A已经没有数据要发送了,但B如果要发送数据的话,A仍然要接收,这也就是B到A这个方向的连接并未关闭,还是可能持续一段时间。
A收到B的确认之后,就进入到了FIN-WAIT-2(终止等待2)状态,等待B发出的连接释放报文段。若B已经没有要向A发送的数据了,其应用进程就通过操作系统对外暴露的TCP接口请求操作系统释放连接。这时,B发出的连接释放报文段必须使FIN = 1. 现假定B的序号为w(在半关闭状态B可能又发送了一些数据)。B还必须重复上次已发送过的确认好ack = u + 1.这时B就进入LAST-ACK(最后确认)状态,等待A的确认。
A在收到B的连接释放报文段后,必须对此发出确认。在确认报文段中把ACK置为1、确认号ack = w + 1 ,而自己的序号是seq = u + 1(根据TCP标准, 前面发送给的FIN报文段要消耗一个序号)。然后进入到TIME-WAIT(时间等待)的状态。请注意,现在TCP连接还没有释放掉。必须经过时间等待计时器(TIME-WAIT timer) 设置的时间2MSL后,A才进入到CLOSED状态。时间MSL叫做最长报文段寿命(Maxumum Segment Lifetime),RFC-793(现在看来教材确实写错了,这里写的是RFC-793,刚开始介绍TCP理论的是RFC-973),RFC-793建议设置为两分钟。这完全是从工程上考虑的,对于现在的网络来说,MSL = 2分钟可能太长了一些。因此TCP允许不同的实现可根据具体情况使用更小的MSL值。因此A进入到TIME-WAIT状态,要经过4分钟才能进入到CLOSED状态,当A撤销相应的传输控制块TCB后,就结束了这次的TCP连接。
我们提出的第一个问题就是为什么回收需要四次? 其实上面的挥手过程已经给出答案了:
- 关闭连接时,客户端向服务端发送FIN时,仅仅表示客户端不再发送数据了,但是还能接收数据。
- 服务器接收到客户端的FIN报文时,先回一个ACK应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送FIN报文给客户端来表示统一现在关闭连接。
从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的ACK和FIN一般都会分开发送,从而比三次握手导致多了一次。我们画个图来描绘一下四次挥手的过程:
从上面的过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的ACK和FIN一般都会分开发送,从而比三次握手导致多了三次。
下一个问题是为什么A发送完最后一个连接释放报文之后,还需要等待2MSL。
- 为了保证A发送的最后一个ACK报文段能够到达B。这个ACK报文有可能丢失,因而使处在LAST—ACK状态的B收不到已发送到确认,而A能够在这段时间重传一次确认,重新启动2MSL计时器。最后,A和B都能够正常进入关闭状态。
- 防止旧连接的数据包被收到。假设客户端没有TIME-WAIT时间或者等待时间过短,有延迟的数据包到达会发生什么呢?
如果此时相同的端口的TCP连接被复用之后, 那么就有可能正常接收这个过期的报文,那么就会产生数据错乱等严重问题。等待的这段时间,经过2MSL这个时间足够让两个方向上的数据包被丢弃,使得原来连接的数据包在网络中自然消失,再出现的数据包一定是新连接所建立的。
TIME_WAIT等待2倍的MSL,比较合理的解释是: 网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会向对方发送响应,所以这一来一回需要等待2倍的时间。
举一个例子如果被动关闭方没有收到断开连接的最后的ACK报文,就会触发超时重发Fin报文,另一方接收到FIN后,会重发ACK给被动关闭方,这一来一回刚好2个MSL。
其实看到这里的疑问是TCP是如何保证最长报文段寿命,我以为TCP的报文中会有这样一个字段来说明自己的寿命,查阅诸多资料发现,没有找到对应的资料说明。StackOverFlow中也有人跟我有一样的疑问, 搜索: What is Maximum Segment Lifetime(MSL) IN TCP相关问题就能看到答主跟我有一样的疑问.
那如果客户端无故重启,那服务端的连接会关闭不了吗?当然不会TCP还有一个保活计时器。服务器没收到一次客户的数据,就重新设置保活计时器,时间的设置是通常是两小时。若两小时没收到客户的数据,服务器就发送一个探测报文段,以后每隔75秒钟发送一次。若一连发送10个探测报文段仍无客户的响应,服务器就认为客户端出了故障,接着就关闭这个连接。
处于time-wait的TCP连接不能被重用,一般有人看到这里可能有人会问,我用多线程使用HTTP Client多线程调用,两个间隔非常短的情况下,也实现重用了呀。那是因为在操作系统中提供了重用端口这个选项,即使该端口还被上一个连接所占用还没释放,我们依然可以通过重用地址发起新的连接:
public void serverSocketDemo() throws Exception{ Socket socket = new Socket(); // 允许处于time-wait中的端口,发起新的连接。 socket.setReuseAddress(true); }
总结一下
我们经常谈起的三次握手, 更为贴切的称呼是三报文握手, 使用SYN相关的报文,客户端和服务端交换三次。为什么客户端收到服务端的确认之后,还需要再发送一次确认。原因在于防止旧的连接造成连接混乱和资源浪费,假如A发送的请求建立连接报文,此报文在网络中滞留了很长时间,超过了超时间,A再次发了一个请求建立连接的报文,如果只有两次握手,那么滞留的报文也将建立连接,白白造成资源浪费。
那为啥关闭连接要四次,因为客户端发送数据发送完毕之后,服务端还可能有数据没处理和发送完,所以多了一次握手。那为什么收到服务端发送的连接关闭报文,客户端不立即关闭连接,而是进入TIMEWAIT状态,有两个原因,第一个保证连接正确的被关闭,因为A发送的确认ACK报文可能在网络中滞留和丢失,如果A在发送确认报文ACK之后立即关闭,假设丢失之后,服务端就可能无法正确关闭连接。第二个就是防止旧的的数据包被新的连接收到,考虑以下两种情况:
- 如果网络极差,B始终未收到A发送的ACK,B会不断重传直到到达操作系统的重传时间或次数,然后关闭连接;如果A如果始终未收到B重传的FIN,会再等待2个MSL后释放资源。最终A,B也完成了连接的断开,资源的释放。
- TIME_WAIT状态等待2MSL,考虑的是B总是能收到A的ACK,但由于拥塞,可能收到的比较慢,最慢为1个MSL,如果经过1MSL, B还是没收到,就永远收不到ACK了,此时退化为第一种情况。此时距离A发送ACK过了1MSL,B已经可能多次重传了FIN,并且因为收到ACK,之后不会再重传FIN。此时A如果收到过新的FIN,就已经开始了重新计时,重新等待2个MSL;A如果一直没收到新的FIN,就再等待1个MSL,让B之前重传的报文在网络中消逝。