【计算机网络】传输层TCP协议1:https://developer.aliyun.com/article/1383984
为什么要三次握手,难道四次或更多次不可以吗?
因为三次握手是安全的,并且效率是最高的。如果客户端发送请求时出现了丢包情况,因为自己没发送,又重新传了一遍,然而等数据传输完成后客户端和服务端都释放了连接,重发的传输的数据在释放连接前给服务器传了过去,但第一次传输的数据假如由于网络原因滞留的时间长了,在释放连接后到达了服务端,这个时候服务端就会误以为客户端又发出了一次新的请求,服务端确认了客户端第一次发出的报文段并同意建立了新的连接,发送报文给客户端,此时服务端会一直等待客户端的答复,而客户端此时正处于释放连接状态,所以导致白白浪费了资源。
半连接队列和全连接队列
- 半连接队列(syn queue)
客户端发送SYN包,服务端收到后回复SYN+ACK后,服务端进入SYN_RCVD状态,此时双方还没有完全建立连接,这个时候的socket会放到半连接队列。
- 全连接队列(accept queue)
当服务端收到客户端的ACK后,socket会从半连接队列移出到全连接队列。当调用accpet函数的时候,会从全连接队列的头部返回可用socket给用户进程。全连接队列中存放的是已完成TCP三次握手的过程,等待被处理的连接,在客户端及服务端的状态均为 ESTABLISHED。
四次挥手
当双方结束通信,断开连接的过程称为四次挥手。四次挥手,顾名思义就是客户端和服务端四个步骤的释放连接,断开连接需要发送四个包,别名连接终止协议。因为TCP连接是全双工的,因此每个方向的连接都必须分别断开。断开的基本原则是,双方完成了数据传输的任务之后,由一方先发起一个FIN的报文来终止这个方向上的连接。收到一个FIN只意味着这一方向上没有数据流动,但另一方向上还可以发送数据,因此需要对方再发送一个FIN报文断开另一个方向上的连接。例如:
双方再断开连接前都处于ESTABLISHED状态,
第一次挥手:客户端主动断开连接,向服务端发送一个带FIN的报文。其中包含将FIN标志位置为1,序列号seq = u。发送完毕之后客户端进入FIN_WAIT_1状态,即关闭自己到服务端的连接,等待客户端的回应,但是此时可以接收服务端发来的报文。
第二次挥手:服务端收到FIN后,知道了客户端想要与自己断开连接,因此进入CLOSE_WAIT状态,并且向客户端响应一个带ACK的确认报文,此时客户端收到该报文就知道服务端接收到了自己的断开连接请求,但是此时服务端还可能会发送数据。
第三次挥手:此时服务端要与客户端断开自己这个方向上的连接,向客户端发送一个FIN报文,然后服务端进入LAST_ACK状态,等待来自客户端最后的确认。
第四次挥手:客户端收到 FIN 报文之后,同样会发送一个 ACK 报文作为应答,此时客户端进入TIME_WAIT状态,TIME_WAIT状态是为了等待足够的时间以确保服务器能够接收到到连接中断请求的确认。
注意:
此时由服务端到客户端的 TCP 连接并未释放掉,客户端需要经过时间等待计时器设置的时间 2MSL(一个报文的来回时间) 后才会进入
CLOSED状态,服务端收到 ACK 报文之后,就关闭连接了,处于 CLOSED 状态。
这样做的目的是确保服务端收到自己的 ACK 报文。如果服务端在规定时间内没有收到客户端发来的 ACK 报文的话,服务端会重新发送 FIN 报文给客户端,客户端再次收到 FIN 报文之后,就知道之前的 ACK 报文丢失了,然后再次发送 ACK报文给服务端。
**为什么是等待2MSL? **
防⽌客户端最后⼀次发给服务器的确认在⽹络中丢失以⾄于客户端关闭,⽽服务端并未关闭,导致资源的浪费。
等待最⼤的2MSL可以让本次连接的所有的⽹络包在链路上都完全传输完毕,以防造成不必要的⼲扰。
为什么客户端需要TIME_WAIT状态?
- 假设最终的ACK丢失,服务端将重发FIN,客户端必须维护TCP状态信息以便可以重发最终的ACK,否则服务端认为传输中发生错误,导致会发送RST报文进行重新连接。
- TCP实现必须可靠地终止连接的两个方向(全双工关闭),客户端必须进入TIME_WAIT 状态,因为客户端可能面临重发最终ACK的情形。
同样的全双工,为什么握手是三次,挥手是四次?
- 因为握手的时候并没有数据传输,所以服务端的 SYN 和 ACK 报文可以一起发送,但是挥手的时候有数据在传输,所以 ACK 和 FIN 报文不能同时发送,需要分两步,所以会比握手多一步。
为什么三次挥手不可行?
- 因为服务端在接收到FIN,往往不会立即返回FIN,必须等到服务端所有的报文都发送完毕了,才能发FIN。因此先发一个ACK表示已经收到客户端的FIN,延迟一段时间才发FIN,这就造成了四次挥手。
- 如果是三次挥手会造成: 如果将服务端的两次挥手合为一次,等于说服务端将ACK和FIN的发送合并为一次挥手,这个时候长时间的延迟可能会导致客户端误以为FIN没有到达客户端,从而让客户端不断的重发FIN。
- 所有只能第二次握手先发送ACK确认接收到了客户端的数据,等服务器发送完了数据,再发送FIN包进行第三次挥手。
滑动窗口
每发送一个数据就发出一个确认应答,直到发送端收到ACK报文再发送下一个数据段,这样的做最大的缺点就是性能比较差,尤其是在数据往返时间较长的时候表现得尤为明显。
既然这样一发一收的方式性能较低,那么就可以考虑一次发送多条数据,就可以大大的提高数据传输的性能。实际上就是是将多个数据段的等待时间重叠在一起,这样TCP就引入了滑动窗口的机制。
滑动窗口的概念:
滑动窗口(Sliding window)是一种流量控制技术。早期的网络通信中,通信双方不会考虑网络的拥挤情况直接发送数据。由于大家不知道网络拥塞状况,同时发送数据,导致中间节点阻塞掉包,谁也发不了数据,所以就有了滑动窗口机制来解决此问题。滑动窗口协议是用来改善吞吐量的一种技术,即容许发送方在接收任何应答之前传送附加的包。接收方告诉发送方在某一时刻能送多少包(称窗口尺寸)。
滑动窗口原理:
滑动窗口协议的基本原理就是在任意时刻,发送方都维持了一个连续的允许发送的帧的序号,称为发送窗口;同时,接收方也维持了一个连续的允许接收的帧的序号,称为接收窗口。发送窗口和接收窗口的序号的上下界不一定要一样,甚至大小也可以不同。不同的滑动窗口协议窗口大小一般不同。发送方窗口内的序列号代表了那些已经被发送,但是还没有被确认的帧,或者是那些可以被发送的帧。
例如下图:
- 滑动窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。上图的窗口大小就是4000个字节(四个段)。
- 发送前四个段的时候,不需要等待任何ACK,直接发送即可。
- 当主机A收到第一个ACK后,滑动窗口向后移动,继续发送第五个段的数据。后续数据的发送依次类推。
- 操作系统内核为了维护这个滑动窗口,需要开辟发送缓冲区来记录当前还有哪些数据没有应答。只有确认应答过的数据,才能从缓冲区删掉。
- 滑动窗口越大,则网络的吞吐率就越高。
主机B向主机A发送请求序列号为2001的报文,主机A收到确认报文后,滑动窗口向右移动至2001的位置。
如果出现了丢包的情况,窗口又该如何滑动呢?
情况一:主机A发送的数据包已经被主机B接收了,但是发给主机A的确认报文ACK丢失了。
这种情况下,部分ACK丢失并不会影响数据包的正常传输,因为主机A接收到的对后续数据包的确认,同时也能对前面发出的数据包进行确认。
情况二:主机A发送的数据包部分直接丢失了。
- 在这种情况下,主机A发送的在1001 ~ 2000的数据包丢失,但该滑动窗口中的其他数据包还是继续正常发送,只是由于主机B没有收到1001 ~ 2000的数据包,因此会一直向主机A发送带有1001确认号的响应,表示没有接收的1001 ~ 2000的数据包。
- 如果主机A连续3次收到同样的带有确认号1001这样的确认应答,就会对 1001 ~ 2000 的数据包进行重传。
- 当主机B收到了 1001 ~ 2000 的数据包后,就会向主机A响应确认号为7001的确认报文,因为2001 ~ 7000 的数据主机B已经收到了,被放在了其操作系统内核的接收缓冲区中。
这种机制被称为 “高速重发机制”,也叫做 “快重传”。
流量控制
接收端处理数据的速度是有限的,如果发送端发送得太快,就会导致接收端的缓冲区迅速被写满,如果这个时候发送端继续发送数据就会造成丢包,继而引起丢包重传等一系列连锁反应。
因此TCP支持根据接收端的处理能力来决定发送端的发送速度,这个机制就叫做流量控制(Flow Control)。
流量控制的基本原理如下:
- 接收端将自己可以接受数据的缓冲区大小写入TCP首部的 “窗口大小” 字段中,通过发送的ACK确认报文响应给发送端。
- 窗口大小字段越大,说明网络的吞吐量和接收端的缓冲区大小越大。
- 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置为一个更小的值响应给发送端。
- 当发送端知道了这个窗口大小之后,就会减慢自己的发送速度。
- 如果接收端的缓冲区满了,就会将窗口大小设置为0,此时发送端不再给接收端发送数据,但是需要定期发送一个窗口探测的数据段,让接
- 收端把自己的窗口大小告诉发送端。
接收端如何把窗口大小告诉发送端呢?回忆前面的TCP首部中,有一个16位窗口字段,存放的就是窗口大小信息。那么问题来了,16位数字最大表示65535,那么TCP窗口最大就是65535字节么?
实际上,TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移 M 位。
拥塞控制
虽然TCP有了滑动窗口机制,能够高效可靠的发送大量数据,但是如果在刚刚传输数据的阶段就发送大量的数据,仍然可能会引起问题。因为当今世界上时时刻刻都存在着大量的计算机在进行网络通信,可能当前的网络状态就比较拥堵了,如果此时在不清楚网络状况的情况下就贸然发送大量的数据,还是可能会造成丢包等情况。
为了解决这个问题,TCP就引入了 “慢启动” 机制,即开始先发送少量的数据,去摸清当前网络的拥堵状况,再决定按照多大的速度发送数据。
此时便为 “慢机制” 引入了一个拥塞窗口的概念,其基本原理如下:
- 在发送开始时,设置拥塞窗口的大小为1,发送端每次收到一个ACK确认应答,拥塞窗口大小就增长一倍。
- 每次发送数据包的时候,发送端就会对自己的拥塞窗口和接收端反馈的滑动窗口的大小进行比较,取二者的较小值作为实际的窗口大小。
像上面这样的拥塞窗口大小的增长速度是指数级别的增长,“满启动” 只是指初始传输数据的速度满,但其增长的速度很快。解决方法如下:
- 为了阻止拥塞窗口增长过快,不能使拥塞窗口大小单纯的只进行加倍
- 因此引进了一个叫做 “慢启动” 的阈值。
- 当拥塞窗口超过这个阈值的时候,步骤按照指数方式进行增长,而是以线性的方式增长。
如图:
- 当TCP开始启动的时候,“慢启动” 的阈值等于窗口的最大值。
- 每次进行超时重传的时候,“慢启动” 的阈值就会减少为原来的一半,同时拥塞窗口被重置为1。
当数据传输过程中出现少量的丢包,仅仅会触发TCP的超时重传机制,如果出现大量的丢包现象,那么就可以判断为出现了网络拥塞。当TCP通信开始时,网络吞吐量会逐渐上升;当网络出现了拥塞,吞吐量就会立即下降。拥塞控制,归根结底是TCP协议尽快可能的想将数据传输给对方,但是又要避免给网络造成太大压力的折中方案。
延迟应答
如果每次接收端接收到数据就立刻响应ACK应答的话,这个时候返回的滑动窗口大小就会比较小,例如:
- 假如接收端缓冲区大小为1M,一次性收到的数据大小为500K,如果立即应答,那么返回的滑动窗口大小也就是500K,
- 但是实际上可能处理端的处理速度很快,10ms之内就将500K的数据从缓冲区中处理完了。在这种情况下,接收端接收数据的能力还远远没有达到自己的极限,即使窗口再大一点,也得处理的过来。
- 如果接收端延迟一会再做出应答,比如等待200ms后再应答,此时向发送端返回的窗口大小就是1M了,提高了传输数据的效率。
但是我们一定要记住,窗口越大,网络吞吐量就越大,传输的效率就越大,但是是在保证网络不拥塞的基础之上尽量提高传输效率的。因此不少所有的包都可以延迟应答,例如:
- 存在数量限制:比如每隔n个数据包就应答一次。
- 存在时间限制:超过最大延迟时间就应答一次。
具体的数量和延迟时间并不统一,随着操作系统的不同也会存在差异,但是一般n取2,最大延迟时间取200ms。
捎带应答
在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是 “一发一收” 的,意味着客户端给服务器说了 “How are you”,服务器也会给客户端回一个 “Fine, thank you” 。那么这个时候ACK就可以搭顺风车,和服务器回应的 “Fine, thank you” 一起发送给客户端。这就是捎带应答。
面向字节流
我们都知道TCP传输数据是面向字节流的,比如创建一个TCP的socket,同时会在系统内核中创建一个发送缓冲区和一个接收缓冲区。
- 当调用write时,数据会先写入发送缓冲区中。
- 如果发送的字节数太长,会被拆分成多个TCP的数据包发出。
- 如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去。
- 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区。然后应用程序可以调用read从接收缓冲区拿数据。
- 另一方面,TCP的一个连接,既有发送缓冲区,也有接收缓冲区。那么对于这一个连接,既可以读数据,也可以写数据。这个概念叫做 “全双工” 。
由于缓冲区的存在,TCP程序的读和写不需要一一匹配,例如:
- 写100个字节数据时,可以调用一次write写100个字节,也可以调用100次write,每次写一个字节。
- 读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read 100个字节,也可以一次read一个字节,重复100次。
粘包问题
粘包问题中的 “包” ,是指的应用层的数据包。在TCP的协议头中,没有如同UDP一样的 “报文长度” 这样的字段,但是有一个序号这样的字段。站在传输层的角度,TCP是将一个一个报文传输过来的,按照序号排好序放在缓冲区中。但是站在应用层的角度,看到的只是一串连续的字节数据,那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。
那么如何避免粘包问题呢?归根结底就是一句话,明确两个包之间的边界。
- 对于定长的包,保证每次都按固定大小读取即可。
- 对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。例如TCP的头部长度。
- 对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议,是程序猿自己来定的,只要保证分隔符不和正文冲突即可) 。
对于UDP协议来说,是否也存在 “粘包问题” 呢?
对于UDP,如果还没有上层交付数据,UDP的报文长度仍然在。同时,UDP是把一个一个把数据交付给应用层,因此就有很明确的数据边界。站在应用层的站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收,不会出现"半个" 的情况。因此对于UDP而言不会出现 “粘包问题” 。
TCP异常情况
TCP常见的异常情况如下:
- 进程终止:进程终止会释放文件描述符,仍然可以发送FIN,和正常断开TCP连接没有什么区别。
- 计算机重启:和进程终止的情况相同。
- 机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作, TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会释放连接。
- 另外,应用层的某些协议,也有一些这样的检测机制。比如在HTTP长连接中,也会定期检测对方的状态。例如QQ,在QQ断线之后,也会定期尝试重新连接。
总结
为什么TCP协议会这么复杂?因为既要保证其可靠性,同时又尽可能的提高性能,导致实现TCP就变得很困难。
TCP的可靠性得益于:
- 校验和
- 序列号(按序到达)
- 确认应答
- 超时重传
- 连接管理
- 流量控制
- 拥塞控制
提供性得益于:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
其他:
- 定时器(超时重传定时器、保活定时器、TIME_WAIT定时器等)