上一篇我们拆解了 TCP
连接管理与状态机——三次握手完成后,tcp_sock
在 ESTABLISHED 状态就位,两端开始交换数据。但 TCP
数据传输远不是”把字节塞进 sk_buff 然后发出去”这么简单。
你调用 send(fd, buf, len, 0) 发送 1MB
数据。内核不会一口气全部发出去——它首先把数据拷到发送缓冲区的
sk_buff 链表上,然后受到至少四重约束:
- Nagle 算法——小包是否合并
- TSQ(TCP Small Queues)——qdisc 层排队量上限
- 拥塞窗口(cwnd)——网络容量的估算
- 接收窗口(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
write_seq - snd_una:发送缓冲区已使用量(用户写入但未被 ACK 确认)snd_nxt - snd_una:in-flight 字节数(等于packets_out × MSS的近似值)min(snd_cwnd × MSS, snd_wnd) - (snd_nxt - snd_una):当前可发送的字节数
接收侧序号
struct tcp_sock {
/* RX hotpath */
u32 rcv_nxt; /* 期望接收的下一个序号——即接收窗口左沿 */
u32 copied_seq; /* 用户态已读取到的序号——recv 的进度 */
u32 rcv_wnd; /* 本端通告的接收窗口大小 */
u32 rcv_wup; /* 上次发出窗口更新时的 rcv_nxt */
};rcv_nxt - copied_seq:接收缓冲区中等待用户读取的数据量rcv_wnd:本端能接受的数据量,由tcp_select_window()动态计算
下面这张图概括了从 send()
到网卡的完整发送路径,以及 ACK
回程触发拥塞控制更新的闭环:
二、发送路径: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_NODELAY、TCP_CORK、MSG_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
队列可能堆积数百个包,导致:
- 本机延迟膨胀——其他流的包被排在后面,延迟增加
- 拥塞信号滞后——包已经在本机排了队,但 cwnd 还在增长
- 内存浪费——每个 skb 占用内存,排在 qdisc 的包既不能被用户态回收,也还没到网卡
TSQ 的解决方案很简洁:给每个 TCP 连接在 qdisc 层的排队量设上限。
实现机制
TSQ 通过 sk->sk_wmem_queued 和
sk->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 同时使用两种方式:
- skb 时间戳——
skb->tstamp记录发送时间,ACK 回来时计算差值 - 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_ops(include/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_rate 和
cwnd,完全绕过了传统的”丢包即减窗”逻辑。
拥塞状态机
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)不看丢包,而是持续估算两个关键参数:
- BtlBw(瓶颈带宽):最近一段时间内观测到的最大交付速率
- RTprop(传播延迟):最近一段时间内观测到的最小 RTT
然后按 pacing_rate = BtlBw × gain 和
cwnd = 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()
用 hrtimer 在 sk_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 状态:
TCPCB_SACKED_ACKED:已被 SACK 确认(不需要重传)TCPCB_SACKED_RETRANS:正在重传中TCPCB_LOST:标记为丢失(需要重传)
这些标记与 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.1Mbpsss -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 |
参考文献
- Linux
内核源码,
include/net/tcp.h,6.8(tcp_congestion_ops 定义于第 1116 行) - Linux
内核源码,
include/linux/tcp.h,6.8(tcp_sock 定义于第 192 行) - Linux
内核源码,
include/uapi/linux/tcp.h,6.8(tcp_ca_state 枚举定义于第 185 行) - Van Jacobson, “Congestion Avoidance and Control”, SIGCOMM 1988
- Ha, Rhee, Xu, “CUBIC: A New TCP-Friendly High-Speed TCP Variant”, ACM SIGOPS OSR, 2008
- Cardwell et al., “BBR: Congestion-Based Congestion Control”, ACM Queue, 2016
- Cheng, Cardwell, Dukkipati, Jha, “The RACK-TLP Loss Detection Algorithm for TCP”, RFC 8985, 2021
- Linux
内核文档,
Documentation/networking/ip-sysctl.rst
下一篇:UDP 内核实现与 socket lookup 优化
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【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 状态机在内核中的真实转换路径。
【Linux 网络子系统深度拆解】发包路径全解:从 send() 到网线
一个用户态 send() 调用要走过 TCP 分段、IP 路由、Netfilter 钩子、Qdisc 排队、GSO 分段、驱动 DMA 映射六个阶段才能把数据送上网线。本文从 Linux 6.6 内核源码出发,逐函数拆解完整的 TX 发包路径,深入 TSQ 限流、Qdisc 调度、BQL 防膨胀、GSO/TSO 分段决策等核心机制。
【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 的关系。
【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 头缓存优化。