土法炼钢兴趣小组的算法知识备份

【Linux 网络子系统深度拆解】TCP 内核实现(下):数据传输与拥塞控制

文章导航

分类入口
linuxnetworking
标签入口
#tcp#linux-kernel#congestion-control#tcp-sendmsg#tcp-write-xmit#tsq#pacing#rack-tlp#cubic#bbr#bpftrace

目录

上一篇我们拆解了 TCP 连接管理与状态机——三次握手完成后,tcp_sock 在 ESTABLISHED 状态就位,两端开始交换数据。但 TCP 数据传输远不是”把字节塞进 sk_buff 然后发出去”这么简单。

你调用 send(fd, buf, len, 0) 发送 1MB 数据。内核不会一口气全部发出去——它首先把数据拷到发送缓冲区的 sk_buff 链表上,然后受到至少四重约束:

  1. Nagle 算法——小包是否合并
  2. TSQ(TCP Small Queues)——qdisc 层排队量上限
  3. 拥塞窗口(cwnd)——网络容量的估算
  4. 接收窗口(rwnd)——对端接收能力的通告

只有同时满足所有约束,tcp_write_xmit() 才会把 sk_buff 从发送队列推入 IP 层。而当 ACK 回来时,拥塞控制算法根据新的反馈调整 cwnd——这个闭环构成了 TCP 数据传输的核心引擎。

本文从内核源码出发,沿着 tcp_sendmsg()tcp_push()tcp_write_xmit()__tcp_transmit_skb() 的发送路径,和 tcp_rcv_established()tcp_ack()tcp_cong_avoid() 的 ACK 处理路径,完整拆解 TCP 数据传输与拥塞控制的内核实现。

一、tcp_sock 关键字段:发送与接收的记账系统

在进入发送路径之前,先理解 tcp_sock 中与数据传输直接相关的字段。这些字段定义在 include/linux/tcp.h(6.8 内核,第 192 行起):

发送侧序号

struct tcp_sock {
    /* TX hotpath */
    u32 write_seq;      /* 发送缓冲区的尾部序号——下一个 write 写到这里 */
    u32 pushed_seq;     /* 最后一次 push 的序号——用于与 Windows 兼容 */
    u32 snd_nxt;        /* 下一个要发送的序号 */
    u32 snd_una;        /* 最老的未确认序号——"发送窗口左沿" */
    u32 snd_wnd;        /* 对端通告的接收窗口大小 */
    u32 snd_cwnd;       /* 拥塞窗口——以 MSS 为单位 */
    u32 snd_ssthresh;   /* 慢启动阈值——cwnd < ssthresh 时在慢启动 */
    u32 packets_out;    /* "in flight" 的包数——已发未确认 */
};

这些字段之间的关系构成了发送控制的核心逻辑:

发送缓冲区序号空间:

 已确认 |  已发送未确认  | 可发送但未发送 |  用户已写入但不可发  | 缓冲区空
--------+---------------+--------------+--------------------+--------
     snd_una        snd_nxt    snd_una+min(cwnd,rwnd)    write_seq

接收侧序号

struct tcp_sock {
    /* RX hotpath */
    u32 rcv_nxt;        /* 期望接收的下一个序号——即接收窗口左沿 */
    u32 copied_seq;     /* 用户态已读取到的序号——recv 的进度 */
    u32 rcv_wnd;        /* 本端通告的接收窗口大小 */
    u32 rcv_wup;        /* 上次发出窗口更新时的 rcv_nxt */
};

下面这张图概括了从 send() 到网卡的完整发送路径,以及 ACK 回程触发拥塞控制更新的闭环:

TCP 数据发送路径与四重门控

二、发送路径:tcp_sendmsg 到 tcp_write_xmit

tcp_sendmsg:用户数据入队

当用户调用 send() / write() / sendmsg(),最终都到达 tcp_sendmsg()include/net/tcp.h:338),它锁住 socket 后调用 tcp_sendmsg_locked()

用户态 send(fd, buf, 1MB)
    ↓
tcp_sendmsg(sk, msg, size)
    ↓ lock_sock(sk)
tcp_sendmsg_locked(sk, msg, size)
    ├── 循环: 每次拷贝一个 MSS 大小的数据
    │   ├── skb = tcp_send_head(sk) 取队尾 skb
    │   │   └── 如果 skb 空间不足, sk_stream_alloc_skb() 分配新 skb
    │   ├── skb_entail(sk, skb) 挂到发送队列尾部
    │   ├── skb_add_data_nocache() 拷贝用户数据到 skb
    │   └── write_seq += copied  更新发送序号
    └── tcp_push(sk, flags, mss_now, nonagle, size_goal)

关键设计要点:

发送队列是 sk_buff 链表sk->sk_write_queue 挂载所有待发送的 skb。每个 skb 最多携带一个 MSS(或 GSO 聚合的多个 MSS)的数据。write_seq 持续递增,标记缓冲区写入进度。

零拷贝路径。如果使用 MSG_ZEROCOPY 标志,tcp_sendmsg_locked() 不拷贝数据,而是通过 skb_fill_page_desc() 直接引用用户态页面(需要 SO_ZEROCOPY socket 选项)。引用计数保证页面在发送完成前不被回收。

内存压力检控。每次分配 skb 前检查 sk_stream_memory_free(sk)——即 sk->sk_wmem_queued 是否超过 sk->sk_sndbuf。超过则进入等待(sk_stream_wait_memory()),直到 ACK 回来释放空间。

tcp_push:Nagle 决策

tcp_push()include/net/tcp.h:345)决定是否立刻触发实际发送:

void tcp_push(struct sock *sk, int flags, int mss_now,
              int nonagle, int size_goal)

Nagle 算法(RFC 896)的内核实现判断逻辑:

是否立即发送?
    ├── flags 包含 MSG_MORE? → 不发,等更多数据
    ├── TCP_CORK 已设置? → 不发,等显式解除
    ├── TCP_NODELAY 已设置? → 立即发
    ├── 数据量 >= MSS? → 立即发(满包不需要等)
    ├── 没有未确认的包 (packets_out == 0)? → 立即发
    └── 有未确认的小包? → 等待 ACK 回来再发

nonagle 字段在 tcp_sock 中是 4 bit 位域(include/linux/tcp.h:302),编码了 TCP_NODELAYTCP_CORKMSG_MORE 的组合状态。tcp_nagle_check() 内联函数根据这些标志做出判断。

tcp_push() 决定发送时,它调用 __tcp_push_pending_frames(sk, mss_now, nonagle),最终进入 tcp_write_xmit()

tcp_write_xmit:发送引擎

tcp_write_xmit() 是 TCP 发送路径的核心循环——它从发送队列中逐个取出 skb,检查是否满足发送条件,然后调用 __tcp_transmit_skb() 发出去。这个函数定义在 net/ipv4/tcp_output.c(非头文件导出),简化后的核心循环:

tcp_write_xmit(sk, mss_now, nonagle, push_one, gfp)
    ├── while (skb = tcp_send_head(sk)) {
    │   ├── cwnd_quota = tcp_cwnd_test(tp, skb)
    │   │   └── 检查 snd_cwnd - packets_out > 0
    │   │       → 为 0 则 cwnd 已满,停止发送
    │   │
    │   ├── tcp_snd_wnd_test(tp, skb, mss_now)
    │   │   └── 检查 snd_nxt + skb->len <= snd_una + snd_wnd
    │   │       → 超出接收窗口,停止发送
    │   │
    │   ├── tcp_tso_should_defer(sk, skb, ...)
    │   │   └── TSO/GSO 延迟判断:攒够一个大包再发更高效
    │   │
    │   ├── tcp_small_queue_check(sk, skb, 0)
    │   │   └── TSQ 检查:qdisc 层排队是否已过多
    │   │       → 过多则停止发送,等 TX 完成中断释放
    │   │
    │   ├── tcp_transmit_skb(sk, skb, 1, gfp)
    │   │   └── 构造 TCP 头 → 调用 ip_queue_xmit()
    │   │
    │   └── packets_out++, snd_nxt += skb->len
    │       └── 更新序号和 in-flight 计数
    └── }

四重门控是这个循环的关键:

cwnd 门控tcp_cwnd_test() 计算 snd_cwnd - packets_out,如果 ≤ 0,表示拥塞窗口已满,不能再发新包。这是拥塞控制的核心执行点。

rwnd 门控tcp_snd_wnd_test() 检查 snd_nxt + skb->len 是否超过 snd_una + snd_wnd。接收窗口是对端的承受能力,不能逾越。

TSO/GSO 延迟tcp_tso_should_defer() 判断是否等一下再发——如果网卡支持 TSO,攒够 64KB 的大 GSO 包比发小包更高效。这是延迟换吞吐量的权衡。

TSQ 门控。这是最值得深入的一个。

三、TSQ:TCP Small Queues

问题:BufferBloat 在本机

在 TSQ 出现之前(Linux 3.6,2012 年),TCP 发送的行为是”能发就发”——只要 cwnd 和 rwnd 允许,tcp_write_xmit() 就一直往 qdisc 队列塞包。在高带宽场景下,qdisc 队列可能堆积数百个包,导致:

  1. 本机延迟膨胀——其他流的包被排在后面,延迟增加
  2. 拥塞信号滞后——包已经在本机排了队,但 cwnd 还在增长
  3. 内存浪费——每个 skb 占用内存,排在 qdisc 的包既不能被用户态回收,也还没到网卡

TSQ 的解决方案很简洁:给每个 TCP 连接在 qdisc 层的排队量设上限

实现机制

TSQ 通过 sk->sk_wmem_queuedsk->sk_pacing_rate 配合实现。在 tcp_write_xmit() 的循环中,tcp_small_queue_check() 检查:

static bool tcp_small_queue_check(struct sock *sk,
                                  const struct sk_buff *skb,
                                  unsigned int factor)
{
    unsigned int limit;

    limit = max_t(unsigned int, 2 * skb->truesize,
                  READ_ONCE(sk->sk_pacing_rate) >> READ_ONCE(sk->sk_pacing_shift));
    if (sk->sk_pacing_status != SK_PACING_NONE)
        limit = min_t(unsigned int, limit,
                      sock_net(sk)->ipv4.sysctl_tcp_limit_output_bytes);
    limit <<= factor;

    if (refcount_read(&sk->sk_wmem_alloc) > limit) {
        /* 队列太深,设置 TSQ_THROTTLED 标记 */
        set_bit(TSQ_THROTTLED, &sk->sk_tsq_flags);
        return true;  /* 停止发送 */
    }
    return false;
}

三个关键参数:

参数 含义 默认值
sysctl_tcp_limit_output_bytes TSQ 的全局字节上限 1048576(1MB)
sk_pacing_rate >> sk_pacing_shift 基于速率的动态上限 ~1ms 的数据量
2 × skb->truesize 最小下限——至少允许 2 个 skb 取决于 MSS

释放与唤醒

当网卡 TX 完成中断(NAPI 清理)调用 consume_skb() 释放 skb 时,skb 的析构函数 tcp_wfree() 被触发:

TX 完成中断
    ↓
consume_skb(skb)
    ↓
skb->destructor = tcp_wfree
    ↓
tcp_wfree(skb)
    ├── sk_wmem_alloc -= skb->truesize  释放内存记账
    ├── 检查 TSQ_THROTTLED 标记
    │   └── 已设置 → tcp_tsq_handler(sk)
    │       └── tasklet_schedule → tcp_tasklet_func
    │           └── tcp_write_xmit(sk, ...)  恢复发送
    └── sock_wfree(skb)

这个”发送 → 排队 → TX 完成 → 释放 → 继续发送”的闭环,让 TCP 的发送节奏与网卡的实际发送能力自适应匹配——qdisc 层始终只排少量包,既保证低延迟,又充分利用带宽。

四、__tcp_transmit_skb:TCP 头构造与发出

tcp_write_xmit() 决定发送一个 skb,它调用 tcp_transmit_skb()(实际是 __tcp_transmit_skb() 的包装)。这个函数完成最后的 TCP 头构造和 IP 层移交:

__tcp_transmit_skb(sk, skb, clone_it, gfp)
    ├── skb_push(skb, tcp_header_size)
    │   └── 在 skb 数据前预留 TCP 头空间
    │
    ├── 填充 TCP 头
    │   ├── th->source = inet->inet_sport
    │   ├── th->dest   = inet->inet_dport
    │   ├── th->seq    = tcb->seq  (从 tcp_skb_cb 取序号)
    │   ├── th->ack_seq = rcv_nxt  (捎带 ACK)
    │   ├── th->window = tcp_select_window(sk)  动态计算接收窗口
    │   └── th->check  = 校验和(可卸载给网卡)
    │
    ├── 填充 TCP 选项
    │   ├── SACK 块(如果有乱序/丢包信息)
    │   ├── Timestamp(用于 RTT 测量和 PAWS)
    │   └── Window Scale(仅 SYN 包)
    │
    ├── tcp_ecn_send(sk, skb, th, tcp_header_size)
    │   └── 设置 ECN 标志位(如果启用了 ECN)
    │
    ├── icsk->icsk_af_ops->queue_xmit(sk, skb, &inet->cork.fl)
    │   └── = ip_queue_xmit()  进入 IP 层
    │       → 路由查找 → Netfilter → 分片 → 发出
    │
    └── 记录发送时间戳(用于 RTT 采样)

ACK 是免费的。每个数据包都捎带 ACK(th->ack_seq = rcv_nxt),TCP 几乎不需要单独发 ACK 包。只有当接收端没有数据要发、延迟 ACK 定时器到期时,才会发纯 ACK。

接收窗口动态计算tcp_select_window() 根据当前接收缓冲区的空闲量计算 rcv_wnd,但为了遵守 RFC 的窗口不缩小规则,用 rcv_wup + rcv_wnd 作为下界。

五、接收路径:tcp_rcv_established

数据包到达接收端后的处理路径:

tcp_v4_rcv(skb)
    ↓ 查找 socket
tcp_v4_do_rcv(sk, skb)
    ↓ sk->sk_state == ESTABLISHED
tcp_rcv_established(sk, skb)
    ├── Fast Path(头部预测成功)
    │   ├── 条件:预期序号、无乱序、无 SACK
    │   ├── 直接 __skb_queue_tail(&sk->sk_receive_queue, skb)
    │   └── 更新 rcv_nxt += skb->len
    │
    └── Slow Path(有异常情况)
        ├── tcp_validate_incoming(sk, skb)
        │   └── 序号检查、RST 处理、SYN 检查
        ├── tcp_ack(sk, skb, FLAG_SLOWPATH)
        │   └── 处理 ACK——这是拥塞控制的入口
        ├── tcp_data_queue(sk, skb)
        │   ├── 序号 == rcv_nxt → 顺序到达,入接收队列
        │   └── 序号 > rcv_nxt → 乱序,入 out-of-order 队列
        └── tcp_ack_snd_check(sk)
            └── 决定是否发 ACK(延迟 ACK 或立即 ACK)

Fast Path 的头部预测

tcp_rcv_established() 的 Fast Path 是 TCP 接收的性能关键——Linux 用 pred_flags 字段(tcp_sock 第一个缓存行)做头部预测:

if ((tcp_flag_word(th) & TCP_HP_BITS) == tp->pred_flags &&
    TCP_SKB_CB(skb)->seq == tp->rcv_nxt)
{
    /* Fast Path: 跳过大部分检查,直接入队 */
}

当以下条件全部满足时走 Fast Path: - 没有 SYN/FIN/RST/URG 标志 - ACK 已设置 - 窗口大小没变 - 序号恰好等于 rcv_nxt(顺序到达)

在正常的批量数据传输中,Fast Path 命中率极高。

tcp_recvmsg:用户读取

用户调用 recv() / read() 时,最终到达 tcp_recvmsg()include/net/tcp.h:426):

tcp_recvmsg(sk, msg, len, flags, addr_len)
    ├── lock_sock(sk)
    ├── 循环: 从 sk_receive_queue 拷贝数据到用户缓冲区
    │   ├── skb = skb_peek(&sk->sk_receive_queue)
    │   ├── skb_copy_datagram_msg(skb, offset, msg, used)
    │   │   └── 拷贝到用户空间(可能触发缺页中断)
    │   ├── copied_seq += used  更新已读序号
    │   └── tcp_cleanup_rbuf(sk, copied)
    │       └── 如果释放了足够空间,发送窗口更新
    └── release_sock(sk)

tcp_cleanup_rbuf() 的窗口更新机制很关键——当用户读走数据后,接收缓冲区空出空间,rcv_wnd 增大。如果增量超过阈值(通常是 MSS 的两倍),就立刻发 ACK 通知发送端扩大窗口。否则等延迟 ACK 定时器。

六、ACK 处理与拥塞控制入口

当 ACK 包到达发送端的 tcp_rcv_established() 时,进入 tcp_ack()——这是拥塞控制的核心入口:

tcp_ack(sk, skb, flag)
    ├── ack_seq = TCP_SKB_CB(skb)->ack_seq
    ├── 更新 snd_una = ack_seq  移动发送窗口左沿
    ├── tcp_clean_rtx_queue(sk, ...)
    │   └── 清理已确认的 skb,计算 RTT 样本
    │       ├── rtt_us = 当前时间 - skb 发送时间
    │       └── tcp_rtt_estimator(sk, rtt_us)
    │           → 更新 srtt(平滑 RTT)和 rttvar(RTT 方差)
    │
    ├── tcp_ack_update_window(sk, skb, ack_seq)
    │   └── 更新 snd_wnd(对端通告窗口可能变了)
    │
    ├── tcp_in_ack_event(sk, flag)
    │   └── 调用 ca_ops->in_ack_event()(如果拥塞算法注册了)
    │
    ├── tcp_cong_control(sk, ack, delivered, flag, rs)
    │   ├── 如果算法提供了 cong_control() → 直接调用
    │   │   └── BBR 用这个接口——它自己管 cwnd
    │   └── 否则走标准路径:
    │       ├── tcp_cong_avoid(sk, ack, acked)
    │       │   └── ca_ops->cong_avoid(sk, ack, acked)
    │       │       → CUBIC: 计算三次函数增长
    │       │       → Reno: cwnd += 1/cwnd per ACK
    │       └── tcp_update_pacing_rate(sk)
    │           → 更新 sk_pacing_rate
    │
    └── packets_out -= acked  减少 in-flight 计数

RTT 测量

tcp_clean_rtx_queue() 在清理已确认 skb 时采集 RTT 样本。Linux 同时使用两种方式:

  1. skb 时间戳——skb->tstamp 记录发送时间,ACK 回来时计算差值
  2. TCP Timestamp 选项——TSecr 字段回显发送端的时间戳,用于计算 RTT

采集到的 RTT 样本经过 Karn 算法过滤(重传包的 RTT 不计入),然后用指数加权移动平均更新 srtt_us(平滑 RTT)和 mdev_us(平均偏差):

srtt = (7/8) × srtt_old + (1/8) × rtt_sample
rttvar = (3/4) × rttvar_old + (1/4) × |rtt_sample - srtt|
RTO = srtt + 4 × rttvar  /* 至少 200ms */

七、拥塞控制框架:tcp_congestion_ops

Linux 的拥塞控制是可插拔的框架——每个算法实现 struct tcp_congestion_opsinclude/net/tcp.h:1116),通过 tcp_register_congestion_control() 注册。

核心接口

struct tcp_congestion_ops {
    /* === 快路径回调(一级缓存行) === */

    /* 必须实现:计算新的慢启动阈值 */
    u32  (*ssthresh)(struct sock *sk);

    /* 必须实现(二选一):每收到 ACK 时更新 cwnd */
    void (*cong_avoid)(struct sock *sk, u32 ack, u32 acked);

    /* 可选:拥塞状态变更通知 */
    void (*set_state)(struct sock *sk, u8 new_state);

    /* 可选:cwnd 事件通知 */
    void (*cwnd_event)(struct sock *sk, enum tcp_ca_event ev);

    /* 可选:ACK 到达事件(在 cong_avoid 之前调用) */
    void (*in_ack_event)(struct sock *sk, u32 flags);

    /* 可选:每个 ACK 确认的包数统计 */
    void (*pkts_acked)(struct sock *sk, const struct ack_sample *sample);

    /* 可选:覆盖最小 TSO 段数 */
    u32  (*min_tso_segs)(struct sock *sk);

    /* 可选:基于交付速率的更新——BBR 用这个 */
    void (*cong_control)(struct sock *sk, const struct rate_sample *rs);

    /* === 慢路径回调 === */
    u32  (*undo_cwnd)(struct sock *sk);          /* 撤销减窗后的新 cwnd */
    u32  (*sndbuf_expand)(struct sock *sk);       /* 发送缓冲区扩展系数 */

    /* 元数据 */
    char name[TCP_CA_NAME_MAX];   /* 算法名,如 "cubic" */
    struct module *owner;
    u32  key;                      /* 算法的哈希键 */
    u32  flags;                    /* TCP_CONG_NON_RESTRICTED 等 */

    void (*init)(struct sock *sk);               /* 连接初始化 */
    void (*release)(struct sock *sk);            /* 连接关闭清理 */
};

两种更新接口

拥塞控制算法有两种更新 cwnd 的方式:

传统接口(cong_avoid):每收到一个 ACK,内核调用 cong_avoid(sk, ack, acked),算法在其中增大 tp->snd_cwnd。Reno 和 CUBIC 用这种方式。内核框架负责在丢包时调用 ssthresh() 减窗。

交付速率接口(cong_control):BBR 等算法使用。内核传入 struct rate_sample,包含:

struct rate_sample {
    u64  prior_mstamp;     /* 上一次采样时间 */
    u32  prior_delivered;  /* 上一次已交付字节数 */
    u32  delivered;        /* 本次新交付字节数 */
    u32  interval_us;      /* 采样间隔 */
    /* ... */
    s32  rtt_us;           /* 本次 RTT 样本 */
    bool is_app_limited;   /* 应用是否限制了发送 */
};

算法自己决定 cwnd 怎么调——BBR 根据估算的瓶颈带宽(BtlBw)和最小 RTT(RTprop)计算 pacing_ratecwnd,完全绕过了传统的”丢包即减窗”逻辑。

拥塞状态机

Linux 内核维护五个拥塞状态(include/uapi/linux/tcp.h:185):

                    ┌─────────────────────────────────────────┐
                    │                                         │
                    ▼                                         │
             ┌──────────┐    DUPACK/SACK    ┌────────────┐   │
    正常  →  │ CA_Open  │ ───────────────→  │ CA_Disorder│   │
             └──────────┘                   └────────────┘   │
                  ▲                              │            │
                  │ 恢复完成                      │ 确认丢包   │
                  │                              ▼            │
             ┌──────────┐                   ┌────────────┐   │
             │ CA_CWR   │ ◀── ECN-CE ───── │ CA_Recovery│   │
             └──────────┘                   └────────────┘   │
                                                 │            │
                                                 │ RTO 超时   │
                                                 ▼            │
                                            ┌────────────┐   │
                                            │  CA_Loss   │ ──┘
                                            └────────────┘
状态 触发条件 cwnd 行为
TCP_CA_Open 正常传输,无异常 慢启动或拥塞避免增长
TCP_CA_Disorder 收到 DUPACK 或 SACK,但未确认丢包 暂不调整,等待更多信息
TCP_CA_CWR 收到 ECN-CE 标记 cwnd 逐步减半
TCP_CA_Recovery 确认丢包(3 DUPACK 或 RACK) ssthresh = cwnd × β,快速重传
TCP_CA_Loss RTO 超时,所有发送包标为丢失 cwnd = 1,重新慢启动

tcp_set_ca_state() 函数在状态转换时调用 ca_ops->set_state(),让拥塞算法知道状态变了。

八、CUBIC 与 BBR:两种典型实现

CUBIC:默认的丢包型算法

CUBIC 是 Linux 默认的拥塞控制算法(自 2.6.19 起)。它的 cwnd 增长遵循三次函数:

W(t) = C × (t - K)³ + W_max

其中:
  W_max = 丢包时的 cwnd 值
  K = ∛(W_max × β / C)  到达 W_max 所需时间
  C = 0.4(增长因子)
  β = 0.7(乘法减小系数——丢包后 cwnd 变为 0.7 × W_max)
  t = 距上次丢包的时间

CUBIC 的核心优势是窗口增长只依赖时间,不依赖 RTT——这让它在高 RTT 链路上比 Reno(每 RTT 加 1)快得多。关于 CUBIC 的算法细节,参见 TCP 拥塞控制经典算法

在内核中,CUBIC 实现了传统的 cong_avoid 接口:

static struct tcp_congestion_ops cubictcp __read_mostly = {
    .init       = cubictcp_init,
    .ssthresh   = cubictcp_recalc_ssthresh,
    .cong_avoid = cubictcp_cong_avoid,    /* 每 ACK 更新 cwnd */
    .set_state  = cubictcp_state,
    .undo_cwnd  = tcp_reno_undo_cwnd,
    .pkts_acked = cubictcp_acked,
    .owner      = THIS_MODULE,
    .name       = "cubic",
};

BBR:基于带宽的革命

BBR(Bottleneck Bandwidth and Round-trip propagation time)不看丢包,而是持续估算两个关键参数:

然后按 pacing_rate = BtlBw × gaincwnd = BtlBw × RTprop × gain 控制发送速率。

BBR 使用 cong_control 接口,完全自主管理 cwnd 和 pacing_rate:

static struct tcp_congestion_ops tcp_bbr_cong_ops __read_mostly = {
    .cong_control   = bbr_main,           /* 基于 rate_sample 的核心逻辑 */
    .ssthresh       = bbr_ssthresh,
    .undo_cwnd      = bbr_undo_cwnd,
    .cwnd_event     = bbr_cwnd_event,
    .min_tso_segs   = bbr_min_tso_segs,
    .sndbuf_expand  = bbr_sndbuf_expand,
    .set_state      = bbr_set_state,
    .init           = bbr_init,
    .owner          = THIS_MODULE,
    .name           = "bbr",
};

关于 BBR 的状态机、带宽探测和公平性问题,参见 BBR 深度剖析。本文关注的是内核框架层面——BBR 之所以能实现完全不同的拥塞控制逻辑,正是因为 cong_control 接口赋予了算法对 cwnd 和 pacing_rate 的完全控制权。

九、Pacing:软件限速

拥塞控制算法计算出 cwnd 后,如果 TCP 把窗口内的所有包一口气发出去(burst),会在短时间内对网络造成冲击。Pacing 的作用是把发送均匀分布在一个 RTT 内

内核实现

TCP pacing 通过两层机制实现:

第一层:sk_pacing_rate

tcp_update_pacing_rate()(在每次 ACK 处理后调用)根据 cwnd 和 srtt 计算:

/* 简化逻辑 */
rate = (u64)tp->snd_cwnd * tp->mss_cache * USEC_PER_SEC;
rate = div64_u64(rate, max(tp->srtt_us, 1U));

/* 慢启动阶段翻倍,拥塞避免阶段 1.2 倍 */
if (tp->snd_cwnd < tp->snd_ssthresh)
    rate *= 2;  /* 慢启动需要快速探测 */
else
    rate = rate + (rate >> 2);  /* 1.25x headroom */

WRITE_ONCE(sk->sk_pacing_rate, min(rate, sk->sk_max_pacing_rate));

BBR 则直接在 bbr_main() 中设置 sk->sk_pacing_rate = BtlBw × pacing_gain,绕过默认计算。

第二层:FQ qdisc

sk_pacing_rate 本身只是一个标记。真正实现包间隔的是 qdisc 层的 FQ(Fair Queuing)调度器。FQ 为每个 socket 维护一个队列,按 sk_pacing_rate 计算每个包的 EDT(Earliest Departure Time):

departure_time = last_departure + packet_size / sk_pacing_rate

只有到了 departure_time,FQ 才会把包从队列中取出发给驱动。这实现了精确的速率控制。

# 启用 FQ qdisc(现代发行版通常默认启用)
tc qdisc replace dev eth0 root fq

# 查看 FQ 统计
tc -s qdisc show dev eth0

没有 FQ,TCP pacing 退化为基于定时器的软件限速——tcp_internal_pacing()hrtimersk_pacing_rate 允许的间隔后唤醒发送,精度和效率都不如 FQ。

十、丢包检测与恢复:RACK-TLP

传统 TCP 用三次重复 ACK(3 DUPACK)检测丢包。但 DUPACK 有明显缺陷——它依赖后续包的到达来发现丢包,如果丢的是窗口末尾的包,就必须等 RTO 超时。

Linux 从 4.x 开始引入 RACK(Recent ACKnowledgment)和 TLP(Tail Loss Probe),到 6.x 已成为默认的丢包检测机制。

RACK:基于时间的丢包检测

RACK 的核心思想:如果一个包发送后,比它晚发送的包先被 ACK 了,且时间差超过 reordering window,就判定它丢了

/* include/net/tcp.h:2370-2376 */
extern s32 tcp_rack_skb_timeout(struct tcp_sock *tp, struct sk_buff *skb,
                                 s32 reo_wnd);
extern bool tcp_rack_mark_lost(struct sock *sk);
extern void tcp_rack_advance(struct tcp_sock *tp, u8 sacked,
                             u32 end_seq, u64 xmit_time);
extern void tcp_rack_reo_timeout(struct sock *sk);
extern void tcp_rack_update_reo_wnd(struct sock *sk, struct rate_sample *rs);

RACK 维护 tp->rack.mstamp——最近被 ACK 的包的发送时间。当一个 skb 的发送时间 < rack.mstamp - reo_wnd 时,RACK 判定它已丢失。

时间线示例:

  发送: P1  P2  P3  P4  P5
  ACK:          ✓       ✓
                ↑       ↑
           rack.mstamp = P5 的发送时间

  判断: P1, P2, P4 的发送时间 < P5.xmit - reo_wnd
        → 标记为丢失,触发重传

reo_wnd(reordering window)用于容忍网络中的包重排序——默认值是 min_rtt / 4。如果没有检测到重排序,reo_wnd 可能为 0。

TLP:尾部丢包探测

TLP 解决的是”窗口末尾丢包”问题——如果最后一个包丢了,没有后续包到达触发 DUPACK,只能等 RTO(通常 200ms 起步)。TLP 的做法是在 RTO 之前发一个探测包:

正常 RTO 流程:
  发送 P1 P2 P3(P3 丢了)→ 等 RTO(200ms+)→ 重传 P3

TLP 流程:
  发送 P1 P2 P3(P3 丢了)→ PTO 定时器(2×srtt + 抖动)→ 
  发送 TLP 探测包(P4 或重传 P3)→ ACK 回来触发 RACK → 快速检测丢包

PTO(Probe Timeout)远小于 RTO——大约 2 × srtt + jitter,在低延迟环境下通常只有几毫秒。TLP 把尾部丢包的恢复延迟从几百毫秒降到了几十毫秒。

SACK 处理

SACK(Selective Acknowledgment)让接收端报告它收到了哪些非连续的数据段。内核在 tcp_sock 中维护 SACK 状态:

struct tcp_sack_block {
    u32 start_seq;
    u32 end_seq;
};

tcp_sacktag_write_queue() 根据收到的 SACK 块标记发送队列中的 skb 状态:

这些标记与 RACK 配合,精确定位哪些包需要重传。

十一、可观测性实战

拥塞窗口与发送速率追踪

# 追踪 cwnd 变化——每次 ACK 处理后记录
bpftrace -e '
kprobe:tcp_cong_avoid {
    $sk = (struct sock *)arg0;
    $tp = (struct tcp_sock *)$sk;
    $dport = $sk->__sk_common.skc_dport;
    $dport = ($dport >> 8) | (($dport & 0xFF) << 8);
    if ($dport == 80 || $dport == 443) {
        printf("cwnd=%u ssthresh=%u packets_out=%u pacing_rate=%lu\n",
               $tp->snd_cwnd,
               $tp->snd_ssthresh,
               $tp->packets_out,
               $sk->sk_pacing_rate);
    }
}'

拥塞状态变化追踪

# 追踪拥塞状态机转换
bpftrace -e '
tracepoint:tcp:tcp_cong_state_set {
    printf("%s sport=%d dport=%d old=%d new=%d\n",
           comm, args->sport, args->dport,
           args->oldstate, args->newstate);
}'

状态值含义:0=Open,1=Disorder,2=CWR,3=Recovery,4=Loss。

重传监控

# 追踪 TCP 重传——每次重传记录源端口、目标、序号
bpftrace -e '
tracepoint:tcp:tcp_retransmit_skb {
    printf("%s %s:%d → %s:%d seq=%u\n",
           comm,
           ntop(args->saddr), args->sport,
           ntop(args->daddr), args->dport,
           args->seq);
}'
# 统计重传原因分布(RACK 触发 vs RTO 触发)
bpftrace -e '
kprobe:tcp_retransmit_skb {
    $sk = (struct sock *)arg0;
    $tp = (struct tcp_sock *)$sk;
    $state = $tp->inet_conn.icsk_ca_state;
    @retrans_by_state[$state] = count();
}
END { print(@retrans_by_state); }'

TSQ 限流监控

# 追踪 TSQ 限流事件
bpftrace -e '
kprobe:tcp_small_queue_check {
    $sk = (struct sock *)arg0;
    $wmem = $sk->sk_wmem_alloc.refs.counter;
    @tsq_wmem = hist($wmem);
}
interval:s:5 { print(@tsq_wmem); clear(@tsq_wmem); }'

ss 工具查看 TCP 内部状态

# 查看所有 ESTABLISHED 连接的拥塞控制状态
ss -tni state established | grep -E 'cubic|bbr|cwnd|rtt'

# 输出示例:
#  cubic wscale:7,7 rto:204 rtt:1.5/0.5 ato:40 mss:1448
#  cwnd:10 ssthresh:7 bytes_sent:123456 bytes_acked:123000
#  bytes_received:456000 send 77.3Mbps pacing_rate 92.8Mbps
#  delivery_rate 75.1Mbps

ss -tni 输出的关键字段对应 tcp_sock 的字段:

ss 输出 内核字段 含义
cwnd:10 snd_cwnd 拥塞窗口(MSS 数)
ssthresh:7 snd_ssthresh 慢启动阈值
rtt:1.5/0.5 srtt_us / mdev_us 平滑 RTT / RTT 方差(ms)
pacing_rate sk_pacing_rate 当前 pacing 速率
delivery_rate rate_delivered 交付速率
bytes_sent 计算值 已发送总字节数

perf 追踪 TCP 发送延迟

# 测量 tcp_sendmsg 的耗时分布
bpftrace -e '
kprobe:tcp_sendmsg { @start[tid] = nsecs; }
kretprobe:tcp_sendmsg /@start[tid]/ {
    @sendmsg_us = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
}
interval:s:10 { print(@sendmsg_us); clear(@sendmsg_us); }'

十二、关键参数速查

参数 默认值 内核对应 调优建议
net.ipv4.tcp_congestion_control cubic tcp_congestion_ops 数据中心内网建议 bbr;公网长肥管道建议 bbr
net.ipv4.tcp_limit_output_bytes 1048576 TSQ 全局上限 降低可减少 bufferbloat,但可能影响吞吐
net.ipv4.tcp_notsent_lowat -1(禁用) 通知用户态的未发送下限 设为 131072 可减少延迟(HTTP/2 推荐)
net.ipv4.tcp_early_retrans 3 RACK/TLP 控制 3=启用 RACK+TLP(默认推荐)
net.ipv4.tcp_recovery 1 RACK 恢复策略 1=RACK 丢包检测(默认)
net.core.default_qdisc fq_codel qdisc 默认类型 配合 BBR 建议改为 fq
net.ipv4.tcp_sack 1 SACK 开关 保持开启,关闭会严重影响丢包恢复
net.ipv4.tcp_dsack 1 D-SACK 开关 保持开启,帮助检测伪重传
net.ipv4.tcp_window_scaling 1 窗口缩放开关 保持开启,否则窗口最大 64KB
net.ipv4.tcp_wmem 4096 16384 4194304 发送缓冲区(最小/默认/最大) 高带宽场景增大最大值
net.ipv4.tcp_rmem 4096 131072 6291456 接收缓冲区(最小/默认/最大) 高 BDP 链路增大最大值
net.ipv4.tcp_moderate_rcvbuf 1 自动调整接收缓冲区 保持开启
net.ipv4.tcp_autocorking 1 自动合并小包 保持开启,等效于智能 Nagle
net.ipv4.tcp_slow_start_after_idle 1 空闲后重置 cwnd 长连接池建议设为 0

参考文献

  1. Linux 内核源码,include/net/tcp.h,6.8(tcp_congestion_ops 定义于第 1116 行)
  2. Linux 内核源码,include/linux/tcp.h,6.8(tcp_sock 定义于第 192 行)
  3. Linux 内核源码,include/uapi/linux/tcp.h,6.8(tcp_ca_state 枚举定义于第 185 行)
  4. Van Jacobson, “Congestion Avoidance and Control”, SIGCOMM 1988
  5. Ha, Rhee, Xu, “CUBIC: A New TCP-Friendly High-Speed TCP Variant”, ACM SIGOPS OSR, 2008
  6. Cardwell et al., “BBR: Congestion-Based Congestion Control”, ACM Queue, 2016
  7. Cheng, Cardwell, Dukkipati, Jha, “The RACK-TLP Loss Detection Algorithm for TCP”, RFC 8985, 2021
  8. Linux 内核文档,Documentation/networking/ip-sysctl.rst

上一篇TCP 内核实现(上):连接管理与状态机

下一篇UDP 内核实现与 socket lookup 优化

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-20 · linux / networking

【Linux 网络子系统深度拆解】TCP 内核实现(上):连接管理与状态机

TCP 连接在内核中不只是一个状态机——它是一组精心设计的数据结构和队列。本文从 Linux 6.6 内核源码出发,拆解 TCP 连接建立的 SYN Queue / Accept Queue 二级队列模型、request_sock 半连接对象、tcp_sock 全连接对象、SYN Cookie 无状态防御、TCP Fast Open 零 RTT 机制、inet_timewait_sock 轻量级 TIME_WAIT 实现,以及完整的 TCP 状态机在内核中的真实转换路径。

2026-04-20 · linux / networking

【Linux 网络子系统深度拆解】发包路径全解:从 send() 到网线

一个用户态 send() 调用要走过 TCP 分段、IP 路由、Netfilter 钩子、Qdisc 排队、GSO 分段、驱动 DMA 映射六个阶段才能把数据送上网线。本文从 Linux 6.6 内核源码出发,逐函数拆解完整的 TX 发包路径,深入 TSQ 限流、Qdisc 调度、BQL 防膨胀、GSO/TSO 分段决策等核心机制。

2026-04-23 · linux / networking

【Linux 网络子系统深度拆解】Traffic Control 深度拆解:qdisc、class 与 filter

dev_queue_xmit() 不是直接把包交给网卡——中间还有一层 Traffic Control。本文从 Linux 6.6 内核源码拆解 TC 框架的完整实现:struct Qdisc 与 Qdisc_ops 操作表、pfifo_fast/fq_codel/HTB/TBF 的内核实现差异、TCQ_F_CAN_BYPASS 快路径、TCQ_F_NOLOCK 无锁排队、EDT(Earliest Departure Time)调度模型、TC BPF direct-action 模式,以及 MQ 多队列根 qdisc 与 netdev_queue 的关系。

2026-04-20 · linux / networking

【Linux 网络子系统深度拆解】邻居子系统与 ARP:L2 地址解析的内核实现

IP 层知道下一跳是 10.0.0.1,但网卡发帧需要 MAC 地址。ARP 解析只是表面——底层是邻居子系统(neighbour subsystem)的完整状态机:NUD_INCOMPLETE → NUD_REACHABLE → NUD_STALE → NUD_DELAY → NUD_PROBE → NUD_FAILED。本文从 Linux 6.6 内核源码拆解 struct neighbour、neigh_table 双哈希表、ARP 请求/响应处理、NDP(IPv6)、Proxy ARP、GC 回收机制,以及 neigh_connected_output 快路径的 L2 头缓存优化。


By .