Linux TCP拥塞控制算法CUBIC与BBR内核实现对比
TCP拥塞控制是Linux内核net/ipv4/目录下的核心模块,通过struct tcp_congestion_ops接口统一抽象。CUBIC(net/ipv4/tcp_cubic.c)与BBR(net/ipv4/tcp_bbr.c)代表了两种截然不同的设计哲学:基于丢包模型的窗口调整与基于带宽和RTT测量的 pacing 模型。本文从内核源码层面拆解两者的关键路径。
```c
// include/net/tcp.h
struct tcp_congestion_ops {
struct list_head list;
u32 key;
u32 flags;
char name[TCP_CA_NAME_MAX];
int (*init)(struct sock *sk);
void (*release)(struct sock *sk);
void (*cong_control)(struct sock *sk, const struct rate_sample *rs);
void (*cong_avoid)(struct sock *sk, u32 ack, u32 acked);
void (*ssthresh)(struct sock *sk);
void (*undo_cwnd)(struct sock *sk);
u32 (*tcp_reno_ssthresh)(struct sock *sk);
u32 (*undo_cwnd)(struct sock *sk);
u32 (*min_tso_segs)(struct sock *sk);
struct tcp_congestion_ops *to_fastretrans_alt;
...
};
```
CUBIC通过cong_avoid挂入主路径,BBR则通过cong_control使用rate_sample输入。这个差异本身就暴露了设计分歧:CUBIC只在ACK到达时决策,BBR在每个RTT内的多个时间点都可以调整。
CUBIC的核心状态机由bictcp结构体管理,关键字段包括epoch_start、origin_point、last_max_cwnd、bic_scale和tcp_friendliness。
```c
// net/ipv4/tcp_cubic.c
struct bictcp {
u32 cnt; /* cwnd增长步长倒数 */
u32 last_max_cwnd; /* 上次窗口最大值,用于W_max跟踪 */
u32 loss_cwnd; /* 上次丢包时的cwnd */
u32 last_cwnd; /* 上次bic_update期间计算的cwnd */
u32 epoch_start; /* 当前拥塞避免阶段的开始时间戳 */
u32 origin_point; /* W_max减去窗口下降量 */
u32 bic_scale; /* 影响三次函数曲率的缩放因子 */
u8 delay_min; /* 最小RTT(用于hybrid slow start) */
u32 ack_cnt; /* 累计ACK计数 */
...
};
```
CUBIC在丢包发生后调用tcp_cubic_ssthresh,将ssthresh设为当前cwnd的β倍(默认717 = 0.7,实际用右移实现近似)。关键在恢复阶段结束后,cwnd重新进入拥塞避免时调用bictcp_cong_avoid,它使用一个三次函数计算目标窗口:
```c
// net/ipv4/tcp_cubic.c -- bictcp_cong_avoid
static void bictcp_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)) {
if (hystart && after(tp->snd_una, ca->end_seq))
bictcp_hystart_reset(sk);
acked = tcp_slow_start(tp, acked);
if (!acked)
return;
}
bictcp_update(ca, tp->snd_cwnd, tcp_ca_dst_ecn_ecn(sk));
tcp_cong_avoid_ai(tp, ca->cnt, acked);
}
```
这里注意到tcp_is_cwnd_limited检查。如果发送方不受cwnd限制(比如受应用层或pacing限制),CUBIC不会增加窗口。这是很多人在读CUBIC代码时忽略的边界条件:当SO_MAX_PACING_RATE设置较低导致实际发送速率低于cwnd允许值时,CUBIC实际上不会增长cwnd,所以从BBR切换到CUBIC时不会出现窗口激增。
bictcp_update函数实现了三次函数的核心计算:
```c
// net/ipv4/tcp_cubic.c -- bictcp_update
static inline void bictcp_update(struct bictcp *ca, u32 cwnd, u32 ecn)
{
u32 delta, bic_target, offs;
u64 t, log_cnt;
ca->ack_cnt++;
if (ca->epoch_start == 0) {
ca->epoch_start = tcp_jiffies32;
ca->ack_cnt = 1;
ca->tcp_cnt = 0;
ca->last_max_cwnd = cwnd;
ca->bic_scale = 8; /* 默认BIC缩放因子 */
}
/* 三次函数参数:t = elapsed time / minRTT */
t = ((tcp_jiffies32 - ca->epoch_start) << 3) / HZ;
t = max(t, 1ULL);
/* 计算W(t) = W_max - beta * W_max + C * (t - K)^3 */
offs = ca->last_max_cwnd - cwnd;
if (offs < 0)
offs = 0;
/* K = cubic_root(W_max * beta / C) */
log_cnt = (u64)ca->last_max_cwnd * 717 / 1024;
log_cnt = (log_cnt << 5) / ca->bic_scale;
...
}
```
三次函数的凹区域和凸区域切换点在K时间点。当t < K时,函数处于凹增长区域(增速加快),当t > K时进入凸区域(增速减慢)。但内核实现中为了稳定性,在bictcp_update里对cnt做了clamp:cnt最小值不能低于2,这防止了cwnd在每个ACK上增长超过1个MSS。这对应着tcp_cong_avoid_ai中的加法增加逻辑。
现在对比BBR。BBR的状态机完全不依赖丢包事件,而是通过pacing_rate和cwnd_gain两个参数控制发送:
```c
// net/ipv4/tcp_bbr.c
struct bbr {
u32 lt_use_bw; /* 是否使用带宽滤波器的长期值 */
u32 bw_lo; /* 带宽下界(用于probe RTT) */
u32 bw_hi; /* 带宽上界(max bw filter输出) */
u32 rtt_cnt; /* 当前阶段的RTT计数 */
u32 next_round_delivered; /* 用于轮次追踪的delivered标记 */
struct {
u32 bw; /* 带宽(单位:bps) */
u32 rtt; /* 最小RTT(单位:us) */
} max_bw_filter[2]; /* 带宽窗滤波器 */
struct {
u32 bw; /* 窗口内的最大带宽 */
u32 rtt; /* 窗口内的最小RTT */
} win;
u8 state; /* BBR状态:STARTUP, DRAIN, PROBE_BW, PROBE_RTT */
u8 mode; /* pacing_gain和cwnd_gain模式 */
...
};
```
BBR的核心更新函数是bbr_main,挂载在cong_control回调中。每收到一个ACK时,内核会计算rate_sample并传入:
```c
// net/ipv4/tcp_bbr.c -- bbr_main
static void bbr_main(struct sock *sk, const struct rate_sample *rs)
{
struct bbr *bbr = inet_csk_ca(sk);
if (!bbr->initialized)
bbr_init(sk);
bbr_update_model(sk, rs);
bbr_update_gains(sk);
bbr_update_pacing_rate(sk);
bbr_update_cwnd(sk);
bbr_update_ack_aggregation(sk);
}
```
bbr_update_model包含两条并行的滤波器路径:最大带宽的max滤波器和最小RTT的min滤波器。带宽滤波器是BBR最微妙的设计,它的窗口长度由bbr_bw_rtts决定,单位是RTT轮次而非时间绝对值:
```c
// net/ipv4/tcp_bbr.c -- bbr_update_bw
static void bbr_update_bw(struct sock *sk, const struct rate_sample *rs)
{
struct bbr *bbr = inet_csk_ca(sk);
u32 bw = rs->delivered * 1000 / (rs->interval_us ? : 1);
u64 bw_usecs = (u64)bw * rs->interval_us;
if (!rs->acked_sacked || rs->interval_us <= 0)
return;
/* 窗口滤波:维护最近N个RTT轮次内的最大带宽 */
bbr->max_bw_filter[bbr->id] = max(bbr->max_bw_filter[bbr->id], bw);
if (bbr->rounds_since_bw_high > 0) {
bbr->rounds_since_bw_high--;
} else {
bbr->id ^= 1;
bbr->rounds_since_bw_high = 1;
}
}
```
这里一个关键边界是rs->interval_us <= 0的保护。当TSval(时间戳)回绕或者接收端ACK没有携带有用时间信息时,测量到的interval_us可能为0或负值,此时除零会导致内核崩溃。另一个边界是bbr->rounds_since_bw_high的减法,它可能下溢——但bbr_init中初始化它为0,而bbr_update_bw只在bbr->rounds_since_bw_high递减到0时切换滤波器桶,所以不会出现负数。但需要明确:如果某个RTT内没有收到任何ACK(即bbr_main没有被调用),滤波器不会更新,这可能导致旧带宽值在窗口中驻留过久,当发送链路突然变差时,BBR需要至少一个完整RTT才能响应。
BBR的pacing_rate计算直接控制发送节奏:
```c
// net/ipv4/tcp_bbr.c -- bbr_set_pacing_rate
static void bbr_set_pacing_rate(struct sock *sk)
{
struct bbr *bbr = inet_csk_ca(sk);
u32 rate = bbr->max_bw_filter[bbr->id];
/* 应用pacing_gain */
rate = (u64)rate * bbr->pacing_gain >> BBR_SCALE;
/* 应用probe_rtt状态的带宽下限 */
if (bbr->state == BBR_PROBE_RTT)
rate = min_t(u32, rate, bbr->bw_lo);
/* 最终pacing rate不能超过应用设置的MAX_PACING_RATE */
rate = min_t(u32, rate, sk->sk_max_pacing_rate);
/* 写入内核pacing引擎 */
sk->sk_pacing_rate = rate;
}
```
这里有一个重要的竞态场景:sk_pacing_rate的写入与tcp_write_xmit中的pacing FQ(fair queueing)调度器读取不是原子操作。当32-bit rate赋值被拆分成两个16-bit写入时,pacing调度器可能读到撕裂的中间值,导致短时间的rate尖峰或低谷。内核通过将sk_pacing_rate定义为atomic_t解决了这个问题(4.19+内核已修复)。
BBR的cwnd计算使用bbr_quantization_budget,将pacing_rate乘以RTT得到BDP,再乘以cwnd_gain:
```c
// net/ipv4/tcp_bbr.c -- bbr_update_cwnd
static void bbr_update_cwnd(struct sock *sk)
{
struct bbr *bbr = inet_csk_ca(sk);
u32 cwnd = 0, target_cwnd = 0;
/* 目标cwnd = BDP * cwnd_gain */
target_cwnd = (u64)bbr->max_bw_filter[bbr->id] *
bbr->min_rtt_us * bbr->cwnd_gain >> BBR_SCALE;
/* 转换为MSS单位 */
target_cwnd = bbr_quantization_budget(sk, target_cwnd, 0);
/* 保证cwnd至少为4个MSS(RFC要求) */
target_cwnd = max_t(u32, target_cwnd, 4);
if (bbr->state == BBR_PROBE_RTT) {
cwnd = bbr_probe_rtt_cwnd(sk);
} else {
cwnd = max(target_cwnd, bbr->prior_cwnd);
cwnd = min(cwnd, bbr->max_cwnd);
}
tp->snd_cwnd = cwnd;
}
```
target_cwnd的计算使用了bbr->max_bw_filter[bbr->id]和bbr->min_rtt_us。这里有一个精度问题:当RTT非常小(例如本地回环测试中RTT < 10us)时,min_rtt_us取整导致BDP计算偏差巨大。BBR在bbr_init中强制min_rtt_us的最小值为10us(BBR_MIN_RTT)来缓解:
```c
// net/ipv4/tcp_bbr.c -- bbr_init
#define BBR_MIN_RTT 10 /* 最小RTT边界:10微秒 */
static void bbr_init(struct sock *sk)
{
struct bbr *bbr = inet_csk_ca(sk);
bbr->min_rtt_us = tcp_min_rtt(sk);
if (bbr->min_rtt_us == ~0U)
bbr->min_rtt_us = BBR_MIN_RTT;
bbr->min_rtt_stamp = tcp_jiffies32;
...
}
```
tcp_min_rtt返回的是TCP层维护的平滑最小RTT,如果从未采样到则返回~0U,所以BBR必须做这个检查。但如果连接在首次RTT采样前就发了大量数据?实际上tcp_min_rtt在SYN-ACK握手阶段就已经被初始化了,所以生产环境下不会触发这个fallback。
CUBIC和BBR最本质的差异体现在丢包响应上。CUBIC在tcp_cubic_ssthresh中直接将cwnd乘以β:
```c
// net/ipv4/tcp_cubic.c
static u32 tcp_cubic_ssthresh(struct sock *sk)
{
const struct tcp_sock *tp = tcp_sk(sk);
struct bictcp *ca = inet_csk_ca(sk);
ca->loss_cwnd = tp->snd_cwnd;
ca->last_max_cwnd = tp->snd_cwnd;
/* β = 717/1024 ≈ 0.7,通过右移避免浮点数 */
return max((tp->snd_cwnd * 717) >> 10, 2U);
}
```
而BBR完全忽略丢包对cwnd的直接缩减:
```c
// net/ipv4/tcp_bbr.c
static void bbr_state_loss(struct sock *sk)
{
struct bbr *bbr = inet_csk_ca(sk);
/* BBR在丢包时不下降cwnd,只重置带宽滤波器 */
bbr->lt_use_bw = 0;
bbr->bw_lo = ~0U;
/* 如果正处于STARTUP阶段,丢包导致进入DRAIN */
if (bbr->state == BBR_STARTUP)
bbr_set_state(sk, BBR_DRAIN);
/* 进入PROBE_RTT以重新校准min_rtt */
bbr->probe_rtt_done_stamp = 0;
}
```
当CUBIC遇到持续丢包时,cwnd随着每次丢包事件不断收缩,在浅缓存链路中可能收敛到2个MSS的ssthresh下限。BBR则只靠带宽滤波器感知链路变化——如果丢包没有引起带宽下降(例如路由器队列尾丢弃但链路速率不变),BBR会维持原有发送速率,导致持续丢包。这在BBRv1中是个已知缺陷,也是BBRv3引入ECN和丢包率阈值的原因。
CUBIC还有一个隐藏的竞态问题:当CA_ACK_FAST_PATH被触发时,部分ACK处理会绕过cong_avoid路径。查看net/ipv4/tcp_input.c中tcp_ack的fast path逻辑:
```c
// net/ipv4/tcp_input.c
static int tcp_ack(struct sock *sk, const struct sk_buff *skb, int flag)
{
...
if (flag & FLAG_CA_ALERT)
tcp_process_cong_alert(sk, ack, flag); /* 进入cong_control路径 */
...
if (tcp_ack_is_dubious(sk, flag)) {
...
} else {
/* Fast path: 直接进入CA_EVENT_FAST_ACK事件处理 */
tcp_ca_event(sk, CA_EVENT_FAST_ACK);
/* 如果icsk_ca_ops->cong_avoid存在则调用 */
if (icsk->icsk_ca_ops->cong_avoid)
icsk->icsk_ca_ops->cong_avoid(sk, ack, acked);
}
...
}
```
这里的边界是,当tcp_ack_is_dubious返回真时(例如接收到DUPACK或SACKed序列号异常),进入拥塞响应路径,可能会调用tcp_fastretrans_alert直接修改cwnd。如果此时CUBIC的bictcp_update刚刚基于旧cwnd计算了cnt,下一次ACK使用这个陈旧的cnt可能导致过度增长。不过CUBIC用ca->ack_cnt和epoch_start规避了这个问题——每次bictcp_update都会基于实时cwnd重新计算。
BBR的pacing引擎依赖内核的FQ(Fair Queueing)qdisc或TSO(TCP Segmentation Offload)的burst机制。当TSO开启时,内核在tcp_tso_should_defer中判断是否需要延迟发送以匹配pacing rate:
```c
// net/ipv4/tcp_output.c
static bool tcp_tso_should_defer(struct sock *sk, struct sk_buff *skb,
bool *is_cwnd_limited, u32 *max_segs)
{
const struct tcp_sock *tp = tcp_sk(sk);
u32 send_forward, hint;
...
if (tp->tcp_mstamp - tp->tcp_wstamp_max < 0)
return true; /* 上次发送的时间戳还在pacing interval内,推迟发送 */
/* 计算下一个TSO burst可以发送多少个segments */
...
}
```
这里tp->tcp_wstamp_max - tp->tcp_mstamp的减法用u32计算,如果时间戳回绕会导致误判。内核在tcp_mstamp_refresh中维护了时间戳单调递增性来防止回绕。但如果连接长时间静默(超过49.7天的jiffies回绕周期),pacing间隔计算会出错,BBR可能短时间内爆发大量数据包——好在这种场景在现实网络中几乎不可能出现。
另一个值得深挖的边界是CUBIC的hybrid slow start(hystart)。它在tcp_cubic.c中通过检测ACK间隔(ACK train)和RTT增长来退出慢启动,而不是等丢包。hystart的检测逻辑:
```c
// net/ipv4/tcp_cubic.c
static void hystart_update(struct sock *sk, u32 delay)
{
struct tcp_sock *tp = tcp_sk(sk);
struct bictcp *ca = inet_csk_ca(sk);
if (!(ca->found & HYSTART_ACKTRAIN)) {
/* ACK train检测:如果连续ACK到达间隔小于delay_min/2,认为在慢启动 */
if (ca->last_ack_delta > ca->delay_min >> 3)
ca->found |= HYSTART_ACKTRAIN;
}
if (!(ca->found & HYSTART_DELAY)) {
/* RTT增长检测:如果当前RTT超过最小RTT的阈值,退出 */
if (delay > ca->delay_min >> 1)
ca->found |= HYSTART_DELAY;
}
if (ca->found & HYSTART_ACKTRAIN || ca->found & HYSTART_DELAY)
ca->found |= HYSTART_START; /* 准备退出慢启动 */
}
```
delay_min >> 3和delay_min >> 1都是经验值,没有理论保证。在噪声较大的无线网络中,RTT抖动可能导致hystart提前退出,cwnd在慢启动阶段只增长到几十个MSS,极大影响短流性能。这可以通过tcp_cubic.c模块参数hystart_detect关闭。
性能影响方面,CUBIC的bictcp_update包含64位乘法运算((u64)ca->last_max_cwnd * 717 / 1024),在每次ACK到达时执行。对于万兆网卡每秒数万个ACK的场景,这个乘法的CPU开销不可忽视。BBR的bbr_main中的计算虽然更多(带宽滤波、pacing rate、cwnd),但由于使用了移位和条件赋值而非64位除法,实际指令数更少。但BBR需要FQ qdisc配合才能发挥效果——在没有fq_codel的场景下,仅靠TSO/GSO的burst发送会破坏pacing的精度,导致队列堆积和bufferbloat。
BBR的probe RTT状态(BBR_PROBE_RTT)设计为每10秒(BBR_PROBE_RTT_INTERVAL)进入一次,将cwnd缩减为4个MSS,持续至少200ms(BBR_PROBE_RTT_MIN_MS)来清空网络管道并重新测量最小RTT。但实现中存在一个重要的边界:如果min_rtt_us_floor被错误更新,probe RTT的退出条件可能永远不满足:
```c
// net/ipv4/tcp_bbr.c -- bbr_update_min_rtt
static void bbr_update_min_rtt(struct sock *sk, const struct rate_sample *rs)
{
struct bbr *bbr = inet_csk_ca(sk);
u32 min_rtt_us = rs->rtt_us;
if (rs->rtt_us && rs->rtt_us < bbr->min_rtt_us) {
bbr->min_rtt_us = rs->rtt_us;
bbr->min_rtt_stamp = tcp_jiffies32;
}
/* probe RTT超时后,只有min_rtt_us更新了才退出 */
if (bbr->probe_rtt_done_stamp &&
after(tcp_jiffies32, bbr->probe_rtt_done_stamp))
bbr->probe_rtt_done_stamp = 0;
}
```
如果链路的实际最小RTT在probe RTT期间没有改善(比如receiver端的处理延迟主导了RTT),min_rtt_us不会下降,但probe_rtt_done_stamp的超时机制仍然会让BBR退出PROBE_RTT状态。所以这个问题在超时机制上不会死锁,但在min_rtt_us得不到更新的情况下,后续的BDP计算会使用一个偏大的RTT值,导致cwnd被高估。
CUBIC和BBR的共存问题是多流竞争时的核心关注点。在同一个瓶颈链路中,CUBIC流遇到丢包会立刻降窗,BBR流则不受影响——这导致CUBIC流获得的带宽远低于BBR流。内核在tcp_register_congestion_control层面提供了公平性钩子,但没有对算法间的竞争做任何干预:
```c
// net/ipv4/tcp_cong.c
int tcp_register_congestion_control(struct tcp_congestion_ops *ca)
{
int ret = 0;
spin_lock(&tcp_cong_list_lock);
if (ca->key != ~0U) {
ca->key = tcp_cong_control_key(ca->name, ca->name_len);
if (ca->key == ~0U) {
pr_err("TCP: %s key collision\n", ca->name);
ret = -EEXIST;
goto out;
}
}
list_add_tail_rcu(&ca->list, &tcp_cong_list);
...
}
```
RTT不公平性是另一个根深蒂固的问题。CUBIC的cwnd增速与RTT的三次方成反比(因为三次函数的时间轴以RTT为单位),所以RTT小的流增长更快,获得更多带宽。BBR在STARTUP阶段使用pacing_gain=2.89(BBR_HIGH_GAIN = 2885 / BBR_SCALE),cwnd_gain=2,理论上与RTT无关——但实际中RTT影响采样轮次切换频率,间接影响带宽滤波器的更新粒度,导致短RTT流仍然占优。
最后,两者在undo路径上的差异也值得注意。当发生虚假重传(如reordering或DSACK)时,CUBIC调用tcp_cubic_undo_cwnd恢复丢包前的cwnd:
```c
// net/ipv4/tcp_cubic.c
static u32 tcp_cubic_undo_cwnd(struct sock *sk)
{
const struct tcp_sock *tp = tcp_sk(sk);
struct bictcp *ca = inet_csk_ca(sk);
ca->last_max_cwnd = tp->snd_cwnd; // 恢复旧值
return max(tp->snd_cwnd, ca->loss_cwnd);
}
```
BBR的undo实现是空操作:
```c
// net/ipv4/tcp_bbr.c
static u32 bbr_undo_cwnd(struct sock *sk)
{
struct bbr *bbr = inet_csk_ca(sk);
return bbr->prior_cwnd;
}
```
BBR在虚假重传后不恢复任何状态,因为它的模型不依赖丢包来维持cwnd。但prior_cwnd可能已经被后续的带宽更新覆盖,所以undo后可能仍然低于应有值。好在BBR在接下来几个RTT内会通过带宽滤波器快速恢复到正确速率,所以这在实际中影响不大。
从代码行数看,tcp_cubic.c约650行,tcp_bbr.c约1200行。BBR的复杂度来自其模型维护和状态机管理。但CUBIC的数学计算虽然代码量少,其三次函数中K值的开方运算(基于二分查找近似cubic_root)在极端cwnd下(如超过10万MSS的数据中心链路)存在收敛慢的问题,PATCH版本(如tcp_cubic_fast)通过查表法优化了这一路径。
Linux TCP拥塞控制算法CUBIC与BBR内核实现对比