TCP协议工作机制详解

  • Post author:
  • Post category:其他

一、TCP协议

TCP,即Transmission Control Protocol,传输控制协议。能够在可靠性和效率方面对数据的传输进行一个详细的控制。

TCP协议段格式

在这里插入图片描述

  • 4位TCP报头长度:表示该TCP头部有多少个32位bit(有多少个4字节);所以TCP头部最大长度是15*4=60
  • 6位标志位:
    URG:紧急指针是否有效
    ACK:确认号是否有效
    PSH:提示接收端应用程序立刻从TCP缓冲区把数据读走
    RST:对方要求重新建立连接;我们把携带RST标识的称为复位报文段
    SYN:请求建立连接;我们把携带SYN标识的称为同步报文段
    FIN:通知对方,本端要关闭了,我们称携带FIN标识的为结束报文段
  • 16位校验和:对数据进行验证。发送端填充,CRC校验。接收端校验和不通过,则认为数据有问题。此处的校验和不光包括TCP首部也包括TCP数据部分。
  • 16位紧急指针:标识哪部分数据是紧急指针。

二、TCP工作机制

TCP对数据传输提供的管控机制,主要体现在两个方面:可靠性效率。在保证数据可靠传输的前提下,尽可能的提高数据传输效率。

1. 确认应答

确认序号ACK:自该序号之前,前面的数据都已经发送完毕!
TCP将每个字节的数据都进行编号,即为序列号。每一个ACK都带有对应的确认序列号,告诉发送方,我已经收到了哪些数据,下一次你从哪里开始发送。
在这里插入图片描述

2. 超时重传

在这里插入图片描述
这两种丢包情况在特定的时间间隔内主机A都会接收不到来自对方的ACK,就会进行重发,这时主机B就会收到很多重复的数据包,那么TCP协议需要能够识别出那些包是重复的包,并且把重复的丢弃掉。它是怎么做到的呢?
利用序列号去重。接收方接收到的数据会先放在内核的“接收缓冲区”中,根据序列号进行判断,此时在应用程序中读取出来的数据就是不重复的。

如果ACK返回超时,这个时间如何确定?
最理想的情况是设置一个最小时间,保证“确认应答一定能在这个时间段返回”;但是随着网络的差异,设置是有差异的;如果超时时间设定太长,会影响整体的传输效率;如果太短,会频发发送重复的包。
TCP为了保证在任何环境下都能有比较高性能的通信,因此会动态计算这个最大超时时间。
Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。如果重发一次之后,仍然得不到应答,等待 2500ms 后再进行重传。如果仍然得不到应答,等待 4500ms 进行重传。依次类推,以指数形式递增(让重试的频率尽量降低)累计到一定的重传次数,TCP认为网络或者对端主机出现异常,会触发RST复位报文段,尝试重置连接;之后放弃连接强制关闭,最后对资源进行回收。

3. 连接管理

在正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接。
三次握手是客户端向服务器发送请求建立连接;
四次挥手是客户端和服务器都可以发送请求断开连接。
在这里插入图片描述
服务器状态转换:
[CLOSED -> LISTEN] 服务器端调用listen后进入LISTEN状态,等待客户端连接;
[LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段),就将该连接放入内核等待队列中,并向客户端发送SYN确认报文。
[SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文,就进入ESTABLISHED状态,可以进行读写数据了。
[ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调用close),服务器会收到结束报文段,服务器返回确认报文段并进入CLOSE_WAIT;
[CLOSE_WAIT -> LAST_ACK] 进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据);当服务器真正调用close关闭连接时,会向客户端发送FIN,此时服务器进入LAST_ACK状态,等待最后一个ACK到来(这个ACK是客户端确认收到了FIN)
[LAST_ACK -> CLOSED] 服务器收到了对FIN的ACK,彻底关闭连接。
客户端状态转换:
[CLOSED -> SYN_SENT] 客户端调用connect,发送同步报文段;
[SYN_SENT -> ESTABLISHED] connect调用成功,则进入ESTABLISHED状态,开始读写数据;
[ESTABLISHED -> FIN_WAIT_1] 客户端主动调用close时,向服务器发送结束报文段,同时进入FIN_WAIT_1;
[FIN_WAIT_1 -> FIN_WAIT_2] 客户端收到服务器对结束报文段的确认,则进入FIN_WAIT_2,开始等待服务器的结束报文段;
[FIN_WAIT_2 -> TIME_WAIT] 客户端收到服务器发来的结束报文段,进入TIME_WAIT,并发出LAST_ACK;
[TIME_WAIT -> CLOSED] 客户端要等待一个2MSL(Max Segment Life,报文最大生存时间)的时间,才会进入CLOSED状态。
问题1:为什么要三次握手?
①确认主机A和B之间的传输是否连通,各自发送和接收能力是否正常(投石问路的过程)
②协商参数。选择传输中合适的参数(eg. TCP的序号从几开始…)
问题2:三次握手可以是四次吗?
三次握手:SYN同步报文段,正常情况下为0,在尝试建立连接的请求中SYN=1;ACK确认报文段,正常情况下为0,确认应答时ACK=1;SYN和ACK中间两次操作可以合并在一起;应答ACK和发起请求SYN是同时触发的,分为四次握手完全可以。但是没有必要!
问题3:四次挥手中的FIN和ACK为什么不是同时触发?
FIN结束报文段:FIN的触发表面上是socket.close(),实际上是内核里面释放了对应PCB的文件描述符(eg:被垃圾回收机制回收、进程结束…)
ACK和FIN不是同时触发的。服务器只要收到FIN就会立即出发ACK,这是由内核完成的。而发送FIN是由用户代码控制的(代码中出现了socket.close()时才会触发FIN,再比如代码中有延迟sleep(1000)就不同时了)。
问题4:有关TIME_WAIT状态,为什么TIME_WAIT的时间是2MSL?
MSL是TCP报文是最大生存时间,因此TIME_WAIT持续存在2MSL的话,①能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的);②同时在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失,那么服务器会再重发一个FIN。这时虽然客户端的进程不在了,但是TCP连接还在,服务器进入TIME_WAIT状态(延迟一段时间但是状态依然存在),仍然可以重发LAST_ACK);如果服务器出现大量的TIME_WAIT状态,主动发起FIN的一方会进入这个状态,需要排查服务器是否主动断开连接。
问题5:服务器出现大量的CLOSE_WAIT状态?
原因就是服务器没有正确的关闭 socket,导致四次挥手没有正确完成。这是一个 BUG。只需要加上对应的 close 即可解决问题。

4. 滑动窗口

上面提到的确认应答机制,对于每一个发送的数据段,都要给一个ACK确认应答,保证其可靠性;收到ACK之后呢在发送下一个数据段。但是这样有一个缺点,就是性能比较差,特别是数据往返时间较长的时候,所以我们引入滑动窗口机制提升数据传输的效率。
在这里插入图片描述
窗口大小指的是无需等待确认应答而可以继续发送数据的最大值(一次批量发送数据的长度)。上图的窗口大小就是400个字节(四个段)。
发送前四个段的时候,不需要等待任何ACK,直接发送;收到第一个ACK后,滑动窗口向后移动,继续发送第五个段的数据;依次类推;操作系统内核为了维护这个滑动窗口,需要开辟 发送缓冲区记录当前还有哪些数据没有应答;只有确认应答过的数据,才能从缓冲区删掉;窗口越大,则网络的吞吐率就越高;传输效率越高,同时资源开销也越多。
问题:那么如果出现了丢包,如何进行重传呢?
在这里插入图片描述
以上两种情况:
①数据包已经到达,返回的确认ACK丢失。
这种情况下,部分ACK丢了并不要紧,可以通过后续的ACK进行确认。
②数据包就直接丢了!

  • 当某一段报文段丢失之后,发送端会一直收到 501 这样的ACK,就像是在提醒发送端 “我想要的是 501” 一样;
  • 如果发送端主机连续三次收到了同样一个 “501” 这样的应答,就会将对应的数据 501-600 重新发送;
  • 这个时候接收端收到了 501之后,再次返回的ACK就是801了(因为501- 800)接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中;(谁丢传谁,已经传输的不影响,填空缺)
    这种机制被称为 “高速重发控制”(也叫 “快重传”)。

5. 流量控制

接收端处理数据的速度是有限的。如果发送端发的太快,导致接收端的缓冲区满了,这个时候如果发送端持续发送,就很有可能出现丢包的现象,由此引发丢包重传等一系列的连锁反应。因此TCP支持根据接收缓冲区的剩余空间大小,来动态的决定发送端的发送速度,即控制窗口大小。这个机制就叫做流量控制(Flow Control)。
在这里插入图片描述
那么,接收端如何把窗口大小告诉发送端呢?
TCP首部中,有一个16位窗口字段,就是存放了窗口大小信息。
接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段,通过ACK端通知发送端;窗口大小字段越大,说明网络的吞吐量越高;接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端;发送端接受到这个窗口之后,就会减慢自己的发送速度;如果接收端缓冲区满了,就会将窗口置为0;这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。
那么问题来了,16位数字最大表示65535,那么TCP窗口最大就是65535字节么?
实际上,TCP首部40字节选项中还包含一个窗口扩大因子M,实际窗口大小是窗口字段的值左移 M位。

6. 拥塞控制

虽然有了TCP滑动窗口机制确保效率,但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题。当前的网络状态可能就已经比较拥堵。在不清楚当前网络状态下,贸然发送大量的数据,是很有可能引起雪上加霜的。
TCP引入 慢启动 机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据;
不丢包 –> 网络流畅 –> 增加发送速率
丢包 –> 网络拥堵 –> 减小发送速率

拥塞窗口:发送开始的时候,定义拥塞窗口大小为1;每次收到一个ACK应答,拥塞窗口加1;每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口;
拥塞窗口增长速度,是指数级别的。“慢启动” 只是指初使时慢,但是增长速度非常快。
为了不增长的那么快,因此不能使拥塞窗口单纯的加倍。此处引入一个叫做慢启动的阈值
当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。

在这里插入图片描述

  • 当TCP开始启动的时候,慢启动阈值等于窗口最大值;
  • 在每次超时重发的时候,慢启动阈值会变成原来的一半,同时拥塞窗口置回1
  • 少量的丢包,我们仅仅是触发超时重传;大量的丢包,我们就认为网络拥塞;
  • 当TCP通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降;拥塞控制,归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。

7. 延迟应答

如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小。窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。

  • 假设接收端缓冲区为1M。一次收到了500K的数据;如果立刻应答,返回的窗口就是500K;但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了;在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来;如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M;
    那么,所有的包都可以延迟应答吗? NO!
    ①数量限制:每隔N个包就应答一次;一般N取2
    ②时间限制:超过最大延迟时间就应答一次,一般超时时间为200ms

8. 捎带应答

在延迟应答的基础上,我们发现,很多情况下,客户端服务器在应用层也是 “一发一收” 的。意味着客户端给服务器说了 “How are you”,服务器也会给客户端回一个 “Fine, thank you”;
那么,这个时候ACK就可以搭顺风车,和服务器回应的 “Fine,thank you” 一起返回给客户端。这样可以提高数据传输的效率。

三、TCP其他特性

缓冲区

创建一个TCP的socket,同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;

  • 调用write时,数据会先写入发送缓冲区中;
    如果发送的字节数太长,会被拆分成多个TCP的数据包发出;
    如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去;
  • 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区;然后应用程序可以调用read从接收缓冲区拿数据;
  • TCP连接,既有发送缓冲区,也有接收缓冲区,那么对于一个连接,既可以读数据,也可以写数据。叫做 全双工
    由于缓冲区的存在,TCP程序的读和写不需要匹配(多次发送,多次接收),eg:
    写100个字节数据时,可以调用一次write写100个字节,也可以调用100次write,每次写一个字节;
    读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read 100个字节,也可以一次read一个字节,重复100次;

粘包问题

TCP是面向字节流的数据传输协议,站在传输层的角度,TCP是一个一个报文过来的,按照序号排好序放在缓冲区中;站在应用层的角度,看到的只是一连串的字节数据,应用程序从缓冲区中取数据,不知道是从哪个部分开始到哪个部分结束;这就出现了粘包问题。这里的“包”指的是应用层的数据包。
只要是面向字节流的数据传输,都会存在类似的问题。(文件传输)
如何避免粘包问题呢?明确两个包之间边界!
①对于定长的包,保证每次都按固定大小读取
②对于变长的包,可以在每个数据包前面的位置,约定一个包总长度字段,接收方读取数据时根据长度取数据。
还可以在包之间设定明确的分隔符或结束符(自定义应用层协议),保证不和正文冲突即可。
思考:对于UDP协议来说,是否也存在 “粘包问题” 呢?
对于UDP,如果还没有上层交付数据,UDP的报文长度仍然在。同时,UDP是一个一个把数据交付给应用层。就有很明确的数据边界。
站在应用层的站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收。不会出现”半个”的情况。

TCP异常情况

进程终止:进程终止会释放文件描述符,相当于调用close()方法,释放对应的PCB,触发四次挥手,但仍然可以发送FIN。和正常关闭没有什么区别。(进程终止不代表连接终止)
机器重启:和进程终止的情况相同。重启要先杀掉进程,仍然可以进行四次挥手。
机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。
另外,应用层的某些协议,也有一些这样的检测机制。例如HTTP长连接中,也会定期检测对方的状态。例如QQ,在QQ断线之后,也会定期尝试重新连接。
掉电有两种情况:
①接收方掉电:发送方拿不到ACK,之后进入超时重传,几次重传之后,触发RST复位报文段,之后放弃连接,对资源回收。
②发送方掉电:此时,接收方还在尝试接收数据。采用保活机制(心跳包机制)每隔一段时间,向对方发送一个PING包,期待对方返回一个PONG包,若没有返回,说明对方已经挂了。

总结

为什么TCP这么复杂?因为要保证可靠性,同时又尽可能的提高性能。
可靠性:校验和、 序列号(按序到达)、确认应答、超时重发、 连接管理、 流量控制、 拥塞控制
提高传输效率:滑动窗口、快速重传、 延迟应答、 捎带应答
其他:定时器(超时重传定时器,保活定时器,TIME_WAIT定时器等)
基于TCP应用层协议有哪些?
HTTP 、HTTPS、SSHTelnet、FTP、SMTP
当然,也包括自己写TCP程序时自定义的应用层协议。


版权声明:本文为fxt579810原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。