拥塞窗口与接收窗口

前言

传输速度和 “数据接收方的接受窗口” (RWND) 以及 “数据发送方的拥塞窗口” (CWND) 有关,传输速率取 RWND 和 CWND 中小的那一个。其中:
1、数据接收方的 接收窗口(RWND)可以直接在 Wireshark 中直接看到,每次回应的ACK报文中就会附带这个WINDOW值,比如:

$ tcpdump -i enp0s3 host www.baidu.com
dropped privs to tcpdump
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on enp0s3, link-type EN10MB (Ethernet), snapshot length 262144 bytes
11:39:50.843686 IP test.33928 > 220.181.38.149.http: Flags [S], seq 1821692018, win 64240, options [mss 1460,sackOK,TS val 3628210121 ecr 0,nop,wscale 7], length 0
11:39:50.846408 IP 220.181.38.149.http > test.33928: Flags [S.], seq 1600001, ack 1821692019, win 65535, options [mss 1460], length 0
11:39:50.846425 IP test.33928 > 220.181.38.149.http: Flags [.], ack 1, win 64240, length 0
11:39:50.846478 IP test.33928 > 220.181.38.149.http: Flags [P.], seq 1:78, ack 1, win 64240, length 77: HTTP: GET / HTTP/1.1
11:39:50.846558 IP 220.181.38.149.http > test.33928: Flags [.], ack 78, win 65535, length 0
11:39:50.849819 IP 220.181.38.149.http > test.33928: Flags [P.], seq 1:1441, ack 78, win 65535, length 1440: HTTP: HTTP/1.1 200 OK
11:39:50.849828 IP test.33928 > 220.181.38.149.http: Flags [.], ack 1441, win 63900, length 0
11:39:50.850349 IP 220.181.38.149.http > test.33928: Flags [P.], seq 1441:2782, ack 78, win 65535, length 1341: HTTP
11:39:50.850359 IP test.33928 > 220.181.38.149.http: Flags [.], ack 2782, win 63900, length 0
11:39:50.850812 IP test.33928 > 220.181.38.149.http: Flags [F.], seq 78, ack 2782, win 63900, length 0
11:39:50.850900 IP 220.181.38.149.http > test.33928: Flags [.], ack 79, win 65535, length 0
11:39:50.853689 IP 220.181.38.149.http > test.33928: Flags [F.], seq 2782, ack 79, win 65535, length 0
11:39:50.853711 IP test.33928 > 220.181.38.149.http: Flags [.], ack 2783, win 63900, length 0

这里的 win 大小就是接收方的接收窗口大小。通过滑动窗口机制来控制接收窗口,表示着自己此刻还能接收多少字节的报文。当这个窗口太小,那发送方只能按照这个窗口去发送数据,整体下来传输速率就肯定会低很多。

RWND默认情况下占头部的16个bit,可以通过windows scaling 放大到32个bit。

2、发送方的 拥塞窗口(CWND) 基本上是没办法具体确认的,它的变化遵循一系列大佬给定的算法和接受到的ACK有关,这些算法涉及到:慢启动、拥塞避免、拥塞发生、快速恢复。

来简单的说下上面四个过程,其中慢启动、拥塞避免差不多就如下所示:

图片

  • 慢启动是从一个叫初始CWND的数值开始增长,这个初始CWND可能是2,可能是3也可能是10,代表多少个MSS的大小。Linux后来的版本里默认的初始值是10。
  • 慢启动过程中,发送方不断的接受到确认的ACK,在每经历一个RTT时间后CWND呈指数增长。所以虽然刚开始只有几个报文可以发送,但是指数的增长提速很快。
  • 慢启动到了一定的大小(ssthresh)就开始进入拥塞避免阶段,增长速度由指数增长变成了线性增长,主要是怕速度太快导致网络拥塞。
  • 拥塞避免的线性增长过程中如果发生了丢包导致超时重传或者快速重传就会启动拥塞算法,拥塞算法很多,都是为了减小CWND以及减小ssthresh的值。
  • 然后重新进入线性增长阶段,继续慢慢的增长。

用一张图来说明慢启动的过程差不多就是这样:
图片-1649832781006

解决方案

网络自身质量差导致的大量的重传、丢包导致传输速率上不去

网络中大量的丢包和重传,势必会严重的影响CWND的增长。

这种情况下,如果网络本来就烂的要死,那我们还想着继续用大马力去传文件只会给这个网络添堵。所以适当的做法应该是尽可能的让自己传输不影响网络,所以有两种方案可以选择

  • 降低发送方CWND的增长;
  • 降低接收方的RWND;

降低发送方拥塞窗口的增长(CWND)

发送方的CWND增长的慢了,也就是发送数据的速率慢了,自然对网络的影响就小了。但是这里有一点疑问,我们的本意是提高传输速率,那降低CWND的增长岂不是反而降低了传输速率了?实际上并不是这样,要知道网络质量差导致的丢包重传对CWND的影响是巨大的,如下图所示

图片-1649833128379

大多数拥塞算法在遇到快速重传的时候都会把CWND降低到一半并降低ssthresh,在遇到超时重传更严重,直接把CWND置成1了又得重新开始。而如果只是降低CWND的增长速度,顶多就是早点进入拥塞避免阶段的线性增长,整体的传输速率仍然是得到了提升。

降低CWND增长的方法,可以通过改小ssthresh的值,使指数增长过程持续的更短一点以及改小初始CWND,让增长的数值变小:

在不修改内核的情况下,Linux提供了一种使用ip命令修改初始拥塞窗口和接收窗口的机制。

# 修改初始拥塞窗口
sudo ip route change default via 192.168.1.1 dev eth0  proto static initcwnd 10

# 其中 default via 192.168.1.1 dev eth0  proto static 就是 ip route 显示的路由条目。
# 也可以同时修改 ssthresh 的值
sudo ip route change default via 192.168.1.1 dev eth0  proto static initcwnd 10 10000
# ssthresh 的值很多时候默认都是 65535,也就是到这个值的时候开始拥塞避免

# 修改初始接收窗口
sudo ip route change default via 192.168.1.1 dev eth0  proto static initrwnd 10

降低接收方的RWND

降低接收方的RWND,这样就限制了发送方发送数据的速率,从而实现降低传输速率来防止丢包。

可以在 /etc/sysctl.conf 里面插入
net.ipv4.tcp_rmem = <MIN> <DEFAULT> <MAX>
然后sysctl -p 刷新,就可以限制接收窗口小于MAX的值;
修改完了以后可以使用 sysctl -a | grep tcp_rmem 来查看是否已经生效。

需要注意,上面给出的linux的修改方式只是通过修改 tcp_rmem 缓冲区大小来间接的影响RWND,实际上 tcp_rmem 里面还得维护一些tcp的状态信息,所以真实获得的 RWND 会比 tcp_rmem 要小1/2或者小1/4,具体小多少根据linux的某些设置。

CWND的增长过慢导致速度上不去

前面也提到了CWND是决定发送方的发送数据的窗口大小,在一个带宽以及网络质量都正常的环境下,如果CWND因为某些限制一直提不上去,那么本着传输速率取RWND和CWND中小的那一个原则,RWND配置的再大也无济于事。

影响CWND的增长可能有以下几个方面:

  • 1)拥塞算法,不同的算法对CWND的增长和减少都有一定的差别,可以修改但肯定不是最优的方案。
  • 2)初始CWND的值,CWND在慢启动时间内都是 *2 的速度增长,初始的CWND变大的话肯定能提高增长速度,但是一般也不推荐改动,修改方式在前面已经提到。
  • sthresh门限,CWND的慢启动在什么时候结束就是由ssthresh限制,可以把ssthresh的值改大,这样慢启动的过程就会持续的更长时间,CWND的增长速度自然就会加大,修改方法在前面已经提到。
  • 增加接收方的ACK数量,CWND的增长和接受到的报文ACK数量是有直接关系的,也就是最理想的情况下,发送方的每个报文都能触发接收方回复一个ACK,这样CWND就能按照算法预期的增长。但实际上有某些软硬件的特性会影响ACK报文的数量,比如:网卡的TSO/GSO/LSO/LRO功能,目的在于减轻CPU的负载,让TCP分段的时候交给网卡处理,这样上层处理的一个大包由好几个报文组成,进行回复ACK的时候自然也只是回复大包,那ACK的数量就肯定会变少。

接收方窗口RWND太小

在一个带宽以及网络质量都正常的环境下,如果RWND因为某些限制一直提不上去,那么本着传输速率取RWND和CWND中小的那一个原则,CWND配置的再大也无济于事。

在TCP三次握手的过程中会交互一个叫 window scale 的选项,其作用为扩大RWND的因子,比如客户端告诉服务器自己的WS是8,那么后续接收端在回复ACK报文的时候携带的window大小就要 *8处理。在wireshark中显示出来的window是已经自动计算过后的,比如:

图片-1649840818493

客户端向服务器发起连接的 SYN 包中,携带 ws = 128,2的7次方等于128,也就是 window scale 标志位为 7。

图片-1649840877989

而服务端的 SYC、ACK 包中没有携带 WS

所以后续客户端发送的包中,该标志位在 wireshark 中显示
[Window size scaling factor: -2 (no window scaling used)]

解释如下:

-2 : Either the client or the server did not negotiate(!) window scaling, so no window scaling used
-1 : We have not seen the 3WHS and can therefor not tell whether or not to use window scaling
0+ : We do window scaling and this is the announced(!) window scaling factor for this flow

RWND同样是一个从较小的值慢慢增大到最大值的过程,具体是否变化还得看发送方发送数据的频率和自己这边应用层处理数据的频率,通过接收方的ACK报文中总是能看到当前的RWND大小。当RWND被占满且应用层一直没来及取数据的时候,接收方或者发送方告知RWND已满,此时发送方会停止发送,然后定期通过keeplive报文来探测接收方的RWND情况,当RWND腾出空间会通告给发送方 window update报文来更新窗口。

TCP窗口扩大选项Window Scale

这个选项只能出现在SYN报文段中,因此当连接建立起来后,每个方向上的扩大因子是固定的。为了使用窗口扩大,两端必须在他们的SYN报文段中发送该选项。主动建立连接的一方在其SYN中发送这个选项,但是被动建立连接的一方只能够在接收到带这个选项的SYN之后才可以发送这个选项。每个方向上的扩大因此可以不同;

如果主动连接的一方发送一个非零的扩大因子,但是没有从另一端收到一个扩大选项,它就将发送和接收的移位计数器设置为0。这就允许较新的系统能够与较旧的系统进行互相操作;

假定我们正在使用窗口扩大选项,发送移位计数为S,而接收到的移位计数为R。于是我们从另一端接收到的16bit的通告窗口将被左移R位以获得实际的通告窗口大小。每次当我们向对方发送一个窗口通告的时候,我们将实际的32bit窗口大小右移S位,然后用它来替换TCP首部中的16bit窗口值;

TCP根据接收缓存的大小自动选择位移技术。这个大小是由系统设置的,但是通常向应用程序提供了修改途径;

cat /proc/sys/net/ipv4/tcp_window_scaling

查看是否开启WS,在没开启WS的情况下,RWND总是使用65535大小。

  • 1 为 开启
  • 0 为 关闭

关于RWND的窗口被耗尽的提示在wireshark中有两种:

  • TCP window FULL,是发送方在发送报文的时候,发现自己已经发出去的没有被ACK确认的报文总大小已经超过了接收方宣称的window值;此时发送方只能等待接收方的新的ACK,才能继续发送报文。
  • TCP zero window,是接收方自己发送的,表明自己的window已经被占满无法接受更多的数据,只能等待应用来把数据取走;此时发送方只能等待 window update 消息才能继续发送报文。

如何修改接收窗口的总大小

1. TCP receive buffer

系统层面 (net.ipv4.tcp_rmem/net.core.rmem_max/net.ipv4.tcp_adv_win_scale)

TCP接收窗口的大小在Linux系统中取决于TCP receive buffer的大小,而TCP receive buffer的大小默认由内核根据系统可用内存的情况和内核参数net.ipv4.tcp_rmem动态调节。net.ipv4.tcp_rmem在Linux 2.4中被引入,设置包括[min, default, max]

  • min: 每个TCP socket receive buffer的最小size。默认值是4K。
  • default: TCP socket receive
    buffer的默认大小。这个值能够覆盖全局设置net.core.rmem_default定义的初始默认buffer
    size。默认值是87380字节。
  • max: 每个TCP socket receive buffer的最大size。这个值不能覆盖全局设置net.core.rmem_max。

如下是一个内核3.10.0版本,内存8G的ECS云主机上的默认值设置:
sysctl -a | grep tcp_rmem
net.ipv4.tcp_rmem = 4096 87380 6291456

同时,不是TCP receive buffer的大小就等于TCP接收窗口的大小。
bytes/2^tcp_adv_win_scale 的大小分配给应用。
如果net.ipv4.tcp_adv_win_scale的大小为2,表示有1/4的TCP buffer给应用,TCP把其余的3/4给TCP接窗口。

进程设置

进程可以利用系统调用setsockopt()设置socket属性,用SO_RCVBUF参数手动设置TCP receive buffer大小。比如NGINX可以在listen中配置rcvbuf=size。

2. net.ipv4.tcp_window_scaling

在前面提到,如果要让TCP接收窗口超过64KB大小,需要利用TCP Options的Window scale字段。而在系统内核参数设置里,对应的就是net.ipv4.tcp_window_scaling参数,这个参数默认是开启的。

优化拥塞窗口的初始值(cwnd)

较大的初始拥塞窗口值虽然能显式的提高内容下载速度,但是相对应的丢包率和重传率也会显著上升,因此这个取值需要兼顾下载速度和可用性等多方面来确定;

  • 探测结果显示,行业内各大cdn厂商都调整过init_cwnd值,普遍取值在10-20之间;
  • 取值初始拥塞窗口值:15
  • centos 7.1默认init_cwnd 10,调整后init_cwnd 20;下载速度提升还是很明显的;

init_cwnd调整方法

1、ip route change 路由 initcwnd 15
示例: ip route change default via 58.16.246.249 initcwnd 15

2、加入/etc/rc.local文件中

3、重启服务器

总结

  • 窗口缩放选项(window scaleing)可以在tcp握手时候在SYN分组中的连接期间仅发送一次。可以通过修改TCP标头中的窗口字段的值来动态调整窗口大小,但是在TCP连接的持续时间内,标度乘数保持静态。仅当两端都包含选项时,缩放才有效;如果只有连接的一端支持窗口缩放,则不会在任一方向上启用它。最大有效比例值为14。

一些概念和其它会导致TCP性能差的原因

跟速度相关的几个概念

  • CWND:Congestion Window,拥塞窗口,负责控制单位时间内,数据发送端的报文发送量。TCP 协议规定,一个 RTT(Round-Trip Time,往返时延,大家常说的 ping 值)时间内,数据发送端只能发送 CWND 个数据包(注意不是字节数)。TCP 协议利用 CWND/RTT 来控制速度。这个值是根据丢包动态计算出来的
  • SS:Slow Start,慢启动阶段。TCP 刚开始传输的时候,速度是慢慢涨起来的,除非遇到丢包,否则速度会一直指数性增长(标准 TCP 协议的拥塞控制算法,例如 cubic 就是如此。很多其它拥塞控制算法或其它厂商可能修改过慢启动增长特性,未必符合指数特性)。
  • CA:Congestion Avoid,拥塞避免阶段。当 TCP 数据发送方感知到有丢包后,会降低 CWND,此时速度会下降,CWND 再次增长时,不再像 SS 那样指数增,而是线性增(同理,标准 TCP 协议的拥塞控制算法,例如 cubic 是这样,很多其它拥塞控制算法或其它厂商可能修改过慢启动增长特性,未必符合这个特性)。
  • ssthresh:Slow Start Threshold,慢启动阈值。当数据发送方感知到丢包时,会记录此时的 CWND,并计算合理的 ssthresh 值(ssthresh <= 丢包时的 CWND),当 CWND 重新由小至大增长,直到 sshtresh 时,不再 SS 而是 CA。但因为数据确认超时(数据发送端始终收不到对端的接收确认报文),发送端会骤降 CWND 到最初始的状态。
  • tcp_wmem 对应send buffer,也就是滑动窗口大小

图片-1649923216937

上图一旦发生丢包,cwnd降到1 ssthresh降到cwnd/2,一夜回到解放前,太保守了,实际大多情况下都是公网带宽还有空余但是链路过长,非带宽不够丢包概率增大,对此没必要这么保守(tcp诞生的背景主要针对局域网、双绞线来设计,偏保守)。RTT越大的网络环境(长肥管道)这个问题越是严重,表现就是传输速度抖动非常厉害。

所以改进的拥塞算法一旦发现丢包,cwnd和ssthresh降到原来的cwnd的一半。

图片-1649923339284

TCP性能优化点

  • 建连优化:TCP 在建立连接时,如果丢包,会进入重试,重试时间是 1s、2s、4s、8s 的指数递增间隔,缩短定时器可以让 TCP 在丢包环境建连时间更快,非常适用于高并发短连接的业务场景。
  • 首包优化:此优化其实没什么实质意义,若要说一定会有意义的话,可能就是满足一些评测标准的需要吧,例如有些客户以首包时间作为性能评判的一个依据。所谓首包时间,简单解释就是从 HTTP Client 发出 GET 请求开始计时,到收到 HTTP 响应的时间。为此,Server 端可以通过 TCP_NODELAY 让服务器先吐出 HTTP 头,再吐出实际内容(分包发送,原本是粘到一起的),来进行提速和优化。据说更有甚者先让服务器无条件返回 “HTTP/“ 这几个字符,然后再去 upstream 拿数据。这种做法在真实场景中没有任何帮助,只能欺骗一下探测者罢了,因此还没见过有直接发 “HTTP/“ 的,其实是一种作弊行为。
    • TCP_NODELAY 是用来禁用Nagle’s Algorithm的。Nagle’s Algorithm设计的目的是提高网络带宽利用率,其做法是合并小的TCP包为一个大的TCP包,避免过多的小的TCP的报文的TCP头部浪费网络带宽,操作系统默认是开启这个算法的,如果开启这个算法,TCP/IP协议栈会累积数据,直到以下条件满足,才会将数据真正发送出去。
  • 平滑发包:如前文所述,在 RTT 内均匀发包,规避微分时间内的流量突发,尽量避免瞬间拥塞,此处不再赘述。
  • 丢包预判:有些网络的丢包是有规律性的,例如每隔一段时间出现一次丢包,例如每次丢包都连续丢几个等,如果程序能自动发现这个规律(有些不明显),就可以针对性提前多发数据,减少重传时间、提高有效发包率。
  • RTO 探测:如前文讲 TCP 基础时说过的,若始终收不到 ACK 报文,则需要触发 RTO 定时器。RTO 定时器一般都时间非常长,会浪费很多等待时间,而且一旦 RTO,CWND 就会骤降(标准 TCP),因此利用 Probe 提前与 RTO 去试探,可以规避由于 ACK 报文丢失而导致的速度下降问题。
  • 带宽评估:通过单位时间内收到的 ACK 或 SACK 信息可以得知客户端有效接收速率,通过这个速率可以更合理的控制发包速度。
  • 带宽争抢:有些场景(例如合租)是大家互相挤占带宽的,假如你和室友各 1Mbps 的速度看电影,会把 2Mbps 出口占满,而如果一共有 3 个人看,则每人只能分到 1/3。若此时你的流量流量达到 2Mbps,而他俩还都是 1Mbps,则你至少仍可以分到 2/(2+1+1) * 2Mbps = 1Mbps 的 50% 的带宽,甚至更多,代价就是服务器侧的出口流量加大,增加成本。(TCP 优化的本质就是用带宽换用户体验感)
  • 链路质量记忆(后面有反面案例):如果一个 Client IP 或一个 C 段 Network,若已经得知了网络质量规律(例如 CWND 多大合适,丢包规律是怎样的等),就可以在下次连接时,优先使用历史经验值,取消慢启动环节直接进入告诉发包状态,以提升客户端接收数据速率。

net.ipv4.tcp_slow_start_after_idle

内核协议栈参数 net.ipv4.tcp_slow_start_after_idle 默认是开启的,这个参数的用途,是为了规避 CWND 无休止增长,因此在连接不断开,但一段时间不传输数据的话,就将 CWND 收敛到 initcwnd,kernel-2.6.32 是 10,kernel-2.6.18 是 2。因此在 HTTP Connection: keep-alive 的环境下,若连续两个 GET 请求之间存在一定时间间隔,则此时服务器端会降低 CWND 到初始值,当 Client 再次发起 GET 后,服务器会重新进入慢启动流程。

这种友善的保护机制,对于 CDN 来说是帮倒忙,因此我们可以通过命令将此功能关闭,以提高 HTTP Connection: keep-alive 环境下的用户体验感。

sysctl net.ipv4.tcp_slow_start_after_idle=0

查看 CWND 和 ssthresh

运行中每个连接 CWND/ssthresh(slow start threshold) 的确认

for i in {1..1000}; do ss -i | grep -A 1 100.118.58.7 | grep ssthresh ; done
 reno wscale:9,9 rto:233 rtt:29.171/14.585 mss:1444 cwnd:40 ssthresh:361 send 15.8Mbps lastsnd:10 lastrcv:909498308 lastack:10 pacing_rate 7.9Mbps unacked:1 rcv_space:29200
 reno wscale:9,9 rto:230 rtt:29.237/3.534 redis:40 mss:1444 cwnd:40 ssthresh:361 send 15.8Mbps lastsnd:8 lastrcv:38 lastack:8 pacing_rate 31.6Mbps unacked:40 rcv_space:29200
 reno wscale:9,9 rto:230 rtt:29.201/0.111 redis:40 mss:1444 cwnd:155 ssthresh:361 send 61.3Mbps lastsnd:7 lastrcv:96 lastack:8 pacing_rate 122.6Mbps unacked:151 rcv_space:29200
 reno wscale:9,9 rto:230 rtt:29.381/0.193 redis:40 mss:1444 cwnd:362 ssthresh:361 send 142.3Mbps lastsnd:6 lastrcv:153 lastack:6 pacing_rate 284.7Mbps unacked:360 rcv_space:29200
 reno wscale:9,9 rto:230 rtt:29.351/0.081 redis:40 mss:1444 cwnd:364 ssthresh:361 send 143.3Mbps lastsnd:5 lastrcv:211 lastack:5 pacing_rate 286.5Mbps unacked:360 rcv_space:29200

从系统cache中查看 tcp_metrics item

sudo ip tcp_metrics show | grep  100.118.58.7
100.118.58.7 age 1457674.290sec tw_ts 3195267888/5752641sec ago rtt 1000us rttvar 1000us ssthresh 361 cwnd 40 metric_5 8710 metric_6 4258

每个连接的ssthresh默认是个无穷大的值,但是内核会cache对端ip上次的ssthresh(大部分时候两个ip之间的拥塞窗口大小不会变),这样大概率到达ssthresh之后就基本拥塞了,然后进入cwnd的慢增长阶段。

如果因为之前的网络状况等其它原因导致tcp_metrics缓存了一个非常小的ssthresh(这个值默应该非常大),ssthresh太小的话tcp的CWND指数增长阶段很快就结束,然后进入CWND+1的慢增加阶段导致整个速度感觉很慢

# 清除 tcp_metric
sudo ip tcp_metrics flush all
# 关闭 tcp_metrics 功能
net.ipv4.tcp_no_metrics_save = 1

sudo ip tcp_metrics delete 100.118.58.7

tcp_metrics 会记录下之前已关闭TCP连接的状态,包括发送端 CWND 和 ssthresh,如果之前网络有一段时间比较差或者丢包比较严重,就会导致 TCP 的 ssthresh 降低到一个很低的值,这个值在连接结束后会被 tcp_metrics cache 住,在新连接建立时,即使网络状况已经恢复,依然会继承 tcp_metrics 中cache 的一个很低的 ssthresh 值。

对于rt很高的网络环境,新连接经历短暂的“慢启动”后(ssthresh太小),随即进入缓慢的拥塞控制阶段(rt太高,CWND增长太慢),导致连接速度很难在短时间内上去。而后面的连接,需要很特殊的场景之下(比如,传输一个很大的文件)才能将ssthresh 再次推到一个比较高的值更新掉之前的缓存值,因此很有很能在接下来的很长一段时间,连接的速度都会处于一个很低的水平。

ssthresh 是如何降低的

在网络情况较差,并且出现连续dup ack情况下,ssthresh 会设置为 cwnd/2, cwnd 设置为当前值的一半,如果网络持续比较差那么ssthresh 会持续降低到一个比较低的水平,并在此连接结束后被tcp_metrics 缓存下来。下次新建连接后会使用这些值,即使当前网络状况已经恢复,但是ssthresh 依然继承一个比较低的值。

ssthresh 降低后为何长时间不恢复正常

ssthresh 降低之后需要在检测到有丢包的之后才会变动,因此就需要机缘巧合才会增长到一个比较大的值。此时需要有一个持续时间比较长的请求,在长时间进行拥塞避免之后在cwnd 加到一个比较大的值,而到一个比较大的值之后需要有因dup ack 检测出来的丢包行为将 ssthresh 设置为 cwnd/2, 当这个连接结束后,一个较大的ssthresh 值会被缓存下来,供下次新建连接使用。

也就是如果ssthresh 降低之后,需要传一个非常大的文件,并且网络状况超级好一直不丢包,这样能让CWND一直慢慢稳定增长,一直到CWND达到带宽的限制后出现丢包,这个时候CWND和ssthresh降到CWND的一半那么新的比较大的ssthresh值就能被缓存下来了。

tcp windows scale

网络传输速度:单位时间内(一个 RTT)发送量(再折算到每秒),不是 CWND(Congestion Window 拥塞窗口),而是 min(CWND, RWND)。除了数据发送端有个 CWND 以外,数据接收端还有个 RWND(Receive Window,接收窗口)。在带宽不是瓶颈的情况下,单连接上的速度极限为 MIN(cwnd, slide_windows)*1000ms/rt

tcp windows scale用来协商RWND的大小,它在tcp协议中占16个位,如果通讯双方有一方不支持tcp windows scale的话,TCP Windows size 最大只能到2^16 = 65535 也就是64k

如果网络rt是35ms,滑动窗口<CWND,那么单连接的传输速度最大是: 64K*1000/35=1792K(1.8M)

如果网络rt是30ms,滑动窗口>CWND的话,传输速度:CWND1500(MTU)1000(ms)/rt

一般通讯双方都是支持tcp windows scale的,但是如果连接中间通过了lvs,并且lvs打开了 synproxy功能的话,就会导致 tcp windows scale 无法起作用,那么传输速度就被滑动窗口限制死了(rt小的话会没那么明显)。

RTT越大,传输速度越慢

RTT大的话导致拥塞窗口爬升缓慢,慢启动过程持续越久。RTT越大、物理带宽越大、要传输的文件越大这个问题越明显带宽B越大,RTT越大,低带宽利用率持续的时间就越久,文件传输的总时间就会越长,这是TCP慢启动的本质决定的,这是探测的代价。

TCP的拥塞窗口变化完全受ACK时间驱动(RTT),长肥管道对丢包更敏感,RTT越大越敏感,一旦有一个丢包就会将CWND减半进入避免拥塞阶段

RTT对性能的影响关键是RTT长了后丢包的概率大,一旦丢包进入拥塞阶段就很慢了。如果一直不丢包,只是RTT长,完全可以做大增加发送窗口和接收窗口来抵消RTT的增加

socket send/rcv buf

有些应用会默认设置 socketSendBuffer 为16K,在高rt的环境下,延时20ms,带宽100M,如果一个查询结果22M的话需要25秒

图片-1649929727300

细化看下问题所在:

图片-1649929753398

这个时候也就是buf中的16K数据全部发出去了,但是这16K不能立即释放出来填新的内容进去,因为tcp要保证可靠,万一中间丢包了呢。只有等到这16K中的某些ack了,才会填充一些进来然后继续发出去。由于这里rt基本是20ms,也就是16K发送完毕后,等了20ms才收到一些ack,这20ms应用、OS什么都不能做。

调整 socketSendBuffer 到256K,查询时间从25秒下降到了4秒多,但是比理论带宽所需要的时间略高

继续查看系统 net.core.wmem_max 参数默认最大是130K,所以即使我们代码中设置256K实际使用的也是130K,调大这个系统参数后整个网络传输时间大概2秒(跟100M带宽匹配了,scp传输22M数据也要2秒),整体查询时间2.8秒。测试用的mysql client短连接,如果代码中的是长连接的话会块300-400ms(消掉了慢启动阶段),这基本上是理论上最快速度了

图片-1649929801944

$sudo sysctl -a | grep --color wmem
vm.lowmem_reserve_ratio = 256   256     32
net.core.wmem_max = 131071
net.core.wmem_default = 124928
net.ipv4.tcp_wmem = 4096        16384   4194304
net.ipv4.udp_wmem_min = 4096

如果指定了tcp_wmem,则net.core.wmem_default被tcp_wmem的覆盖。send Buffer在tcp_wmem的最小值和最大值之间自动调节。如果调用setsockopt()设置了socket选项SO_SNDBUF,将关闭发送端缓冲的自动调节机制,tcp_wmem将被忽略,SO_SNDBUF的最大值由net.core.wmem_max限制。

默认情况下Linux系统会自动调整这个buf(net.ipv4.tcp_wmem), 也就是不推荐程序中主动去设置SO_SNDBUF,除非明确知道设置的值是最优的。

这个buf调到1M有没有帮助,从理论计算BDP(带宽时延积) 0.02秒*(100MB/8)=250Kb 所以SO_SNDBUF为256Kb的时候基本能跑满带宽了,再大实际意义也不大了。

ip route | while read p; do sudo ip route change $p initcwnd 30 ; done

TCP 问题实例总结

为什么这么多CLOSE_WAIT

图片-1650162313439

反复看这个图的右下部分的CLOSE_WAIT ,从这个图里可以得到如下结论:
CLOSE_WAIT是被动关闭端在等待应用进程的关闭

可能的原因:

  • 机器超卖严重、IO卡顿,导致应用线程卡顿,来不及调用socket.close()
  • 程序问题:如果代码层面忘记了 close 相应的 socket 连接,那么自然不会发出 FIN 包,从而导致 CLOSE_WAIT 累积;或者代码不严谨,出现死循环之类的问题,导致即便后面写了 close 也永远执行不到。
  • 响应太慢或者超时设置过小:如果连接双方不和谐,一方不耐烦直接 timeout,另一方却还在忙于耗时逻辑,就会导致 close 被延后。响应太慢是首要问题,不过换个角度看,也可能是 timeout 设置过小。
  • BACKLOG 太大:此处的 backlog 不是 syn backlog,而是 accept 的 backlog,如果 backlog 太大的话,设想突然遭遇大访问量的话,即便响应速度不慢,也可能出现来不及消费的情况,导致多余的请求还在队列里就被对方关闭了。

TCP传输速度案例分析

长肥网络(高rtt)场景下tcp_metrics记录的ssthresh太小导致传输慢的案例

tcp_metrics会记录下之前已关闭tcp 连接的状态,包括发送端拥塞窗口和拥塞控制门限,如果之前网络有一段时间比较差或者丢包比较严重,就会导致tcp 的拥塞控制门限ssthresh降低到一个很低的值,这个值在连接结束后会被tcp_metrics cache 住,在新连接建立时,即使网络状况已经恢复,依然会继承 tcp_metrics 中cache 的一个很低的ssthresh 值,在长肥管道情况下,新连接经历短暂的“慢启动”后,随即进入缓慢的拥塞控制阶段, 导致连接速度很难在短时间内上去。而后面的连接,需要很特殊的场景之下才能将ssthresh 再次推到一个比较高的值缓存下来,因此很有很能在接下来的很长一段时间,连接的速度都会处于一个很低的水平

因为 tcp_metrics记录的ssthresh非常小,导致后面新的tcp连接传输数据时很快进入拥塞控制阶段,如果传输的文件不大的话就没有机会将ssthresh撑大。除非传输一个特别大的文件,忍受拥塞控制阶段的慢慢增长,最后tcp_metrics记录下撑大后的ssthresh,整个网络才会恢复正常。

清除: sudo ip tcp_metrics flush all
关闭:net.ipv4.tcp_no_metrics_save = 1

查看 tcp_metrics

ip tcpmetrics show

双网卡部分网络不通问题

图片-1650280211688

机器有两块网卡,请求走eth0 进来(绿线),然后走 eth1回复(路由决定的,红线),但是实际没走eth1回复,像是丢包了。

修改一下route,让eth0成为默认路由,这样北京、杭州都能走eth0进出了

但是部分服务器是正常的,检查一下 rp_filter 参数。果然看到7U2的系统默认 rp_filter 开着,而5U7是关着的,于是反复开关这个参数稳定重现了问题

sysctl -w net.ipv4.conf.eth0.rp_filter=0

rp_filter 原理和监控

rp_filter参数用于控制系统是否开启对数据包源地址的校验, 收到包后根据source ip到route表中检查是否否和最佳路由,否的话扔掉这个包【可以防止DDoS,攻击等】

  • 0:不开启源地址校验。
  • 1:开启严格的反向路径校验。对每个进来的数据包,校验其反向路径是否是最佳路径。如果反向路径不是最佳路径,则直接丢弃该数据包。
  • 2:开启松散的反向路径校验。对每个进来的数据包,校验其源地址是否可达,即反向路径是否能通(通过任意网口),如果反向路径不通,则直接丢弃该数据包。

那么对于这种丢包,可以打开日志:/proc/sys/net/ipv4/conf/eth0/log_martians 来监控到:
图片-1650280365287

也就是rp_filter在收包的流程中检查每个进来的包,是不是符合rp_filter规则,而不是回复的时候来做判断,这也就是为什么抓包只能看到进来的syn就没有然后了

开启rp_filter参数的作用

  • 减少DDoS攻击: 校验数据包的反向路径,如果反向路径不合适,则直接丢弃数据包,避免过多的无效连接消耗系统资源。
  • 防止IP Spoofing: 校验数据包的反向路径,如果客户端伪造的源IP地址对应的反向路径不在路由表中,或者反向路径不是最佳路径,则直接丢弃数据包,不会向伪造IP的客户端回复响应。

通过netstat -s来观察IPReversePathFilter

通过netstat -s来观察IPReversePathFilter

$netstat -s | grep -i filter
    IPReversePathFilter: 35428
$netstat -s | grep -i filter
    IPReversePathFilter: 35435

能明显看到这个数字在增加,如果没开rp_filter 就看不到这个指标或者数值不变
图片-1650280632916

TCP 接收窗口和缓存之间的关系

理解 backlog/somaxconn 内核参数

TCP SYN_RCVD, ESTABELLISHED 状态对应的队列

TCP 建立连接时要经过 3 次握手,在客户端向服务器发起连接时,
对于服务器而言,一个完整的连接建立过程,服务器会经历 2 种 TCP 状态:

  • SYN_RCVD,
  • ESTABELLISHED。

对应也会维护两个队列:

  • 一个存放 SYN 的队列(半连接队列)
  • 一个存放已经完成连接的队列(全连接队列)

当一个连接的状态是 SYN RECEIVED 时,它会被放在 SYN 队列中。当它的状态变为 ESTABLISHED 时,它会被转移到另一个队列。所以后端的应用程序只从已完成的连接的队列中获取请求。

如果一个服务器要处理大量网络连接,且并发性比较高,那么这两个队列长度就非常重要了。

因为,即使服务器的硬件配置非常高,服务器端程序性能很好,但是这两个队列非常小,那么经常会出现客户端连接不上的现象,因为这两个队列一旦满了后,很容易丢包,或者连接被复位。所以,如果服务器并发访问量非常高,那么这两个队列的设置就非常重要了。

半连接队列与全连接队列

图片-1650167135917

Linux backlog 参数意义

对于 Linux 而言,基本上任意语言实现的通信框架或服务器程序在构造 socket server 时,都提供了 backlog 这个参数,因为在监听端口时,都会调用系统底层 API:
int listen(int sockfd, int backlog)

backlog 参数描述的是服务器端 TCP ESTABELLISHED 状态对应的全连接队列长度。

全连接队列长度如何计算?

如果 backlog 大于内核参数 net.core.somaxconn,则以 net.core.somaxconn 为准,
即全连接队列长度 = min(backlog, 内核参数 net.core.somaxconn),net.core.somaxconn 默认为 128。
这个很好理解,net.core.somaxconn 定义了系统级别的全连接队列最大长度,
backlog 只是应用层传入的参数,不可能超过内核参数,所以 backlog 必须小于等于 net.core.somaxconn。

查看全连接队列

ss -nltp
State      Recv-Q       Send-Q          Local Address:Port            Peer Address:Port          Process
LISTEN     0            10                    0.0.0.0:8080                 0.0.0.0:*              users:(("ncat",pid=25760,fd=3))

Recv-Q是全连接队列的当前大小
Send-Q是全连接队列的最大值

注意:
在「非 LISTEN 状态」时,Recv-Q/Send-Q 表示的含义与 Listen 状态下是不同的,如下:
ss -nt
Recv-Q:已收到但未被应用进程读取的字节数;
Send-Q:已发送但未收到确认的字节数;

修改全连接队列的值

TCP 全连接队列足最大值取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog)。从下面的 Linux 内核代码可以得知:

somaxconn 是 Linux 内核的参数,默认值是 128,可以通过/proc/sys/net/core/somaxconn 来设置其值;

backlog 是 listen(int sockfd, int backlog) 函数中的 backlog 大小,Nginx 默认值是 511,可以通过修改配置文件设置其长度;

测试环境:

  • somaxconn 是默认值 128;
  • Nginx 的 backlog 是默认值 511

所以测试环境的 TCP 全连接队列最大值为 min(128, 511),也就是 128

把 somaxconn 设置成 5000:

sysctl -w net.core.somaxconn=32768

把 Nginx 的 backlog 也同样设置成 5000:

server {
	listen 80 default backlog=5000;
}

最后要重启 Nginx 服务,因为只有重新调用 listen() 函数, TCP 全连接队列才会重新初始化。

重启完后 Nginx 服务后,服务端执行 ss 命令,查看 TCP 全连接队列大小,可以发现 TCP 全连接最大值为 5000。

全连接队列满了,就只会丢弃连接吗?

实际上,丢弃连接只是 Linux 的默认行为,我们还可以选择向客户端发送 RST 复位报文,告诉客户端连接已经建立失败。

sysctl -a | grep  net.ipv4.tcp_abort_on_overflow
net.ipv4.tcp_abort_on_overflow = 0

tcp_abort_on_overflow 共有两个值分别是 0 和 1,其分别表示:
0 :表示如果全连接队列满了,那么 server 扔掉 client 发过来的 ack ;
1 :表示如果全连接队列满了,那么 server 发送一个 reset 包给 client

半连接队列长度如何计算?

半连接队列长度由内核参数 tcp_max_syn_backlog 决定,
当使用 SYN Cookie 时(就是内核参数 net.ipv4.tcp_syncookies = 1),这个参数无效,
半连接队列的最大长度为 backlog、内核参数 net.core.somaxconn、内核参数 tcp_max_syn_backlog 的最小值。
即半连接队列长度 = min(backlog, 内核参数 net.core.somaxconn,内核参数 tcp_max_syn_backlog)。
这个公式实际上规定半连接队列长度不能超过全连接队列长度。

查看半连接队列

查看连接状态,synrecv 代表半连接队列大小

第一种方法,通过 ss -s 命令查看:


# ss -s 这个命令最快,几乎是立即得到结果,但synrecv一直显示为0,所以没法用。除此之外,其它信息是完整的。
ss -s
	Total: 30234 (kernel 30462)
	TCP: 115175 (estab 30148, closed 77237, orphaned 7771, synrecv 0, timewait 77237/0), ports 1139

	Transport Total IP IPv6
	* 30462 - -
	RAW 0 0 0
	UDP 1 1 0
	TCP 37938 37938 0
	INET 37939 37939 0
	FRAG 0 0 0

第二种方法,通过 ss -ant 单行脚本查看

# ss -ant 这个命令速度较快,十万连接,一秒出结果。推荐使用这个。
ss -ant | awk 'NR>1 {++s[$1]} END {for(k in s) print k,s[k]}'
	LAST-ACK 2309
	SYN-RECV 416
	ESTAB 44375
	FIN-WAIT-1 8138
	CLOSING 95
	FIN-WAIT-2 46328
	TIME-WAIT 50611
	CLOSE-WAIT 7
	LISTEN 7

第三种方法,通过 netstat 命令查看

netstat -natp | grep SYN_RECV | wc -l

修改半连接队列

要想增大半连接队列,不能只单纯增大 tcp_max_syn_backlog 的值,还需一同增大 somaxconn 和 backlog,也就是增大 accept 队列。否则,只单纯增大 tcp_max_syn_backlog 是无效的。

  • 当 max_syn_backlog > min(somaxconn, backlog) 时, 半连接队列最大值 max_qlen_log = min(somaxconn, backlog) * 2;
  • 当 max_syn_backlog < min(somaxconn, backlog) 时, 半连接队列最大值 max_qlen_log = max_syn_backlog * 2;

增大 tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 内核参数:

net.core.somaxconn = 262144
tcp_max_syn_backlog = 262144

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

并不是这样,开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接,在前面我们源码分析也可以看到这点,当开启了 syncookies 功能就不会丢弃连接。

syncookies 是这么做的:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。

开启 syncookies 功能

syncookies 参数主要有以下三个值:

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

echo 1 > /proc/sys/net/ipv4/tcp_syncookies

针对 IPv4 的内核的网络参数优化

netdev_max_backlog

查看数据处理情况

netdev_max_backlog是内核从网卡收到数据包后,交由协议栈(如IP、TCP)处理之前的缓冲队列。每个CPU核都有一个backlog队列,当协议栈处理速度满足不了接收包速率时会发生丢包。

cat /proc/net/softnet_stat
00c4e395 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
00d9b4e0 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000

行:一行代表一个cpu
列:第一列为接收的总包数;第二列为由于溢出丢弃的包数。

查看 netdev_max_backlog

cat /proc/sys/net/core/netdev_max_backlog
1000

设置 netdev_max_backlog

sysctl -w net.core.netdev_max_backlog=4096
echo "4096"  > /proc/sys/net/core/netdev_max_backlog

rp_filter

反向路由过滤机制是Linux通过反向路由查询,检查收到的数据包源IP是否可路由(Loose mode)、是否最佳路由(Strict mode),如果没有通过验证,则丢弃数据包,设计的目的是防范IP地址欺骗攻击。rp_filter提供了三种模式供配置:

  • 0 - 不验证
  • 1 - RFC3704定义的严格模式:对每个收到的数据包,查询反向路由,如果数据包入口和反向路由出口不一致,则不通过
  • 2 - RFC3704定义的松散模式:对每个收到的数据包,查询反向路由,如果任何接口都不可达,则不通过

查看

cat /proc/sys/net/ipv4/conf/eth0/rp_filter

设置

所有不验证:
sysctl -w net.ipv4.conf.all.rp_filter=0

网卡eth0不验证:
sysctl -w net.ipv4.conf.eth0.rp_filter=2

tcp_synack_retries

对于一个新建连接,内核要发送多少个 SYN 连接请求才决定放弃。不应该大于255,默认值是5,对应于180秒左右时间。(对于大负载而物理通信良好的网络而言,这个值偏高,可修改为2.这个值仅仅是针对对外的连接,对进来的连接,是由tcp_retries1决定的)

网卡

关于 ring buffer

物理介质上的数据帧到达后首先由NIC(网络适配器)读取,写入设备内部缓冲区 Ring Buffer中,再由中断处理程序触发 Softirq 从中消费,Ring Buffer 的大小因网卡设备而异。当网络数据包到达(生产)的速率快于内核处理(消费)的速率时,Ring Buffer 很快会被填满,新来的数据包将被丢弃;

网络数据传输:数据帧传输,由网卡读取并放入设备缓冲区ring buffer,当网络数据包到达的速率快于内核处理的速率时,ring buffer很快会被填满,新来的数据包将被丢弃。

查看 ring buffer

ethtool -g ens192
Ring parameters for ens192:
Pre-set maximums:
RX:             4096
RX Mini:        2048
RX Jumbo:       4096
TX:             4096
Current hardware settings:
RX:             1024
RX Mini:        128
RX Jumbo:       256
TX:             512

设置 ring buffer

ethtool -G eth0 rx 4096 tx 4096

TCP 状态机及相关优化配置

图片-1650193836748

优化三次握手的策略

图片-1650244809356

SYN_SENT 与 tcp_syn_retries

客户端作为主动发起连接方,首先它将发送 SYN 包,于是客户端的连接就会处于SYN_SENT

tcp_syn_retries

客户端在等待服务端回复的 ACK 报文,正常情况下,服务器会在几毫秒内返回 SYN+ACK ,但如果客户端长时间没有收到 SYN+ACK 报文,则会重发 SYN 包,重发的次数由 tcp_syn_retries 参数控制,默认是 5 次:

sysctl -a | grep tcp_syn_retries
net.ipv4.tcp_syn_retries = 5

通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后。没错,每次超时的时间是上一次的 2 倍。

当第五次超时重传后,会继续等待 32 秒,如果仍然服务端没有回应 ACK,客户端就会终止三次握手。

所以,总耗时是 1+2+4+8+16+32=63 秒,大约 1 分钟左右。

图片-1650193969163

你可以根据网络的稳定性和目标服务器的繁忙程度修改 SYN 的重传次数,调整客户端的三次握手时间上限。比如内网中通讯时,就可以适当调低重试次数,尽快把错误暴露给应用程序。

SYN_RCV 状态相关优化

在这个状态下,Linux 内核就会建立一个「半连接队列」来维护「未完成」的握手信息,当半连接队列溢出后,服务端就无法再建立新的连接。

图片-1650194030592

tcp_synack_retries

当客户端接收到服务器发来的 SYN+ACK 报文后,就会回复 ACK 给服务器,同时客户端连接状态从 SYN_SENT 转换为 ESTABLISHED,表示连接建立成功。

服务器端连接成功建立的时间还要再往后,等到服务端收到客户端的 ACK 后,服务端的连接状态才变为 ESTABLISHED。

如果服务器没有收到 ACK,就会重发 SYN+ACK 报文,同时一直处于 SYN_RCV 状态。

当网络繁忙、不稳定时,报文丢失就会变严重,此时应该调大重发次数。反之则可以调小重发次数。修改重发次数的方法是,调整 tcp_synack_retries 参数:

sysctl -a | grep tcp_synack_retries
net.ipv4.tcp_synack_retries = 5

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

fastopen

TCP建立连接需要三次握手,这个大家都知道。但是三次握手会导致传输效率下降,尤其是HTTP这种短连接的协议,虽然HTTP有keep-alive来让一些请求频繁的HTTP提高性能,避免了一些三次握手的次数,但是还是希望能绕过三次握手提高效率,或者说在三次握手的同时就把数据传输的事情给做了,这就是我们这次要说的TCP Fast Open,简称TFO。

TCP Fast Open 功能可以绕过三次握手,使得 HTTP 请求减少了 1 个 RTT 的时间,Linux 下可以通过 tcp_fastopen 开启该功能,同时必须保证服务端和客户端同时支持。

图片-1650261719140

这里客户端在最后ACK的时候,完全可以把想要发送的第一条数据也一起带过去,所以这是TFO做的其中一个优化方案。然后还参考了HTTP登录态的流程,采用Cookie的方案,让服务端知道某个客户端之前已经“登录”过了,那么它发过来的数据就可以直接接收了,不必要一开始必须三次握手后再发数据。

当客户端第一次连接服务端时,是没有Cookie的,所以会发送一个空的Cookie,意味着要请求Cookie,如下图:

图片-1650261751866

这样服务端就会将Cookie通过SYN+ACK的路径返回给客户端,客户端保存后,将发送的数据三次握手的最后一步ACK同时发送给服务端。

当客户端断开连接,下一次请求同一个服务端的时候,会带上之前存储的Cookie和要发送的数据,在SYN的路径上一起发送给服务端,如下图:

图片-1650261781581

这样之后每次握手的时候还同时发送了数据信息,将数据传输提前了。服务端只要验证了Cookie,就会将发送的数据接收,否则会丢弃并且再通过SYN+ACK路径返回一个新的Cookie,这种情况一般是Cookie过期导致的。

开启

TFO是需要开启的,开启参数在:/proc/sys/net/ipv4/tcp_fastopen

  • 0:关闭
  • 1:作为客户端使用Fast Open功能,默认值
  • 2:作为服务端使用Fast Open功能
  • 3:无论是客户端还是服务端都使用Fast Open功能

经过试验,客户端存储的Cookie是跟服务端的IP绑定的,而不是跟进程或端口绑定。当客户端程序发送到同一个IP但是不同端口的进程时,使用的是同一个Cookie,而且服务端也认证成功。

优化四次挥手的策略

图片-1650244889036

FIN_WAIT1 状态的优化

主动方发送 FIN 报文后,连接就处于 FIN_WAIT1 状态,正常情况下,如果能及时收到被动方的 ACK,则会很快变为 FIN_WAIT2 状态。

tcp_orphan_retries

但是当迟迟收不到对方返回的 ACK 时,连接就会一直处于 FIN_WAIT1 状态。此时,内核会定时重发 FIN 报文,其中重发次数由 tcp_orphan_retries 参数控制(注意,orphan 虽然是孤儿的意思,该参数却不只对孤儿连接有效,事实上,它对所有 FIN_WAIT1 状态下的连接都有效),默认值是 0。

sysctl -a | grep net.ipv4.tcp_orphan_retries
net.ipv4.tcp_orphan_retries = 0

echo 5 > /proc/sys/net/ipv4/tcp_orphan_retries

你可能会好奇,这 0 表示几次?实际上当为 0 时,特指 8 次

对于普遍正常情况时,调低 tcp_orphan_retries 就已经可以了。如果遇到恶意攻击,FIN 报文根本无法发送出去,这由 TCP 两个特性导致的:

  • 首先,TCP 必须保证报文是有序发送的,FIN 报文也不例外,当发送缓冲区还有数据没有发送时,FIN 报文也不能提前发送。
  • 其次,TCP 有流量控制功能,当接收方接收窗口为 0 时,发送方就不能再发送数据。所以,当攻击者下载大文件时,就可以通过接收窗口设为 0 ,这就会使得 FIN 报文都无法发送出去,那么连接会一直处于 FIN_WAIT1 状态。

解决这种问题的方法,是调整 tcp_max_orphans 参数,它定义了「孤儿连接」的最大数量

当进程调用了 close 函数关闭连接,此时连接就会是「孤儿连接」,因为它无法在发送和接收数据。Linux 系统为了防止孤儿连接过多,导致系统资源长时间被占用,就提供了 tcp_max_orphans 参数。如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送 RST 复位报文强制关闭。

tcp_max_orphans

sysctl -a | grep tcp_max_orphans
net.ipv4.tcp_max_orphans = 4096

FIN_WAIT2 状态的优化

当主动方收到 ACK 报文后,会处于 FIN_WAIT2 状态,就表示主动方的发送通道已经关闭,接下来将等待对方发送 FIN 报文,关闭对方的发送通道。

这时,如果连接是用 shutdown 函数关闭的,连接可以一直处于 FIN_WAIT2 状态,因为它可能还可以发送或接收数据。但对于 close 函数关闭的孤儿连接,由于无法在发送和接收数据,所以这个状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60 秒

tcp_fin_timeout

sysctl -a | grep tcp_fin_timeout
net.ipv4.tcp_fin_timeout = 60

它意味着对于孤儿连接(调用 close 关闭的连接),如果在 60 秒后还没有收到 FIN 报文,连接就会直接关闭。

这个 60 秒不是随便决定的,它与 TIME_WAIT 状态持续的时间是相同的,后面我们在来说说为什么是 60 秒。

TIME_WAIT 状态的优化

TIME_WAIT 是主动方四次挥手的最后一个状态,也是最常遇见的状态。

当收到被动方发来的 FIN 报文后,主动方会立刻回复 ACK,表示确认对方的发送通道已经关闭,接着就处于 TIME_WAIT 状态。在 Linux 系统,TIME_WAIT 状态会持续 60 秒后才会进入关闭状态。

TIME_WAIT 状态的连接,在主动方看来确实快已经关闭了。然后,被动方没有收到 ACK 报文前,还是处于 LAST_ACK 状态。如果这个 ACK 报文没有到达被动方,被动方就会重发 FIN 报文。重发次数仍然由前面介绍过的 tcp_orphan_retries 参数控制。

TIME-WAIT 的状态尤其重要,主要是两个原因:

  • 防止具有相同「四元组」的「旧」数据包被收到;
  • 保证「被动关闭连接」的一方能被正确的关闭,即保证最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭;

因此,TIME_WAIT 和 FIN_WAIT2 状态的最大时长都是 2 MSL,由于在 Linux 系统中,MSL 的值固定为 30 秒,所以它们都是 60 秒。

虽然 TIME_WAIT 状态有存在的必要,但它毕竟会消耗系统资源。如果发起连接一方的 TIME_WAIT 状态过多,占满了所有端口资源,则会导致无法创建新连接。

  • 客户端受端口资源限制:如果客户端 TIME_WAIT 过多,就会导致端口资源被占用,因为端口就65536个,被占满就会导致无法创建新的连接;
  • 服务端受系统资源限制:由于一个 四元组表示TCP连接,理论上服务端可以建立很多连接,服务端确实只监听一个端口 但是会把连接扔给处理线程,所以理论上监听的端口可以继续监听。但是线程池处理不了那么多一直不断的连接了。所以当服务端出现大量 TIME_WAIT 时,系统资源被占满时,会导致处理不过来新的连接;

tcp_max_tw_buckets

Linux 提供了 tcp_max_tw_buckets 参数,当 TIME_WAIT 的连接数量超过该参数时,新关闭的连接就不再经历 TIME_WAIT 而直接关闭:

sysctl -a | grep tcp_max_tw_buckets
net.ipv4.tcp_max_tw_buckets = 4096

当服务器的并发连接增多时,相应地,同时处于 TIME_WAIT 状态的连接数量也会变多,此时就应当调大 tcp_max_tw_buckets 参数,减少不同连接间数据错乱的概率。

tcp_max_tw_buckets 也不是越大越好,毕竟内存和端口都是有限的。

tcp_tw_reuse

有一种方式可以在建立新连接时,复用处于 TIME_WAIT 状态的连接,那就是打开 tcp_tw_reuse 参数。但是需要注意,该参数是只用于客户端(建立连接的发起方),因为是在调用 connect() 时起作用的,而对于服务端(被动连接方)是没有用的。

sysctl -a | grep tcp_tw_reuse
net.ipv4.tcp_tw_reuse = 2

TCP 传输数据的性能提升

图片-1650261195770

TCP 连接是由内核维护的,内核会为每个连接建立内存缓冲区:

  • 如果连接的内存配置过小,就无法充分使用网络带宽,TCP 传输效率就会降低;
  • 如果连接的内存配置过大,很容易把服务器资源耗尽,这样就会导致新连接无法建立;

滑动窗口是如何影响传输速度的?

TCP 会保证每一个报文都能够抵达对方,它的机制是这样:报文发出去后,必须接收到对方返回的确认报文 ACK,如果迟迟未收到,就会超时重发该报文,直到收到对方的 ACK 为止。

所以,TCP 报文发出去后,并不会立马从内存中删除,因为重传时还需要用到它。

由于 TCP 是内核维护的,所以报文存放在内核缓冲区。如果连接非常多,我们可以通过 free 命令观察到 buff/cache 内存是会增大。

如果 TCP 是每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了, 再发送下一个。这个模式就有点像我和你面对面聊天,你一句我一句,但这种方式的缺点是效率比较低的。

图片-1650247452079

所以,这样的传输方式有一个缺点:数据包的往返时间越长,通信的效率就越低。

要解决这一问题不难,并行批量发送报文,再批量确认报文即刻。

图片-1650247481904

然而,这引出了另一个问题,发送方可以随心所欲的发送报文吗?当然这不现实,我们还得考虑接收方的处理能力。

当接收方硬件不如发送方,或者系统繁忙、资源紧张时,是无法瞬间处理这么多报文的。于是,这些报文只能被丢掉,使得网络效率非常低。

为了解决这种现象发生,TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是滑动窗口的由来。

接收方根据它的缓冲区,可以计算出后续能够接收多少字节的报文,这个数字叫做接收窗口。当内核接收到报文时,必须用缓冲区存放它们,这样剩余缓冲区空间变小,接收窗口也就变小了;当进程调用 read 函数后,数据被读入了用户空间,内核缓冲区就被清空,这意味着主机可以接收更多的报文,接收窗口就会变大。

因此,接收窗口并不是恒定不变的,接收方会把当前可接收的大小放在 TCP 报文头部中的窗口字段,这样就可以起到窗口大小通知的作用。

发送方的窗口等价于接收方的窗口吗?如果不考虑拥塞控制,发送方的窗口大小「约等于」接收方的窗口大小,因为窗口通知报文在网络传输是存在时延的,所以是约等于的关系。

图片-1650247552341

从上图中可以看到,窗口字段只有 2 个字节,因此它最多能表达 65535 字节大小的窗口,也就是 64KB 大小。

这个窗口大小最大值,在当今高速网络下,很明显是不够用的。所以后续有了扩充窗口的方法:在 TCP 选项字段定义了窗口扩大因子,用于扩大TCP通告窗口,使 TCP 的窗口大小从 2 个字节(16 位) 扩大为 30 位,所以此时窗口的最大值可以达到 1GB(2^30)。

tcp_window_scaling

Linux 中打开这一功能,需要把 tcp_window_scaling 配置设为 1(默认打开):

cat /proc/sys/net/ipv4/tcp_window_scaling
1

要使用窗口扩大选项,通讯双方必须在各自的 SYN 报文中发送这个选项:

  • 主动建立连接的一方在 SYN 报文中发送这个选项;
  • 而被动建立连接的一方只有在收到带窗口扩大选项的 SYN 报文之后才能发送这个选项。

这样看来,只要进程能及时地调用 read 函数读取数据,并且接收缓冲区配置得足够大,那么接收窗口就可以无限地放大,发送方也就无限地提升发送速度。

这是不可能的,因为网络的传输能力是有限的,当发送方依据发送窗口,发送超过网络处理能力的报文时,路由器会直接丢弃这些报文。因此,缓冲区的内存并不是越大越好。

如何确定最大传输速度?

在前面我们知道了 TCP 的传输速度,受制于发送窗口与接收窗口,以及网络设备传输能力。其中,窗口大小由内核缓冲区大小决定。如果缓冲区与网络传输能力匹配,那么缓冲区的利用率就达到了最大化。

问题来了,如何计算网络的传输能力呢?

相信大家都知道网络是有「带宽」限制的,带宽描述的是网络传输能力,它与内核缓冲区的计量单位不同:

  • 带宽是单位时间内的流量,表达是「速度」,比如常见的带宽 100 MB/s;
  • 缓冲区单位是字节,当网络速度乘以时间才能得到字节数;

这里需要说一个概念,就是带宽时延积,它决定网络中飞行报文的大小,它的计算方式:

带宽时延积 BDR = RTT * 带宽

比如最大带宽是 100 MB/s,网络时延(RTT)是 10ms 时,意味着客户端到服务端的网络一共可以存放 100MB/s * 0.01s = 1MB 的字节。

这个 1MB 是带宽和时延的乘积,所以它就叫「带宽时延积」(缩写为 BDP,Bandwidth Delay Product)。同时,这 1MB 也表示「飞行中」的 TCP 报文大小,它们就在网络线路、路由器等网络设备上。如果飞行报文超过了 1 MB,就会导致网络过载,容易丢包。

由于发送缓冲区大小决定了发送窗口的上限,而发送窗口又决定了「已发送未确认」的飞行报文的上限。因此,发送缓冲区不能超过「带宽时延积」。

发送缓冲区与带宽时延积的关系:

  • 如果发送缓冲区「超过」带宽时延积,超出的部分就没办法有效的网络传输,同时导致网络过载,容易丢包;
  • 如果发送缓冲区「小于」带宽时延积,就不能很好的发挥出网络的传输效率。所以,发送缓冲区的大小最好是往带宽时延积靠近。

怎样调整缓冲区大小?

在 Linux 中发送缓冲区和接收缓冲都是可以用参数调节的。设置完后,Linux 会根据你设置的缓冲区进行动态调节。

tcp_wmem 调节发送缓冲区范围

先来看看发送缓冲区,它的范围通过 tcp_wmem 参数配置;

# 调整 TCP 发送缓冲区范围
echo "4096 16384 4194304" > /proc/sys/net/ipv4/tcp_wmem

上面三个数字单位都是字节,它们分别表示:

  • 第一个数值是动态范围的最小值,4096 byte = 4K;
  • 第二个数值是初始默认值,16384 byte = 16K;
  • 第三个数值是动态范围的最大值,4194304 byte = 4096K(4M);

发送缓冲区是自行调节的,当发送方发送的数据被确认后,并且没有新的数据要发送,就会把发送缓冲区的内存释放掉。

tcp_rmem 调节接收缓冲区范围

单位是字节,接收缓冲区大小,缓存对端接收的数据,后续被应用程序读取。

而接收缓冲区的调整就比较复杂一些,先来看看设置接收缓冲区范围的 tcp_rmem 参数:

# 调整 TCP 接收缓冲区范围
echo "4096 131072 6291456" > /proc/sys/net/ipv4/tcp_rmem

上面三个数字单位都是字节,它们分别表示:

  • 第一个数值是动态范围的最小值,表示即使在内存压力下也可以保证的最小接收缓冲区大小,4096 byte = 4K;
  • 第二个数值是初始默认值,131072 byte ≈ 128K;
  • 第三个数值是动态范围的最大值,6291456 byte = 6144K(6M);

接收缓冲区可以根据系统空闲内存的大小来调节接收窗口:

  • 如果系统的空闲内存很多,就可以自动把缓冲区增大一些,这样传给对方的接收窗口也会变大,因而提升发送方发送的传输数据数量;
  • 反正,如果系统的内存很紧张,就会减少缓冲区,这虽然会降低传输效率,可以保证更多的并发连接正常工作;

tcp_moderate_rcvbuf

发送缓冲区的调节功能是自动开启的,而接收缓冲区则需要配置 tcp_moderate_rcvbuf 为 1 来开启调节功能:

sysctl -a | grep tcp_moderate_rcvbuf
net.ipv4.tcp_moderate_rcvbuf = 1

# 启动 TCP 接收缓冲区自动调节功能
echo 1 > /proc/sys/net/ipv4/tcp_moderate_rcvbuf

调节 TCP 内存范围

接收缓冲区调节时,怎么知道当前内存是否紧张或充分呢?这是通过 tcp_mem 配置完成的

tcp_mem

单位是 page,此值是动态的,Linux 根据机器自身内存情况进行分配,一个 page 是 4k

# 调整 TCP 内存范围
echo "88560 118080 177120" > /proc/sys/net/ipv4/tcp_mem

上面三个数字单位不是字节,而是「页面大小」,1 页表示 4KB,它们分别表示:

  • 当 TCP 内存小于第 1 个值时,不需要进行自动调节;
  • 在第 1 和第 2 个值之间时,内核开始调节接收缓冲区的大小;
  • 大于第 3 个值时,内核不再为 TCP 分配新内存,此时新连接是无法建立的;

一般情况下这些值是在系统启动时根据系统内存数量计算得到的。根据当前 tcp_mem 最大内存页面数是 177120,当内存为 (177120 * 4) / 1024K ≈ 692M 时,系统将无法为新的 TCP 连接分配内存,即 TCP 连接将被拒绝。

根据实际场景调节的策略

在高并发服务器中,为了兼顾网速与大量的并发连接,我们应当保证缓冲区的动态调整的最大值达到带宽时延积,而最小值保持默认的 4K 不变即可。而对于内存紧张的服务而言,调低默认值是提高并发的有效手段。

同时,如果这是网络 IO 型服务器,那么,调大 tcp_mem 的上限可以让 TCP 连接使用更多的系统内存,这有利于提升并发能力。 需要注意的是,tcp_wmem 和 tcp_rmem 的单位是字节,而 tcp_mem 的单位是页面大小。而且,千万不要在 socket 上直接设置 SO_SNDBUF 或者 SO_RCVBUF,这样会关闭缓冲区的动态调整功能。

TCP协议-TCP的拥塞控制

TCP模块除了要进行流量控制外,还有一个重要的任务,就是提高网络利用率,降低丢包率,并保证网络资源对每条TCP连接的数据流的公平性。这就是拥塞控制要解决的问题。

TCP的拥塞控制方法

TCP 进行拥塞控制的过程有四个部分,分别是:慢开始(slow-start)、拥塞避免(congestion avoidance)、快重传(fast retransmit) 和 快恢复(fast recovery)

拥塞方法

拥塞控制算法在 Linux 下有多种实现,比如:reno算法、vegas算法 和 cubic算法等。它们或者部分或者全部实现了上述四个部分。

cat /proc/sys/net/ipv4/tcp_congestion_control
cubic

下面介绍这些算法的原理。为了集中精力讨论拥塞控制,我们假定:
(1)数据是单方向传送,对方只传送确认报文段。
(2)接收方总是有足够大的缓存空间,因而发送窗口的大小由网络的拥塞程度来决定。
(3)以TCP报文段的个数为讨论问题的单位,而不是以字节为单位。

拥塞窗口(cwnd)

TCP 的拥塞控制也叫做基于窗口的拥塞控制。为此,发送方维持了叫做拥塞窗口 cwnd(congestion window)的状态变量。拥塞窗口的大小取决于网络的拥塞程度,并且动态地变化。发送方让自己的发送窗口等于拥塞窗口。

发送方控制拥塞窗口cwnd的原则是:只有网络没有出现拥塞,拥塞窗口就可以再增大一些,以便把更多的分组发送出去,这样就可以提供网络的利用率。但只要网络出现拥塞或者用可能出现拥塞,就必须把拥塞窗口减小一些,以减少注入到网络中的分组数,以便缓解网络出现的拥塞。

发送方又是如何知道网络出现了拥塞呢?我们知道,当网络发生拥塞时,路由器就要丢弃分组。因此,只要发送方没有按时收到应当到达的确认报文,也就是说,只要出现了超时,就可以猜测网络可能出现了拥塞。现在通信线路的质量一般都很好,因传输出差错而丢弃分组的概率很小(远小于1%)。因此,判断网络拥塞的依据就是出现了超时

慢开始和拥塞避免

慢开始算法的思路是这样的:当主机开始发送数据时,由于不清楚网络的负荷情况,所以如果立即把大量数据字节注入到网络,那么就有可能引起网络拥塞。经验证明,较好的方法是先探测一下,即由小大到逐渐增大发送窗口,也就是说,由小到大逐渐增大拥塞窗口数值。

旧的规定是这样的:在刚刚开始发送报文段时,先把初始拥塞窗口 cwnd 设置为 1 至 2 个发送方的最大报文段 SMSS(Sender Maximum Segment Size) 的数值,但新的 RFC 5681 把初始拥塞窗口 cwnd 设置为不超过 2 至 4 个 SMSS 的数值。具体的规定如下:

  • 若 SMSS > 2190 字节,则设置初始拥塞窗口 cwnd = 2 × SMSS 字节,且不得超过 2 个报文段。
  • 若 (SMSS > 1095 字节) && (SMSS ≤ 2190 字节),则设置初始拥塞窗口 cwnd = 3 × SMSS 字节,且不得超过 3 个报文段。
  • 若 SMSS ≤ 1095 字节,则设置初始拥塞窗口 cwnd = 4 × SMSS 字节,且不得超过 4 个报文段。

由此可见,这个规定就是限制初始拥塞窗口 cwnd 的字节数。慢开始规定,在每收到一个对新的报文段的确认后,可以把拥塞窗口增加最多一个 SMSS 的数值。即:

拥塞窗口 cwnd 每次的增加量 = min(N, SMSS)

其中,N 是原先未被确认的、但现在被刚收到的确认报文段所确认的字节数。不难看出,当 N < SMSS 时,拥塞窗口每次的增加量要小于 SMSS。用这样的方法逐步增大发送方的拥塞窗口 cwnd,可以使分组注入到网络的速率更加合理。

慢开始

每经过一个传输轮次(transmission round),拥塞窗口 cwnd 就加倍。

慢开始的“慢”并不是指 cwnd 的增长速率慢,而是指在 TCP 开始发送报文段时先设置 cwnd = 1,使得发送方在开始时只发送一个报文段(目的是试探一下网络当前的拥塞情况),然后再逐渐增大 cwnd。这当然比设置大的 cwnd 值一下子把许多报文段注入到网络中要“慢得多”。这对防止网络出现拥塞是一个非常好的方法。

拥塞避免

拥塞避免算法的思路是让拥塞窗口 cwnd 缓慢地增长,即每经过一个往返时间 RTT 就把发送方的 拥塞窗口 cwnd 加 1,而不是像慢开始阶段那样加倍增长。因此在拥塞避免阶段就有“加法增大” AI(Additive Increase)的特点。这表明在拥塞避免阶段,拥塞窗口 cwnd 按线性规律缓慢增长,比慢开始算法的拥塞窗口增长速率缓慢得多。

  • 上面说的 “每经过一个往返时间 RTT 就把发送方的拥塞窗口 cwnd 加 1”,实际上应当是“拥塞窗口仅增加一个 MSS 的大小,单位是字节”。在具体实现拥塞避免算法的方法时可以这样来完成:只要收到一个新的确认,就是拥塞窗口 cwnd 增加 (MSS × MSS / cwnd)个字节。
  • 例如,假定 cwnd = 10 个 MSS的长度,而 MSS = 1460 字节。发送方可一次一连发送 14600 字节(即10个报文段)。
  • 假定接收方每收到一个报文段就发回一个确认。于是发送方每收到一个新的确认,就把拥塞窗口稍微增大一些,即增大 0.1 MSS = 146 字节。经过一个往返时间 RTT (或一个传输轮次)后,发送方共收到 10 个新的确认,拥塞窗口 cwnd 就增大了 1460字节,正好是一个 MSS 的大小。

下图的图 用具体的例子说明了在拥塞控制的过程中,TCP 的拥塞窗口 cwnd 是怎样变化的?图中的数字 ① 至 ⑤ 是特别要注意的几个点。现假定 TCP 的发送窗口等于拥塞窗口。

当 TCP 建立连接进行初始化时,把拥塞窗口 cwnd 置为1。为了便于理解,图中的窗口单位不使用字节而使用报文段的个数。在本例中,慢开始门限的初始值设置为 16 个报文段,即 ssthresh = 16。在执行慢开始算法时,发送方每收到一个对新报文段的确认 ACK,就把拥塞窗口值加 1,然后开始下一轮的传输(请注意,图 的横坐标是传输轮次,不是时间)。因此拥塞窗口 cwnd 随着传输轮次按指数规律增长。当拥塞窗口 cwnd 增长到慢开始门限值 ssthresh 时(图中的点①,此时拥塞窗口 cwnd = 16),就开始改为执行拥塞避免算法,拥塞窗口按线性规律增长。但请注意,“拥塞避免”并非完全能够避免了拥塞。“拥塞避免” 是说把拥塞窗口控制为按线性规律增长,使网络比较不容易出现拥塞。

图片-1650268168412

当拥塞窗口 cwnd = 24 时,网络出现了超时(图中的点②),发送方判断为网络拥塞。于是调整门限值 ssthresh = cwnd / 2 = 12,同时设置拥塞窗口 cwnd =1,又重新进入慢开始阶段。

按照慢开始算法,发送方每收到一个对新报文段的确认 ACK,就把拥塞窗口值加1。当拥塞窗口 cwnd = ssthresh = 12 时(图中的点③,这是新的 ssthresh 值),改为执行拥塞避免算法,拥塞窗口按线性规律增大。

当拥塞窗口 cwnd = 16 时(图中的点④),出现了一个新的情况,就是发送方一连收到 3 个对同一报文段的重复确认(图中记为 3-ACK)。关于这个问题,解释如下:
有时,个别报文段会在网络中丢失,但实际上网络并未发生拥塞。如果发送方迟迟收不到确认,就会产生超时,就会误认为网络发生了拥塞。这就导致发送方错误地启动慢开始,把拥塞窗口 cwnd 又置为1,因而降低了传输效率。

为了解决这个问题,需要用到快重传算法。采用快重传算法的目的是:可以让发送方尽早知道发生了个别报文段的丢失。

ssthresh

为了防止拥塞窗口 cwnd 增长过大引起网络拥塞,还需要设置一个慢开始门限 ssthresh 状态变量。慢开始门限 ssthresh 的用法如下:

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

快重传和快恢复

快重传

所谓 “快重传” ,就是让发送方尽快重传丢失报文段,而不是等待超时重传计时器超时后再重传。有以下几点要求:

  • 要求接收方不要等待自己发送数据时才进行捎带确认,而是要立即发送确认。
  • 即使接收方收到了失序的报文段,也要立即发出对已收到的报文段的重复确认。
  • 发送方只要一连收到 3 个对同一报文段的重复确认,就将相应的报文段立即重传,而不是等待报文段的超时重传计时器超时后再重传。

快重传示意图

图片-1650268350191

1、接收方收到了报文段 M1 和 M2 后,都分别及时发出了确认。
2、现假定接收方没有收到 M3 但却收到了 M4。本来接收方可以什么都不做。但按照快重传算法,接收方必须立即发送对 M2 的重复确认,以便让发送方及早知道接收方没有收到报文段 M3。发送方接着发送 M5 和 M6。
3、接收方收到 M5 和 M6 后,仍要再次分别发出对 M2 的重复确认。这样,发送方共收到了接收方的 4 个对 M2 的确认,其中后 3 个都是重复确认。
4、快重传算法规定,发送方只要一连收到 3 个对同一报文段(该例子中指的是报文段 M2)的重复确认,就知道接收方确实没有收到报文段 M3,因而应当立即进行重传(即 “快重传”),这样发送方就不会出现超时,也就不会认为出现了网络拥塞。

使用快重传算法可以使整个网络的吞吐量提供约 20%。

快恢复

在上图的图 中的点④,发送方知道现在只是丢失了个别的报文段,而不是发生了网络拥塞。于是不启动慢开始,而是执行快恢复算法。

这时,发送方调整门限值 ssthresh = cwnd / 2 = 8,同时设置拥塞窗口 cwnd = ssthresh = 8(图中的点⑤),并开始执行拥塞避免算法。

<注> 虽然 RFC 5681 给出了根据已发送出去但还未收到确认的数据的字节数来设置 ssthresh 值的新的计算公式,即:ssthresh = max { FlightSize / 2, 2 × SMSS}
这里 FlightSize 表示的是正在网络中传送的数据量。但在讨论拥塞控制原理时,我们为了简化问题,采用的是把慢开始门限值减半的方式来表述。

请注意,也有的快恢复实现是把快恢复开始时的拥塞窗口 cwnd 的值再增大一些(增大 3 个报文段的长度),即:新的 ssthresh + 3 × MSS。这样做的理由是:既然发送发收到了3个重复的确认,就表明有3个分组已经离开了网络。这 3 个分组不再消耗网络的资源而是停留在接收方的接收缓存中(接收方发送出 3 个重复的确认就证明了这个事实)。可见网络中并不是堆积了分组而是减少了 3 个分组。因此可以适当把拥塞窗口扩大些。

从图可以看出,在拥塞避免阶段,拥塞窗口 cwnd 是按照线性规律增大的,这常称为“加法增大” AI(Additive Increase)。而一旦出现超时或 3 个重复的确认,就要把慢开始门限值 ssthresh 设置为当前 拥塞窗口值 cwnd 的一半,并大大减小拥塞窗口的数值。这常称为 “乘法减小” MD(Multiplicative Decrease)。二者合在一起就是所谓的 AIMD 算法。

快恢复的两个特点
  • 当发送方连续收到 3 个重复确认时,就执行“乘法减小”算法,把慢开始门限值减半,这是为了预防网络发生拥塞。
  • 由于发送方认为当前网络没有发生拥塞,因此现在不执行慢开始算法,而是把拥塞窗口值 cwnd 设置为慢开始门限减半后的值,然后开始执行拥塞避免算法,使拥塞窗口值线性增大。

快恢复和慢开始的区别

慢开始算法只是在TCP连接建立时或是遇到网络拥塞后才使用,快恢复是在遇到 3-ACK 时触发,常常伴随着快重传算法。

流程梳理

图片-1650268841720

对图示例中的整个流程进行一下梳理:
1、当TCP连接进行初始化时,把拥塞窗口值 cwnd 置为 1;同时把慢开始门限的初始值设置为 16 个报文段的长度,即:ssthresh = 16。
2、开始执行慢开始算法。发送方每收到一个对新报文段的确认ACK,就把拥塞窗口值加1。一个传输轮次,拥塞窗口值就增加一倍。即拥塞窗口值随着传输轮次按指数规律增长。
3、当拥塞窗口增大到等于慢开始门限值时(图中的点①),此时拥塞窗口 cwnd = 16。
4、改为执行拥塞避免算法。每经过一个传输轮次,拥塞窗口 cwnd 就增加 1。此时,拥塞窗口随着传输轮次按线性规律增长。
5、当拥塞窗口 cwnd = 24 时,发生了超时现象(图中的点②),发送方判断为网络拥塞,于是调整门限值 ssthresh=cwnd/2=12,同时重新设置拥塞窗口 cwnd =1,进入慢开始阶段。
6、按照慢开始算法,发送方每收到一个对新报文段的确认ACK,就把拥塞窗口值加1。一个传输轮次,拥塞窗口值就增加一倍。
7、当拥塞窗口 cwnd 增长到等于 慢开始门限值 ssthresh = 12 时(图中的点③,这是新的 ssthresh 值),改为执行拥塞避免算法,拥塞窗口按线性规律增长。
8、当拥塞窗口 cwnd = 16 时(图中的点④),出现了一个新的情况,就是发送方一连收到 3 个对同一报文段的重复确认(图中记为 3-ACK)。
9、根据图中的点④的情况,发送方知道了现在只是个别报文段出现了丢失的情况,于是不启动慢开始算法,而是执行快恢复算法。
10、这时,发送方调整门限值 ssthresh = cwnd/2 = 8,同时设置拥塞窗口 cwnd = ssthresh = 8(图 的点⑤),并开始执行拥塞避免算法。

TCP拥塞控制流程图

图片-1650268981756

从流程图中,我们可以很清晰地看到在拥塞控制的各个阶段,采用哪种算法。例如,在慢开始阶段,如果出现了超时(即出现了网络拥塞)或出现了 3-ACK,发送方应采取什么措施。

发送方窗口值的限制因素

上文中所阐述的拥塞控制,我们设置了一个假定条件:接收方总是有足够大的缓存空间。因而发送窗口的大小由网络的拥塞程度决定。但实际上接收方的缓存空间总是有限的。接收方根据自己的接收能力设定了一个接收方窗口 rwnd,并把这个窗口值写入 TCP 报文段的首部中的窗口字段,传送给发送方。因此,接收方窗口 又称为 通知窗口(advertised window)。因此,从接收方对发送方的流量控制的角度考虑,发送方的发送窗口一定不能超过对方给出的接收方窗口值 rwnd。

如果把本节所讨论的拥塞控制和接收方对发送发的流量控制一起考虑的话,那么很显然,发送方的窗口的上限值应当取为接收方窗口 rwnd 和 拥塞窗口 cwnd 这两个变量中较小的一个,也就是说:

发送方窗口值的上限值 = Min[rwnd, cwnd]

  • 当 rwnd < cwnd 时,是接收方的接收能力限制发送方窗口的最大值。
  • 反之,当 cwnd < rwnd 时,则是网络的拥塞程度限制发送方窗口的最大值。
  • 也就是说,rwnd 和 cwnd 中数值较小的一个,控制了发送方发送数据的速率。

接收窗口的限制因素

kernel相关参数

sudo sysctl -a | egrep "rmem|wmem|tcp_mem|adv_win|moderate"
net.core.rmem_default = 212992
net.core.rmem_max = 212992
net.core.wmem_default = 212992 //core是给所有的协议使用的,
net.core.wmem_max = 212992
net.ipv4.tcp_adv_win_scale = 1
net.ipv4.tcp_moderate_rcvbuf = 1
net.ipv4.tcp_rmem = 4096    87380    6291456
net.ipv4.tcp_wmem = 4096    16384    4194304 //tcp有自己的专用选项就不用 core 里面的值了
net.ipv4.udp_rmem_min = 4096
net.ipv4.udp_wmem_min = 4096
vm.lowmem_reserve_ratio = 256    256    32
net.ipv4.tcp_mem = 88560        118080  177120

发送buffer系统比较好自动调节,依靠发送数据大小和rt延时大小,可以相应地进行调整;但是接受buffer就不一定了,接受buffer的使用取决于收到的数据快慢和应用读走数据的速度,只能是OS根据系统内存的压力来调整接受buffer。系统内存的压力取决于 net.ipv4.tcp_mem.

需要特别注意:tcp_wmem 和 tcp_rmem 的单位是字节,而 tcp_mem 的单位的页面

图片-1650276797453

Linux网络栈之队列

数据包队列是任何一个网络栈的核心组件,数据包队列实现了异步模块之间的通讯,提升了网络性能,并且拥有影响延迟的副作用。本文的目标,是解释Linux的网络栈中IP数据包在何处排队,新的延迟降低技术如BQL是多么的有趣,以及如何控制缓冲区以降低延迟。

图片-1650349076507

驱动队列(Driver Queue,又名环形缓冲)

在内核的IP stack和网络接口控制器(NIC)之间,存在一个驱动队列。这个队列典型地以一个先进先出的环形缓冲区实现 —— 即一个固定大小的缓冲区。驱动队列并不附带数据包数据,而是持有指向内核中名为socket kernel buffers(SKBs)的结构体的描述符,SKBs持有数据包的数据并且在整个内核中使用。

图片-1650349246103

驱动队列的输入来源是一个为所有数据包排队的IP stack,这些数据包可能是本地生成,或者在一个路由器上,由一个NIC接收然后选路从另一个NIC发出。数据包从IP stack入队到驱动队列后,将会被驱动程序执行出队操作,然后通过数据总线进行传输。

驱动队列之所以存在,是为了保证系统无论在任何需要传输数据, NIC都能立即传输。换言之,驱动队列从硬件上给予了IP stack一个异步数据排队的地方。一个可选的方式是当NIC可以传输数据时,主动向IP stack索取数据,但这种设计模式下,无法实时对NIC响应,浪费了珍贵的传输机会,损失了网络吞吐量。另一个与此相反的方法是IP stack创建一个数据包后,需要同步等待NIC,直到NIC可以发送数据包,这也不是一个好的设计模式,因为在同步等待的过程中IP stack无法执行其它工作。

巨型数据包

绝大多数的NIC都拥有一个固定的最大传输单元(MTU),意思是物理媒介可以传输的最大帧。以太网默认的MTU是1,500字节,但一些以太网络支持上限9,000字节的巨型帧(Jumbo Frames)。在IP 网络栈中,MTU描述了一个可被传输的数据包大小上限。例如,一个应用程序通过TCP socket发送了2,000字节的数据,IP stack就需要把这份数据拆分成数个数据包,以保持单个数据包的小于或等于MTU(1,500)。传输大量数据时,小的MTU将会产生更多分包。

为了避免大量数据包排队,Linux内核实现了数个优化:TCP segmentation offload (TSO), UDP fragmentation offload (UFO) 和 generic segmentation offload (GSO),这些优化机制允许IP stack创建大于出口NIC MTU的数据包。以IPv4为例,可以创建上限为65,536字节的数据包,并且可以入队到驱动队列。在TSO和UFO中,NIC在硬件上实现并负责拆分大数据包,以适合在物理链路上传输。对于没有TSO和UFO支持的NIC,GSO则在软件上实现同样的功能。

前文提到,驱动队列只有固定容量,只能存放固定数量的描述符,由于TSO,UFO和GSO的特性,使得大型的数据包可以加入到驱动队列当中,从而间接地增加了队列的容量。图三与图二的比较,解释了这个概念。

图片-1650349366108

虽然本文的其余部分重点介绍传输路径,但值得注意的是Linux也有工作方式像TSO,UFO和GSO的接收端优化。这些优化的目标也是减少每一个数据包的开销。特别地,generic receive offload (GRO)允许NIC驱动把接收到的数据包组合成一个大型数据包,然后加入IP stack。在转发数据包的时候,为了维护端对端IP数据包的特性,GRO会重新组合接收到的数据包。然而,这只是单端效果,当大型数据包在转发方处拆分时,将会出现多个数据包一次性入队的情况,这种数据包"微型突发"会给网络延迟带来负面影响。

饥饿与延迟

先不讨论必要性与优点,在IP stack和硬件之间的队列描述了两个问题:饥饿与延迟。

如果NIC驱动程序要处理队列,此时队列为空,NIC将会失去一个传输数据的机会,导致系统的生产量降低。这种情况定义为饥饿。需要注意:当操作系统没有任何数据需要传输时,队列为空的话,并不归类为饥饿,而是正常。为了避免饥饿,IP stack在填充驱动队列的同时,NIC驱动程序也要进行出队操作。糟糕的是,队列填满或为空的事件持续的时间会随着系统和外部的情况而变化。例如,在一个繁忙的操作系统上,IP stack很少有机会往驱动队列中入队数据包,这样有很大的几率出现驱动队列为空的情况。拥有一个大容量的驱动队列缓冲区,有利于减少饥饿的几率,提高网络吞吐量。

虽然一个大的队列有利于增加吞吐量,但缺点也很明显:提高了延迟。

图片-1650349487890

图片4展示了驱动队列几乎被单个高流量(蓝色)的TCP段填满。队列中最后一个数据包来自VoIP或者游戏(黄色)。交互式应用,例如VoIP或游戏会在固定的间隔发送小数据包,占用大量带宽的数据传输会使用高数据包传输速率传输大量数据包,高速率的数据包传输将会在交互式数据包之间插入大量数据包,从而导致这些交互式数据包延迟传输。为了进一步解释这种情况,假设有如下场景:

一个网络接口拥有5 Mbit/sec(或5,000,000 bit/sec)的传输能力

每一个大流量的数据包都是1,500 bytes或12,000 bits。

每一个交互式数据包都是500 bytes。

驱动队列的长度为128。

有127个大流量数据包,还有1个交互式数据包排在队列末尾。

在上述情况下,发送127个大流量的数据包,需要(127 * 12,000) / 5,000,000 = 0.304 秒(以ping的方式来看,延迟值为304毫秒)。如此高的延迟,对于交互式程序来说是无法接受的,然而这还没计算往返时间。前文提到,通过TSO,UFO,GSO技术,大型数据包还可以在队列中排队,这将导致延迟问题更严重。

大的延迟,一般由过大、疏于管理的缓冲区造成,如Bufferbloat。更多关于此现象的细节,可以查阅控制队列延迟(Controlling Queue Delay),以及Bufferbloat项目。

如上所述,为驱动队列选择一个合适的容量是一个Goldilocks问题 – 这个值不能太小,否则损失吞吐量,也不能太大,否则过增延迟。

字节级队列限制(Byte Queue Limits (BQL))

Byte Queue Limits (BQL)是一个在Linux Kernel 3.3.0加入的新特性,以自动解决驱动队列容量问题。BQL通过添加一个协议,计算出的当前情况下避免饥饿的最小数据包缓冲区大小,以决定是否允许继续向驱动队列中入队数据包。根据前文,排队的数据包越少,数据包排队的最大发送延迟就越低。

需要注意,驱动队列的容量并不能被BQL修改,BQL做的只是计算出一个限制值,表示当时有多少的数据可以被排队。任何超过此限制的数据,是等待还是被丢弃,会根据协议而定。

BQL机制在以下两种事件发生时将会触发:数据包入队,数据包传输完成。一个简化的BQL算法版本概括如下IMIT为BQL根据当前情况计算出的限制值。

****
** 数据包入驱动队列后
****

如果队列排队数据包的总数据量超过当前限制值
禁止数据包入驱动队列

这里要清楚,被排队的数据量可以超过LIMIT,因为在TSO,UFO或GSO启用的情况下,一个大型的数据包可以通过单个操作入队,因此LIMIT检查在入队之后才发生,如果你很注重延迟,那么可能需要考虑关闭这些功能,本文后面将会提到如何实现这个需求。

BQL的第二个阶段在硬件完成数据传输后触发(pseudo-code简化版):

****
** 当硬件已经完成一批次数据包的发送
** (一个周期结束)
****

如果硬件在一个周期内处于饥饿状态
提高LIMIT

否则,如果硬件在一个周期内都没有进入饥饿状态,并且仍然有数据需要发送
使LIMIT减少"本周期内留下未发送的数据量"

如果驱动队列中排队的数据量小于LIMIT
  允许数据包入驱动队列

如你所见,BQL是以测试设备是否被饥饿为基础实现的。如果设备被饥饿,LIMIT值将会增加,允许更多的数据排队,以减少饥饿,如果设备整个周期内都处于忙碌状态并且队列中仍然有数据需要传输,表明队列容量大于当前系统所需,LIMIT值将会降低,以避免延迟的提升。

BQL对数据排队的影响效果如何?一个真实世界的案例也许可以给你一个感觉。我的一个服务器的驱动队列大小为256个描述符,MTU 1,500字节,意味着最多能有256 * 1,500 = 384,000字节同时排队(TSO,GSO之类的已被关闭,否则这个值将会更高)。然而,由BQL计算的限制值是3,012字节。如你所见,BQL大大地限制了排队数据量。

BQL的一个有趣方面可以从它名字的第一个词思议——byte(字节)。不像驱动队列和大多数的队列容量,BQL直接操作字节,这是因为字节数与数据包数量相比,能更有效地影响数据传输的延迟。

BQL通过限制排队的数据量为避免饥饿的最小需求值以降低网络延迟。对于移动大量在入口NIC的驱动队列处排队的数据包到queueing discipline(QDisc)层,BQL起到了非常重要的影响。QDisc层实现了更复杂的排队策略,下一节介绍Linux QDisc层。

排队规则(Queuing Disciplines (QDisc))

驱动队列是一个很简单的先进先出(FIFO)队列,它平等对待所有数据包,没有区分不同流量数据包的功能。这样的设计优点是保持了驱动程序的简单以及高效。要注意更多高级的以太网络适配器以及绝大多数的无线网络适配器支持多种独立的传输队列,但同样的都是典型的FIFO。较高层的负责选择需要使用的传输队列。

在IP stack和驱动队列之间的是排队规则(queueing discipline(QDisc))层(见图1)。这一层实现了内核的流量管理能力,如流量分类,优先级和速率调整。QDisc层通过一些不透明的tc命令进行配置。QDisc层有三个关键的概念需要理解:QDiscs,classes(类)和filters(过滤器)。

QDisc是Linux对流量队列的一个抽象化,比标准的FIFO队列要复杂得多。这个接口允许QDisc提供复杂的队列管理机制而无需修改IP stack或者NIC驱动。默认地,每一个网络接口都被分配了一个pfifo_fast QDisc,这是一个实现了简单的三频优先方案的队列,排序以数据包的TOS位为基础。尽管这是默认的,pfifo_fast QDisc离最佳选择还很远,因为它默认拥有一个很深的队列(见下文的txqueuelen)并且无法区分流量。

第二个与QDisc关系很密切的概念是类,独立的QDiscs为了以不同方式处理子集流量,可能实现类。例如,分层令牌桶(Hierarchical Token Bucket (HTB))QDisc允许用户配置一个500 Kbps和300 Kbps的类,然后根据需要,把流量归为特定类。需要注意,并非所有QDiscs拥有对多个类的支持——那些被称为类的QDiscs。

过滤器(也被称为分类器),是一个用于流量分类到特定QDisc或类的机制。各种不同的过滤器复杂度不一,u32是一个最通用的也可能是一个最易用的流量过滤器。流量过滤器的文档比较缺乏,不过你可以在此找到使用例子:我的一个QoS脚本。

更多关于QDiscs,classes和filters的信息,可阅LARTC HOWTO,以及tc的man pages。

更多关于QDiscs,classes和filters的信息,可阅LARTC HOWTO,以及tc的man pages。

在前面的图片中,你可能会发现排队规则层并没有数据包队列。这意思是,网络栈直接放置数据包到排队规则中或者当队列已满时直接放回到更上层(例如socket缓冲区)。这很明显的一个问题是,如果接下来有大量数据需要发送,会发送什么?这种情况会在TCP链接发生大量堵塞或者甚至有些应用程序以其最快的速度发送UDP数据包时出现。对于一个持有单个队列的QDisc,与图4中驱动队列同样的问题将会发生,亦即单个大带宽或者高数据包传输速率流会把整个队列的空间消耗完毕,从而导致丢包,极大影响其它流的延迟。更糟糕的是,这产生了另一个缓冲点,其中可以形成standing queue,使得延迟增加并导致了TCP的RTT和拥塞窗口大小计算问题。Linux默认的pfifo_fast QDisc,由于大多数数据包TOS标记为0,因此基本可以视作单个队列,因此这种现象并不罕见。

Linux 3.6.0(2012-09-30),加入了一个新的特性,称为TCP小型队列,目标是解决上述问题。TCP小型队列限制了每个TCP流每次可在QDisc与驱动队列中排队的字节数。这有一个有趣的影响:内核会更早调度回应用程序,从而允许应用程序以更高效的优先级写入套接字。目前(2012-12-28),其它单个传输流仍然有可能淹没QDisc层。

另一个解决传输层洪水问题的方案是使用具有多个队列的QDisc,理想情况下每个网络流一个队列。随机公平队列(Stochastic Fairness Queueing (SFQ))和延迟控制公平队列(Fair Queueing with Controlled Delay (fq_codel))都有为每个网络流分配一个队列的机制,因此很适合解决这个洪水问题。

如何控制Linux的队列容量

驱动队列

ethtool命令可用于控制以太网设备驱动队列容量。ethtool也提供了底层接口分析,可以启用或关闭IP stack和设备的一些特性。

-g参数可以输出驱动队列的信息:

$ ethtool -g eth0
Ring parameters for eth0:
Pre-set maximums:
RX:        16384
RX Mini:    0
RX Jumbo:    0
TX:        16384
Current hardware settings:
RX:        512
RX Mini:    0
RX Jumbo:    0
TX:        256

你可以从以上的输出看到本NIC的驱动程序默认拥有一个容量为256描述符的传输队列。早期,在Bufferbloat的探索中,这个队列的容量经常建议减少以降低延迟。随着BQL的使用(假设你的驱动程序支持它),再也没有任何必要去修改驱动队列的容量了(如何配置BQL见下文)。

Ethtool也允许你管理优化特性,例如TSO,UFO和GSO。-k参数输出当前的offload设置,-K修改它们。

$ ethtool -k eth0
Offload parameters for eth0:
rx-checksumming: off
tx-checksumming: off
scatter-gather: off
tcp-segmentation-offload: off
udp-fragmentation-offload: off
generic-segmentation-offload: off
generic-receive-offload: on
large-receive-offload: off
rx-vlan-offload: off
tx-vlan-offload: off
ntuple-filters: off
receive-hashing: off

由于TSO,GSO,UFO和GRO极大的提高了驱动队列中可以排队的字节数,如果你想优化延迟而不是吞吐量,那么你应该关闭这些特性。如果禁用这些特性,除非系统正在处理非常高的数据速率,否则您将不会注意到任何CPU影响或吞吐量降低。

Byte Queue Limits (BQL)

BQL是一个自适应算法,因此一般来说你不需要为此操心。然而,如果你想牺牲数据速率以换得最优延迟,你就需要修改LIMIT的上限值。BQL的状态和设置可以在/sys中NIC的目录找到,在我的服务器上,eth0的BQL目录是:

/sys/devices/pci0000:00/0000:00:14.0/net/eth0/queues/tx-0/byte_queue_limits

在该目录下的文件有:

hold_time: 修改LIMIT值的时间间隔,单位为毫秒

inflight: 还没发送且在排队的数据量

limit: BQL计算的LIMIT值,如果NIC驱动不支持BQL,值为0

limit_max: LIMIT的最大值,降低此值可以优化延迟

limit_min: LIMIT的最小值,增高此值可以优化吞吐量

要修改LIMIT的上限值,把你需要的值写入limit_max文件即可,单位为字节:

echo "3000" > limit_max

什么是txqueuelen?

在早期的Bufferbload讨论中,经常会提到静态地减少NIC传输队列长度。当前队列长度值可以通过ip和ifconfig命令取得。令人疑惑的是,这两个命令给了传输队列的长度不同的名字:

Linux默认的传输队列长度为1,000个数据包,这是一个很大的缓冲区,尤其在低带宽的情况下。

有趣的问题是,这个变量实际上是控制什么?

我也不清楚,因此我花了点时间深入探索内核源码。我现在能说的,txqueuelen只是用来作为一些排队规则的默认队列长度。例如:
pfifo_fast(Linux默认排队规则)
sch_fifo
sch_gred
sch_htb(只有默认队列)
sch_plug
sch_sfb
sch_teql

见图1,txqueuelen参数在排队规则中控制以上列出的队列类型的长度。绝大多数这些排队规则,tc的limit参数默认会覆盖掉txqueuelen。总的来说,如果你不是使用上述的排队规则,或者如果你用limit参数指定了队列长度,那么txqueuelen值就没有任何作用。

顺便一提,我发现一个令人疑惑的地方,ifconfig命令显示了网络接口的底层信息,例如MAC地址,但是txqueuelen却是来自高层的QDisc层,很自然的地,看起来ifconfig会输出驱动队列长度。

传输队列的长度可以使用ip或ifconfig命令修改:

ip link set txqueuelen 500 dev eth0

需要注意,ip命令使用"txqueuelen"但是输出时使用"qlen" —— 另一个不幸的不一致性。

排队规则

正如前文所描述,Linux内核拥有大量的排队规则(QDiscs),每一个都实现了自己的数据包排队方法。讨论如何配置每一个QDiscs已经超出了本文的范围。关于配置这些队列的信息,可以查阅tc的man page(man tc)。你可以使用"man tc qdisc-name"(例如:“man tc htb"或"man tc fq_codel”)找到每一个队列的细节。LARTC也是一个很有用的资源,但是缺乏了一些新特性的信息。

以下是一些可能对你使用tc命令有用的建议和技巧:

HTB QDisc实现了一个接收所有未分类数据包的默认队列。一些如DRR QDiscs会直接把未分类的数据包丢进黑洞。使用命令"tc qdisc show",通过direct_packets_stat可以检查有多少数据包未被合适分类。

HTB类分层只适用于分类,对于带宽分配无效。所有带宽分配通过检查Leaves和它们的优先级进行。

QDisc中,使用一个major和一个minor数字作为QDiscs和classes的基本标识,major和minor之间使用英文冒号分隔。tc命令使用十六进制代表这些数字。由于很多字符串,例如10,在十进制和十六进制都是正确的,因此很多用户不知道tc使用十六进制。见我的tc脚本,可以查看我是如何处理这个问题的。

如果你正在使用基于ATM的ADSL(绝大多数的DLS服务是基于ATM,新的变体例如VDSL2可能不是),你很可能需要考虑添加一个"linklayer adsl"的选项。这个统计把IP数据包分解成一组53字节的ATM单元所产生的开销。

如果你正在使用PPPoE,你很可能需要考虑通过"overhead"参数统计PPPoE开销。

TCP小型队列

每个TCP Socket的队列限制可以通过/proc中的文件查看或修改:

/proc/sys/net/ipv4/tcp_limit_output_bytes

Linux TCP队列相关参数的总结

连接建立

图片-1650433285172

可以看到建立连接涉及两个队列:

  • 半连接队列,保存SYN_RECV状态的连接。队列长度由net.ipv4.tcp_max_syn_backlog 和 somaxconn 设置
  • accept队列,保存ESTABLISHED状态的连接。队列长度为min(net.core.somaxconn,backlog)。其中backlog是我们创建ServerSocket(intport,int backlog)时指定的参数,最终会传递给listen方法:
    #include int listen(int sockfd, int backlog); 如果我们设置的backlog大于net.core.somaxconn,accept队列的长度将被设置为net.core.somaxconn

另外,为了应对SYNflooding(即客户端只发送SYN包发起握手而不回应ACK完成连接建立,填满server端的半连接队列,让它无法处理正常的握手请求),Linux实现了一种称为SYNcookie的机制,通过net.ipv4.tcp_syncookies控制,设置为1表示开启。简单说SYNcookie就是将连接信息编码在ISN(initialsequencenumber)中返回给客户端,这时server不需要将半连接保存在队列中,而是利用客户端随后发来的ACK带回的ISN还原连接信息,以完成连接的建立,避免了半连接队列被攻击SYN包填满。对于一去不复返的客户端握手,不理它就是了。

数据包的接收

先看看接收数据包经过的路径:

图片-1650433396146

数据包的接收,从下往上经过了三层:

  • 网卡驱动
  • 系统内核空间
  • 最后到用户态空间的应用。

Linux内核使用sk_buff(socketkernel buffers)数据结构描述一个数据包。当一个新的数据包到达,NIC(networkinterface controller)调用DMAengine,通过RingBuffer将数据包放置到内核内存区。RingBuffer的大小固定,它不包含实际的数据包,而是包含了指向sk_buff的描述符。当RingBuffer满的时候,新来的数据包将给丢弃。

一旦数据包被成功接收,NIC发起中断,由内核的中断处理程序将数据包传递给IP层。经过IP层的处理,数据包被放入队列等待TCP层处理。每个数据包经过TCP层一系列复杂的步骤,更新TCP状态机,最终到达recvBuffer,等待被应用接收处理。

有一点需要注意,数据包到达recvBuffer,TCP就会回ACK确认,既TCP的ACK表示数据包已经被操作系统内核收到,但并不确保应用层一定收到数据(例如这个时候系统crash),因此一般建议应用协议层也要设计自己的确认机制。

上面就是一个相当简化的数据包接收流程,让我们逐层看看队列缓冲有关的参数。

网卡Bonding模式

当主机有1个以上的网卡时,Linux会将多个网卡绑定为一个虚拟的bonded网络接口,对TCP/IP而言只存在一个bonded网卡。多网卡绑定一方面能够提高网络吞吐量,另一方面也可以增强网络高可用。Linux支持7种Bonding模式:

详细的说明参考内核文档LinuxEthernet Bonding Driver HOWTO。我们可以通过cat/proc/net/bonding/bond0查看本机的Bonding模式:

图片-1650433881356

一般很少需要开发去设置网卡Bonding模式,自己实验的话可以参考这篇文档

  • Mode 0(balance-rr) Round-robin策略,这个模式具备负载均衡和容错能力
  • Mode 1(active-backup) 主备策略,在绑定中只有一个网卡被激活,其他处于备份状态
  • Mode 2(balance-xor) XOR策略,通过源MAC地址与目的MAC地址做异或操作选择slave网卡
  • Mode 3 (broadcast) 广播,在所有的网卡上传送所有的报文
  • Mode 4 (802.3ad) IEEE 802.3ad动态链路聚合。创建共享相同的速率和双工模式的聚合组
  • Mode 5 (balance-tlb) Adaptive transmit loadbalancing
  • Mode 6 (balance-alb) Adaptive loadbalancing

网卡多队列及中断绑定

随着网络的带宽的不断提升,单核CPU已经不能满足网卡的需求,这时通过多队列网卡驱动的支持,可以将每个队列通过中断绑定到不同的CPU核上,充分利用多核提升数据包的处理能力。
首先查看网卡是否支持多队列,使用lspci-vvv命令,找到Ethernetcontroller项:

图片-1650433935228

如果有MSI-X, Enable+ 并且Count > 1,则该网卡是多队列网卡。
然后查看是否打开了网卡多队列。使用命令cat/proc/interrupts,如果看到eth0-TxRx-0表明多队列支持已经打开:

图片-1650434075295

最后确认每个队列是否绑定到不同的CPU。cat/proc/interrupts查询到每个队列的中断号,对应的文件/proc/irq/${IRQ_NUM}/smp_affinity为中断号IRQ_NUM绑定的CPU核的情况。以十六进制表示,每一位代表一个CPU核:

(00000001)代表CPU0(00000010)代表CPU1(00000011)代表CPU0和CPU1

如果绑定的不均衡,可以手工设置,例如:

echo "1" > /proc/irq/99/smp_affinity
echo "2" > /proc/irq/100/smp_affinity 
echo "4" > /proc/irq/101/smp_affinity 
echo "8" > /proc/irq/102/smp_affinity 
echo "10" > /proc/irq/103/smp_affinity 
echo "20" > /proc/irq/104/smp_affinity 
echo "40" > /proc/irq/105/smp_affinity
echo "80" > /proc/irq/106/smp_affinity

RingBuffer

Ring Buffer位于NIC和IP层之间,是一个典型的FIFO(先进先出)环形队列。RingBuffer没有包含数据本身,而是包含了指向sk_buff(socketkernel buffers)的描述符。

可以使用ethtool-g eth0查看当前RingBuffer的设置:
图片-1650434217014

上面的例子接收队列为4096,传输队列为256。可以通过ifconfig观察接收和传输队列的运行状况:

图片-1650434232942

  • RXerrors:收包总的错误数
  • RX dropped:表示数据包已经进入了RingBuffer,但是由于内存不够等系统原因,导致在拷贝到内存的过程中被丢弃。
  • RX overruns:overruns意味着数据包没到RingBuffer就被网卡物理层给丢弃了,而CPU无法及时的处理中断是造成RingBuffer满的原因之一,例如中断分配的不均匀。当dropped数量持续增加,建议增大RingBuffer,使用ethtool-G进行设置。

InputPacket Queue

当接收数据包的速率大于内核TCP处理包的速率,数据包将会缓冲在TCP层之前的队列中。接收队列的长度由参数net.core.netdev_max_backlog设置。

recvBuffer

recv buffer是调节TCP性能的关键参数。BDP(Bandwidth-delayproduct,带宽延迟积) 是网络的带宽和与RTT(roundtrip time)的乘积,BDP的含义是任意时刻处于在途未确认的最大数据量。RTT使用ping命令可以很容易的得到。为了达到最大的吞吐量,recvBuffer的设置应该大于BDP,即recvBuffer >= bandwidth * RTT。假设带宽是100Mbps,RTT是100ms,那么BDP的计算如下:

BDP = 100Mbps * 100ms = (100 / 8) * (100 / 1000) = 1.25MB

Linux在2.6.17以后增加了recvBuffer自动调节机制,recvbuffer的实际大小会自动在最小值和最大值之间浮动,以期找到性能和资源的平衡点,因此大多数情况下不建议将recvbuffer手工设置成固定值。
当 net.ipv4.tcp_moderate_rcvbuf 设置为1时,自动调节机制生效.

在缓冲的动态调优机制开启的情况下,我们将 net.ipv4.tcp_rmem 的最大值设置为BDP。

注意这里还有一个细节,缓冲除了保存接收的数据本身,还需要一部分空间保存socket数据结构等额外信息。因此上面讨论的recvbuffer最佳值仅仅等于BDP是不够的,还需要考虑保存socket等额外信息的开销。Linux根据参数 net.ipv4.tcp_adv_win_scale 计算额外开销的大小:

图片-1650434935988

如果 net.ipv4.tcp_adv_win_scale 的值为1,则二分之一的缓冲空间用来做额外开销,如果为2的话,则四分之一缓冲空间用来做额外开销。因此recvbuffer的最佳值应该设置为:

图片-1650434942238

数据包的发送

发送数据包经过的路径:

图片-1650434968726

和接收数据的路径相反,数据包的发送从上往下也经过了三层:

  • 用户态空间的应用
  • 系统内核空间
  • 最后到网卡驱动。

应用先将数据写入TCP sendbuffer,TCP层将sendbuffer中的数据构建成数据包转交给IP层。IP层会将待发送的数据包放入队列QDisc(queueingdiscipline)。数据包成功放入QDisc后,指向数据包的描述符sk_buff被放入RingBuffer输出队列,随后网卡驱动调用DMAengine将数据发送到网络链路上。

同样我们逐层来梳理队列缓冲有关的参数。

sendBuffer

同recvBuffer类似,和sendBuffer有关的参数如下:
net.ipv4.tcp_wmem
net.core.wmem_default
net.core.wmem_max

发送端缓冲的自动调节机制很早就已经实现,并且是无条件开启,没有参数去设置。

如果指定了tcp_wmem,则net.core.wmem_default被tcp_wmem的覆盖。

sendBuffer 在 tcp_wmem 的最小值和最大值之间自动调节。如果调用 setsockopt() 设置了 socket 选项 SO_SNDBUF,将关闭发送端缓冲的自动调节机制,tcp_wmem 将被忽略,SO_SNDBUF 的最大值由net.core.wmem_max 限制。

QDisc 与 txqueuelen

QDisc(queueing discipline )位于IP层和网卡的ringbuffer之间。我们已经知道,ringbuffer是一个简单的FIFO队列,这种设计使网卡的驱动层保持简单和快速。而QDisc实现了流量管理的高级功能,包括流量分类,优先级和流量整形(rate-shaping)。可以使用tc命令配置QDisc。

QDisc的队列长度由txqueuelen设置,和接收数据包的队列长度由内核参数net.core.netdev_max_backlog控制所不同,txqueuelen是和网卡关联,可以用 ifconfig 命令查看当前的大小:

图片-1650435929751

使用ifconfig调整txqueuelen的大小:

ifconfig eth0 txqueuelen 2000

ip link set eth0 txqueuelen 2000

发送队列就是指的这个 txqueuelen,和网卡关联着。每个 Core 接收队列由内核参数 net.core.netdev_max_backlog 来设置。

RingBuffer

和数据包的接收一样,发送数据包也要经过RingBuffer,使用ethtool-g eth0查看:
图片-1650435948510

其中TX项是RingBuffer的传输队列,也就是发送队列的长度。设置也是使用命令ethtool-G。

TCPSegmentation 和 Checksum Offloading

操作系统可以把一些TCP/IP的功能转交给网卡去完成,特别是Segmentation(分片)和checksum的计算,这样可以节省CPU资源,并且由硬件代替OS执行这些操作会带来性能的提升。

一般以太网的MTU(MaximumTransmission Unit)为1500 bytes,假设应用要发送数据包的大小为7300bytes,MTU1500字节- IP头部20字节 -TCP头部20字节=有效负载为1460字节,因此7300字节需要拆分成5个segment:

图片-1650436012056

Segmentation(分片)操作可以由操作系统移交给网卡完成,虽然最终线路上仍然是传输5个包,但这样节省了CPU资源并带来性能的提升:

图片-1650436028291

可以使用ethtool-k eth0查看网卡当前的offloading情况:

图片-1650436039065

上面这个例子checksum和tcpsegmentation的offloading都是打开的。如果想设置网卡的offloading开关,可以使用ethtool-K(注意K是大写)命令,例如下面的命令关闭了tcp segmentation offload:

sudo ethtool -K eth0 tso off

linux tc流量控制

什么是tc

tc全称为traffic control,是iproute2包中控制内核中流量的工具。在内核的网络协议栈中,专门有这样一个处理网络流量的地方(在XDP之后,netfilter之前),tc就是在这个地方读取网络数据包(此时已经是sk_buffer)进行控制、分发、丢弃等操作。需要注意的是,tc既可以处理传出的数据包(egress),也可以处理传入的数据包(ingress),但对传入的数据包处理的功能较少。本文不涉及ingress内容。

核心概念:qdisc

我们使用tc的时候,最先会遇到一个叫做qdisc的名词,其实我们经常能看见它,就是在每次我们执行ip命令的时时候

root@ubuntu:~# ip l
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: ens33: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    link/ether 00:0c:29:58:48:55 brd ff:ff:ff:ff:ff:ff
    altname enp2s1

比如在这里,我们可以看到每个网络设备都在mtu后面都有一个qdisc,qdisc后面还有一个词,上面的lo的qdisc是noqueue,ens33的qdisc是fq_codel。

qdisc实际是queueing discipline的缩写,我们可以将其看作一个具有一定规则的队列。当tc处理网络包时,会将包入队到qdisc中,这些包会根据指定的规则被内核按照一定顺序取出。tc中已经内置了很多不同的qdisc,有些qdisc可以带参数,比如ens33上面的qdisc参数是这样的。

root@ubuntu:~# tc qdisc show dev ens33
qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5.0ms interval 100.0ms memory_limit 32Mb ecn 

注意到lo上面的qdisc是noqueue,那我们可不可以把ens33的qdisc删掉呢,尝试:

root@ubuntu:~# tc qdisc del dev ens33 root
Error: Cannot delete qdisc with handle of zero.

先暂时忽略命令里的root。这条删除命令失败,网络上的一篇文章给出的结论是,对于这种非虚拟的网络设备(虽然是虚拟机的网卡,它也不是虚拟设备哈),不可以将其qdisc删除掉使其设置为noqueue,所以如果我们想修改他的qdisc,不能先删除再添加,但可以通过add和replace进行修改,该文章也提到了add和replace效果是一样的。对此我有不同的看法。

首先试试再添加一个其他类型的qdisc上去:

root@ubuntu:~# tc qdisc add dev ens33 root pfifo
root@ubuntu:~# tc qdisc show dev ens33
qdisc pfifo 8005: root refcnt 2 limit 1000p

添加成功,那我们在添加一个其他类型的qdisc上去:

root@ubuntu:~# tc qdisc add dev ens33 root pfifo_fast
Error: Exclusivity flag on, cannot modify.
root@ubuntu:~# tc qdisc show dev ens33
qdisc pfifo 8005: root refcnt 2 limit 1000p

这样会报错,还是使用原先的qdisc。此时我们尝试使用del进行删除

root@ubuntu:~# tc qdisc del dev ens33 root
root@ubuntu:~# tc qdisc show dev ens33
qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5.0ms interval 100.0ms memory_limit 32Mb ecn 

发现它又变回了fq_codel。如果我们这里使用replace再删除,结果是这样的

root@ubuntu:~# tc qdisc replace dev ens33 root pfifo_fast
root@ubuntu:~# tc qdisc show dev ens33
qdisc pfifo_fast 8006: root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1
root@ubuntu:~# tc qdisc replace dev ens33 root pfifo
root@ubuntu:~# tc qdisc show dev ens33
qdisc pfifo 8007: root refcnt 2 limit 1000p
root@ubuntu:~# tc qdisc del dev ens33 root
root@ubuntu:~# tc qdisc del dev ens33 root
Error: Cannot delete qdisc with handle of zero.
root@ubuntu:~# tc qdisc show dev ens33
qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5.0ms interval 100.0ms memory_limit 32Mb ecn 

所以我们可以推测:fq_codel是非虚拟设备的默认值,此时网络设备并没有设置qdisc,所以执行删除会报错,但我们可以为其设定qdisc(使用add或者replace),此时设置了qdisc,删除便不会失败,删除后qdisc又变成了默认值。注意到lo的值是noqueue,如果我们用ip link命令手动添加设备,它们的qdisc也是noqueue,所以我们也可以推测虚拟设备的默认值是noqueue。

这里的命令中有一个root参数,实际上这个参数表示对出口流量(egress)处理的根节点,从上面的命令可以看出,一个网络设备的root点只能设置一个qdisc。执行tc qdisc show命令,可以看到当前qdisc的参数,有些qdisc不可以指定参数,有些可以,如果需要指定参数,可以在add命令的结尾添加:

tc qdisc add dev <dev> root <qdisc> <qdisc-param>

qdisc有两类,一类qdisc比较简单,向上面展示的一样,只能设置一些参数然后在设备的(egress)root点生效,这种的叫做classless qdisc。另一类比较复杂,他们内部还包括叫做class的组件,还可以进一步将包传递给其他的qdisc,所有的数据包在一个类似树的结构中流动,这种叫做classful qdisc。这篇文章只会介绍相对简单的classless qdisc。