tcp 机制

五层网络模型

五层模型的网络体系也经常被提到,这五层的名字与功能分别如下所述:

应用层:确定进程之间通信的性质,以满足用户需求。应用层协议有很多。如支持万维网应用的 HTTP 协议、支持电子邮件的 SMTP 协议、等等。

传输层:负责主机间不同进程的通信。这一层中的协议有面向连接的 TCP (传输控制协议)、无连接的 UDP (用户数据报协议);数据传输的单位称为报文段或用户数据报。

网络层:负责分组交换网中不同主机间的通信。作用为:发送数据时,将运输层中的报文段或用户数据报封装成 IP 数据报,并选择合适路由。

数据链路层:负责将网络层的 IP 数据报组装成帧。

物理层 :透明地传输比特流。

TCP 和 UDP 的区别

TCP是一个面向连接的、可靠的、基于字节流的传输层协议。

UDP是一个面向无连接的传输层协议。

具体来分析,和 UDP 相比,TCP 有三大核心特性:

  • 面向连接。所谓的连接,指的是客户端和服务器的连接,在双方互相通信之前,TCP 需要三次握手建立连接,而 UDP 没有相应建立连接的过程。
  • 可靠性。TCP 花了非常多的功夫保证连接的可靠,这个可靠性体现在哪些方面呢?一个是有状态,另一个是可控制。

    TCP 会精准记录哪些数据发送了,哪些数据被对方接收了,哪些没有被接收到,而且保证数据包按序到达,不允许半点差错。这是有状态

    当意识到丢包了或者网络环境不佳,TCP 会根据具体情况调整自己的行为,控制自己的发送速度或者重发。这是可控制

    相应的,UDP 就是无状态, 不可控的。

  • 面向字节流。UDP 的数据传输是基于数据报的,这是因为仅仅只是继承了 IP 层的特性,而 TCP 为了维护状态,将一个个 IP 包变成了字节流。

一个 TCP 连接需要四元组( src_ip,src_port,dst_ip,dst_port )来表示是同一个连接

linux tcp连接

获取tcp状态数量

1
netstat -n| awk '/^tcp/ {++S[$NF]} END {for(a in S) print a,S[a]}'

img

名词解析

MSL (Maximum Segment Lifetime),报文最大生存时间,他是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。

MSS(Maximum Segment Size) 最大报文长度,是TCP协议定义的一个选项,MSS选项用于在TCP连接建立时,收发双方协商通信时每一个报文段所能承载的最大数据长度。

RTT(round-trip time) 就是数据从网络一端传送到另一端所需的时间,也就是包的往返时间。

RTO (Retransmission Timeout) 超时重传时间

IP头部中有个TTL(time to live)字段存储了一个ip数据报可以经过的最大路由数(由源主机设置初始值),每经过一个处理他的路由器此值就减1,当此值为0则数据报将被丢弃,同时发送ICMP报文通知源主机。

三次握手

建立连接 TCP/IP 协议中, TCP 协议提供可靠的连接服务,采用三次握手建立一个连接。

  • 建立连接时,客户端发送 SYN 包到服务器,并进入 SYN_SEND 状态,等待服务器确认。
  • 服务器收到 SYN 包,必须确认客户的 SYN ,同时自己也发送一个 SYN 包,即 SYN+ACK 包,此时服务器进入 SYN_RECV 状态。
  • 客户端收到服务器的 SYN + ACK 包,向服务器发送确认包 ACK,此包发送完毕,客户端和服务器进入 ESTABLISHE 态。

完成 三次握手,客户端与服务器开始传送数据,也就是 ESTABLISHED 状态。

为什么不是两次

根本原因: 无法确认客户端的接收能力。

分析如下:

如果是两次,你现在发了 SYN 报文想握手,但是这个包滞留在了当前的网络中迟迟没有到达,TCP 以为这是丢了包,于是重传,两次握手建立好了连接。

看似没有问题,但是连接关闭后,如果这个滞留在网路中的包到达了服务端呢?这时候由于是两次握手,服务端只要接收到然后发送相应的数据包,就默认建立连接,但是现在客户端已经断开了。

这就带来了连接资源的浪费。

为什么不是四次

三次握手的目的是确认双方发送接收的能力,那四次握手可以嘛?

当然可以,100 次都可以。但为了解决问题,三次就足够了,再多用处就不大了。

三次握手过程中可以携带数据

第三次握手的时候,可以携带。前两次握手不能携带数据。

如果前两次握手能够携带数据,那么一旦有人想攻击服务器,那么他只需要在第一次握手中的 SYN 报文中放大量数据,那么服务器势必会消耗更多的时间内存空间去处理这些数据,增大了服务器被攻击的风险。

第三次握手的时候,客户端已经处于ESTABLISHED状态,并且已经能够确认服务器的接收、发送能力正常,这个时候相对安全了,可以携带数据。

握手优化

客户端优化

我们看一下系统默认是如何控制连接建立超时时间的?

TCP三次握手的第一个SYN报文没有收到ACK,客户端会重发SYN,

最大重试次数由系统参数net.ipv4.tcp_syn_retries控制,\(\mathrm{最大超时时间}=2^{x+1}-1\;(x是net.ipv4.tcp\_syn\_etries)\),默认值为6。初始RTO为1s,如果一直收不到SYN ACK,依次等待1s、2s、4s、8s、16s、32s发起重传,最后一次重传等待64s后放弃,最终在127s后才会返回ETIMEOUT超时错误。

建议根据整个公司的业务场景,调整系统参数进行兜底。该参数设为3,即最大15s左右可返回超时错误。

大量客户端可能会同时发起TCP重连及进行应用层请求

  • 当网络异常恢复后,大量客户端可能会同时发起TCP重连及进行应用层请求,可能会造成服务端过载、网络带宽耗尽等问题,从而导致客户端连接与请求处理失败,进而客户端触发新的重试。如果没有退让与窗口抖动机制,该状况可能会一直持续下去,很难快速收敛。

  • 建议增加指数退让,如1s、2s、4s、8s…,同时必须限制最大退让时间(如64s),否则重试等待时间可能越来越大,同样导致无法快速收敛。同时,为了降低大量客户端同时建连并请求,也需要增加窗口抖动,窗口大小可以与退让等待时间保持一致,如: \(nextRetryWaitTime\;=\;backOffWaitTime\;+\;rand(0.0,\;1.0)\;\ast\;backOffWaitTime\)

  • 在进行网络异常测试或演练时,需要把网络异常时间变量考虑进来,因为不同的时长,给应用带来的影响可能会完全不同。

服务端优化

避免服务端的SYN接收队列

SYN_RCV(RCV 是 received 的缩写)。这个状态下,服务器必须建立一个 SYN 半连接队列来维护未完成的握手信息,当这个队列溢出后,服务器将无法再建立新连接。

img

新连接建立失败的原因有很多,怎样获得由于队列已满而引发的失败次数呢?netstat -s 命令给出的统计结果中可以得到。

1
2
netstat -s | grep "SYNstoLISTEN"
1192450 SYNs to LISTEN sockets dropped

这里给出的是队列溢出导致 SYN 被丢弃的个数。注意这是一个累计值,如果数值在持续增加,则应该调大 SYN 半连接队列。修改队列大小的方法,是设置 Linux 的tcp_max_syn_backlog 参数

1
net.ipv4.tcp_max_syn_backlog = 1024

SYN 半连接队列已满,只能丢弃连接?

设置 tcp_syncookies = 1开启 syncookies 功能就可以在不使用 SYN 队列的情况下成功建立连接,当 SYN 队列满了后,TCP 会通过src_port、dst_port和时间戳打造出一个特别的 Sequence Number (又叫 cookie)。放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功。

如果是攻击者则不会有响应,如果是正常连接,则会把这个 SYN Cookie 发回来,然后服务端可以通过 cookie 建连接。

img

其中值为 0 时表示关闭该功能,2 表示无条件开启功能,而 1 则表示仅当 SYN 半连接队列放不下时,再启用它。

由于 syncookie 仅用于应对 SYN 泛洪这种方式建立的连接,许多 TCP 特性都无法使用。所以,应当把 tcp_syncookies 设置为 1,仅在队列满时再启用。

1
net.ipv4.tcp_syncookies = 1

如果服务器没有收到 ACK,就会一直重发 SYN+ACK 报文。当网络繁忙、不稳定时,报文丢失就会变严重,此时应该调大重发次数。反之则可以调小重发次数。修改重发次数的方法是,调整 tcp_synack_retries 参数:

1
net.ipv4.tcp_synack_retries = 5

tcp_synack_retries 的默认重试次数是 5 次,与客户端重发 SYN 类似,它的重试会经历1、2、4、8、16 秒,最后一次重试后等待 32 秒,若仍然没有收到 ACK,才会关闭连接,故共需要等待 63 秒。

服务器收到 ACK 后连接建立成功,此时,内核会把连接从 SYN 半连接队列中移出,再移入 accept 队列,等待进程调用 accept 函数时把连接取出来。如果进程不能及时地调用accept 函数,就会造成 accept 队列溢出,最终导致建立好的 TCP 连接被丢弃。

实际上,丢弃连接只是 Linux 的默认行为,我们还可以选择向客户端发送 RST 复位报文,告诉客户端连接已经建立失败。打开这一功能需要将 tcp_abort_on_overflow 参数设置为1。

1
net.ipv4.tcp_abort_on_overflow = 0

通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量。举个例子,当 accept 队列满导致服务器丢掉了 ACK,与此同时,客户端的连接状态却是 ESTABLISHED,进程就在建立好的连接上发送请求。只要服务器没有为请求回复ACK,请求就会被多次重发。如果服务器上的进程只是短暂的繁忙造成 accept 队列满,那么当 accept 队列有空位时,再次接收到的请求报文由于含有 ACK,仍然会触发服务器端成功建立连接。所以,tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率,只有你非常肯定 accept 队列会长期溢出时,才能设置为 1 以尽快通知客户端

调整 accept 队列的长度呢?

listen 函数的 backlog 参数就可以设置 accept队列的大小。事实上,backlog 参数还受限于 Linux 系统级的队列长度上限,当然这个上限阈值也可以通过 somaxconn 参数修改

1
net.core.somaxconn = 128

当下各监听端口上的 accept 队列长度,可以看到究竟有多少个连接因为队列溢出而被丢弃。

1
2
# netstat -s | grep "listenqueue"
14 times the listen queue of a socket overflowed

如果持续不断地有连接因为 accept 队列溢出被丢弃,就应该调大 backlog 以及somaxconn 参数。

TFO

TCP fast open 方案(简称TFO)

分两个流程

  • 第一阶段为首次建立连接,这时走正常的三次握手,但在客户端的 SYN 报文会明确地告诉服务器它想使用 TFO 功能,这样服务器会把客户端 IP 地址用只有自己知道的密钥加密(比如 AES 加密算法),作为Cookie 携带在返回的 SYN+ACK 报文中,客户端收到后会将 Cookie 缓存在本地。
  • 在后面的三次握手中,客户端会将之前缓存的 CookieSYNHTTP请求发送给服务端,服务端验证了 Cookie 的合法性,如果不合法直接丢弃;如果是合法的,那么就正常返回SYN + ACK

客户端最后握手的 ACK 不一定要等到服务端的 HTTP 响应到达才发送,两个过程没有任何关系。

当然,为了防止 SYN 泛洪,服务器的 TFO 实现必须能够自动化地定时更新密钥。

所以 tcp_fastopen 参数是按比特位控制的。其中,第1 个比特位为 1 时,表示作为客户端时支持 TFO;第 2 个比特位为 1 时,表示作为服务器时支持 TFO,所以当 tcp_fastopen 的值为 3 时(比特为 0x11)就表示完全支持 TFO 功能。

1
net.ipv4.tcp_fastopen = 3

四次挥手

img

  • 客户端要断开了,向服务器发送 FIN 报文,发送后客户端变成了FIN-WAIT-1状态。
  • 服务端接收后向客户端确认,变成了CLOSED-WAIT状态。客户端接收到了服务端的确认,变成了FIN-WAIT2状态。这时候客户端同时也变成了half-close(半关闭)状态,即无法向服务端发送报文,只能接收。
  • 服务端向客户端发送FIN,自己进入LAST-ACK状态
  • 客户端收到服务端发来的FIN后,自己变成了TIME-WAIT状态,然后发送 ACK 给服务端。

状态

  • LISTENING 服务启动处于侦听状态

  • ESTABLISHED 建立连接。表示两台机器正在通信

  • CLOSE_WAIT 对方主动关闭连接或者网络异常导致连接中断

  • TIME_WAIT 我方主动调用close()断开连接,收到对方确认后状态变为TIME_WAIT

    TIME_WAIT 是主动关闭链接时形成的,等待2MSL时间,约4分钟。主要是防止最后一个ACK丢失。

  • TCP连接主动关闭方存在持续2MSL的TIME_WAIT状态;
  • TCP连接由是由四元组<本地地址,本地端口,远程地址,远程端口>来确定的

why 2MSL

  • 确保最后一个确认报文能够到达。如果没能到达,服务端就会重发FIN请求释放连接。等待一段时间没有收到重发就说明服务的已经CLOSE了。如果有重发,则客户端再发送一次LAST ack信号
  • 确保当前连接所产生的所有报文都从网络中消失,使得下一个新的连接不会出现旧的连接请求报文

为什么是四次挥手而不是三次

因为服务端在接收到FIN, 往往不会立即返回FIN, 必须等到服务端所有的报文都发送完毕了,才能发FIN。因此先发一个ACK表示已经收到客户端的FIN,延迟一段时间才发FIN。这就造成了四次挥手。

如果是三次挥手会有什么问题?

等于说服务端将ACKFIN的发送合并为一次挥手,这个时候长时间的延迟可能会导致客户端误以为FIN没有到达客户端,从而让客户端不断的重发FIN

TIME_WAIT优化

TIME_WAIT存在的意义主要有两点

  1. 维护连接状态,使TCP连接能够可靠地关闭。如果连接主动关闭端发送的最后一条ACK丢失,连接被动关闭端会重传FIN报文。因此,主动关闭方必须维持连接状态,以支持收到重传的FIN后再次发送ACK。如果没有TIME_WAIT,并且最后一个ACK丢失,那么此时被动关闭端还会处于LAST_ACK一段时间,并等待重传;如果此时主动关闭方又立即创建新TCP连接且恰好使用了相同的四元组,连接会创建失败,会被对端重置。
  2. 等待网络中所有此连接老的重复的、走失的报文消亡,避免此类报文对新的相同四元组的TCP连接造成干扰,因为这些报文的序号可能恰好落在新连接的接收窗口内。

因为每个TCP报文最大存活时间为MSL,一个往返最大是2*MSL,所以TIME_WAIT需要等待2MSL。

当进程关闭时,进程会发起连接的主动关闭,连接最后会进入TIME_WAIT状态。当新进程bind监听端口时,就会报错,因为有对应本地端口的连接还处于TIME_WAIT状态。

优化time_wait

实际上,只有当新的TCP连接和老的TCP连接四元组完全一致,且老的迷走的报文序号落在新连接的接收窗口内时,才会造成干扰。为了使用TIME_WAIT状态的端口,现在大部分系统的实现都做了相关改进与扩展:

  • 新连接SYN告知的初始序列号,要求一定要比TIME_WAIT状态老连接的序列号大,可以一定程度保证不会与老连接的报文序列号重叠。
  • 开启TCP timestamps扩展选项后,新连接的时间戳要求一定要比TIME_WAIT状态老连接的时间戳大,可以保证老连接的报文不会影响新连接。

因此,在开启了TCP timestamps扩展选项的情况下(net.ipv4.tcp_timestamps = 1),可以放心的设置SO_REUSEADDR选项,支持程序快速重启

注意不要与net.ipv4.tcp_tw_reuse系统参数混淆,该参数仅在客户端调用connect创建连接时才生效,可以使用TIME_WAIT状态超过1秒的端口(防止最后一个ACK丢失)

SO_REUSEADDR是在bind端口时生效,一般用于服务端监听时,可以使用本地非LISTEN状态的端口(另一个端口也必须设置SO_REUSEADDR),不仅仅是TIME_WAIT状态端口。

  • 调整系统内核参数

    1
    2
    3
    4
    net.ipv4.tcp_tw_reuse = 1 表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭;
    # nat环境有大问题 linux 4.12 会移除该配置
    net.ipv4.tcp_tw_recycle = 1 表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。
    net.ipv4.tcp_max_tw_buckets = x 服务器TIME-WAIT状态套接字的数量限制,如果超过这个数量, 新来的TIME-WAIT套接字会直接释放 默认是180000
    

    sysctl -p命令,来激活上面的设置永久生效

  • 短链接为长链接

    • client到nginx的连接是长连接

      默认情况下,nginx已经自动开启了对client连接的keep alive支持(同时client发送的HTTP请求要求keep alive)。一般场景可以直接使用,但是对于一些比较特殊的场景,还是有必要调整个别参数(keepalive_timeout和keepalive_requests)。

      1
      2
      3
      4
      http {
          keepalive_timeout  120s 120s;
          keepalive_requests 10000;
      }
      
      • keepalive_timeout: 第一个参数:设置keep-alive客户端连接在服务器端保持开启的超时值(默认75s);值为0会禁用keep-alive客户端连接;
      • 第二个参数:可选、在响应的header域中设置一个值“Keep-Alive: timeout=time”;通常可以不用设置;
    • 保持和server的长连接

      为了让nginx和后端server(nginx称为upstream)之间保持长连接,典型设置如下:(默认nginx访问后端都是用的短连接(HTTP1.0)一个请求来了,Nginx 新开一个端口和后端建立连接,后端执行完毕后主动关闭该链接)

      upstream已经支持keep-alive的,所以我们可以开启Nginx proxy的keep-alive来减少tcp连接

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      upstream http_backend {
       server 127.0.0.1:8080;
          
       keepalive 1000;//设置nginx到upstream服务器的空闲keepalive连接的最大数量
      }
          
      server {
       ...
          
      location /http/ {
       proxy_pass http://http_backend;
       proxy_http_version 1.1;//开启长链接
       proxy_set_header Connection "";
       ...
       }
      }
          
      

过多危害

  • socket的TIME_WAIT状态结束之前,该socket所占用的本地端口号将一直无法释放。
  • 在高并发(每秒几万qps)并且采用短连接方式进行交互的系统中运行一段时间后,系统中就会存在大量的time_wait状态,如果time_wait状态把系统所有可用端口都占完了且尚未被系统回收时,就会出现无法向服务端创建新的socket连接的情况。此时系统几乎停转,任何链接都不能建立。
  • 大量的time_wait状态也会系统一定的fd,内存和cpu资源,当然这个量一般比较小,并不是主要危害

why CLOSE_WAIT很多

表示说要么是你的应用程序写的有问题,没有合适的关闭socket;要么是说,你的服务器CPU处理不过来(CPU太忙)或者你的应用程序一直睡眠到其它地方(锁,或者文件I/O等等),你的应用程序获得不到合适的调度时间,造成你的程序没法真正的执行close操作。

  • 响应太慢或者超时设置过小:如果连接双方不和谐,一方不耐烦直接 timeout,另一方却还在忙于耗时逻辑,就会导致 close 被延后。响应太慢是首要问题,不过换个角度看,也可能是 timeout 设置过小。

端口使用规范

每个应用、每个通信协议要有固定统一的监听端口,便于在公司内部形成共识,降低协作成本,提升运维效率。如对于一些网络ACL控制,规范统一的端口会给运维带来极大的便利。

应用监听端口不能在net.ipv4.ip_local_port_range区间内,这个区间是操作系统用于本地端口号自动分配的(bind或connect时没有指定端口号)

重传机制

超时重传

img

快速重传

如果超时重发的数据,再次超时的时候,又需要重传的时候,TCP 的策略是超时间隔加倍。

也就是每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。

超时触发重传存在的问题是,超时周期可能相对较长。

img

选择重传

快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题。就是重传的时候,是重传之前的一个,还是重传所有的问题。

因为发送端并不清楚这连续的三个 Ack 2 是谁传回来的

sack

发送端可以根据回传回来的SACK判断需要重传数据包(Linux内核参数:tcp_sack)

当有恶意攻击者,SACK会消耗发送端的资源

img

如果要支持 SACK,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。

D-SACK

D-SACK: 在TCP头加上SACK,通过ACK和SACK值判断丢失的数据包

重复收到数据问题(在 Linux 下可以通过 net.ipv4.tcp_dsack 参数开启/关闭这个功能), 可以让发送方知道,是发出去的包丢了,还是回来的ACK包丢了。

  • ack丢失 相比sack ,ack改变了

ACK 丢包

  • 数据包丢失

img

滑动窗口

引入窗口概念的原因

数据包的往返时间越长,通信的效率就越低

按数据包进行确认应答

TCP 引入了窗口这个概念。即使在往返时间较长的情况下,它也不会降低网络通信的效率。

那么有了窗口,就可以指定窗口大小,窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值

窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。

中途若有 ACK 丢失,可以通过下一个确认应答进行确认

用滑动窗口方式并行处理

只要发送方收到了 ACK 700 确认应答,就意味着 700 之前的所有数据「接收方」都收到了。这个模式就叫累计确认或者累计应答

窗口大小由哪一方决定

TCP 头里有一个字段叫 Window,也就是窗口大小。

这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。

所以,通常窗口的大小是由接收方的窗口大小来决定的。

接收窗口的大小是约等于发送窗口的大小的。

当接收方的应用进程读取数据的速度非常快的话,这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小,是通过 TCP 报文中的 Windows字段来告诉发送方。那么这个传输过程是存在时延的,所以接收窗口和发送窗口是约等于的关系。

发送方的窗口

img

当发送方把数据全部都一下发送出去后,可用窗口的大小就为 0 了,表明可用窗口耗尽,在没收到 ACK 确认之前是无法继续发送数据了

可用窗口耗尽

在下图,当收到之前发送的数据 32~36 字节的 ACK 确认应答后,如果发送窗口的大小没有变化,则滑动窗口往右边移动 5 个字节,因为有 5 个字节的数据被应答确认,接下来 52~56 字节又变成了可用窗口,那么后续也就可以发送 52~56 这 5 个字节的数据了。

32 ~ 36 字节已确认

区分发送方的四个部分

SND.WND、SND.UN、SND.NXT

  • SND.WND:表示发送窗口的大小(大小是由接收方指定的);
  • SND.UNA:是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是 #2 的第一个字节。
  • SND.NXT:也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是 #3 的第一个字节。

可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)

接收方的滑动窗口

接收窗口

  • RCV.WND:表示接收窗口的大小,它会通告给发送方。
  • RCV.NXT:是一个指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字节。
  • 指向 #4 的第一个字节是个相对指针,它需要 RCV.NXT 指针加上 RCV.WND 大小的偏移量,就可以指向 #4 的第一个字节了。

流量控制

发送方不能无脑的发数据给接收方,要考虑接收方处理能力。

如果一直无脑的发数据给对方,但对方处理不过来,那么就会导致触发重发机制,从而导致网络流量的无端的浪费。

为了解决这种现象发生,TCP 提供一种机制可以让发送方根据接收方的实际接收能力控制发送的数据量,这就是所谓的流量控制。

操心系统的缓冲区对影响发送窗口和接收窗口

发送窗口和接收窗口中所存放的字节数,都是放在操作系统内存缓冲区中的,而操作系统的缓冲区,会被操作系统调整

  • 服务端非常的繁忙,当收到客户端的数据时,应用层不能及时读取数据。
  • 当服务端系统资源非常紧张的时候,操心系统直接减少了接收缓冲区大小,数据会溢出缓冲区,那么这时候就有严重的事情发生了,会出现数据包丢失的现象

img

为了防止这种情况发生,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间再减少缓存,这样就可以避免了丢包情况。

接收方向发送方通告窗口大小时,是通过 ACK 报文来通告的。

那么,当发生窗口关闭时,接收方处理完数据后,会向发送方通告一个窗口非 0 的 ACK 报文,如果这个通告窗口的 ACK 报文在网络中丢失了,那麻烦就大了。

窗口关闭潜在的危险

这会导致发送方一直等待接收方的非 0 窗口通知,接收方也一直等待发送方的数据,如不采取措施,这种相互等待的过程,会造成了死锁的现象。

TCP 是如何解决窗口关闭时,潜在的死锁现象

只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器。

如果持续计时器超时,就会发送窗口探测 ( Window probe ) 报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。

窗口探测

窗口探测

  • 如果接收窗口仍然为 0,那么收到这个报文的一方就会重新启动持续计时器;
  • 如果接收窗口不是 0,那么死锁的局面就可以被打破了。

窗口探测的次数一般为 3 次,每次大约 30-60 秒(不同的实现可能会不一样)。如果 3 次过后接收窗口还是 0 的话,有的 TCP 实现就会发 RST 报文来中断连接。

防止发送方发送小数据

到最后,如果接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节,这就是糊涂窗口综合症

要知道,我们的 TCP + IP 头有 40 个字节,为了传输那几个字节的数据,要达上这么大的开销,这太不经济了。

  • 让接收方不通告小窗口给发送方

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

    等到接收方处理了一些数据后,窗口大小 >= MSS,或者接收方缓存空间有一半可以使用,就可以把窗口打开让发送方发送数据过来。

  • 让发送方避免发送小数据

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

    • 要等到窗口大小 >= MSS 或是 数据大小 >= MSS
    • 收到之前发送数据的 ack 回包

    只要没满足上面条件中的一条,发送方一直在囤积数据,直到满足上面的发送条件。

拥塞控制

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

在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大

于是,就有了拥塞控制,控制的目的就是避免发送方的数据填满整个网络。

拥塞窗口

拥塞窗口 cwnd发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的

我们在前面提到过发送窗口 swnd 和接收窗口 rwnd 是约等于的关系,那么由于加入了拥塞窗口的概念后,此时发送窗口的值是swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。

拥塞窗口 cwnd 变化的规则:

  • 只要网络中没有出现拥塞,cwnd 就会增大;
  • 但网络中出现了拥塞,cwnd 就减少;

判断拥塞

其实只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了用拥塞。

拥塞算法

  • 网络拥塞的原因:

    1. 独享整个网络资源,TCP的流量控制必然会导致网络拥塞,只关注了对端接收空间,无法知道链路上的容量
    2. 路由器接入网络会拉低网络的总带宽,路由器如果出现瓶颈,很容易出现堵塞
    • 拥塞控制主要依赖于拥塞窗口(cwnd);所以发送端发送的真正窗口是:min(rwnd,cwnd)
    • 拥塞控制的4种机制:慢开始/拥塞避免/快速重传/快速恢复
    • 拥塞控制过程:
      1. 慢启动: 进行试探的过程 -> 一般从cwnd=1开始加倍增长
      2. 当cwnd > ssthresh初始值后开始拥塞避免-> 每次cwnd+1
      3. 判断网络拥塞 a) 没收到ACK时,重新执行慢开始,并且将sshresh初始值重置为: 发生拥塞时的cwnd/2 b) 收到3个重复ACK时,进行快速重传,
      4. 快速恢复: 从新ssthresh=[ 发生拥塞时的cwnd/2 ]开始执行拥塞避免

有一个叫慢启动门限 ssthresh (slow start threshold)状态变量,一般来说 ssthresh 的大小是 65535 字节

  • cwnd < ssthresh 时,使用慢启动算法。
  • cwnd >= ssthresh 时,就会使用「拥塞避免算法」。

image-20210321181947219

image-20210321182411996

TCP如何保证可靠性

  1. TCP分段:应用数据被分割成合适的TCP段发送(对UDP来说,应用程序产生的数据段长度将保持不变)
  2. 超时重传:每发出一个TCP段都会启动一个”重传定时器”;如果不能及时收到一个确认,将重传这个报文段
  3. 流量控制:缓冲区固定大小,TCP接收端只允许另一端发送接收缓冲区所能接纳的数据(滑动窗口)
  4. 数据校验:TCP首部(校验和);如果收到段的校验和有差错,会选择丢弃和不确认
  5. 处理IP数据包:a) 丢弃重复的IP数据包;b) 将失序的IP数据包重新排序后交