Switch-Router

TCP拥塞控制之ABC(Appropriate Byte Counting)

Published at 2020-01-13 | Last Update 2020-01-13

ABC 的来源

我们知道,TCP 发送端的数据发送速度受到本端拥塞窗口(cwnd)和对端通告的接收窗口(rwnd)的限制,只有同时在这两个窗口内的待发送数据才允许被发送到网络中。其中对端接收窗口是由对端接收缓冲区确定的,由于本文主要关注拥塞窗口,因此不考虑接收窗口的影响(视为接收窗口很大很大)。而拥塞窗口的大小则是由不停的拥塞控制算法计算而来。经典的拥塞控制算法都至少包含慢启动(Slow Start)和拥塞避免(Congestion Avoid)两个阶段。

通常我们这样描述这两个阶段的窗口增加规则:慢启动阶段,每收到一个 ACK,拥塞窗口增加 1 个 MSS;拥塞避免阶段,每收到前一整窗的 ACK,窗口增加 1 个 MSS,换句话说,也就是说,每收到一个ACK,窗口增加 1/cwnd 个 MSS !

那么问题就来了,对于像 telnet 这类大部分情况下报文很短的 TCP 连接来说,当发送方收到 ACK (通常只应答几个 Bytes) 时,应该让发送方的拥塞窗口增加 1 个 MSS 或者 1/cwnd 个 MSS 吗?

显然不太合理,一个例子是这种仅应答几个 Bytes 的 ACK 并不能反馈链路的拥塞情况:这些 ACK 可能将拥塞窗口撑得过大,而如果之后发送端真的发送拥塞窗口大小的数据,就有可能出现大量丢包。

倘若 TCP 接收端采用了 delay-ACK ,将多个 TCP 报文放在一个 ACK 中应答,或者有一些 ACK 丢失了( ACK 不会重传),又会使得发送端拥塞窗口增加地较慢,影响 TCP 的传输性能。

以上问题的本质在于,发送端是根据收到 ACK 报文的数量来调整拥塞窗口,而不是根据 ACK 实际应答的数据长度来调整。

RFC 3465 TCP Congestion Control with ABC 给出了一种更加恰当(Appropriate)的以应答数据字节数((Byte Counting))为基础的窗口调整方案

    1. 慢启动阶段:当 ACK 的数据长度达到 MSS 时,窗口增加 1 个 SMSS;
    1. 拥塞避免阶段:当 ACK 的数据长度达到整个拥塞窗口的大小时,窗口增加 1 个 SMSS。

这样,在 TCP 传输的报文是满 MSS 长度的报文时,是否使用 ABC 对拥塞窗口调整没有影响。

L = 2*SMSS

RFC 3465 TCP Congestion Control with ABC中,定义了一个变量 L, 它表示在慢启动阶段,收到 ACK 后拥塞窗口增加的最大值,其建议值为 2 倍 SMSS bytes,这一点严格来说是违反 RFC 5681 的,后者是这样描述的

We note that RFC3465 allows for cwnd increases of more than SMSS bytes for incoming acknowledgments during slow start on an experimental basis; however, such behavior is not allowed as part of the standard.

对此,RFC3465的理由是,选择 L=2*SMSS 是为了弥补 delayed-ACK 带来的多个性能影响

This document specifies that TCP implementations MAY use L=2SMSS bytes and MUST NOT use L > 2SMSS bytes. This choice balances between being conservative (L=1SMSS bytes) and being potentially very aggressive. In addition, L=2SMSS bytes exactly balances the negative impact of the delayed ACK algorithm

Linux 的实现

Linux 对 ABC 的实现分为三个时期:显式sysctl选项支持ABC、移除 ABC、隐式实现ABC

显式sysctl选项支持ABC

ABC 功能在[TCP]: Appropriate Byte Count support中引入内核。

下面以 2.6.32 版本为例,看看它是如何工作的

sysctl_tcp_abc: Appropriate Byte Counting 的开关

0 -- 关闭 ABC
1 -- 开启 ABC, 在慢启动阶段收到 ACK 时,拥塞窗口最多增加 1  SMSS
2 -- 开启 ABC, 在慢启动阶段收到 ACK 时,拥塞窗口最多增加 2  SMSS

内核使用tcp_sock->bytes_acked来记录 ACK 报文应答的数据长度,该字段在收到 ACK 报文时更新。

static int tcp_ack(struct sock* sk, struct sk_buff *skb, int flag)
{
    // code omitted...
    if (sysctl_tcp_abc){
        if (icsk->icsk_ca_state < TCP_CA_CWR)
            tp->bytes_acked += ack - prior_snd_una;
        else if (icsk->icsk_ca_state == TCP_CA_Loss)
            tp->bytes_acked += min(ack - prior_snd_una, tp->mss_cache);
    }
}

慢启动阶段时,当累积的bytes_acked不够一个 SMSS 时,便会直接返回,不更新拥塞窗口。否则才更新,具体增加 1 个 SMSS 还是 2 个 SMSS,取决于sysctl_tcp_abc的值以及是否累积应答的数据长度。

void tcp_slow_start(struct tcp_sock* tp)
{
    int cnt;
    
    if (sysctl_tcp_abc && tp->bytes_acked < tp->mss_cache)
        return;
        
    // code omitted
    else
        cnt = tp->snd_cwnd;
        
    
    if (sysctl_tcp_abc > 1 && tp->bytes_acked >= 2*tp->mss_cache)
        cnt <<= 1;
        
    tp->bytes_acked = 0;

    tp->snd_cwnd_cnt += cnt;
    while(tp->snd_cwnd_cnt >= tp->snd_cwnd)
    {
        tp->snd_cwnd_cnt -= tp->snd_cwnd;
        if (tp->snd_cwnd < tp->snd_cwnd_clamp)
            tp->snd_cwnd++;
    }
}

而在拥塞避免阶段,也有 ABC 专门的处理逻辑。

void tcp_reno_cong_avoid(struct sock* sk, u32 ack, u32 in_flight)
{
    // code omitted...
    if (tp->snd_cwnd <= tp->snd_ssthresh)
        tcp_slow_start(tp);
    else if (sysctl_tcp_abc){
        if (tp->bytes_acked >= tp->snd_cwnd*tp->mss_cache){
            tp->bytes_acked -= tp->snd_cwnd*tp->mss_cache;
            if (tp->snd_cwnd < tp->snd_cwnd_clamp)
                tp->snd_cwnd++;
        }
    }
    else {
        // code omitted...
    } 
}

当 ABC 引入内核时,sysctl_tcp_abc的默认值是 1(开启), 不过,在后来的一个补丁中,将其修改为了 0 (关闭)

移除 ABC

内核在 2013 年 2 月的一个补丁中 tcp: remove Appropriate Byte Count support

短暂移除了 ABC 功能.

隐式支持 ABC

同年 10 月,内核用一种新的隐式方式实现了 ABC tcp: properly handle stretch acks in slow start

以慢启动为例, 实际上是增加了一个 acked 参数,它表示本次收到的 ACK 完整应答的数据长度(以SMSS为单位)

-void tcp_slow_start(struct tcp_sock *tp)
+int tcp_slow_start(struct tcp_sock *tp, u32 acked)
 {
-	int cnt; /* increase in packets */
-	unsigned int delta = 0;
-	u32 snd_cwnd = tp->snd_cwnd;
-
-	if (unlikely(!snd_cwnd)) {
-		pr_err_once("snd_cwnd is nul, please report this bug.\n");
-		snd_cwnd = 1U;
-	}
+	u32 cwnd = tp->snd_cwnd + acked;
 
-	if (sysctl_tcp_max_ssthresh > 0 && tp->snd_cwnd > sysctl_tcp_max_ssthresh)
-		cnt = sysctl_tcp_max_ssthresh >> 1;	/* limited slow start */
-	else
-		cnt = snd_cwnd;				/* exponential increase */
-
-	tp->snd_cwnd_cnt += cnt;
-	while (tp->snd_cwnd_cnt >= snd_cwnd) {
-		tp->snd_cwnd_cnt -= snd_cwnd;
-		delta++;
-	}
-	tp->snd_cwnd = min(snd_cwnd + delta, tp->snd_cwnd_clamp);
+	if (cwnd > tp->snd_ssthresh)
+		cwnd = tp->snd_ssthresh + 1;
+	acked -= cwnd - tp->snd_cwnd;
+	tp->snd_cwnd = min(cwnd, tp->snd_cwnd_clamp);
+	return acked;

而在拥塞避免阶段,也同样使用了这个acked

以 4.4.0 内核为例,下面的 w 表示当前拥塞窗口大小

void tcp_cong_avoid_ai(struct tcp_sock *tp, u32 w, u32 acked)
{
	/* If credits accumulated at a higher w, apply them gently now. */
	if (tp->snd_cwnd_cnt >= w) {
		tp->snd_cwnd_cnt = 0;
		tp->snd_cwnd++;
	}

	tp->snd_cwnd_cnt += acked;
	if (tp->snd_cwnd_cnt >= w) {
		u32 delta = tp->snd_cwnd_cnt / w;

		tp->snd_cwnd_cnt -= delta * w;
		tp->snd_cwnd += delta;
	}
	tp->snd_cwnd = min(tp->snd_cwnd, tp->snd_cwnd_clamp);
}

关键的 acked 在收到 ACK 时计算,每从重传队列删除 1 个 skb,就视为该 ACK 报文应答了 1 个 SMSS 的报文。

	/* See if we can take anything off of the retransmit queue. */
	acked = tp->packets_out;
	flag |= tcp_clean_rtx_queue(sk, prior_fackets, prior_snd_una, 
				    &sack_state);
	acked -= tp->packets_out;