0%

TCP/IP 协议栈系列(十一):TCP 协议详解


TCP 协议是面向连接的传输层协议,具备可靠传输、点对点连接、全双工通信等特性。主要特点:

  • 面向连接的运输层协议,使用前必须先创建连接,传输完数据后必须关闭连接
  • 点对点传输,由一个进程的一个端口传输到另一个进程的端口
  • TCP 传输的数据无差错、不会丢失、不会重复
  • TCP 是双向数据流,两端都可以读写数据
  • 面向字节流

TCP 报文格式

TCP 数据包是由首部和数据两部分组成,首部为固定 20 个字节,数据包没有长度限制,理论上可以无限长,但是为了保证网络的效率,通常 TCP 数据包的长度不会超过 IP 数据包的长度,以确保单个 TCP 数据包不必再分割。首部数据报文格式如下:

tcp

  1. 源端口号:16 bits,2个字节,范围 0 ~ 65525
  2. 目的端口:16 bits,2个字节,范围 0 ~ 65525
  3. seq:数据序号 32bits,4个字节,指的是本报文段所发送的数据的第一个字节的序号
  4. ack:确认序号 32bits,4个字节,期望收到对方的下一个报文段的数据的第一个字节的序号
  5. 数据偏移:4bits,0.5字节,它指出报文数据距TCP报头的起始处有多远(TCP报文头长度)
  6. 保留字段:6bits,保留今后使用,目前置0处理
  7. 标志位:各占 1bits,总共6bits
    • URG:紧急比特,1bit,当 URG=1 时,表明紧急指针字段有效。它告诉系统此报文段中有紧急数据,应尽快传送(相当于高优先级的数据)
    • ACK:确认比特,1bit,只有当 ACK=1时确认号字段才有效。当 ACK=0 时,确认号无效
    • PSH:推送比特,1bit,接收方 TCP 收到推送比特置1的报文段,就尽快地交付给接收应用进程,而不再等到整个缓存都填满了后再向上交付
    • RST:复位比特,1bit,当RST=1时,表明TCP连接中出现严重差错(如由于主机崩溃或其他原因),必须释放连接,然后再重新建立运输连接
    • SYN:同步比特,1bit,同步比特 SYN 置为 1,就表示这是一个连接请求或连接接受报文
    • FIN:终止比特,1bit,用来释放一个连接。当FIN=1 时,表明此报文段的发送端的数据已发送完毕,并要求释放运输连接
  8. 窗口大小:16bits,2字节,窗口字段用来控制对方发送的数据量,单位为字节。TCP 连接的一端根据设置的缓存空间大小确定自己的接收窗口大小,然后通知对方以确定对方的发送窗口的上限
  9. 检验和:16bits,2字节,检验和字段检验的范围包括首部和数据这两部分。在计算检验和时,要在 TCP 报文段的前面加上 12 字节的伪首部
  10. 紧急指针字段:16bits,2字节,紧急指针指出在本报文段中的紧急数据的最后一个字节的序号
  11. 选项字段,长度可变。TCP首部可以有多达40字节的可选信息,用于把附加信息传递给终点,或用来对齐其它选项。 这部分最多包含40字节,因为TCP头部最长是60字节(其中还包含前面讨论的20字节的固定部分)

TCP 三次握手

TCP 建立一个连接必须经过三次握手,请求 -> 应答 -> 再次确认,示意图如下:

tcp-open

第一次:客户端向服务端发起 创建连接 请求,并在报文头携带创建连接信息:SYN=1(标示是同步消息) ACK=0 ,TCP规定 SYN=1 时不能携带数据,但要消耗一个序号,因此声明自己的序号是 seq=x

第二次:服务端收到创建连接请求,确认后回复,报文头信息:SYN=1(标示是同步消息) ACK=1 seq=y,ack=x+1(说明已经确认了之前的 x 序号的报文数据,需要确认下一段报文的第一个字节序号是 x + 1)

第三次:客户端再进行一次确认,但不用 SYN 了,这时即为 ACK=1, seq=x+1,ack=y+1

为什么要进行三次握手(两次确认)?

这主要是为了防止已失效的连接请求报文突然又传送到了B,因而产生错误。那么 “已失效的连接请求报文段” 是如何产生的?

  1. A 向 B 发送了 SYN 请求,但因为连接丢失未得到确认
    这种情况,A 可以继续重传数据给 B ,B 收到后确认发挥给 A,连接建立,这种的话不需要三次握手,两次握手也可以

  2. A 向 B 发送了 SYN 请求,但因为网络延迟,连接并没有丢失,而是在某些网络节点长时间滞留了,以致延误到连接释放以后的某个时间才到 B,本来这是一个已失效的报文段。但是B收到此失效的连接请求报文段后,就误认为是A又发出一次新的连接请求。于是就向A发出确认报文段,同意建立连接。假定不采用三次握手,那么只要B发出确认,新的连接就建立了。这种情况下,因为对于 A 来说这个连接已经早早失效,所以不会给 B 发送消息,但是 B 还在一直等待着数据,这样连接资源就会白白浪费掉。

问题的本质是信道的不可靠造成的,所以在不可靠信道需要传输可靠的数据信息那么至少需要三次确认。

SYN 攻击是典型的 DDOS 攻击,简述其原理?

在三次握手的过程中,服务器发送SYN和ACK之后,收到客户端的ACK之前的连接成为半连接。此时服务器处于SYN_RCVD状态,收到客户端的ACK之后进入ESTABLISHED状态。SYN攻击就是利用这个时间间隔伪造大量不存在的IP地址向服务器不断发送SYN包,服务器回复确认包,并等待客户的确认。由于源地址不存在,服务器需要不断地重发直至超时,这些伪造的SYN包将长时间占用未连接队列,而正常的SYN请求被丢弃,目标系统运行缓慢,引起网络的严重瘫痪。因此大多数操作系统都闲着半连接的数量。

如果三次握手每次握手信息对方都没有收到会怎样?

第一次握手对方没收到消息(服务端没收到客户端发送的 SYN 报文)

  • 客户端:客户端会在等待一段时间收不到服务端的确认消息后重新发送 SYN 同步报文,若仍然没有回应,则重复上述过程直到发送次数超过最大重传次数限制后,建立连接的系统调用会返回 -1
  • 服务端:服务端不会采取任何措施

第二次握手对方没收到消息(客户端没收到服务端回复的 ACK 报文)

  • 客户端:客户端会采取第一次握手失败时的动作,超时重传
  • 服务端:服务端重传ACK、SYN

第三次握手对方没收到消息(服务端没收到客户端发送的 ACK 报文)

  • 客户端:此时客户端认为自己已经连接成功了,因此开始向服务器端发送数据,但是服务器端的 accept() 系统调用已返回,此时没有在监听状态。因此服务器端接收到来自客户端发送来的数据时会发送 RST 报文给 客户端,消除客户端单方面建立连接的状态。
  • 服务端:会采取类似于客户端的超时重传机制,若重传次数超过限制后仍然没有回应,则 accep() 系统调用返回 -1,服务器端连接建立失败

第 2 次握手传回了 ACK,为什么还要传回 SYN ?

ACK 是为了告诉客户端发来的数据已经接收无误,而传回 SYN 是为了把自己的初始序列号(Seq)同步给客户端。

TCP 四次挥手

TCP 关闭一个连接必须经过四次挥手,TCP是全双工通信的,所以在一端发送数据完毕后,还具有接收另一端的数据的能力,这就所谓的半关闭。示意图如下:

tcp-close

发起关闭请求的可以是两端的任何一方,这里以 客户端 A 发起关闭连接为例说明。

  1. 客户端 A 调用系统 close() 发起主动关闭操作,发送 FIN 包,报文头信息未 FIN=1
  2. 服务端 B 接受到 FIN 包后,回复客户端一个 ACK 包确认已经收到关闭通知,同时通知应用程序准备关闭连接
  3. 应用层的收到对方的EOF(end of file,对方的FIN包作为EOF传到应用层的application)后,得知这条连接不会有数据传输,于是也调用 close() 关闭连接,该 close() 会促使传输层发送 FIN 包
  4. 发起主动关闭的客户端A到服务端 B 的FIN包后,回复 ACK 包,至此,TCP 连接关闭。

这里出现的几种状态要搞清楚它们的含义:

  • FIN_WAIT 1:主动关闭的一方在发送FIN包后,此时该socket进入FIN_WAIT 1状态;
  • FIN_WAIT 2:主动关闭的一方收到对方的ACK后,进入FIN_WAIT 2。FIN_WAIT 1和FIN_WAIT 2这两种状态实际上都是在等待对方的FIN报文;
  • TIME_WAIT:表示收到了对方的FIN包,并发送了ACK包后的状态,也称为 2MSL 状态;
  • CLOSE_WAIT:表示正在等待关闭;

为什么TCP建立连接时是三次握手,而关闭连接时是四次挥手?

这是由于TCP传输协议是全双工的原因造成的。
在建立连接时,服务器收到客户端的SYN包后,可以将应答的ACK包和同步的SYN包放在同一个报文里发送给客户端。但在关闭连接时,当收到对方的FIN包后,仅仅表示对方没有数据传给你了,并不表示你的所有数据都已经传给了对方,因此不必马上关闭SOCKET,先发送一个ACK包确认已收到对方的关闭请求。然后等你的所有数据都发送完了再发送一个FIN包给对方表示同意关闭连接,因此ACK和FIN需要分开发送,故比建立连接时多了一个交互过程。

为什么需要TIME_WAIT状态,即为什么需要等待2MSL?

MSL(Max Segment Life)是一个报文段在网络上最大的生存时间

  1. 为实现TCP这种全双工(full-duplex)连接的可靠释放
    如果客户端在发完ACK之后直接进入CLOSED状态,若由于某种原因这个ACK丢失,那么服务器由于超时将重传FIN包,而此时客户端已经关闭连接,就找不到与重发的FIN包对应的连接,最后服务器收到的将是RST而不是ACK,服务器以为是连接错误而把问题报告给上层。这样的情况虽然不会导致数据丢失,但是却导致TCP协议不可靠。因此,客户端发完ACK之后必须维护这条连接直至2MSL的时间结束。

  2. 为使旧的数据包在网络因过期而消失
    若不存在TIME_WAIT阶段,若有一个新的四元组(local_ip,local_port,rempte_ip,remote_port)建立一条新的TCP连接。由于TCP协议栈是无法区分前后两条连接是否相同,在它看来,这条连接跟上一个关闭的连接使用的端口号完全相同。而等待2MSL的时间,保证网络中旧的数据包已经完全消失,这样建立新的连接时可以使用旧的端口号,避免两次连接数据错乱的情况。

CLOSE-WAIT 和 TIME-WAIT 的状态和意义?

CLOSE-WAIT
是服务端发出第一次挥手(整体第二次)进入的状态,表示”我准备关闭了,但是还有自己的事情处理一下,你等我处理完”
等服务器处理好自己的数据业务,则表示我准备好了,再发送 fin 包

TIME-WAIT
是第四次挥手后,客户端进入的状态,是客户端必要的等待时间,目的是等待:1-服务端的对应端口关闭与客户端发送到服务端的数据到达(可能出现延迟),如果不存在这个步骤就会导致两个问题:
客户端立即关闭后,立即又用同样的端口握手并建立通信,此时上次的连接残留的数据包会被误认为是本次的,造成数据异常
客户端直接关闭后,若服务端重新发送 fin 包,客户端就会回应 RST,会报异常,但是其实是没有问题的

TIME_WAIT 状态会导致什么问题,怎么解决?

考虑高并发短连接的业务场景,在高并发短连接的 TCP 服务器上,当服务器处理完请求后主动请求关闭连接,这样服务器上会有大量的连接处于 TIME_WAIT 状态,服务器维护每一个连接需要一个 socket,也就是每个连接会占用一个文件描述符,而文件描述符的使用是有上限的,如果持续高并发,会导致一些正常的 连接失败。
解决方案:修改配置或设置 SO_REUSEADDR 套接字,使得服务器处于 TIME-WAIT 状态下的端口能够快速回收和重用。如果你的服务器程序停止后想立即重启,而新的套接字依旧希望使用同一端口,此时 SO_REUSEADDR 选项就可以避免 TIME-WAIT 状态。

有很多 CLOSE-WAIT 怎么解决?

  • 首先检查是不是自己的代码问题(看是否服务端程序忘记关闭连接),如果是,则修改代码。
  • 调整系统参数,包括句柄相关参数和 TCP/IP 的参数,一般一个 CLOSE_WAIT 会维持至少 2 个-小时的时间,我们可以通过调整参数来缩短这个时间。

TCP 连接状态

根据上面 tcp 连接的握手和挥手过程,在不同的阶段会处于不同的状态,以下是握手和挥手的状态图:

tcp-status

客户端状态轮转:CLOSE -> SYN-SEND -> ENSTABLISED -> FIN—WAIT-1 -> FIN-WAIT-2 -> TIME-WAIT -> CLOSE
服务端状态轮转:LISTEN -> SYN-RCVD -> ENSTABLISED -> CLOSE-WAIT -> LAST-WAIT -> CLOSE

  • CLOSE:两端的初始状态,都是关闭状态
  • LISTEN:特指服务端的 socket 处于监听状态,可接收客户端连接
  • SYN-SEND:客户端发送了 SYN 创建连接请求后的状态,表示创建连接请求已发送
  • SYN-RCVD:服务端接收到了客户端的 SYN 的创建请求报文并回复 ACK + SYN 之后的状态,表示已经接受到 SYN 消息。SYN-SEND 对应。这个过程很短暂。
  • ENSTABLISED:表示TCP连接已经成功建立。
  • FIN-WAIT-1:这个状态是发起关闭请求的一方才有的状态,出现的时机是向对方发送了 FIN 报文之后,表示我想主动关闭连接,等待对方的 FIN 报文返回。
  • FIN-WAIT-2:这个状态也是发起关闭请求的一方才有的状态,出现的时机是对方已经接受到了自己发送的 FIN 包并且回复给自己 ACK 消息表示已经收到关闭请求。这种状态下的连接是半关闭连接。因为对方还有未传完的数据。继续等待对方的 FIN 报文返回。
  • TIME_WAIT:这个状态也是发起关闭请求的一方才有的状态,出现的时机是对方确认所有的数据已经传送完成后也发送了 FIN 包给自己。这个时候会发送一个 ACK 报文给对方
  • CLOSE-WAIT:这个状态是接受关闭请求的一方才有的状态,怎么理解呢?当对方close()一个SOCKET后发送FIN报文给自己,你的系统毫无疑问地将会回应一个ACK报文给对方,此时TCP连接则进入到CLOSE_WAIT状态。接下来呢,你需要检查自己是否还有数据要发送给对方,如果没有的话,那你也就可以close()这个SOCKET并发送FIN报文给对方,即关闭自己到对方这个方向的连接。有数据的话则看程序的策略,继续发送或丢弃。简单地说,当你处于CLOSE_WAIT 状态下,需要完成的事情是等待你去关闭连接。
  • LAST_ACK:当被动关闭的一方在发送FIN报文后,等待对方的ACK报文的时候,就处于LAST_ACK 状态。当收到对方的ACK报文后,也就可以进入到 CLOSED 可用状态了。

TCP 可靠性

本文一开始介绍了 TCP 的特点,其中一个重要的特性就是可靠性,可靠性就要求数据不丢失不重复,如果要做到这一点,需要依赖信道的环境,理想的信道传输的条件是:

  • 信道足够可靠,不会产生任何差错
  • 不管发送方以多快的速度发送数据,接收方总是来得及处理收到的数据

那如果是在现实环境,信道不可靠等环境下,需要设计相应的策略或机制来保证数据的可靠性,那如果出现了差错怎么办?tcp 依靠下面的机制来保证可靠性:

  • 数据分块:应用数据被分割成 TCP 认为最适合发送的数据块。
  • 序列号和确认应答:给发送的每一个包进行编号,在传输的过程中,每次接收方收到数据后,都会对传输方进行确认应答,即发送 ACK 报文,这个 ACK 报文当中带有对应的确认序列号,告诉发送方成功接收了哪些数据以及下一次的数据从哪里开始发。除此之外,接收方可以根据序列号对数据包进行排序,把有序数据传送给应用层,并丢弃重复的数据。保证了数据不重复。
  • 校验和:TCP 将保持它首部和数据部分的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到报文段的检验和有差错,TCP 将丢弃这个报文段并且不确认收到此报文段。保证了数据不损失。
  • 流量控制:TCP 连接的双方都有一个固定大小的缓冲空间,发送方发送的数据量不能超过接收端缓冲区的大小。当接收方来不及处理发送方的数据,会提示发送方降低发送的速率,防止产生丢包。TCP 通过滑动窗口协议来支持流量控制机制。
  • 拥塞控制:当网络某个节点发生拥塞时,减少数据的发送。
  • ARQ协议:也是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组。
  • 超时重传:当 TCP 发出一个报文段后,它启动一个定时器,等待目的端确认收到这个报文段。如果超过某个时间还没有收到确认,将重发这个报文段。保证了数据不丢失。

TCP 超时重传

只要超过一段时间没有收到确认,就认为刚才发送的分组丢失了,因而重传前面发送过的分组。这就叫做 超时重传。要实现 超时重传,就要在每发送完一个分组时设置一个超时计时器:

  • 发送完一个分组后,必须暂时保留已发送的分组的副本(在发生超时重传时使用)。只有在收到相应的确认后才能清除暂时保留的分组副本
  • 分组和确认分组都必须进行编号。这样才能明确是哪一个发送出去的分组收到了确认,而哪一个分组还没有收到确认
  • 超时计时器的重传时间应当比数据在分组传输的平均往返时间更长一些

超时时间的计算是超时的核心,而定时时间的确定往往需要进行适当的权衡,因为当定时时间过长会造成网络利用率不高,定时太短会造成多次重传,使得网络阻塞。在 TCP 连接过程中,会参考当前的网络状况从而找到一个合适的超时时间。

TCP 流量控制

ARQ 停止等待协议

最简单的方式来保证数据的可靠传输就是 ARQ 停止等待协议,原理就是每发完一个数据分组就停止发送,等待对方的确认。在收到确认后再发送下一个分组。这样能保证数据是完整不丢失。很显然,如果每次只发送一个数据包等待确认后再发送下一个数据包,如果数据包较大的情况下,传输效率低。

为了提高传输效率,发送方可以不使用低效率的停止等待协议,而是采用流水线传输。流水线传输就是发送方可连续发送多个分组,不必每发完一个分组就停顿下来等待对方的确认。这样可使信道上一直有数据不间断地在传送。这种传输方式可以获得很高的信道利用率。

于是就产生了连续 ARQ 协议,接收方一般都是采用 累积确认 的方式。接收方不需要对收到的分组逐个发送确认,而是在收到几个分组后,对按序到达的最后一个分组发送确认。

TCP 引入了窗口这个概念。即一次可以发送多个数据包,这个窗口大小就是我们一次传输几个数据,并且允许发送方在停止并等待确认前可以连续发送多个分组。

固定窗口

有了窗口,就可以指定窗口大小,发送方在发送过程中始终保持着一个发送窗口,只有落在发送窗口内的帧才允许被发送;同时接收方也维持着一个接收窗口,只有落在接收窗口内的帧才允许接收。这样通过调整发送方窗口和接收方窗口的大小可以实现流量控制。

固定窗口大小有什么问题?

tcp-window-1

假设客户端和服务端默认的窗口大小是3,那么每次客户端发送3个数据包给服务端,服务端正常处理完成 ACK 确认后继续下一个窗口的数据。但是网络环境和服务端处理数据的能力往往并不是理想的;比如由于服务端处理能力有限,每次只能处理了2个数据包,但是发送方每次都需要把未处理的数据包继续传给服务端,这种情况下窗口的大小应该更小才合理。但如果窗口大小过于小,就增加了网络传输的次数,延迟就会增大。总结一下:

  • 固定窗口太小可能会造成网络延迟增大
  • 固定窗口太大超过了服务端的最大处理能力会造成不必要的数据拥塞链路

所以在传输的过程中需要可变的滑动窗口来避免上面的问题

可变滑动窗口

滑动窗口机制就是窗口的大小并不是固定的,而是在传输过程中根据两端之间的链路打带宽的大小,链路是否阻塞、接收方的处理能力决定的。

tcp-window-dy

  • 初始发送数据窗口大小是根据链路带宽的大小来决定的,我们假设这个时候窗口的大小是 3,发送方发送 3 个数据
  • 接收方收到数据后对数据进行确认,但只能处理 2 个,所以回复 ACK 是 3 (期望下次从 3 开始的序列发给我数据)Window size = 2(我当前最大只能处理 2 个数据)
  • 发送方就知道了刚刚发送的数据只处理了 2 个,第 3 个数据对方没有收到。下次在发送的时候就从第 3 个数据开始发。同时根据接收方返回的窗口大小,只发送 2 个数据给接收方
  • 接收方收到后返回 ACK=5,window size=2,下次从 5 的序号开始发吧

tcp-window-3

这就是滑动窗口的工作机制,当链路变好了或者变差了这个窗口还会发生变话,并不是第一次协商好了以后就永远不变了。
滑动窗口协议,是TCP使用的一种流量控制方法。该协议有两个重要的特性:

  • 允许发送方在停止并等待确认前可以连续发送多个分组。由于发送方不必每发一个分组就停下来等待确认,因此该协议可以加速数据的传输。
  • 只有在接收窗口向前滑动时(与此同时也发送了确认),发送窗口才有可能向前滑动。
  • 收发两端的窗口按照以上规律不断地向前滑动,因此这种协议又称为滑动窗口协议。

当发送窗口和接收窗口的大小都等于1时,就是停止等待协议。

如果窗口大小变为 0 ,会发生什么?

当 B 设置当前自己的接收窗口大小为0,表示不让A发送数据。A 会停止等待一段时间后,等 B 的缓冲区大小有了富裕,就发送报文通知A可以继续发送数据了。TCP 引入了坚持计时器,当A收到B的零窗口通知时,就启用该计时器,时间一到就发送一个字节的探测报文,对方在此时回应自己的窗口大小,如果仍为0,则重设计时器,继续等待,直至窗口打开。

TCP 传输效率

可变滑动窗口让传输效率得到提高,但有一种特殊情况,接收方太忙了,来不及取走接收窗口里的数据,那么就导致发送方的发送窗口会越来越小。到最后,如果接收方缓冲区腾出了几个字节并告诉了发送方窗口大小,而发送方就会直接发送这几个字节,要知道,tcp 一次发送至少有 40 个字节的额外开销(tcp 头部20个字节,IP 头部字 20 个字节),为了发送几个字节也太不划算了。这个就是糊涂窗口综合症

糊涂窗口综合症的现象可以发生在发送方和接收方:

  • 接收方可以通告一个小的窗口
  • 而发送方可以发送小数据

于是糊涂窗口综合症解决需要上面两个问题:

  • 让接收方不通知小窗口给发送方
  • 让发送方避免发送小数据

如何让接收方不通告小窗口呢?

当「窗口大小」小于 min( MSS,缓存空间/2 ) ,也就是小于 MSS 与 1/2 缓存大小中的最小值时,就会向发送方通告窗口为 0,也就阻止了发送方再发数据过来。

怎么让发送方避免发送小数据呢?

TCP 使用 Nagle 算法,它只允许一个TCP连接上最多只有一个未被确认的未完成的小分组。所谓“小分组”,指的是长度小于 MSS 尺寸的数据块,而未被确认则是指没有收到对方的 ACK 数据包。

该算法的思路是延时处理,它满足以下两个条件中的一条才可以发送数据:

  • 要等到窗口大小 >= MSS 或是 数据大小 >= MSS
  • 如果该数据包含有 FIN,则允许发送
  • 设置了 TCP_NODELAY 选项,则允许发送
  • 收到之前发送数据的 ack 回包
  • 上述条件都未满足,但发送了超时(一般为 200 ms),则立即发送

Nagle 算法默认是打开的,如果对于一些需要小数据包交互的场景的程序,比如,telnet 或 ssh 这样的交互性比较强的程序,则需要关闭 Nagle 算法。

TCP 拥塞控制

为什么要有拥塞控制呀,不是有流量控制了吗?

前面的流量控制是避免「发送方」的数据填满「接收方」的缓存,但是并不知道网络的中发生了什么。

拥塞控制,控制的目的就是避免「发送方」的数据填满整个网络。
拥塞控制:防止过多的数据注入到网络中,这样可以使网络中的路由器或链路不致过载。拥塞控制所要做的都有一个前提:网络能够承受现有的网络负荷。拥塞控制是一个全局性的过程,涉及到所有的主机、路由器,以及与降低网络传输性能有关的所有因素。

什么是拥塞窗口?和发送窗口有什么关系呢?

拥塞窗口 cwnd 是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。发送方维持一个拥塞窗口 cwnd ( congestion window )的状态变量。拥塞窗口的大小取决于网络的拥塞程度,并且动态地在变化。发送方让自己的发送窗口等于拥塞。

发送方控制拥塞窗口的原则是:只要网络没有出现拥塞,拥塞窗口就再增大一些,以便把更多的分组发送出去。但只要网络出现拥塞,拥塞窗口就减小一些,以减少注入到网络中的分组数

拥塞控制有哪些控制算法?

根据 tcp 传输的几个阶段,拥塞控制的几种机制:

  • 慢开始( slow-start )
  • 拥塞避免( congestion avoidance )
  • 快重传( fast retransmit )
  • 快恢复( fast recovery )

慢开始

TCP 在刚建立连接完成后,首先是有个慢启动的过程,这个慢启动的意思就是一点一点的提高发送数据包的数量。慢启动的算法记住一个规则就行:当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。

那慢启动涨到什么时候是个头呢?

有一个叫慢启动门限 ssthresh (slow start threshold)状态变量。
当 cwnd < ssthresh 时,使用慢启动算法。
当 cwnd >= ssthresh 时,就会使用「拥塞避免算法」。

拥塞避免

前面说道,当拥塞窗口 cwnd 「超过」慢启动门限 ssthresh 就会进入拥塞避免算法。

一般来说 ssthresh 的大小是 65535 字节。
那么进入拥塞避免算法后,它的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd。
拥塞避免算法就是将原本慢启动算法的指数增长变成了线性增长,还是增长阶段,但是增长速度缓慢了一些。

就这么一直增长着后,网络就会慢慢进入了拥塞的状况了,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传。

快重传

当接收方发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。

TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,则 ssthresh 和 cwnd 变化如下:

  • cwnd = cwnd/2 ,也就是设置为原来的一半;
  • ssthresh = cwnd;

进入快速恢复算法

快恢复

发送发知道当前只是丢失了个别的报文段。于是不启动慢开始,而是执行快恢复算法。这时,发送方调整门限值 ssthresh = cwnd / 2 = 8,同时设置拥塞窗口 cwnd = ssthresh = 8,并开始执行拥塞避免算法。又回到增长阶段。

TCP 定时器

1、超时重传计时器
TCP是可靠的传输协议,在网络交互的过程中,由于TCP报文是封装在IP中的,而IP是无连接的,就可能导致报文在交互的过程中丢失。因此当TCP发送报文时,就会设置一个超时重传计时器,如果计时器溢出,还没有收到来自对方的确认,它就重传该报文。

2、坚持计时器
是为了解决零窗口大小通知时可能发生的死锁状态。

3、保活计时器
是为了防止在两个TCP连接之间出现的长时间的空闲。若客户端打开了服务器的连接,并且发送了数据,之后就保持沉默状态,也许这个客户发生了故障,但是这个连接会一直处于打开状态。因此在大多数的服务器中设置一个保活计时器,每当服务器接收到客户端的报文时,就将该计时器复位。若发送了10个探测报文时,客户端仍然无应答,则关闭该连接。

4、时间等待计时器
时间等待计时器是在终止TCP连接时的四次握手的时候使用的。时间等待计时器是用来记录2MSL这个时间的,当计时器到了2MSL,客服端才能断开连接。

TCP 粘包问题

IP 协议解决了数据包(Packet)的路由和传输,上层的 TCP 协议不再关注路由和寻址,那么 TCP 协议解决的是传输的可靠性和顺序问题,上层不需要关心数据能否传输到目标进程,只要写入 TCP 协议的缓冲区的数据,协议栈几乎都能保证数据的送达。

当应用层协议使用 TCP 协议传输数据时,TCP 协议可能会将应用层发送的数据分成多个包依次发送,而数据的接收方收到的数据段可能有多个『应用层数据包』组成,所以当应用层从 TCP 缓冲区中读取数据时发现粘连的数据包时,需要对收到的数据进行拆分。

粘包并不是 TCP 协议造成的,它的出现是因为应用层协议设计者对 TCP 协议的错误理解,忽略了 TCP 协议的定义并且缺乏设计应用层协议的经验。

TCP 协议中的粘包是如何发生的?

  • TCP 协议是面向字节流的协议,它可能会组合或者拆分应用层协议的数据
  • 应用层协议的没有定义消息的边界导致数据的接收方无法拼接数据

TCP 的核心是面向连接的、可靠的、基于 字节流 的传输层通信协议,字节流是关键,TCP 数据并不是以消息为单位传输的,这些数据在某些情况下会被组合成一个数据段发送给目标的主机。尤其是上面我们介绍为了解决网络阻塞,利用 Nagle 算法来减少数据包来提高传输效率。这也会造成数据重新组合和分段。

① 发送方写入的数据大于套接字缓冲区的大小,此时将发生拆包。
② 发送方写入的数据小于套接字缓冲区大小,由于 TCP 默认使用 Nagle 算法,只有当收到一个确认后,才将分组发送给对端,当发送方收集了多个较小的分组,就会一起发送给对端,这将会发生粘包。
③ 进行 MSS (最大报文长度)大小的 TCP 分段,当 TCP 报文的数据部分大于 MSS 的时候将发生拆包。
④ 发送方发送的数据太快,接收方处理数据的速度赶不上发送端的速度,将发生粘包。

如何解决粘包问题?

① 在消息的头部添加消息长度字段,服务端获取消息头的时候解析消息长度,然后向后读取相应长度的内容。
② 固定消息数据的长度,服务端每次读取既定长度的内容作为一条完整消息,当消息不够长时,空位补上固定字符。但是该方法会浪费网络资源。
③ 设置消息边界,也可以理解为分隔符,服务端从数据流中按消息边界分离出消息内容,一般使用换行符。

什么时候需要处理粘包问题?

当接收端同时收到多个分组,并且这些分组之间毫无关系时,需要处理粘包;而当多个分组属于同一数据的不同部分时,并不需要处理粘包问题。

DDOS 攻击

在 TCP 建立连接的过程中,因为服务端不确定自己发给客户端的 SYN-ACK 消息或客户端反馈的 ACK 消息是否会丢在半路,所以会给每个待完成的半开连接状态设一个定时器,如果超过时间还没有收到客户端的 ACK 消息,则重新发送一次 SYN-ACK 消息给客户端,直到重试超过一定次数时才会放弃。

服务端为了维持半开连接状态,需要分配内核资源维护半开连接。当攻击者伪造海量的虚假 IP 向服务端发送 SYN 包时,就形成了 SYN FLOOD 攻击。攻击者故意不响应 ACK 消息,导致服务端被大量注定不能完成的半开连接占据,直到资源耗尽,停止响应正常的连接请求。

解决方法:

  • 直接的方法是提高 TCP 端口容量的同时减少半开连接的资源占用时间,然而该方法只是稍稍提高了防御能力;
  • 部署能够辨别恶意 IP 的路由器,将伪造 IP 地址的发送方发送的 SYN 消息过滤掉,该方案作用一般不是太大;
  • SYN Cache:首先构造一个全局 Hash Table,用来缓存系统当前所有的半开连接信息。在 Hash Table 中的每个桶的容量大小是有限制的,当桶满时,会主动丢掉早来的信息。当服务端收到一个 SYN 消息后,会通过一个映射函数生成一个相应的 Key 值,使得当前半连接信息存入相应的桶中。当收到客户端正确的确认报文后,服务端才开始分配传输资源块,并将相应的半开连接信息从表中删除。和服务器传输资源相比,维护表的开销要小得多。
  • SYN Proxy:在客户端和服务器之间部署一个代理服务器,类似于防火墙的作用。通过代理服务器与客户端进行建立连接的过程,之后代理服务器充当客户端将成功建立连接的客户端信息发送给服务器

参考资料

TCP协议详解
TCP知识点总结
TCP报文格式
leetcode 网络知识