Linux下TCP延迟确认Delayed Ack机制导致的时延问题怎么解决

95次阅读
没有评论

共计 8632 个字符,预计需要花费 22 分钟才能阅读完成。

本篇文章为大家展示了 Linux 下 TCP 延迟确认 Delayed Ack 机制导致的时延问题怎么解决,内容简明扼要并且容易理解,绝对能使你眼前一亮,通过这篇文章的详细介绍希望你能有所收获。

案例一:同事随手写个压力测试程序,其实现逻辑为:每秒钟先连续发 N 个 132 字节的包,然后连续收 N 个由后台服务回显回来的 132 字节包。其代码简化如下:

char sndBuf[132];
char rcvBuf[132];
while (1) { for (int i = 0; i   N; i++){ send(fd, sndBuf, sizeof(sndBuf), 0);
 ... 
 }
 for (int i = 0; i   N; i++) { recv(fd, rcvBuf, sizeof(rcvBuf), 0);
 ...
 }
 sleep(1);
}

在实际测试中发现,当 N 大于等于 3 的情况,第 2 秒之后,每次第三个 recv 调用,总会阻塞 40 毫秒左右,但在分析 Server 端日志时,发现所有请求在 Server 端处理时耗均在 2ms 以下。

当时的具体定位过程如下:先试图用 strace 跟踪客户端进程,但奇怪的是:一旦 strace attach 上进程,所有收发又都正常,不会有阻塞现象,一旦退出 strace,问题重现。经同事提醒,很可能是 strace 改变了程序或系统的某些东西 (这个问题现在也还没搞清楚),于是再用 tcpdump 抓包分析,发现 Server 后端在回现应答包后,Client 端并没有立即对该数据进行 ACK 确认,而是等待了近 40 毫秒后才确认。经过 Google,并查阅《TCP/IP 详解卷一: 协议》得知,此即 TCP 的延迟确认(Delayed Ack) 机制。

其解决办法如下:在 recv 系统调用后,调用一次 setsockopt 函数,设置 TCP_QUICKACK。最终代码如下:

char sndBuf[132];
char rcvBuf[132];
while (1) { for (int i = 0; i   N; i++) { send(fd, sndBuf, 132, 0);
 ... 
 }
 for (int i = 0; i   N; i++) { recv(fd, rcvBuf, 132, 0); 
 setsockopt(fd, IPPROTO_TCP, TCP_QUICKACK, (int[]){1}, sizeof(int)); 
 }
 sleep(1);
}

案例二:在营销平台内存化 CDKEY 版本做性能测试时,发现请求时耗分布异常:90% 的请求均在 2ms 以内,而 10% 左右时耗始终在 38-42ms 之间,这是一个很有规律的数字:40ms。因为之前经历过案例一,所以猜测同样是因为延迟确认机制引起的时耗问题,经过简单的抓包验证后,通过设置 TCP_QUICKACK 选项,得以解决时延问题。

延迟确认机制

在《TCP/IP 详解卷一:协议》第 19 章对其进行原理进行了详细描述:TCP 在处理交互数据流 (即 Interactive Data Flow,区别于 Bulk Data Flow,即成块数据流,典型的交互数据流如 telnet、rlogin 等) 时,采用了 Delayed Ack 机制以及 Nagle 算法来减少小分组数目。

书上已经对这两种机制的原理讲的很清晰,这里不再做复述。本文后续部分将通过分析 TCP/IP 在 Linux 下的实现,来解释一下 TCP 的延迟确认机制。

1. 为什么 TCP 延迟确认会导致延迟?

其实仅有延迟确认机制,是不会导致请求延迟的 (初以为是必须等到 ACK 包发出去,recv 系统调用才会返回)。一般来说,只有当该机制与 Nagle 算法或拥塞控制(慢启动或拥塞避免) 混合作用时,才可能会导致时耗增长。我们下面来详细看看是如何相互作用的:

延迟确认与 Nagle 算法

我们先看看 Nagle 算法的规则(可参考 tcp_output.c 文件里 tcp_nagle_check 函数注释):

1)如果包长度达到 MSS,则允许发送;

2)如果该包含有 FIN,则允许发送;

3)设置了 TCP_NODELAY 选项,则允许发送;

4)未设置 TCP_CORK 选项时,若所有发出去的包均被确认,或所有发出去的小数据包 (包长度小于 MSS) 均被确认,则允许发送。

对于规则 4),就是说要求一个 TCP 连接上最多只能有一个未被确认的小数据包,在该分组的确认到达之前,不能发送其他的小数据包。如果某个小分组的确认被延迟了(案例中的 40ms),那么后续小分组的发送就会相应的延迟。也就是说延迟确认影响的并不是被延迟确认的那个数据包,而是后续的应答包。

1 00:44:37.878027 IP 171.24.38.136.44792   175.24.11.18.9877: S 3512052379:3512052379(0) win 5840  mss 1448,wscale 7 
2 00:44:37.878045 IP 175.24.11.18.9877   171.24.38.136.44792: S 3581620571:3581620571(0) ack 3512052380 win 5792  mss 1460,wscale 2 
3 00:44:37.879080 IP 171.24.38.136.44792   175.24.11.18.9877: . ack 1 win 46
......
4 00:44:38.885325 IP 171.24.38.136.44792   175.24.11.18.9877: P 1321:1453(132) ack 1321 win 86
5 00:44:38.886037 IP 175.24.11.18.9877   171.24.38.136.44792: P 1321:1453(132) ack 1453 win 2310
6 00:44:38.887174 IP 171.24.38.136.44792   175.24.11.18.9877: P 1453:2641(1188) ack 1453 win 102
7 00:44:38.887888 IP 175.24.11.18.9877   171.24.38.136.44792: P 1453:2476(1023) ack 2641 win 2904
8 00:44:38.925270 IP 171.24.38.136.44792   175.24.11.18.9877: . ack 2476 win 118
9 00:44:38.925276 IP 175.24.11.18.9877   171.24.38.136.44792: P 2476:2641(165) ack 2641 win 2904
10 00:44:38.926328 IP 171.24.38.136.44792   175.24.11.18.9877: . ack 2641 win 134

从上面的 tcpdump 抓包分析看,第 8 个包是延迟确认的,而第 9 个包的数据,在 Server 端 (175.24.11.18) 虽然早就已放到 TCP 发送缓冲区里面 (应用层调用的 send 已经返回) 了,但按照 Nagle 算法,第 9 个包需要等到第个 7 包 (小于 MSS) 的 ACK 到达后才能发出。

延迟确认与拥塞控制

我们先利用 TCP_NODELAY 选项关闭 Nagle 算法,再来分析延迟确认与 TCP 拥塞控制是如何互相作用的。

慢启动:TCP 的发送方维护一个拥塞窗口,记为 cwnd。TCP 连接建立是,该值初始化为 1 个报文段,每收到一个 ACK,该值就增加 1 个报文段。发送方取拥塞窗口与通告窗口 (与滑动窗口机制对应) 中的最小值作为发送上限(拥塞窗口是发送方使用的流控,而通告窗口则是接收方使用的流控)。发送方开始发送 1 个报文段,收到 ACK 后,cwnd 从 1 增加到 2,即可以发送 2 个报文段,当收到这两个报文段的 ACK 后,cwnd 就增加为 4,即指数增长:例如第一个 RTT 内,发送一个包,并收到其 ACK,cwnd 增加 1,而第二个 RTT 内,可以发送两个包,并收到对应的两个 ACK,则 cwnd 每收到一个 ACK 就增加 1,最终变为 4,实现了指数增长。

在 Linux 实现里,并不是每收到一个 ACK 包,cwnd 就增加 1,如果在收到 ACK 时,并没有其他数据包在等待被 ACK,则不增加。

本人使用案例 1 的测试代码,在实际测试中,cwnd 从初始值 2 开始,最终保持 3 个报文段的值,tcpdump 结果如下:

1 16:46:14.288604 IP 178.14.5.3.1913   178.14.5.4.20001: S 1324697951:1324697951(0) win 5840  mss 1460,wscale 2 
2 16:46:14.289549 IP 178.14.5.4.20001   178.14.5.3.1913: S 2866427156:2866427156(0) ack 1324697952 win 5792  mss 1460,wscale 2 
3 16:46:14.288690 IP 178.14.5.3.1913   178.14.5.4.20001: . ack 1 win 1460
......
4 16:46:15.327493 IP 178.14.5.3.1913   178.14.5.4.20001: P 1321:1453(132) ack 1321 win 4140
5 16:46:15.329749 IP 178.14.5.4.20001   178.14.5.3.1913: P 1321:1453(132) ack 1453 win 2904
6 16:46:15.330001 IP 178.14.5.3.1913   178.14.5.4.20001: P 1453:2641(1188) ack 1453 win 4140
7 16:46:15.333629 IP 178.14.5.4.20001   178.14.5.3.1913: P 1453:1585(132) ack 2641 win 3498
8 16:46:15.337629 IP 178.14.5.4.20001   178.14.5.3.1913: P 1585:1717(132) ack 2641 win 3498
9 16:46:15.340035 IP 178.14.5.4.20001   178.14.5.3.1913: P 1717:1849(132) ack 2641 win 3498
10 16:46:15.371416 IP 178.14.5.3.1913   178.14.5.4.20001: . ack 1849 win 4140
11 16:46:15.371461 IP 178.14.5.4.20001   178.14.5.3.1913: P 1849:2641(792) ack 2641 win 3498
12 16:46:15.371581 IP 178.14.5.3.1913   178.14.5.4.20001: . ack 2641 win 4536

上表中的包,是在设置 TCP_NODELAY,且 cwnd 已经增长到 3 的情况,第 7、8、9 发出后,受限于拥塞窗口大小,即使此时 TCP 缓冲区有数据可以发送亦不能继续发送,即第 11 个包必须等到第 10 个包到达后,才能发出,而第 10 个包明显有一个 40ms 的延迟。

注:通过 getsockopt 的 TCP_INFO 选项 (man 7 tcp) 可以查看 TCP 连接的详细信息,例如当前拥塞窗口大小,MSS 等。

2. 为什么是 40ms?这个时间能不能调整呢?

首先在 redhat 的官方文档中,有如下说明:

一些应用在发送小的报文时,可能会因为 TCP 的 Delayed Ack 机制,导致一定的延迟。其值默认为 40ms。可以通过修改 tcp_delack_min,调整系统级别的最小延迟确认时间。例如:

# echo 1 /proc/sys/net/ipv4/tcpdelackmin

即是期望设置最小的延迟确认超时时间为 1ms。

不过在 slackware 和 suse 系统下,均未找到这个选项,也就是说 40ms 这个最小值,在这两个系统下,是无法通过配置调整的。

linux-2.6.39.1/net/tcp.h 下有如下一个宏定义:
#define TCP_DELACK_MIN ((unsigned)(HZ/25)) /* minimal time to delay before sending an ACK */

注:Linux 内核每隔固定周期会发出 timer interrupt(IRQ 0),HZ 是用来定义每秒有几次 timer interrupts 的。举例来说,HZ 为 1000,代表每秒有 1000 次 timer interrupts。HZ 可在编译内核时设置。在我们现有服务器上跑的系统,HZ 值均为 250。

以此可知,最小的延迟确认时间为 40ms。

TCP 连接的延迟确认时间一般初始化为最小值 40ms,随后根据连接的重传超时时间(RTO)、上次收到数据包与本次接收数据包的时间间隔等参数进行不断调整。具体调整算法,可以参考 linux-2.6.39.1/net/ipv4/tcp_input.c, Line 564 的 tcp_event_data_recv 函数。

3. 为什么 TCP_QUICKACK 需要在每次调用 recv 后重新设置?

在 man 7 tcp 中,有如下说明:

TCP_QUICKACK
`Enable quickack mode if set or disable quickack mode if cleared. In quickack mode, acks are sent immediately, rather than delayed if needed in accordance to normal TCP operation. This flag is not permanent, it only enables a switch to or from quickack mode. Subsequent operation of the TCP protocol will once again enter/leave quickack mode depending on internal protocol processing and factors such as delayed ack timeouts occurring and data transfer. This option should not be used in code intended to be portable.`

手册中明确描述 TCP_QUICKACK 不是永久的。那么其具体实现是如何的呢?参考 setsockopt 函数关于 TCP_QUICKACK 选项的实现:

case TCP_QUICKACK:
 if (!val) {
 icsk- icsk_ack.pingpong = 1;
 } else {
 icsk- icsk_ack.pingpong = 0;
 if ((1   sk- sk_state)  
 (TCPF_ESTABLISHED | TCPF_CLOSE_WAIT)  
 inet_csk_ack_scheduled(sk)) {
 icsk- icsk_ack.pending |= ICSK_ACK_PUSHED;
 tcp_cleanup_rbuf(sk, 1);
 if (!(val   1))
 icsk- icsk_ack.pingpong = 1;
 }
 }
 break;

其实 linux 下 socket 有一个 pingpong 属性来表明当前链接是否为交互数据流,如其值为 1,则表明为交互数据流,会使用延迟确认机制。但是 pingpong 这个值是会动态变化的。例如 TCP 链接在要发送一个数据包时,会执行如下函数(linux-2.6.39.1/net/ipv4/tcp_output.c, Line 156):

/* Congestion state accounting after a packet has been sent. */
static void tcp_event_data_sent(struct tcp_sock *tp,struct sk_buff *skb, struct sock *sk)
 ......
 tp- lsndtime = now;
 /* If it is a reply for ato after last received
 * packet, enter pingpong mode.
 */
 if ((u32)(now - icsk- icsk_ack.lrcvtime)   icsk- icsk_ack.ato)
 icsk- icsk_ack.pingpong = 1;
}

最后两行代码说明:如果当前时间与最近一次接受数据包的时间间隔小于计算的延迟确认超时时间,则重新进入交互数据流模式。也可以这么理解:延迟确认机制被确认有效时,会自动进入交互式。

通过以上分析可知,TCP_QUICKACK 选项是需要在每次调用 recv 后重新设置的。

4. 为什么不是所有包都延迟确认?

TCP 实现里,用 tcp_in_quickack_mode(linux-2.6.39.1/net/ipv4/tcp_input.c, Line 197)这个函数来判断是否需要立即发送 ACK。其函数实现如下:

/* Send ACKs quickly, if  quick  count is not exhausted
 * and the session is not interactive.
 */
static inline int tcp_in_quickack_mode(const struct sock *sk)
 const struct inet_connection_sock *icsk = inet_csk(sk);
 return icsk- icsk_ack.quick   !icsk- icsk_ack.pingpong;
}

要求满足两个条件才能算是 quickack 模式:

pingpong 被设置为 0。

快速确认数 (quick) 必须为非 0。

关于 pingpong 这个值,在前面有描述。而 quick 这个属性其代码中的注释为:scheduled number of quick acks,即快速确认的包数量,每次进入 quickack 模式,quick 被初始化为接收窗口除以 2 倍 MSS 值(linux-2.6.39.1/net/ipv4/tcp_input.c, Line 174),每次发送一个 ACK 包,quick 即被减 1。

5. 关于 TCP_CORK 选项

TCP_CORK 选项与 TCP_NODELAY 一样,是控制 Nagle 化的。

打开 TCP_NODELAY 选项,则意味着无论数据包是多么的小,都立即发送(不考虑拥塞窗口)。

如果将 TCP 连接比喻为一个管道,那 TCP_CORK 选项的作用就像一个塞子。设置 TCP_CORK 选项,就是用塞子塞住管道,而取消 TCP_CORK 选项,就是将塞子拔掉。例如下面这段代码:

int on = 1;
setsockopt(sockfd, SOL_TCP, TCP_CORK,  on, sizeof(on)); //set TCP_CORK
write(sockfd, ...); //e.g., http header
sendfile(sockfd, ...); //e.g., http body
on = 0;
setsockopt(sockfd, SOL_TCP, TCP_CORK,  on, sizeof(on)); //unset TCP_CORK

当 TCP_CORK 选项被设置时,TCP 链接不会发送任何的小包,即只有当数据量达到 MSS 时,才会被发送。当数据传输完成时,通常需要取消该选项,以便被塞住,但是又不够 MSS 大小的包能及时发出去。如果应用程序确定能一起发送多个数据集合(例如 HTTP 响应的头和正文),建议设置 TCP_CORK 选项,这样在这些数据之间不存在延迟。为提升性能及吞吐量,Web Server、文件服务器这一类一般会使用该选项。

著名的高性能 Web 服务器 Nginx,在使用 sendfile 模式的情况下,可以设置打开 TCP_CORK 选项:将 nginx.conf 配置文件里的 tcp_nopush 配置为 on。(TCP_NOPUSH 与 TCP_CORK 两个选项实现功能类似,只不过 NOPUSH 是 BSD 下的实现,而 CORK 是 Linux 下的实现)。另外 Nginx 为了减少系统调用,追求性能极致,针对短连接(一般传送完数据后,立即主动关闭连接,对于 Keep-Alive 的 HTTP 持久连接除外),程序并不通过 setsockopt 调用取消 TCP_CORK 选项,因为关闭连接会自动取消 TCP_CORK 选项,将剩余数据发出。

上述内容就是 Linux 下 TCP 延迟确认 Delayed Ack 机制导致的时延问题怎么解决,你们学到知识或技能了吗?如果还想学到更多技能或者丰富自己的知识储备,欢迎关注丸趣 TV 行业资讯频道。

正文完
 
丸趣
版权声明:本站原创文章,由 丸趣 2023-08-16发表,共计8632字。
转载说明:除特殊说明外本站除技术相关以外文章皆由网络搜集发布,转载请注明出处。
评论(没有评论)