Skip to content
Go back

TCP拥塞控制——从慢启动到BBR的演进

TCP 拥塞控制:你的下载速度为什么时快时慢?

一句话结论(30s)

TCP 拥塞控制的核心是动态调整拥塞窗口 cwnd,在”太快会丢包”和”太慢浪费带宽”之间寻找最优发送速率。传统算法(慢启动、拥塞避免、快速重传/恢复)把”丢包”当作拥塞信号:慢启动指数增长探路,到阈值后线性增长,丢包就减半或归零;而 BBR 换思路,靠实时测量带宽和 RTT 找最优点,不把丢包当拥塞,所以在无线丢包场景下吞吐远优于 CUBIC。

核心原理(2min)

底层深入(5-10min)

核心变量:拥塞窗口 cwnd

发送方维护 cwnd(拥塞窗口)——网络中最多能同时有多少字节的数据。实际发送窗口 = min(cwnd, rwnd)(rwnd 是接收方窗口)。

拥塞控制的全部目的:动态调整 cwnd 找”最优发送速率”——太快则丢包(网络拥塞),太慢则浪费带宽。

💭 先自己想一下:既然目标是一步到位找到最优速率,为什么 TCP 不直接测量带宽、一次把 cwnd 设到位?为什么要搞出慢启动、拥塞避免这一堆渐进逼近的状态机?

因为发送方在连接刚建立时,对网络路径的带宽、缓存、竞争流数量一无所知。TCP 没有任何显式的网络反馈信道(不像交换机可以主动通知),它唯一的”探针”就是往网络里注入数据、看 ACK 回来的节奏。这决定了它只能边试探边逼近——先快后慢,撞到拥塞信号(丢包/超时)再退。这一直觉直接解释了下文所有算法设计。

四个算法的状态迁移

        慢启动 (Slow Start)
          cwnd 指数增长: 1→2→4→8→16...
          |
          达到 ssthresh(慢启动阈值)

        拥塞避免 (Congestion Avoidance)
          cwnd 线性增长: 每个 RTT +1
           |
          出现丢包

    ┌──── 超时重传 ───── 3 个重复 ACK ────┐
    │   (严重拥塞)         (轻度拥塞)        │
    ↓                      ↓               │
  慢启动重来           快速重传 → 快速恢复    │
  ssthresh = cwnd/2    重传丢失包          │
  cwnd = 1              cwnd = cwnd/2     │
                        ssthresh = cwnd   │
                        进入拥塞避免 ───────┘

慢启动:为什么是”指数增长”?

慢启动是整条状态链的起点。它的名字其实很有误导性——它一点都不”慢”,恰恰是增长最快的阶段。内核里的实现长这样:

__bpf_kfunc u32 tcp_slow_start(struct tcp_sock *tp, u32 acked)
{
	u32 cwnd = min(tcp_snd_cwnd(tp) + acked, tp->snd_ssthresh);

	acked -= cwnd - tcp_snd_cwnd(tp);
	tcp_snd_cwnd_set(tp, min(cwnd, tp->snd_cwnd_clamp));

	return acked;
}

分析:函数名是 tcp_slow_start,核心只有一行 tcp_snd_cwnd(tp) + acked——每收到一个 ACK,cwnd 就加上被 ACK 的字节数。因为一个 RTT 内会有 cwnd 个包被 ACK,每个 ACK 让 cwnd 翻倍一部分,一个 RTT 下来 cwnd 就翻倍,这就是教科书里的”指数增长”(1→2→4→8)。注意返回值 acked 是”用剩下的 ACK 量”:当 cwnd + acked 撞上 snd_ssthresh 上限时,多出来的那部分 ACK 要留给拥塞避免阶段继续用,实现从慢启动到拥塞避免的无缝切换。

💭 为什么慢启动要指数增长? 试想如果一开始就线性 +1,从 cwnd=1 到填满一条 1000 包的带宽要 1000 个 RTT,传输早就”饿死”了;但如果一步到位直接把 cwnd 设成最大,又可能瞬间淹没网络造成雪崩。指数增长是二者的折中——用尽可能快的速度逼近带宽,同时每一步的代价(翻倍)仍能被”丢包检测”及时刹车。这就是”慢启动”真正想表达的含义:它”慢”在保守(从 1 开始),而不是增长慢。

拥塞避免:为什么”每个 RTT 只 +1”?

当 cwnd 涨到 ssthresh(慢启动阈值)后,继续指数增长风险太高,TCP 转入”谨慎驾驶”模式——线性增长。判断走哪条分支的是通用框架 tcp_reno_cong_avoid

__bpf_kfunc void tcp_reno_cong_avoid(struct sock *sk, u32 ack, u32 acked)
{
	struct tcp_sock *tp = tcp_sk(sk);

	if (!tcp_is_cwnd_limited(sk))
		return;

	/* In "safe" area, increase. */
	if (tcp_in_slow_start(tp)) {
		acked = tcp_slow_start(tp, acked);
		if (!acked)
			return;
	}
	/* In dangerous area, increase slowly. */
	tcp_cong_avoid_ai(tp, tcp_snd_cwnd(tp), acked);
}

分析:注意注释的措辞——“safe area”(安全区)走慢启动、“dangerous area”(危险区)走 tcp_cong_avoid_ai。所谓”安全/危险”的分界就是 tcp_in_slow_start(tp),即 cwnd < ssthresh。真正的”线性 +1”逻辑藏在 tcp_cong_avoid_ai 里:

__bpf_kfunc 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;
		tcp_snd_cwnd_set(tp, tcp_snd_cwnd(tp) + 1);
	}

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

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

分析:这里的 w 就是当前 cwnd,snd_cwnd_cnt 是一个”积分器”。代码的语义是每累计被 ACK 满一个 cwnd 的量,cwnd 才 +1——也就是”每个 RTT +1 个 MSS”(Additive Increase,AI)。它用 snd_cwnd_cnt 累加 acked,累满 w 就兑换一次 +1,剩余的零头继续攒着。这种”攒积分再兑换”的写法是为了在 ACK 被压缩(stretch ACK)或延迟时也能精确地保持线性斜率,而不是简单地在每个 ACK 上粗暴 +1。

💭 为什么要从指数切到线性? 指数增长的问题在于”冲过头”:越接近真实带宽,翻倍就越容易一步跨越红线,造成大规模丢包。线性 +1 则是在贴着带宽上限慢慢磨,用最小的步伐去探测那最后一点余量。这整条”AIMD”曲线(指数探路 + 线性收敛)就是 TCP 在”快”与”稳”之间反复权衡的结果——你在下载时看到的速度先陡升、后缓慢爬升,就是这个过程。

CUBIC:为什么还要一个三次函数?

Reno 的”每个 RTT 线性 +1”在带宽大、RTT 长的网络里太保守了(从减半后恢复到一个很大的 cwnd 要成千上万个 RTT)。CUBIC 的答案是用时间的三次函数去逼近窗口,而不是按 ACK 计数。核心在 bictcp_update

	t = (s32)(tcp_jiffies32 - ca->epoch_start);
	t += usecs_to_jiffies(ca->delay_min);
	/* change the unit from HZ to bictcp_HZ */
	t <<= BICTCP_HZ;
	do_div(t, HZ);

	if (t < ca->bic_K)		/* t - K */
		offs = ca->bic_K - t;
	else
		offs = t - ca->bic_K;

	/* c/rtt * (t-K)^3 */
	delta = (cube_rtt_scale * offs * offs * offs) >> (10+3*BICTCP_HZ);
	if (t < ca->bic_K)                            /* below origin*/
		bic_target = ca->bic_origin_point - delta;
	else                                          /* above origin*/
		bic_target = ca->bic_origin_point + delta;

	/* cubic function - calc bictcp_cnt*/
	if (bic_target > cwnd) {
		ca->cnt = cwnd / (bic_target - cwnd);
	} else {
		ca->cnt = 100 * cwnd;              /* very small increment*/
	}

分析:CUBIC 的窗口是 W(t) = C*(t-K)^3 + Wmax 这条三次曲线,其中 Wmax 是上一次丢包时的窗口(bic_origin_point),K 是从减半恢复到 Wmax 所需的时间(bic_K,由 cubic_root 开立方算出)。上面代码先算出时间偏移 offs = |t-K|,再算 delta = c/rtt * offs^3,用 bic_origin_point ± delta 得到目标窗口 bic_target。关键在最后两行:它不直接设 cwnd,而是把 bic_target 和当前 cwnd 的差距换算成 ca->cnt(每增长一个 MSS 需要被 ACK 的包数),喂给下面的 tcp_cong_avoid_ai

__bpf_kfunc static void cubictcp_cong_avoid(struct sock *sk, u32 ack, u32 acked)
{
	struct tcp_sock *tp = tcp_sk(sk);
	struct bictcp *ca = inet_csk_ca(sk);

	if (!tcp_is_cwnd_limited(sk))
		return;

	if (tcp_in_slow_start(tp)) {
		acked = tcp_slow_start(tp, acked);
		if (!acked)
			return;
	}
	bictcp_update(ca, tcp_snd_cwnd(tp), acked);
	tcp_cong_avoid_ai(tp, ca->cnt, acked);
}

分析:CUBIC 复用了和 Reno 完全相同的骨架——慢启动阶段照样走 tcp_slow_start,只是拥塞避免阶段的”每 RTT +1”被替换成 bictcp_update 算出的 ca->cnt。这就是 Linux 拥塞控制可插拔框架的精髓:每种算法只实现”窗口该长多快”这一件事(cong_avoid 回调),状态机、慢启动、重传机制全部共用。

💭 三次函数好在哪? Reno 的线性增长依赖 RTT 数,RTT 越长恢复越慢(不公平);CUBIC 的曲线只依赖流逝的时间 t,与 RTT 解耦,所以长 RTT 流也能快速恢复。而且三次曲线在 Wmax 附近增长被压得很平(三次函数的”平台区”),意味着快接近上次丢包点时它自动减速,降低再次丢包的概率——这就是比 Reno 更”平滑、稳定”的来源。

丢包减半:ssthresh 到底怎么算?

丢包发生后,第一件事不是重传,而是收缩窗口——把 ssthresh 降到当前 cwnd 的一个比例。Reno 是简单粗暴地除以 2:

/* Slow start threshold is half the congestion window (min 2) */
__bpf_kfunc u32 tcp_reno_ssthresh(struct sock *sk)
{
	const struct tcp_sock *tp = tcp_sk(sk);

	return max(tcp_snd_cwnd(tp) >> 1U, 2U);
}

分析:>> 1U 就是除以 2,max(..., 2U) 保证 ssthresh 至少是 2 个包,避免窗口收缩到死锁。这就是教科书里”乘性减半”(Multiplicative Decrease,MD)的真实实现——一行右移搞定。CUBIC 更讲究,用的是 0.7 而不是 0.5:

__bpf_kfunc static u32 cubictcp_recalc_ssthresh(struct sock *sk)
{
	const struct tcp_sock *tp = tcp_sk(sk);
	struct bictcp *ca = inet_csk_ca(sk);

	ca->epoch_start = 0;	/* end of epoch */

	/* Wmax and fast convergence */
	if (tcp_snd_cwnd(tp) < ca->last_max_cwnd && fast_convergence)
		ca->last_max_cwnd = (tcp_snd_cwnd(tp) * (BICTCP_BETA_SCALE + beta))
			/ (2 * BICTCP_BETA_SCALE);
	else
		ca->last_max_cwnd = tcp_snd_cwnd(tp);

	return max((tcp_snd_cwnd(tp) * beta) / BICTCP_BETA_SCALE, 2U);
}

分析:beta 默认是 717,BICTCP_BETA_SCALE 是 1024,所以乘性减半因子是 717/1024 ≈ 0.7,比 Reno 的 0.5 更温和(丢包后窗口缩得更少,恢复更快)。fast_convergence 分支处理的是”连续丢包”场景:如果这次丢包时的 cwnd 已经比上一次的 Wmax 还小(说明网络持续恶化),就把 Wmax 也往下压,让下次恢复的目标更低——这是为了在多流竞争时更快收敛到公平份额。

💭 为什么丢包只减半,不直接清零? 3 个重复 ACK 意味着”后续包仍在到达”,网络只是轻度拥塞、链路还是通的。如果此时也把窗口清零,等于为了一个包的损失放弃整条还能用的链路,吞吐会断崖式下跌。减半是在”惩罚”和”利用剩余带宽”之间取平衡——保留一半能力继续干活,用另一半作为”谦让”的信号。

超时重传 vs 快速重传

超时重传(RTO 超时):发送方等了一个 RTO(超时计时器)还没收到 ACK —— 严重拥塞—— cwnd 直接降为 1,从头慢启动。这是最严重的惩罚。

快速重传(3 个重复 ACK):发送方连续收到 3 个相同的 ACK(如 ACK=1000, ACK=1000, ACK=1000)——意味着包 1000 丢失了,但后续包仍在到达(所以能产生重复 ACK)——轻度拥塞(网络还在通,只是丢了一个包)。不降到 1,而是减半 + 快速恢复。

“3 个重复 ACK”这个魔法数字,在内核里其实不是硬编码的 3,而是一个可调参数 reordering(默认 3)。真正判定”该进快速恢复”的地方在 NewReno 的丢包标记:

/* RFC6582 NewReno recovery for non-SACK connection. It simply retransmits
 * the next unacked packet upon receiving
 * a) three or more DUPACKs to start the fast recovery
 * b) an ACK acknowledging new data during the fast recovery.
 */
void tcp_newreno_mark_lost(struct sock *sk, bool snd_una_advanced)
{
	const u8 state = inet_csk(sk)->icsk_ca_state;
	struct tcp_sock *tp = tcp_sk(sk);

	if ((state < TCP_CA_Recovery && tp->sacked_out >= tp->reordering) ||
	    (state == TCP_CA_Recovery && snd_una_advanced)) {
		struct sk_buff *skb = tcp_rtx_queue_head(sk);
		u32 mss;

		if (TCP_SKB_CB(skb)->sacked & TCPCB_LOST)
			return;

		mss = tcp_skb_mss(skb);
		if (tcp_skb_pcount(skb) > 1 && skb->len > mss)
			tcp_fragment(sk, TCP_FRAG_IN_RTX_QUEUE, skb,
				     mss, mss, GFP_ATOMIC);

		tcp_mark_skb_lost(sk, skb);
	}
}

分析:条件 tp->sacked_out >= tp->reordering 就是”重复 ACK 数量达到阈值”(Reno 用 sacked_out 模拟 SACK,无 SACK 时每个重复 ACK 让 sacked_out +1)。一旦满足,就把重传队列的队头tcp_rtx_queue_head)标记为丢失并触发重传。这里有个反直觉的细节:重复 ACK 只能告诉你”队头那个包丢了”,因为 ACK 是累积确认的——只有队头丢了,后面到达的包才会一直 ACK 同一个序号。

💭 为什么必须凑够”3 个”才重传,而不是 1 个就重传? 因为 IP 网络允许乱序:包可能只是走得慢、绕了路,晚到而不是丢了。如果看到 1 个重复 ACK 就急着重传,乱序就会造成大量无谓的重复发送。3 个重复 ACK 是一个经验阈值——在”一定是丢包”和”可能是乱序”之间画一条置信度线,凑满 3 个才敢断言队头真的丢了。

拥塞状态机:Linux 的真实实现比教科书多两个状态

教科书只讲”慢启动 / 拥塞避免 / 快速恢复”三个状态,但 Linux 内核里的状态机有五个,源码注释就是最权威的说明:

/* Linux NewReno/SACK/ECN state machine.
 * --------------------------------------
 *
 * "Open"	Normal state, no dubious events, fast path.
 * "Disorder"   In all the respects it is "Open",
 *		but requires a bit more attention. It is entered when
 *		we see some SACKs or dupacks. It is split of "Open"
 *		mainly to move some processing from fast path to slow one.
 * "CWR"	CWND was reduced due to some Congestion Notification event.
 *		It can be ECN, ICMP source quench, local device congestion.
 * "Recovery"	CWND was reduced, we are fast-retransmitting.
 * "Loss"	CWND was reduced due to RTO timeout or SACK reneging.
 */

分析:多出来的 Disorder(乱序)是”Open 的增强版”——收到重复 ACK 或 SACK、怀疑有乱序但还没确定丢包时进入,纯粹为了把处理从快路径挪到慢路径、多做一些检查。CWR(Congestion Window Reduced)则服务于 ECN:网络主动通过 ECE 标志告知拥塞(而不是等丢包),此时窗口收缩但不允许 undo(因为拥塞已被 ECN 证实)。这是 Linux 对 TCP 拥塞控制最完整的一次工程化建模。

状态机的总入口是 tcp_fastretrans_alert,每个可疑 ACK 都会进到这里,按当前状态分派处理:

	/* D. Check state exit conditions. State can be terminated
	 *    when high_seq is ACKed. */
	if (icsk->icsk_ca_state == TCP_CA_Open) {
		WARN_ON(tp->retrans_out != 0 && !tp->syn_data);
		tp->retrans_stamp = 0;
	} else if (!before(tp->snd_una, tp->high_seq)) {
		switch (icsk->icsk_ca_state) {
		case TCP_CA_CWR:
			...
		case TCP_CA_Recovery:
			if (tcp_is_reno(tp))
				tcp_reset_reno_sack(tp);
			if (tcp_try_undo_recovery(sk))
				return;
			tcp_end_cwnd_reduction(sk);
			break;
		}
	}

	/* E. Process state. */
	switch (icsk->icsk_ca_state) {
	...
	default:
		if (tcp_is_reno(tp)) {
			if (flag & FLAG_SND_UNA_ADVANCED)
				tcp_reset_reno_sack(tp);
			tcp_add_reno_sack(sk, num_dupack, ece_ack);
		}

		if (icsk->icsk_ca_state <= TCP_CA_Disorder)
			tcp_try_undo_dsack(sk);

		tcp_identify_packet_loss(sk, ack_flag);
		if (!tcp_time_to_recover(tp)) {
			tcp_try_to_open(sk, flag);
			return;
		}
		...
		/* Otherwise enter Recovery state */
		tcp_enter_recovery(sk, ece_ack);
	}

	*rexmit = REXMIT_LOST;

分析:这段代码揭示了状态迁移的真正判据。high_seq 是”进入恢复/丢包时记录的 snd_nxt”——只要 ACK 推进过了 high_seq,就说明那段丢包风波已经被 ACK 覆盖,可以退出恢复。默认分支(default,对应 Open/Disorder/CWR)先做丢包检测 tcp_identify_packet_loss,再用 tcp_time_to_recover 判断:

/* This function decides, when we should leave Disordered state
 * and enter Recovery phase, reducing congestion window.
 *
 * Main question: may we further continue forward transmission
 * with the same cwnd?
 */
static bool tcp_time_to_recover(const struct tcp_sock *tp)
{
	/* Has loss detection marked at least one packet lost? */
	return tp->lost_out != 0;
}

分析:tcp_time_to_recover 的全部逻辑只有一行——有没有包被确证丢失lost_out != 0)。这回答了一个关键问题:进入恢复(收缩窗口)的充分条件不是”收到几个重复 ACK”,而是”丢包检测器(RACK/NewReno)真的标记了一个包丢失”。重复 ACK 只是信号,最终拍板的是丢包检测。进入恢复的入口函数是:

void tcp_enter_recovery(struct sock *sk, bool ece_ack)
{
	struct tcp_sock *tp = tcp_sk(sk);
	int mib_idx;

	/* Start the clock with our fast retransmit, for undo and ETIMEDOUT. */
	tcp_retrans_stamp_cleanup(sk);

	if (tcp_is_reno(tp))
		mib_idx = LINUX_MIB_TCPRENORECOVERY;
	else
		mib_idx = LINUX_MIB_TCPSACKRECOVERY;

	NET_INC_STATS(sock_net(sk), mib_idx);

	tp->prior_ssthresh = 0;
	tcp_init_undo(tp);

	if (!tcp_in_cwnd_reduction(sk)) {
		if (!ece_ack)
			tp->prior_ssthresh = tcp_current_ssthresh(sk);
		tcp_init_cwnd_reduction(sk);
	}
	tcp_set_ca_state(sk, TCP_CA_Recovery);
}

分析:进入 Recovery 时做了两件关键事:tcp_init_undo 记录”undo 标记”(万一这次丢包是误判,还能回滚窗口收缩),以及 tcp_init_cwnd_reduction 把 ssthresh 收缩到算法回调 ssthresh() 算出的值。prior_cwnd 保存了收缩前的窗口,是后面 PRR(比例速率恢复)和 undo 的依据。与之对照,RTO 超时走的是更严厉的 tcp_enter_loss

void tcp_enter_loss(struct sock *sk)
{
	const struct inet_connection_sock *icsk = inet_csk(sk);
	struct tcp_sock *tp = tcp_sk(sk);
	struct net *net = sock_net(sk);
	bool new_recovery = icsk->icsk_ca_state < TCP_CA_Recovery;
	u8 reordering;

	tcp_timeout_mark_lost(sk);

	/* Reduce ssthresh if it has not yet been made inside this window. */
	if (icsk->icsk_ca_state <= TCP_CA_Disorder ||
	    !after(tp->high_seq, tp->snd_una) ||
	    (icsk->icsk_ca_state == TCP_CA_Loss && !icsk->icsk_retransmits)) {
		tp->prior_ssthresh = tcp_current_ssthresh(sk);
		tp->prior_cwnd = tcp_snd_cwnd(tp);
		WRITE_ONCE(tp->snd_ssthresh, icsk->icsk_ca_ops->ssthresh(sk));
		tcp_ca_event(sk, CA_EVENT_LOSS);
		tcp_init_undo(tp);
	}
	tcp_snd_cwnd_set(tp, tcp_packets_in_flight(tp) + 1);
	tp->snd_cwnd_cnt   = 0;
	tp->snd_cwnd_stamp = tcp_jiffies32;

	/* Timeout in disordered state after receiving substantial DUPACKs
	 * suggests that the degree of reordering is over-estimated.
	 */
	reordering = READ_ONCE(net->ipv4.sysctl_tcp_reordering);
	if (icsk->icsk_ca_state <= TCP_CA_Disorder &&
	    tp->sacked_out >= reordering)
		WRITE_ONCE(tp->reordering,
			   min_t(unsigned int, tp->reordering, reordering));

	tcp_set_ca_state(sk, TCP_CA_Loss);
	tp->high_seq = tp->snd_nxt;
	tp->tlp_high_seq = 0;
	tcp_ecn_queue_cwr(tp);

	/* F-RTO RFC5682 sec 3.1 step 1: retransmit SND.UNA if no previous
	 * loss recovery is underway except recurring timeout(s) on
	 * the same SND.UNA (sec 3.2). Disable F-RTO on path MTU probing
	 */
	tp->frto = READ_ONCE(net->ipv4.sysctl_tcp_frto) &&
		   (new_recovery || icsk->icsk_retransmits) &&
		   !inet_csk(sk)->icsk_mtup.probe_size;
}

分析:这里有个和教科书说法不同的关键细节。文章前面说”超时后 cwnd 归 1 重来”,但 Linux 实际执行的是 tcp_snd_cwnd_set(tp, tcp_packets_in_flight(tp) + 1)——把 cwnd 设为”当前在途包数 + 1”,而不是字面意义的 1。因为在途的包(packets_in_flight)可能不为 0,直接设 1 会导致无法重传任何东西;设成 in_flight + 1 既保证了至少能重传一个包,又保留了”回到最保守状态”的语义。RTO 后配合 tcp_timeout_mark_lost 把整个队列标记为丢失,这是比快速恢复严厉得多的惩罚。

快速恢复的发送节奏:PRR

进入 Recovery 之后,cwnd 已经砍半,但”接下来每个 RTT 到底发多少包”不是拍脑袋定的,而是由 PRR(Proportional Rate Reduction,RFC 6937)精确控制:

void tcp_cwnd_reduction(struct sock *sk, int newly_acked_sacked, int newly_lost, int flag)
{
	struct tcp_sock *tp = tcp_sk(sk);
	int sndcnt = 0;
	int delta = tp->snd_ssthresh - tcp_packets_in_flight(tp);

	if (newly_acked_sacked <= 0 || WARN_ON_ONCE(!tp->prior_cwnd))
		return;

	trace_tcp_cwnd_reduction_tp(sk, newly_acked_sacked, newly_lost, flag);

	tp->prr_delivered += newly_acked_sacked;
	if (delta < 0) {
		u64 dividend = (u64)tp->snd_ssthresh * tp->prr_delivered +
			       tp->prior_cwnd - 1;
		sndcnt = div_u64(dividend, tp->prior_cwnd) - tp->prr_out;
	} else {
		sndcnt = max_t(int, tp->prr_delivered - tp->prr_out,
			       newly_acked_sacked);
		if (flag & FLAG_SND_UNA_ADVANCED && !newly_lost)
			sndcnt++;
		sndcnt = min(delta, sndcnt);
	}
	/* Force a fast retransmit upon entering fast recovery */
	sndcnt = max(sndcnt, (tp->prr_out ? 0 : 1));
	tcp_snd_cwnd_set(tp, tcp_packets_in_flight(tp) + sndcnt);
}

分析:PRR 的核心思路是把窗口收缩分摊到整个恢复周期,而不是进入恢复的一瞬间从 prior_cwnd 硬跳到 ssthresh。当在途包还多于 ssthresh(delta < 0)时,按 ssthresh * prr_delivered / prior_cwnd 的比例算出一个”平滑下坡”的发送量;当在途包已经低于 ssthresh 时,改用”包守恒”——ACK 了多少就补发多少。最后 tcp_snd_cwnd_set(tp, tcp_packets_in_flight(tp) + sndcnt) 把 cwnd 设成”在途 + 本次可发数”,让发送速率平滑收敛到新 ssthresh,避免恢复期出现突发。

💭 为什么快速恢复要专门引入 PRR? 如果不做比例控制,进入恢复的瞬间窗口从 W 掉到 W/2,会有一段”发不动”的真空,恢复结束又可能突然爆发。PRR 保证了恢复过程中的发送速率是单调、平滑下降的,既不会饿死(靠 ACK 时钟续命),也不会二次拥塞。这也是”快速恢复”名字里”恢复”二字的真正含义——它不是简单地减半了事,而是精心编排的一段”下坡路”。

BBR:不用丢包信号,用带宽探测

CUBIC 等基于丢包的算法在有缓冲区队列的网络中会持续增加 cwnd 直到丢包,这意味着网络中的 bufferbloat(缓冲区被填满)——延迟飙升。BBR(Bottleneck Bandwidth and Round-trip propagation time)换了一个思路:

不靠丢包感知拥塞,靠带宽和 RTT 的实时测量。 BBR 交替探测最大带宽和最小 RTT,计算的”最优发送速率”是这两个值交汇的点——网络既不空转也不拥塞。

BBR 在有损链路(如 4G/5G 移动网络)中表现远好于 CUBIC,因为丢包不一定意味着拥塞(无线丢包 ≠ 网络拥塞)。Google 内部 B4 WAN 链路部署 BBR 后吞吐提升 2-20 倍。

💭 一句话想透 BBR 和 CUBIC 的根本分歧:CUBIC 把”丢包”当拥塞,于是它永远在”把网络填到丢包”的边缘试探——这注定会填满瓶颈缓冲区,让延迟飙升(bufferbloat)。BBR 换了个观察对象:不看丢包,看**瓶颈带宽(BtlBw)最小 RTT(RTprop)**这两个物理量。它发送速率的目标是 BtlBw × RTprop 的乘积(带宽延迟积),恰好等于”管道刚好装满、缓冲区不积压”的那个点。所以 BBR 在无线这类”乱丢包但不真拥塞”的链路上能跑满带宽,而 CUBIC 会误伤。

总结

算法拥塞信号优点缺点
CUBIC丢包公平、稳定丢包 ≠ 拥塞(无线/噪声)
BBR带宽+RTT 实时测量不误判无线丢包启动阶段可能抢占过多带宽

章末提问

追问 1:为什么慢启动要指数增长?为什么不一开始就把 cwnd 设大一点?

回答思路:分两层答。第一层是”必要性”——发送方连接建立时对路径带宽零知识,唯一的探测手段就是发数据看 ACK,线性增长会导致收敛过慢、带宽利用率长期上不去;指数增长能用 O(log W) 个 RTT 逼近带宽。第二层是”安全性”——指数增长的每一步代价是翻倍,虽然激进,但一旦越过真实带宽就会触发丢包检测,慢启动阈值(ssthresh)和 HyStart 就是在给这个”翻倍”装刹车。可以顺带提 Linux 的实现:tcp_slow_startcwnd += acked,一个 RTT 内 cwnd 个 ACK 累加,等效每 RTT 翻倍。

追问 2:为什么是”3 个重复 ACK”触发快速重传,1 个或 2 个不行吗?

回答思路:核心是区分”丢包”和”乱序”。IP 网络不保证有序,包可能绕路晚到,如果看到 1 个重复 ACK 就重传,乱序会造成大量虚假重传、浪费带宽。3 是经验阈值,在”确定丢包”和”容忍乱序”之间取平衡。再往上补充:Linux 里这个阈值不是硬编码 3,而是 reordering 参数(默认 3),判定在 tcp_newreno_mark_lostsacked_out >= reordering;且重复 ACK 只能定位队头包(累积确认的特性),这是为什么快速重传只重传那一个包。

追问 3:为什么快速恢复只减半 cwnd,而 RTO 超时要把窗口打回接近 1?

回答思路:因为两者对应的网络状况严重程度不同。3 个重复 ACK 说明”只丢了队头一个包,后续包仍在到达”——链路是通的,只是轻度拥塞,减半即可(保留一半能力继续干活)。RTO 超时说明”整个 RTO 内一个 ACK 都没收到”——网络很可能基本不通,属于最严重拥塞,必须回到最保守状态重来。可补充 Linux 细节:RTO 走 tcp_enter_loss,实际执行的是 cwnd = packets_in_flight + 1 而不是字面意义的 1,同时把整个重传队列标记丢失;快速恢复走 tcp_enter_recovery + PRR 平滑收缩。

追问 4:CUBIC 为什么要用三次函数?它和 Reno 的”每 RTT +1”相比解决什么问题?

回答思路:Reno 的线性增长在高速、长 RTT 网络下恢复太慢(每 RTT 才 +1,恢复一个大窗口要成千上万个 RTT),且对 RTT 长度敏感、不公平。CUBIC 用 W(t) = C*(t-K)^3 + Wmax 让窗口只随流逝时间增长、与 RTT 解耦,长 RTT 流也能快速恢复;三次曲线在 Wmax 附近有一段平台区,逼近上次丢包点时自动减速,降低再次丢包概率。还能点出 Linux 实现的精髓:bictcp_update 算出的不是窗口本身,而是换算成 ca->cnt(增长一个 MSS 需要多少个 ACK),复用 tcp_cong_avoid_ai,体现可插拔框架的设计。

追问 5:为什么 BBR 不怕无线丢包,而 CUBIC 怕?bufferbloat 是怎么来的?

回答思路:先讲本质分歧——CUBIC 把”丢包”当作唯一拥塞信号,于是它永远在”把网络填到丢包”的边缘试探;在有缓冲区的链路(如家用路由器大 buffer)里,这意味着瓶颈队列被持续填满,排队延迟飙升,这就是 bufferbloat。无线链路的丢包多是信号噪声,不是真拥塞,CUBIC 会误判而白白降速。BBR 不看丢包,直接测量瓶颈带宽 BtlBw 和最小 RTT RTprop,目标速率取二者乘积(带宽延迟积),恰好是”管道装满但缓冲区不积压”的点,所以无线丢包不触发降速。最后可以补一句 trade-off:BBR 启动阶段探测激进,可能抢占过多带宽、对 CUBIC 流不够公平。


💡 一句话收尾:拥塞控制的本质是一场”盲人摸带宽”的实验——CUBIC 用丢包当探针,BBR 用带宽和 RTT 当尺子,而 Linux 内核把那套 AIMD + 状态机雕成了 tcp_input.c 里可插拔的工程艺术品。


Share this post on:

Previous Post
TCP粘包——三种应用层解决方案与Netty的实现
Next Post
TCP四次挥手——为什么TIME_WAIT要等2MSL?