在 发包路径全解
一文中,我们看到 dev_queue_xmit()
在把包交给驱动之前,经过了 qdisc
的排队和调度。那篇文章给出了发包路径的全景——本文深入 Traffic
Control 框架本身。
TC 的核心问题:当多个应用同时发包,谁先发、发多快、怎么限速?这不是简单的先进先出——内核需要支持带宽分配、优先级调度、延迟控制、公平排队等复杂策略。TC 框架通过三层抽象实现:
应用层:tc qdisc / tc filter / tc class 命令
───────────────────────────────────────────
内核层:
┌──────────────────────────────────────────┐
│ filter(分类器) │
│ └── tcf_proto: 根据包特征选择 class │
│ ├── u32: 通用字段匹配 │
│ ├── flower: 流表匹配 │
│ └── bpf: BPF 程序分类 │
├──────────────────────────────────────────┤
│ class(分类) │
│ └── 仅分层 qdisc 有:HTB class、CBQ class│
│ 每个 class 可挂自己的子 qdisc │
├──────────────────────────────────────────┤
│ qdisc(排队规则) │
│ └── Qdisc_ops: enqueue → dequeue → 驱动 │
│ ├── pfifo_fast: 三优先级 FIFO │
│ ├── fq_codel: 公平排队 + 延迟控制 │
│ ├── htb: 分层令牌桶 │
│ └── fq: 公平排队 + pacing │
└──────────────────────────────────────────┘
一、struct Qdisc:排队规则对象
核心结构
struct Qdisc(include/net/sch_generic.h:73)是
TC 框架的核心——每个发送队列(netdev_queue)上挂载一个 qdisc
实例:
struct Qdisc {
int (*enqueue)(struct sk_buff *skb,
struct Qdisc *sch,
struct sk_buff **to_free); /* 入队 */
struct sk_buff *(*dequeue)(struct Qdisc *sch); /* 出队 */
unsigned int flags; /* TCQ_F_* 标志 */
const struct Qdisc_ops *ops; /* 操作表 */
struct netdev_queue *dev_queue; /* 所属发送队列 */
struct qdisc_skb_head q; /* 内部包队列 */
struct gso_skb gso_skb; /* GSO 大包暂存 */
unsigned long state; /* 运行状态 */
struct Qdisc *parent; /* 父 qdisc */
/* 统计 */
struct gnet_stats_basic_sync bstats; /* 基本统计 */
struct gnet_stats_queue qstats; /* 队列统计 */
/* 并发控制 */
seqcount_t running; /* 无锁运行标记 */
spinlock_t busylock; /* 并发保护 */
spinlock_t seqlock; /* 序列锁 */
long privdata[] ____cacheline_aligned;
};关键标志
| 标志 | 含义 |
|---|---|
TCQ_F_BUILTIN |
内建 qdisc(不可删除) |
TCQ_F_CAN_BYPASS |
队列空时可跳过排队直接发送 |
TCQ_F_MQROOT |
MQ 多队列根 qdisc |
TCQ_F_NOLOCK |
无锁 qdisc(fq、fq_codel 等) |
Qdisc_ops:操作表
struct Qdisc_ops(sch_generic.h:287)定义
qdisc 实现的接口:
struct Qdisc_ops {
struct Qdisc_ops *next; /* 链表 */
const struct Qdisc_class_ops *cl_ops;/* 分类操作(分层 qdisc 才有) */
char id[IFNAMSIZ];/* 名称:"pfifo_fast"、"htb" */
int priv_size; /* 私有数据大小 */
unsigned int static_flags;/* 默认标志 */
int (*enqueue)(struct sk_buff *, struct Qdisc *,
struct sk_buff **to_free);
struct sk_buff *(*dequeue)(struct Qdisc *);
struct sk_buff *(*peek)(struct Qdisc *);
int (*init)(struct Qdisc *, struct nlattr *arg,
struct netlink_ext_ack *);
void (*reset)(struct Qdisc *);
void (*destroy)(struct Qdisc *);
int (*change)(struct Qdisc *, struct nlattr *arg,
struct netlink_ext_ack *);
int (*dump)(struct Qdisc *, struct sk_buff *);
int (*dump_stats)(struct Qdisc *, struct gnet_dump *);
};二、发送路径中的 TC
dev_queue_xmit 到 qdisc
回顾发包路径中 TC 的位置(详见 发包路径全解):
dev_queue_xmit(skb)
→ __dev_queue_xmit(skb, sb_dev)
→ 选择 TX 队列:netdev_pick_tx()
→ 获取队列的 qdisc:rcu_dereference(txq->qdisc)
→ __dev_xmit_skb(skb, q, dev, txq)
__dev_xmit_skb:快路径与慢路径
/* 简化逻辑 */
static inline int __dev_xmit_skb(struct sk_buff *skb,
struct Qdisc *q, ...)
{
/* 快路径:队列空且支持 bypass */
if ((q->flags & TCQ_F_CAN_BYPASS) && qdisc_empty(q) &&
qdisc_run_begin(q)) {
/* 跳过排队,直接发送 */
sch_direct_xmit(skb, q, dev, txq, root_lock);
qdisc_run_end(q);
return NET_XMIT_SUCCESS;
}
/* 慢路径:入队 */
rc = q->enqueue(skb, q, &to_free);
if (qdisc_run_begin(q)) {
__qdisc_run(q); /* 出队循环 */
qdisc_run_end(q);
}
return rc;
}CAN_BYPASS 优化:当 qdisc
队列为空(没有积压)且标志允许,直接跳过 enqueue/dequeue
流程,走 sch_direct_xmit()
发送。对于低负载场景,这避免了不必要的排队和调度开销。pfifo_fast、fq_codel
等默认都支持 bypass。
__qdisc_run:出队循环
void __qdisc_run(struct Qdisc *q)
{
int quota = dev_tx_weight; /* 默认 64 */
while (qdisc_restart(q, &packets)) {
quota -= packets;
if (quota <= 0) {
/* 配额用尽,调度 NET_TX_SOFTIRQ 稍后继续 */
__netif_schedule(q);
break;
}
}
}qdisc_restart() 每次调用
q->dequeue() 取一个包,交给
sch_direct_xmit()
发送到驱动。dev_tx_weight(默认
64)限制每次软中断处理的包数——防止单个 qdisc 独占 CPU。
无锁 qdisc(TCQ_F_NOLOCK)
传统 qdisc 用 qdisc->root_lock 保护——所有
CPU 竞争同一把锁。现代 qdisc(fq、fq_codel)使用
TCQ_F_NOLOCK 标志,配合 seqlock 和
QDISC_STATE_MISSED 标志实现无锁并发:
static inline bool qdisc_run_begin(struct Qdisc *qdisc)
{
if (qdisc->flags & TCQ_F_NOLOCK) {
if (spin_trylock(&qdisc->seqlock))
return true;
/* 竞争失败:设置 MISSED 标志,通知持有者 */
set_bit(__QDISC_STATE_MISSED, &qdisc->state);
/* 再试一次(避免 MISSED 丢失) */
if (spin_trylock(&qdisc->seqlock))
return true;
return false;
}
/* 传统 qdisc:测试并设置 RUNNING 位 */
return !__test_and_set_bit(__QDISC_STATE2_RUNNING, ...);
}MISSED 标志的作用:CPU-A
持有锁正在出队,CPU-B 入队后发现拿不到锁——设置 MISSED。CPU-A
在 qdisc_run_end() 检查
MISSED,如果有则重新调度出队——保证 CPU-B
入队的包不会被遗忘。
三、常用 qdisc 实现
pfifo_fast:默认 qdisc
pfifo_fast 维护三个优先级 band(band 0 最高),根据 TOS/DSCP 映射选择 band:
Band 0(高优先级)← TOS: Minimize-Delay
Band 1(普通) ← 大多数流量
Band 2(低优先级)← TOS: Maximize-Throughput
dequeue 时从 band 0 开始——只有高优先级 band 为空才取下一级。
特点:实现极简、零开销,但无公平排队——一个大流量可以饿死同 band 的其他流。
fq_codel:公平排队 + 延迟控制
fq_codel(Fair Queuing Controlled Delay)是现代 Linux 的推荐 qdisc——解决 bufferbloat 问题:
入队:
skb → hash(五元组) → 分配到 1024 个桶之一
→ 桶内 FIFO
出队:
DRR(Deficit Round Robin)→ 轮流从各桶出队
→ CoDel AQM → 检测排队延迟
→ 延迟 > target(5ms) 持续 > interval(100ms)
→ 开始丢包(概率递增)
CoDel 算法的核心洞察:排队延迟才是 bufferbloat 的信号,而不是队列长度。CoDel 测量每个包在队列中停留的时间——如果排队延迟持续超过 target(默认 5ms),开始概率性丢包,强迫发送端降速。
fq 与 fq_codel 的区别:fq 支持 pacing——根据 TCP 的 EDT(Earliest Departure Time)时间戳控制每个包的精确发送时间。BBR 拥塞控制依赖 fq 的 pacing 能力。
HTB:分层令牌桶
HTB(Hierarchical Token Bucket)支持树形带宽分配:
tc qdisc add dev eth0 root handle 1: htb default 30
tc class add dev eth0 parent 1: classid 1:1 htb rate 100mbit
tc class add dev eth0 parent 1:1 classid 1:10 htb rate 30mbit ceil 60mbit
tc class add dev eth0 parent 1:1 classid 1:20 htb rate 50mbit ceil 80mbit
tc class add dev eth0 parent 1:1 classid 1:30 htb rate 20mbit ceil 100mbit
每个 class 有两个参数: - rate:保证带宽(即使其他 class 满载也能获得) - ceil:最大带宽(可以借用父 class 的空闲带宽)
HTB 在内核中用红黑树管理 class 层级,令牌桶算法控制每个 class 的发送速率。当 class 用完自己的令牌但 ceil 未满时,可以从父 class”借”令牌。
TBF:简单令牌桶
TBF(Token Bucket Filter)是最简单的限速 qdisc:
# 限速 10Mbps,突发 32KB
tc qdisc add dev eth0 root tbf rate 10mbit burst 32kbit latency 400ms令牌以 rate
速率填充桶,每发一个包消耗对应令牌。桶满时丢弃新到的令牌(不超过
burst)。无令牌时包在队列中等待,超过 latency
则丢弃。
四、EDT(Earliest Departure Time)
EDT 是现代 TC 的调度模型——不再问”每秒发多少包”,而是为每个包标记”这个包应该在什么时间发出”。
工作原理
TCP 层:
skb->tstamp = ktime_get() + pacing_delay
(BBR/CUBIC pacing 计算精确发送时间)
↓
fq qdisc:
按 skb->tstamp 排序
只有 tstamp ≤ 当前时间的包才出队
↓
驱动:
支持 EDT 的网卡可进一步延迟到精确时间
EDT 的优势: 1. 精确 pacing:BBR
可以精确控制每个包的发送间隔 2.
消除排队:包在 qdisc
中按时间排序,不需要大队列 3.
端到端协同:skb->tstamp 从
TCP 层传递到驱动,全路径感知
# 启用 fq qdisc(支持 EDT pacing)
tc qdisc replace dev eth0 root fq五、filter(分类器)
tcf_proto:分类器实例
struct tcf_proto(sch_generic.h:406)是单个分类器:
struct tcf_proto {
struct tcf_proto __rcu *next;
void __rcu *root; /* 分类器特定的数据 */
int (*classify)(struct sk_buff *,
const struct tcf_proto *,
struct tcf_result *);
__be16 protocol; /* 匹配的协议(ETH_P_IP 等) */
const struct tcf_proto_ops *ops;/* 分类器类型操作 */
struct tcf_chain *chain; /* 所属链 */
u32 prio; /* 优先级 */
};常用分类器
| 分类器 | 用途 | 特点 |
|---|---|---|
| u32 | 通用字段匹配 | 可匹配任意包头偏移 |
| flower | 流表匹配 | 支持硬件卸载 |
| bpf | BPF 程序 | 最灵活,直接返回 classid |
| fw | fwmark 匹配 | 配合 iptables MARK |
| route | 路由匹配 | 基于路由 realm |
| basic | 基础匹配 | ematch 表达式 |
TC BPF:direct-action 模式
传统 TC 流程:filter 分类 → 选择 class → class 的 qdisc 处理。TC BPF 的 direct-action 模式跳过中间步骤——BPF 程序直接返回 TC_ACT 动作:
/* BPF 程序返回值 */
TC_ACT_OK (0) /* 放行 */
TC_ACT_SHOT (2) /* 丢弃 */
TC_ACT_PIPE (3) /* 传递给下一个 filter */
TC_ACT_REDIRECT(7) /* 重定向到其他设备 */内核对 TC BPF 有 retpoline
优化——tc_wrapper.h 中为
cls_bpf_classify
提供直接调用路径,避免间接函数调用的 Spectre 缓解开销:
/* tc_wrapper.h 中的快路径 */
if (IS_BUILTIN(CONFIG_NET_CLS_BPF)) {
if (tp->classify == cls_bpf_classify)
return cls_bpf_classify(skb, tp, res); /* 直接调用 */
}# 挂载 TC BPF 程序(direct-action 模式)
tc qdisc add dev eth0 clsact
tc filter add dev eth0 ingress bpf da obj prog.o sec classifier六、MQ 多队列 qdisc
netdev_queue 与 qdisc 的关系
现代网卡有多个 TX 队列——每个队列是一个
struct netdev_queue(include/linux/netdevice.h:640):
struct netdev_queue {
struct Qdisc __rcu *qdisc; /* 活跃 qdisc */
struct Qdisc __rcu *qdisc_sleeping;/* 非活跃 qdisc */
struct net_device *dev;
spinlock_t _xmit_lock; /* TX 锁 */
unsigned long trans_start;
struct dql dql; /* BQL(动态队列限制) */
};MQ 根 qdisc
多队列网卡默认使用 MQ qdisc 作为根——它本身不排队,只是把包分发给各队列的子 qdisc:
root: mq (TCQ_F_MQROOT)
├── queue 0: fq_codel (子 qdisc)
├── queue 1: fq_codel
├── queue 2: fq_codel
└── queue 3: fq_codel
每个子 qdisc 独立运行——不同 TX 队列的入队/出队可以并行,无锁竞争。
BQL(Byte Queue Limits)
struct dql(struct netdev_queue
的成员)动态控制驱动层队列的深度——防止驱动 ring buffer
积攒过多包导致延迟增加:
BQL 算法:
1. 记录每次 xmit 提交的字节数
2. 记录驱动完成(interrupt)的字节数
3. 动态调整 limit = f(inflight, completions)
4. inflight > limit 时暂停提交,等驱动消化
BQL 与 qdisc 配合——qdisc 控制”怎么排队”,BQL 控制”排多深”。
七、可观测性实战
qdisc 状态查看
# 查看所有 qdisc 统计
tc -s qdisc show dev eth0
# 查看 class 统计(HTB)
tc -s class show dev eth0
# 查看 filter
tc filter show dev eth0qdisc 丢包追踪
# 追踪 qdisc 丢包——enqueue 返回非零
bpftrace -e '
kretprobe:htb_enqueue /retval != 0/ {
printf("htb enqueue DROP ret=%d\n", retval);
@drops = count();
}
interval:s:10 { print(@drops); clear(@drops); }'fq_codel 延迟监控
# fq_codel 的 CoDel 丢包事件
bpftrace -e '
tracepoint:qdisc:qdisc_dequeue {
if (args->qdisc_handle == 0x00010000) {
@dequeue_bytes = hist(args->skbaddr);
}
}'
# 查看 fq_codel 内部统计
tc -s qdisc show dev eth0 | grep -A5 fq_codelTC BPF 程序性能
# 查看 TC BPF 程序统计
tc filter show dev eth0 ingress
# BPF 程序执行时间
bpftool prog show
bpftool prog profile id <ID> duration 5__qdisc_run 耗时
# 测量 qdisc 出队循环耗时
bpftrace -e '
kprobe:__qdisc_run { @start[tid] = nsecs; }
kretprobe:__qdisc_run /@start[tid]/ {
@qdisc_run_us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}'八、关键参数速查
| 参数 | 默认值 | 含义 | 调优建议 |
|---|---|---|---|
net.core.default_qdisc |
pfifo_fast | 默认 qdisc | 推荐 fq 或 fq_codel |
net.core.dev_weight |
64 | 每次 softirq 的出队包数 | 高吞吐可增大 |
net.core.dev_weight_tx_bias |
1 | TX/RX 权重比 | 发送密集型增大 |
| fq_codel target | 5ms | CoDel 目标延迟 | 低延迟场景减小 |
| fq_codel interval | 100ms | CoDel 检测间隔 | 通常不需调整 |
| fq_codel quantum | MTU | DRR 调度量 | 通常不需调整 |
| fq_codel flows | 1024 | 哈希桶数 | 高并发可增大 |
| fq pacing | 1 | 启用 pacing | BBR 必须开启 |
| fq initial_quantum | 10×MTU | 新流首包配额 | 通常不需调整 |
| HTB r2q | 10 | rate-to-quantum 转换系数 | 低速 class 可调 |
参考文献
- Linux
内核源码,
include/net/sch_generic.h,6.8(struct Qdisc 于第 73 行,Qdisc_ops 于第 287 行) - Linux
内核源码,
include/net/pkt_sched.h,6.8(qdisc_run 于第 120 行) - Linux
内核源码,
include/net/pkt_cls.h,6.8(tcf_exts 于第 214 行) - Linux
内核源码,
include/net/tc_wrapper.h,6.8(cls_bpf_classify 快路径于第 144 行) - Kathleen Nichols, Van Jacobson, “Controlling Queue Delay”, ACM Queue, 2012(CoDel 算法)
- Linux
内核文档,
Documentation/networking/tc-actions-env-rules.rst
上一篇:Netfilter 内核实现:钩子、conntrack 与 NAT
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【Linux 网络子系统深度拆解】eBPF 网络钩子全景:TC/XDP/socket/cgroup
从内核源码全面拆解 eBPF 在网络子系统中的所有挂载点:TC BPF direct-action 模式与 bpf_mprog 多程序链、XDP 驱动级钩子回顾、socket ops 回调与 TCP 生命周期事件、cgroup BPF 策略控制、sk_msg/sk_skb 的 sockmap 重定向引擎、struct_ops 实现自定义拥塞控制,以及 bpftrace 可观测实战。
【Linux 网络子系统深度拆解】多队列与流量分发:RSS/RPS/RFS/XPS
单队列网卡的时代早已过去,但多队列本身只是起点——如何把包分到正确的 CPU 上,才是性能的关键。本文从 Linux 6.6 内核源码拆解多队列网络的完整流量分发体系:RSS 硬件哈希与 Toeplitz 算法、RPS 软件多队列模拟与 get_rps_cpu() 路径、RFS 应用感知的 rps_sock_flow_table 机制、XPS 发送端 CPU/队列亲和、aRFS 硬件流表加速,以及 netdev_pick_tx() 发送队列选择逻辑。
【Linux 网络子系统深度拆解】TCP 内核实现(下):数据传输与拥塞控制
tcp_sendmsg 把用户数据拷到 sk_buff 就完事了?远没有。后面还有 Nagle 合并、TSQ 限流、cwnd/rwnd 双窗口门控、RACK-TLP 丢包检测、拥塞状态机五态跳转、sk_pacing_rate 软件限速。本文从 Linux 6.6 内核源码拆解 TCP 数据传输的完整路径——从 send() 到 ACK 处理——以及拥塞控制框架 tcp_congestion_ops 的可插拔架构。
【Linux 网络子系统深度拆解】发包路径全解:从 send() 到网线
一个用户态 send() 调用要走过 TCP 分段、IP 路由、Netfilter 钩子、Qdisc 排队、GSO 分段、驱动 DMA 映射六个阶段才能把数据送上网线。本文从 Linux 6.6 内核源码出发,逐函数拆解完整的 TX 发包路径,深入 TSQ 限流、Qdisc 调度、BQL 防膨胀、GSO/TSO 分段决策等核心机制。