TCP详解

TCP详解

TCP报文

TCP报文格式

image-20211207190339906

序列号seq:占4个字节,用来标记数据段的顺序,TCP把连接中发送的所有数据字节都编上一个序号,第一个字节的编号由本地随机产生;给字节编上序号后,就给每一个报文段指派一个序号;序列号seq就是这个报文段中的第一个字节的数据编号。

确认号ack:占4个字节,期待收到对方下一个报文段的第一个数据字节的序号;序列号表示报文段携带数据的第一个字节的编号;而确认号指的是期望接收到下一个字节的编号;因此当前报文段最后一个字节的编号+1即为确认号。

确认ACK:占1位,仅当ACK=1时,确认号字段才有效。ACK=0时,确认号无效

同步SYN:连接建立时用于同步序号。当SYN=1,ACK=0时表示:这是一个连接请求报文段。若同意连接,则在响应报文段中使得SYN=1,ACK=1。因此,SYN=1表示这是一个连接请求,或连接接受报文。SYN这个标志位只有在TCP建产连接时才会被置1,握手完成后SYN标志位被置0。

终止FIN:用来释放一个连接。FIN=1表示:此报文段的发送方的数据已经发送完毕,并要求释放运输连接

PS:ACK、SYN和FIN这些大写的单词表示标志位,其值要么是1,要么是0;ack、seq小写的单词表示序号。

各字段理解

image-20211207190554046

三次握手

image-20211207190952978

image-20211207191328757

(1)第一次握手:Client将标志位SYN置为1(表示要发起一个连接),随机产生一个值seq=J,并将该数据包发送给Server,Client进入SYN_SENT状态,等待Server确认。
(2)第二次握手:Server收到数据包后由标志位SYN=1知道Client请求建立连接,Server将标志位SYN和ACK都置为1,ack=J+1,随机产生一个值seq=K,并将该数据包发送给Client以确认连接请求,Server进入SYN_RCVD状态。
(3)第三次握手:Client收到确认后,检查ack是否为J+1,ACK是否为1,如果正确则将标志位ACK置为1,ack=K+1,并将该数据包发送给Server,Server检查ack是否为K+1,ACK是否为1,如果正确则连接建立成功,Client和Server进入ESTABLISHED状态,完成三次握手,随后Client与Server之间可以开始传输数据了。

三次握手的最主要目的是保证连接是双工的,可靠更多的是通过重传机制来保证的。

四次挥手

image-20211207191457253

image-20211207191516798

由于TCP连接时全双工的,因此,每个方向都必须要单独进行关闭,这一原则是当一方完成数据发送任务后,发送一个FIN来终止这一方向的连接,收到一个FIN只是意味着这一方向上没有数据流动了,即不会再收到数据了,但是在这个TCP连接上另一个方向,服务端仍然能够发送数据,直到这一方向也发送了FIN,说明服务端数据都发送完毕。首先进行关闭的一方将执行主动关闭,而另一方则执行被动关闭,上图描述的即是如此。
(1)第一次挥手:Client发送一个FIN,用来关闭Client到Server的数据传送,Client进入FIN_WAIT_1状态。
(2)第二次挥手:Server收到FIN后,发送一个ACK给Client,确认序号为收到序号+1(与SYN相同,一个FIN占用一个序号),Server进入CLOSE_WAIT状态。
(3)第三次挥手:Server发送一个FIN,表示服务端的数据都发送完毕,用来关闭Server到Client的数据传送,Server进入LAST_ACK状态。
(4)第四次挥手:Client收到FIN后,Client进入TIME_WAIT状态,接着发送一个ACK给Server,确认序号为收到序号+1,Server进入CLOSED状态,完成四次挥手。

TCP可靠传输的实现

TCP 连接的每一端都必须设有两个窗口——一个发送窗口和一个接收窗口。TCP 的可靠传输机制用字节的序号进行控制。TCP 所有的确认都是基于序号而不是基于报文段。
发送过的数据未收到确认之前必须保留,以便超时重传时使用。发送窗口没收到确认不动,和收到新的确认后前移。

发送缓存用来暂时存放: 发送应用程序传送给发送方 TCP 准备发送的数据;TCP 已发送出但尚未收到确认的数据。

接收缓存用来暂时存放:按序到达的、但尚未被接收应用程序读取的数据; 不按序到达的数据。

必须强调三点:
1> A 的发送窗口并不总是和 B 的接收窗口一样大(因为有一定的时间滞后)。
2> TCP 标准没有规定对不按序到达的数据应如何处理。通常是先临时存放在接收窗口中,等到字节流中所缺少的字节收到后,再按序交付上层的应用进程。
3> TCP 要求接收方必须有累积确认的功能,这样可以减小传输开销(累积确认:一般地讲,如果发送方发了包1,包2,包3,包4;接受方成功收到包1,包2,包3。
那么接受方可以发回一个确认包,序号为4(4表示期望下一个收到的包的序号;当然你约定好用3表示也可以),那么发送方就知道包1到包3都发送接收成功,必要时重发包4。一个确认包确认了累积到某一序号的所有包。而不是对没个序号都发确认包。)

滑动窗口协议

原本没有滑动窗口,为了保证传输次序:

image-20211207201227563

发送方发送一个包1,这时候接收方确认包1。发送包2,确认包2。就这样一直下去,知道把数据完全发送完毕,这样就结束了。那么就解决了丢包,出错,乱序等一些情况!同时也存在一些问题。问题:吞吐量非常的低。我们发完包1,一定要等确认包1.我们才能发送第二个包。

image-20211207201118217

提高了吞吐量。

我们能不能把第一个和第二个包发过去后,收到第一个确认包就把第三个包发过去呢?而不是去等到第二个包的确认包才去发第三个包。这样就很自然的产生了我们”滑动窗口”的实现。


滑动窗口实现

image-20211207201349026

在图中,我们可看出灰色1号2号3号包已经发送完毕,并且已经收到Ack。这些包就已经是过去式。4、5、6、7号包是黄色的,表示已经发送了。但是并没有收到对方的Ack,所以也不知道接收方有没有收到。8、9、10号包是绿色的。是我们还没有发送的。这些绿色也就是我们接下来马上要发送的包。 可以看出我们的窗口正好是11格。后面的11-16还没有被读进内存。要等4号-10号包有接下来的动作后,我们的包才会继续往下发送。

正常情况

image-20211207201502033

可以看到4号包对方已经被接收到,所以被涂成了灰色。“窗口”就往右移一格,这里只要保证“窗口”是7格的。 我们就把11号包读进了我们的缓存。进入了“待发送”的状态。8、9号包已经变成了黄色,表示已经发送出去了。接下来的操作就是一样的了,确认包后,窗口往后移继续将未发送的包读进缓存,把“待发送“状态的包变为”已发送“。

丢包情况

image-20211207201558581

发生的情况:一直在等Ack。如果一直等不到的话,我们也会把读进缓存的待发送的包也一起发过去。但是,这个时候我们的窗口已经发满了。所以并不能把12号包读进来,而是始终在等待5号包的Ack。

如果我们这个Ack始终不来怎么办呢?

超时重发

image-20211207201758828

这时候可以看出5号包已经接受到Ack,后面的6、7、8号包也已经发送过去已Ack。窗口便继续向后移动。

这里有一点要说明:这个Ack是要按顺序的。必须要等到5的Ack收到,才会把6-11的Ack发送过去。这样就保证了滑动窗口的一个顺序。

参考博客

拥塞控制

怎么判定网络的拥塞

  1. 段的计时器超时了。

    当发生拥塞时,发送端和接收端之间路径上一个或者多个路由器的缓冲区溢出,导致数据报被丢弃。这会导致发送方长时间收不到反馈从而导致超时;

  2. 收到三个重复的ACK(快速重传机制)

    简单来说,就是某一个包丢了,但是它之后的包都正常达到,那么接受方发现只有某一个包丢了,就向源端连续发送三个ACK,ACK带有期待的包的序号,发送端收到这三个连续ACK,便不等到段计时器超时,直接发送对应的序号的数据包,这就是快速重传机制。

这两个方法,能让发送方掌握网络发生了拥塞,进而采用算法来调整自己的发送速率。

采取的方法

我们假设接收缓冲区足够大,那么对于 min(接收窗口,拥塞窗口),实际中起到影响作用的只有拥塞窗口CongWin. 设 RTT 为TCP段开始发送到ACK返回的时间,也就是一个数据包发送所需要的时间,那么发送端的发送速率近似为CongWin/RTT bps。 所以TCP发送端通过调整CongWin来限制发送速率。

速率调整算法的基本思想是:当发生丢包事件时,降低发送速率(减少拥塞窗口CongWin的大小)


一:慢启动

  1. TCP连接建立时,拥塞窗口CongWin初始为一个MSS(MSS是最大段长度,是能通过网络的最大段的长度),所以初始发送速率为CongWin/RTT。

  2. 每经过一个RTT,CongWin的大小加倍(以MSS为单位)。在初始阶段,按指数增长速度加大发送速率直到发生“丢包事件”。

  3. 发生“丢包事件”后,将CongWin窗口减半,之后就按照按线性速度增大(每个RTT增大一个MSS).

    二:AI(Additive-Increase)MD(Multiplicative Decrease)逐步递增加倍递减算法

这是拥塞避免阶段的算法,采用这个算法的时候已经出现了第一次丢包,丢包后就离开了慢启动部分,开启了AIMD算法。

逐步递增:每当经过一个RTT就将CongWin窗口增大一个MSS。

加倍递减:一旦发现丢失段立即将CongWin窗口减半(最后减到1),对于保留在发送窗口中的段将重传计时器的值加倍。

计时器加倍这件事情很有意义,因为如果不这么做,那么由于已经拥堵,那么大概率下之后的包按照原来的计时器计时很有可能还会超时,所以加倍之后避免超时的发生。所以拥塞窗口的变化成左斜边的锯齿状:


慢开始门限ssthresh状态变量.

当cwnd < ssthresh 时,使用慢开始算法。
当cwnd > ssthresh 时,停止使用慢开始算法而改用拥塞避免算法。
当cwnd = ssthresh 时,即可使用慢开始算法,也可使用拥塞避免算法。


三:响应超时(丢包事件)

对于不同种类的超时,算法的响应是不一样的

超时有两种:①某个段超时 ②三个连续ACK。 这两种超时的意义不同,前者代表不确定后面也丢了还是没丢,但是第二种快速重传代表以后的还存在,只是这个包丢了。前者的网络拥塞程度比后者大,所以算法采用不同的响应。

​ (1)某个段超时:发送端进入“慢速启动”阶段,拥塞发送窗口直接置为1个MSS;按指数增长直到CongWin= 发生超时时的一半(ssthresh设置为出现拥塞时发送方窗口值的一半),按线性增长(进入拥塞避免阶段)。

​ (2)三个重复ACK:拥塞窗口减半;按线性增长。

某个段超时之后直接就会给它的拥塞发送窗口设置为1个MSS,之后就按照之前说的步骤,重头再来,只不过拥塞窗口CongWin变成原来的一半了。而ACK快速重传机制只是把CongWin设为原来的一半,(ssthresh设置为出现拥塞时发送方窗口值的一半,并把cwnd设置为改变后ssthresh的值)按照线性增长,它省去了慢启动的步骤。示例图如下:

image-20211207204328273

段超时的是③曲线对应的,他有着完整地慢启动,逐步递增加倍递减的过程,但是如果只是快速重传④,那么只是拥塞窗口减半,在这个基础上进行逐步递增就可以了,它没有慢启动(指数增长)的过程。

image-20211207210854806

参考博客

常见问题

为什么连接的时候是三次握手,关闭的时候却是四次握手

因为当Server端收到Client端的SYN连接请求报文后,可以直接发送SYN+ACK报文。其中ACK报文是用来应答的,SYN报文是用来同步的。但是关闭连接时,当Server端收到FIN报文时,很可能并不会立即关闭SOCKET,所以只能先回复一个ACK报文,告诉Client端,”你发的FIN报文我收到了”。只有等到我Server端所有的报文都发送完了,我才能发送FIN报文,因此不能一起发送。故需要四步握手。

为什么不能两次握手

TCP进行可靠传输的关键就在于维护一个序列号,三次握手的过程即是通信双方相互告知序列号起始值, 并确认对方已经收到了序列号起始值。

如果只是两次握手, 至多只有客户端的起始序列号能被确认, 服务器端的序列号则得不到确认。

举例:假设改为两次握手,client端发送的一个连接请求在服务器滞留了,这个连接请求是无效的,client已经是closed的状态了,而服务器认为client想要建立一个新的连接,于是向client发送确认报文段,而client端是closed状态,无论收到什么报文都会丢弃。而如果是两次握手的话,此时就已经建立连接了。

服务器此时会一直等到client端发来数据,这样就浪费掉很多server端的资源。

例2:考虑计算机S和C之间的通信,假定C给S发送一个连接请求分组,S收到了这个分组,并发 送了确认应答分组。按照两次握手的协定,S认为连接已经成功地建立了,可以开始发送数据分组。可是,C在S的应答分组在传输中被丢失的情况下,将不知道S 是否已准备好,不知道S建立什么样的序列号,C甚至怀疑S是否收到自己的连接请求分组。在这种情况下,C认为连接还未建立成功,将忽略S发来的任何数据分 组,只等待连接确认应答分组。而S在发出的分组超时后,重复发送同样的分组。这样就形成了死锁。

为什么需要TIME_WAIT

其一:确保发送方的 ACK 信号能到达接收方,让本次通信正常关闭。
其二:处理延迟的重复报文。(重要)这主要是为了避免前后两个使用相同四元组的连接中的前一个连接的报文干扰后一个连接。

例子理解:

A、B 来代指 TCP 连接的两端,A 为主动关闭的一端。

  • 四次挥手中,A 发 FIN, B 响应 ACK,B 再发 FIN,A 响应 ACK 实现连接的关闭。而如果 A 响应的 ACK 包丢失,B 会以为 A 没有收到自己的关闭请求,然后会重试向 A 再发 FIN 包。

    如果没有 TIME_WAIT 状态,A 不再保存这个连接的信息,收到一个不存在的连接的包,A 会响应 RST 包,导致 B 端异常响应。

    此时, TIME_WAIT 是为了保证全双工的 TCP 连接正常终止。

  • TCP 下的 IP 层协议是无法保证包传输的先后顺序的。如果双方挥手之后,一个网络四元组(src/dst ip/port)被回收,而此时网络中还有一个迟到的数据包没有被 B 接收,A 应用程序又立刻使用了同样的四元组再创建了一个新的连接后,这个迟到的数据包才到达 B,那么这个数据包就会让 B 以为是 A 刚发过来的。

    此时, TIME_WAIT 的存在是为了保证网络中迷失的数据包正常过期。

TIMEWAIT的时间为什么是2MSL

首先我们需要了解如下要点:

  1. TCP连接中的一端发送了FIN报文之后如果收不到对端针对该FIN的ACK,则会反复多次重传FIN报文,大约持续几分钟;
  2. 被动关闭处于LAST_ACK状态的一端在收到最后一个ACK之后不会发送任何报文,立即进入CLOSED状态;
  3. 主动关闭的一端在收到被动关闭端发送过来的FIN报文并回复ACK之后进入TIME_WAIT状态;
  4. 之所以TIME_WAIT状态需要维持一段时间而不是进入CLOSED状态,是因为需要处理对端可能重传的FIN报文或其它一些因网络原因而延迟的数据报文,不处理这些报文可能导致前后两个使用相同四元组的连接中的后一个连接出现异常(详见UNIX网络编程卷1的2.7节 第三版);
  5. 处于TIME_WAIT状态的一端在收到重传的FIN时会重新计时

我们设想有一个处于拆链过程中的TCP连接,这个连接的两端分别是A和B,其中A是主动关闭连接的一端,因为刚刚向对端发送了针对对端发送过来的FIN报文的ACK,此时正处于TIME_WAIT状态;而B是被动关闭的一端,此时正处于LAST_ACK状态,在收到最后一个ACK之前它会一直重传FIN报文直至超时。

  1. ACK报文在网络中丢失;如前所述,这种情况我们不需要考虑,因为除非多次重传失败,否则AB两端的状态不会发生变化直至某一个ACK不再丢失。
  2. ACK报文被B接收到。我们假设A发送了ACK报文后过了一段时间t之后B才收到该ACK,则有 0 < t <= MSL。因为A并不知道它发送出去的ACK要多久对方才能收到,所以A至少要维持MSL时长的TIME_WAIT状态才能保证它的ACK从网络中消失。同时处于LAST_ACK状态的B因为收到了ACK,所以它直接就进入了CLOSED状态,而不会向网络发送任何报文。所以晃眼一看,A只需要等待1个MSL就够了,但仔细想一下其实1个MSL是不行的,因为在B收到ACK前的一刹那,B可能因为没收到ACK而重传了一个FIN报文,这个FIN报文要从网络中消失最多还需要一个MSL时长,所以A还需要多等一个MSL。再进一步来看,A收到了FIN,会重新计时,再发送ACK给B,需要1个MSL,它到了 B 端后,B 端由于关闭连接了,会响应 RST 包,这个 RST 包最长也会在 MSL 时长后到达 A,那么 A 端只要保持 TIME_WAIT 到达 2MS 就能保证网络中这个连接的包都会消失。

使用 2MSL 可以让这些报文全部消失,防止网络中存在滞留的 FIN 报文,在新一次连接建立时某一方接收了 FIN 导致网络异常关闭!!(小概率事件)

综上所述,TIME_WAIT至少需要持续2MSL时长,这2个MSL中的第一个MSL是为了等自己发出去的最后一个ACK从网络中消失,而第二MSL是为了等在对端收到ACK之前的一刹那可能重传的FIN报文从网络中消失。另外,虽然说维持TIME_WAIT状态一段时间有2个目的,但这段时间具体应该多长主要是为了达成上述第二个目的而设计的。

如果已经建立了连接,但是客户端突然出现故障了怎么办?

TCP还设有一个保活计时器,显然,客户端如果出现故障,服务器不能一直等下去,白白浪费资源。服务器每收到一次客户端的请求后都会重新复位这个计时器,时间通常是设置为2小时,若两小时还没有收到客户端的任何数据,服务器就会发送一个探测报文段,以后每隔75秒钟发送一次。若一连发送10个探测报文仍然没反应,服务器就认为客户端出了故障,接着就关闭连接。

半连接状态

TCP握手中,当服务器处于SYN_RCVD 状态,服务器会把此种状态下请求连接放在一个队列里,该队列称为半连接队列。

SYN攻击

SYN攻击即利用TCP协议缺陷,通过发送大量的半连接请求,占用半连接队列,耗费CPU和内存资源。

优化方式:

  1. 缩短SYN Timeout时间,规定多少时间没收到客户端的响应,就断开半连接状态
  2. 记录IP,若连续受到某个IP的重复SYN报文,从这个IP地址来的包会被一概丢弃。

TCP粘包现象

发送端可以是一K一K地发送数据,而接收端的应用程序可以两K两K地提走数据,当然也有可能一次提走3K或6K数据,或者一次只提走几个字节的数据。
也就是说,应用程序所看到的数据是一个整体,或说是一个流(stream),一条消息有多少字节对应用程序是不可见的,因此TCP协议是面向流的协议,这也是容易出现粘包问题的原因。
而UDP是面向消息的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据,这一点和TCP是很不同的。
怎样定义消息呢?可以认为对方一次性write/send的数据为一个消息,需要明白的是当对方send一条信息的时候,无论底层怎样分段分片,TCP协议层会把构成整条消息的数据段排序完成后才呈现在内核缓冲区。

TCP是面向流协议,发送的单位是字节流,因此会将多个小尺寸数据被封装在一个tcp报文中发出去的可能性。
可以简单的理解成客户端调用了两次send,服务器端一个recv就把信息都读出来了。

例如基于tcp的套接字客户端往服务端上传文件,发送时文件内容是按照一段一段的字节流发送的,在接收方看了,根本不知道该文件的字节流从何处开始,在何处结束

此外,发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一个TCP段。若连续几次需要send的数据都很少,通常TCP会根据优化算法把这些数据合成一个TCP段后一次发送出去,这样接收方就收到了粘包数据。

UDP不会发生粘包现象

UDP(user datagram protocol,用户数据报协议)是无连接的,面向消息的,提供高效率服务。
不会使用块的合并优化算法,, 由于UDP支持的是一对多的模式,所以接收端的skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的UDP包,在每个UDP包中就有了消息头(消息来源地址,端口等信息),这样,对于接收端来说,就容易进行区分处理了。 即面向消息的通信是有消息保护边界的。
对于空消息:tcp是基于数据流的,于是收发的消息不能为空,这就需要在客户端和服务端都添加空消息的处理机制,防止程序卡住,而udp是基于数据报的,即便是你输入的是空内容(直接回车),也可以被发送,udp协议会帮你封装上消息头发送过去。
不可靠不黏包的udp协议:udp的recvfrom是阻塞的,一个recvfrom(x)必须对唯一一个sendinto(y),收完了x个字节的数据就算完成,若是y;x数据就丢失,这意味着udp根本不会粘包,但是会丢数据,不可靠。


TCP发生粘包的两种情况

情况一 发送方的缓存机制

发送端需要等缓冲区满才发送出去,造成粘包(发送数据时间间隔很短,数据了很小,会合到一起,产生粘包)

如果数据包过小,不会立即发送。先缓存了一小下,通过优化算法,将2次或者多次数据包,一次发送。

如果数据包过大,分配发送。
如果这个时候,再来一个大的数据包,也会拆分包。那么就发生黏包了。

情况二 接收方的缓存机制

接收方不及时接收缓冲区的包,造成多个包接收(客户端发送了一段数据,服务端只收了一小部分,服务端下次再收的时候还是从缓冲区拿上次遗留的数据,产生粘包)

或,或将多个包缓存在缓存中,一次性取出

粘包现象处理方法

  1. 固定包长的数据包

    顾名思义,即每个协议包的长度都是固定的。

  2. 以指定字符(串)为包的结束标志

  3. 包头+包体

    这种格式的包一般分为两部分,即包头和包体,包头是固定大小的,且包头中必须含有一个字段来说明接下来的包体有多大。


   转载规则


《TCP详解》 朱林刚 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
RPC简介 RPC简介
RPC简介Remote Procedure Call Protocol —— 远程过程调用协议 RPC(Remote Procedure Call Protocol),是远程过程调用的缩写,通俗的说就是调用远处的一个函数。 理解RPC
2021-12-24
下一篇 
Go容器 Go容器
Go容器Go容器数组数组的长度不可变 var name [size]T //声明时需要指定大小 var students [3]int //也可以通过指针操作数组 students2 := new([3]int) fmt.Pri
2021-12-08
  目录