☰
WFQ加权公平队列原理与C++实现:虚拟时钟、调度器代码及避坑指南
2026/10/10 6:43:29 网站建设 项目流程

简介:WFQ(Weighted Fair Queuing,加权公平队列)算法C/C++实现工程,面向希望理解与改造网络队列调度机制的开发者。资源覆盖发送端、接收端数据流处理与路由器转发模块,完整呈现按权重分配带宽、对数据包分类入队并动态调度的核心流程,适合用于教学演示或二次开发。包体为RAR压缩包,共344个文件,主要包含C/C++源码(55个cpp、5个c)与头文件(62个h),配套工程配置文件(dsp/dsw/plg)、编译生成的obj/pch/pdb中间文件以及可独立运行的exe程序,另有说明文档doc,整体约14.98MB,结构清晰便于检索。目前已有958人浏览学习。与实践结合紧密是这份资源的突出价值:既可对照源码研究WFQ与FIFO在公平性、时延控制上的差异,也能直接运行exe观察调度效果;备份文件与工程修改痕迹还能帮助排查编译链接中的常见问题,节省从零搭建环境的时间。

1. WFQ 是什么:先搞懂它在网络设备里干的事

做网络转发的人大概率都碰过这个场景:出口链路一拥塞,一个大流量就能把整根管子塞满,后面来的小流量报文全部堆在队尾,时延抖动直接飙升,业务监控图上全是红点。光靠 FIFO 队列解决不了,必须让调度器按权重公平分配带宽,这就是 WFQ(Weighted Fair Queueing)算法要干的事。WFQ 在 C/C++ 里实现,听起来是个老课题,真正落地时才发现:虚拟时钟怎么推进、排序容器怎么选、权重怎么归一化,每一处都有取舍。我接手过的某网关产品 QoS 模块就是从它起步的。这篇按我实际做过的方案讲原理、核心代码、参数设置和踩坑记录,新手照着手写能跑起来,熟手可以直接对照检查自己的实现。

2. 从理想公平模型到包调度:WFQ 的虚拟时钟怎么算

2.1 为什么是 GPS:公平需要一个参照系

如果上来就写排序代码,很容易做出一个“看起来公平”的调度器,比如按权重轮流发送。但轮流不等于公平,因为权重不同的场景下,轮询没有按比例分配;权重相同的场景下,轮询在包长不一致时也不公平——发了一个 1500 字节的大包,再发一个 64 字节的小包,小包流实际分到的带宽只有大包流的 4%。所以工程上必须先回到参照系。

GPS 模型把每条流看成一团连续流体,在任意时刻每条流获得的服务速率正比于它的权重;某条流没有积压报文时,它的服务份额会被其他流临时瓜分。GPS 知道怎么才叫真正的按比例分享,但它只是理想模型,因为报文切不开。WFQ 就是 GPS 的一种包级近似,目标是在报文的粒度上让每条流在一段观察窗口内得到的带宽逼近 GPS 分配结果,同时保持报文边界完整。这也是为什么你在任何一篇关于 WFQ 的讨论里都绕不开 GPS——它是公平性的定义者。

2.2 虚拟完成时间:报文的“插队依据”

WFQ 每次从所有有堆积的流中挑一个报文发送。挑哪个?绝不看真实到达时间,而是看一个叫虚拟完成时间(Virtual Finish Time)的值,谁的完成时间小谁先走。

对流 i 的某个报文:

F_i = max(当前虚拟时间 V, 该流上一个报文的 F_old) + L_i / w_i
  • L_i:报文长度,单位字节
  • w_i:该流权重,越大越快
  • V:当前虚拟时间,表示链路已经服务掉的“虚拟工作量”

这个公式要拆开看。L / w 表示这个报文要消耗的虚拟服务量,权重大的流消耗少。max(V, F_old) 处理的是流的空窗期:如果一个流很久没包了,它的旧完成时间早就落后,新到的报文不能继续叠在旧时间线上,否则一次迟到会拖累整条流。落实到代码里,就是在流从空闲变活跃的那一刻,把它的时间线重置到当前虚拟时间。

虚拟时间的本质是链路服务进度的抽象,它和流数量、权重、包长都相关,唯独不能简单挂到墙上时钟。你要是拿真实时间当 V,后面会有无数麻烦。

2.3 虚拟时间的推进:事件步进而不是时间步进

这是整个算法最容易写错的地方。我最早实现时把 V 当成一个全局计数器,每个循环加 1,结果权重小的流反而先出去了,查了半天才发现是虚拟时间推进节奏不对。

正确做法是事件步进:只有当一个报文被送出时,虚拟时间才跳变到该报文的完成时间;队列全空时,虚拟时间停滞不动。为什么不能用真实时钟?因为真实时钟不管系统里有没有队列都在走,空闲 1 秒再进来一个包,V 已经被拉到很大,所有新包都得到偏大的完成时间,排序被整体扰乱。而且真实时钟均匀推进,但 WFQ 的虚拟时间只关心“已经服务了多少工作量”,不关心墙上过了几秒。

严谨的 GPS 虚拟时间计算还会把当前活跃流的权重总和算进去,工程实现里我一般先做简化版:直接用上一个被发送报文的完成时间作为当前虚拟时间,也就是自时钟公平排队(SCFQ)的思路。这个版本长期统计上逼近 WFQ,调度主循环结构简单,够大多数网关设备的带宽分配需求。要做严格 GPS 仿真再引入随活跃流变化的虚拟时钟,后面的代码结构不用动。

2.4 权重归一化:配置值不能直接当权重用

实际设备里,用户配置的权重通常来自 DSCP 优先级或者 0-7 的队列号,这些值之间不是比例关系。比如 DSCP 值 46 和 34,直接拿来当 w 用,比值是 1.35 而业务预期可能是 4 比 1。我一般会把配置映射到内部归一化权重,最简单的方式是查一张表,把每个优先级映射到 1、2、4、8 这样的二次幂,或者统一除以最小配置值。

权重差距也要控制。我给一个经验值:最大权重和最小权重的比值不要超过 100:1,超过之后小权重流的完成时间增量会特别大,报文在队列里等到的延迟上不封顶,业务侧会先受不了。WFQ 解决的是按比例分享,不负责无限保证低权重流的时延。

对比项虚拟时间 V真实时间
推进依据报文出队事件时钟中断
空队列时保持不动继续前进
单位含义虚拟服务量秒
对排序影响直接影响完成时间只影响包间隔

3. 用 C++ 实现 WFQ 核心:优先队列、入队出队与调度主循环

3.1 调度容器选型:为什么优先队列是主流

WFQ 调度器需要三样东西:每条流一个 FIFO 队列、一个能从所有流队头中取最小完成时间的数据结构、一个把流分类映射到权重的表。调度容器是核心,可选方案有三种。

方案插入取最小优缺点
线性扫表O(1)O(N)流少可行,100 个流以上每次出队都难受
std::multimapO(logN)O(1)能按完成时间精确删除,但过期条目清理麻烦
std::priority_queueO(logN)O(1)实现最简单,只支持取堆顶,需要跳过过期条目

我一般选小顶堆,也就是std::priority_queue加自定义比较器。代价是堆里会留一些过期条目,比如某条流已经有新报文,但旧报文的完成时间条目还在堆里。解决办法是出队时判断条目是否匹配该流队头报文的序号,不匹配就丢弃。这个模式下堆的平均深度略大于活跃报文数,但在网关场景里完全可控。

3.2 核心代码:Flow 数据结构与 WFQ 调度器

下面是按生产项目习惯写的最小实现,可以直接拿去做测试。注释里标了关键逻辑。

#include <cstdint> #include <deque> #include <queue> #include <unordered_map> #include <vector> struct WFQ_Packet { uint32_t flow_id; // 流编号,由分类模块给出 uint32_t len; // 报文长度,单位字节 uint32_t seq; // 该流内单调递增序号,用于区分堆中过期条目 }; struct FlowQueue { std::deque<WFQ_Packet> q; // 该流的 FIFO 队列 double finish_time = 0.0; // 该流下一个报文的虚拟完成时间 uint32_t weight = 1; // 归一化权重,至少为 1 bool active = false; }; struct HeapEntry { double finish_time; // 虚拟完成时间 uint32_t flow_id; uint32_t seq; }; struct HeapCmp { // 注意:std::priority_queue 是最大堆,这里要反过来比较 bool operator()(const HeapEntry& a, const HeapEntry& b) const { return a.finish_time > b.finish_time; } }; class WFQScheduler { public: void set_flow_weight(uint32_t flow_id, uint32_t weight) { flows_[flow_id].weight = weight > 0 ? weight : 1; } bool enqueue(const WFQ_Packet& pkt) { FlowQueue& f = flows_[pkt.flow_id]; if (!f.active) { // 流从空闲变活跃,时间线从当前虚拟时间重新开始 f.finish_time = std::max(virtual_time_, f.finish_time); f.active = true; } f.q.push_back(pkt); // 完成时间 = max(虚拟时间, 该流上一个完成时间) + 长度 / 权重 double ft = std::max(virtual_time_, f.finish_time) + static_cast<double>(pkt.len) / f.weight; f.finish_time = ft; heap_.push({ft, pkt.flow_id, pkt.seq}); return true; } bool dequeue(WFQ_Packet& out) { while (!heap_.empty()) { HeapEntry ent = heap_.top(); heap_.pop(); auto it = flows_.find(ent.flow_id); if (it == flows_.end()) continue; FlowQueue& f = it->second; if (f.q.empty()) continue; // 序号不匹配说明是过期条目,直接丢弃 if (f.q.front().seq != ent.seq) continue; // 事件步进:虚拟时间跳到当前完成时间 virtual_time_ = ent.finish_time; out = f.q.front(); f.q.pop_front(); if (f.q.empty()) { f.active = false; } return true; } return false; } private: double virtual_time_ = 0.0; std::unordered_map<uint32_t, FlowQueue> flows_; std::priority_queue<HeapEntry, std::vector<HeapEntry>, HeapCmp> heap_; };

这段代码里,入队时的max(virtual_time_, f.finish_time)对应 2.2 里的空窗期处理,没有它,一个长时间空闲的流会继承很早以前的时间线。seq比较是为了跳过堆里的旧条目,生产环境如果报文序号是 32 位,要注意回绕处理,用相对差值判断而不是直接等于。权重在set_flow_weight里做了下限保护,权重 0 会造成除零,必须在配置阶段拦截。

3.3 出队逻辑与主循环:谁来驱动它

上面代码里的dequeue()是调度器本身,真正干活时要有一个上层驱动。常见做法是发送线程循环调用,有包就返回,没包就等待信号量。伪代码是这样:

while (running) { WFQ_Packet pkt; if (sched.dequeue(pkt)) { send_to_port(pkt); } else { wait_for_packet_event(); } }

收包线程把报文分类后调enqueue,然后给发送线程一个通知。这个模型是事件驱动的,虚拟时间自然跟着事件的节奏走,不会出现定时器驱动的偏差。

主循环里有个容易被忽略的细节:dequeue()返回 false 只代表当前没有可发送的报文,不代表系统空闲。真正的空闲条件是heap_为空且所有流都inactive。有些实现里为了统计链路的利用率,会额外维护一个active_flows_计数器,当它从 0 变正时记一个时间戳,用来计算空闲时长。

提示:dequeue()里不要用ent.finish_time != f.finish_time判断过期条目,double 比较在权重相同、包长相同的场景下可能误判。用 seq 精确匹配最稳。

3.4 控制面与数据面的耦合:权重表怎么进数据面

WFQ 的调度循环属于数据面,权重表属于控制面。常见做法是控制面把配置编译成一张哈希表,key 是五元组或 DSCP 值,value 是{flow_id, weight},数据面收包时做一次查表。

这里要提醒两点。第一,分类哈希表不要用需要加锁的共享结构,要么用 RCU 风格的双副本切换,要么干脆每次配置变更时重建一个表实例,让数据面原子切换指针。第二,查表不到要有统一的默认流,权重给 1,保证未知流量也能被调度,而不是直接丢包。有些模块为了省时间,默认丢弃未分类流量,这是把调度问题变成了可用性问题。

我习惯把set_flow_weight和分类表的更新放在同一批次配置里,避免先改了权重再改分类表导致中间态下权重错配。设备上配置下发的粒度支持按流集合操作,实际项目里就是这么跟网管系统对接的。

4. WFQ 避坑记录:虚拟时钟、比较器与饥饿

4.1 同权重流带宽不均:虚拟时钟被真实时间驱动

现象:配置了四条权重相同的流,流量形态也接近,统计出来的带宽却是 2:1.5:1.2:0.8,而且这个比例在重启后会变化。

原因:实现里用gettimeofday或系统 tick 作为虚拟时间,真实时间在队列空的时候照样前进,导致空闲前后入队的报文完成时间被整体拉大,排序结果和流积压程度脱钩。

解决:把虚拟时间改成事件步进。只有dequeue()成功时才更新virtual_time_,队列空时保持不动。完成后跑一个同权重多流压测,带宽比应该在 0.95 到 1.05 之间才算正常。

4.2 优先队列先发出最大完成时间:比较器方向写反

现象:日志里总是高权重、大包长的流先出去,低权重小包反而排队。看起来像权重越大优先级越高,完全反了。

原因:std::priority_queue默认是最大堆,top()返回的是比较器意义上的最大值。如果比较器写a.finish_time < b.finish_time,堆顶是完成时间最大的条目,WFQ 直接变 LIFO。

解决:自定义比较器返回a.finish_time > b.finish_time,把最小的完成时间顶到堆顶。写完立刻做单测:插入完成时间 1.0 和 2.0 两个条目,top()必须是 1.0 那个。

4.3 长期运行后完成时间全部相等:浮点精度失效

现象:设备连续运行几天后,调度退化成近似 FIFO,所有流的完成时间看起来一样大,公平性指标掉到 1.0。

原因:虚拟时间和完成时间都是 double,持续累加后数值达到 1e15 量级,double 的尾数精度不足以区分相邻两次加法的差异,所有完成时间在比较器眼里都一样。

解决:给虚拟时间做定期重归一化。当virtual_time_超过某个阈值,比如 1e12,就把所有流的finish_time和virtual_time_同时减去一个基准值。注意这时候堆里的旧条目也要全部失效,简单做法是重建整个堆,反正这种操作一天一次都算多了。

注意:重归一化选阈值时要保证两次归一化之间不会发生精度翻转。1e12 对 double 来说很安全,1e15 就已经开始踩线了。

4.4 高权重流持续积压,低权重流全部饿死

现象:有一条高权重 TCP 流持续占满队列,其他低权重流偶尔有包进来,但延迟高到超时,业务侧频繁报障。

原因:WFQ 保证的是长期带宽比例,不代表低权重流的每次包都能及时走。如果高权重流的队列深度没有上限,它的完成时间不断往后压,其他流的完成时间即使小,也只能在夹缝里被服务。

解决:给每条流设置队列深度上限,超过上限按策略丢弃新包,而不是无限积压。常见做法是配合 WRED 做早期随机丢弃,让大流量在队列满之前先降速。只做 WFQ 不做队列深度管理,调度器早晚被某一条流拖死。

4.5 空闲后重入队的包乱序:active 标志没有及时清

现象:某条流发完一波后隔了几毫秒又来一波,抓包发现第二波的某个包比第一波晚出发,但序号更小,对端协议栈直接告警乱序。

原因:出队时队列清空但没有把active置回 false,导致重新入队时if (!f.active)分支没进,finish_time继续叠在旧时间线上,新包继承了比当前虚拟时间还大的旧完成时间,排到后面去了。

解决:队列清空时立刻置active = false,并保证入队时用max(virtual_time_, f.finish_time)把头重置。这里还要检查出队的空队列分支是不是提前 continue 了,别把 active 状态更新放在堆条目校验之后。

5. 验证与调优:用 3:1 权重流测出的公平性

5.1 最小验证:本地压测而不是上线试错

WFQ 的验证不需要搭建复杂网络,写一个小程序就能把公平性测出来。我的做法是:两个流,权重 3:1,每个流各发 1000 个长度 500 字节的报文,统计每个流从入队到出队的虚拟完成时间总和,算带宽比例。

#include <cstdio> #include "wfq_scheduler.h" // 上文实现的调度器 int main() { WFQScheduler sched; sched.set_flow_weight(1, 3); sched.set_flow_weight(2, 1); // 交错入队,模拟两个流同时有积压 for (uint32_t i = 0; i < 1000; i++) { sched.enqueue({1, 500, i}); sched.enqueue({2, 500, i}); } double flow1_done = 0.0, flow2_done = 0.0; WFQ_Packet pkt; while (sched.dequeue(pkt)) { if (pkt.flow_id == 1) flow1_done++; else flow2_done++; } printf("flow1:%f flow2:%f ratio:%f\n", flow1_done, flow2_done, flow1_done / flow2_done); }

这个测试只是起点,实际验证还要看三组数据:带宽比例、最大延迟、延迟抖动。带宽比例看长期公平性,延迟看短时突发下低权重流是否被压制,抖动看调度器是否平稳。我一般要求 3:1 配置下的实测带宽比落在 2.8 到 3.2,偏离超过 5% 就回去查虚拟时间或比较器。

5.2 参数调优:优先级从高到低排下来

权重归一化是最先要查的。配置值是 3:1,代码里如果权重用的是原始优先级 46:34,实际比例就变成了 1.35:1,差距立刻变形。确认权重表没问题,再看队列深度和丢弃策略,最后才轮得到虚拟时间精度。

延迟敏感的小流量,给更高权重和更浅的队列;吞吐型大流量,给较深的队列但配合 WRED 做早期丢弃。如果业务要求的是稳定时延,考虑把调度粒度从字节改成固定大小的时隙,也就是把报文拆成等价时间片,代价是实现复杂度会上升。

进阶方向是分层 WFQ。把一批流聚合到一组,组间跑 WFQ,组内再跑 WFQ,避免单级 WFQ 下流数量太多导致堆压力和完成时间粒度变粗。需要严格 GPS 语义时,再把虚拟时间改成随活跃流权重总和变化的版本,调度主循环和数据结构不用动。

我现在的习惯是:每次改完调度器,都固定跑一遍 3:1 权重加千包回归。只要带宽比偏离超过 5%,先怀疑虚拟时间推进,再看比较器,用这个顺序排查几乎没有落空过。这套做法帮我少踩了不知道多少坑,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询