
简介WFQ加权公平队列是保障网络公平性与QoS的关键调度算法这份C/C实现工程适合计算机网络学习者、协议栈开发人员及需要优化带宽分配的工程师使用。包内完整呈现发送端与接收端的数据流处理逻辑并用C语言实现路由器转发部分读者可对照源码理解WFQ按权重分配带宽、避免大流量长期挤占小流量以及相较FIFO更注重公平性的设计思路。资源共344个文件压缩包14.98MB以55个cpp和62个h源码文件为主另有117个obj、10个exe等编译产物便于直接构建运行对照验证工程配置和资源文件也已包含在内目录结构清晰。已有958人学习下载适合通过调试示例代码掌握队列分类、权重分配、循环调度等关键实现步骤也可作为课程设计或本科毕业设计的参考原型。1. WFQ算法实现c/c流量调度里“按权重抢带宽”的工程难题做网络转发面或者模拟器QoS时我经常被问“能不能把WFQ算法用C/C实现出来让不同业务的报文按权重公平地分享出口带宽”。WFQWeighted Fair Queueing就是干这个的经典算法给每个流一个权重调度器按照虚拟时间动态计算每个包该什么时候发让高速流不至于饿死低速流。把WFQ用C/C落地的核心难点不在“队列”本身而在虚拟时间的增量计算、浮点/定点精度和空闲状态的恢复。这篇笔记写给要在设备或仿真里亲手实现加权公平队列的开发者拿到就能编、能跑、能调参。2. 拆开WFQ的数学模型虚拟时间、服务量与权重的关系2.1 从FIFO到GPS什么是“按权重公平”FIFO队列的调度逻辑是先来先到实现代价极低但一旦某个大流量流持续占满队头其他流的报文只能在缓冲区里排队业务时延被拉高。我在做设备QoS时最怕的是“队头阻塞”一个视频流把出口带宽吃满VoIP小包全被堵在后面。加权公平队列的出发点不是按到达顺序服务而是按“每个流应该得到的服务份额”来服务。理想模型GPSGeneralized Processor Sharing把所有流看作共享一个服务器的多个队列每个队列按权重比例同时被服务。假设三个流权重分别是1、2、1出口是100MbpsGPS会让它们分别同时获得25Mbps、50Mbps、25Mbps。但现实里报文是完整的数据块不能把一个包切成三段同时发。WFQ要做的就是从所有等待的包里挑出“下一个该发的包”使得最终每个流获得的长时间平均带宽尽可能接近GPS比例。WFQ另一个价值是给时延提供一个可证明的上界一个包的调度延迟最多比GPS理想状态晚“一个最长包的服务时间”。所以做流调度选WFQ不只是为了“看起来公平”而是为了在公平和实时性之间有一个可验证的边界。这也是WFQ在语音、视频等对时延敏感场景里长期被使用的原因。2.2 虚拟时间Virtual Time是怎么来的核心公式与直觉由于不能同时服务所有流需要一个共同的时间轴来判断“谁落后了多少”。GPS里服务进度用虚拟时间 V(t) 度量它的推进速度与当前所有活跃队列的权重总和相关。工程实现上虚拟时间只在每次发出一个包时更新一次并记录在每个包的“虚拟完成时间”finish里。实际落地时最常用的两阶段计算是这样的包入队时如果队列为空虚拟开始时间 S max(V_now, last_finish_i)其中 last_finish_i 是该队列上一次的虚拟完成时间若会话刚建通常为0。如果队列非空虚拟开始时间 S last_finish_i因为新包必须排在旧包后面。包的虚拟完成时间 F S L / w_iL 是包长字节数w_i 是队列权重。更新该队列的 last_finish_i F。包出队时从所有非空队列的队头包中选择 F 最小的包发送。发送后全局虚拟时间 V 更新为 V L_sent / active_weight_sum其中 active_weight_sum 是“发送前”所有非空队列的权重之和。直觉理解是这样权重大的流L/w_i 小所以它的包更容易拿到小的 F自然优先被调度。而虚拟时间 V 的增长速率与 active_weight_sum 挂钩从而把不同权重的队列放到可比较的尺度上。例如所有队列权重之和是10发一个500字节的包V增加50个虚拟单位权重为1的流同样500字节包带来的F增量是500权重为5的流则是100。经过多轮服务权重5的流能发5个包权重1的流只能发1个包总服务字节比例就是5:1。注意虚拟时间 V 不表示真实墙钟时间只表示“服务进度”。如果所有队列都空了V 就不再增长必须冻结或重置。否则空闲前遗留的巨大 V 会让新包的 start 异常大这个坑我在第 4 章细说。2.3 为什么用C/C实现内存布局、零拷贝和生态WFQ直接落地的场景是转发面或网络仿真这两个地方都要求高性能。C/C的优势有三点报文就是内存里的连续缓冲区用结构体指针加链表就能入队出队不需要把报文内容复制来复制去调度器需要频繁插入、删除、比较最小虚拟完成时间C标准库的红黑树std::set开箱即用在流数量几百上千时维护一个活跃流集合比线性扫描快一个数量级Linux内核、DPDK、VPP这些平台的包处理和队列操作都暴露C API用C/C写的WFQ可以直接挂进去不用跨语言封装。如果只是快速验证算法我一般在 VS Code 里配好 C/C 环境写一个单文件仿真程序打断点看 virtual_time 的变化比在路由器上调试方便得多。真要做产品级实现再换成定点数和手工堆优化。3. 动手实现一个WFQ调度器结构体、入队出队与最小堆3.1 包和流的表示用intrusive链表避免拷贝网络仿真里包通常是一块带载荷的缓冲区从内存池里分配。我建议把调度器需要的元信息直接嵌在包结构体里而不是在外层再用一个 map 维护。下面这个结构在C和C里都适用struct wfq_pkt { uint16_t flow_id; uint32_t len; /* 报文长度字节 */ double finish; /* 虚拟完成时间入队时算好 */ struct wfq_pkt *next; /* 单链表串在流队列里 */ };为什么用next而不是std::list因为包可能要经过入队、排队、出队、发送后释放多个阶段用内部指针让包在队列之间转移时只需改指针不移动数据。std::list每个节点还要额外分配一个 list node缓存不友好在DPDK环境里也不允许用标准库容器。我把next嵌在包里队列就只是两个指针head/tail。流和调度器定义#define WFQ_MAX_FLOWS 1024 struct wfq_flow { double weight; /* 配置权重必须 0 */ double finish_time; /* 队列最后一个包的虚拟完成时间 */ struct wfq_pkt *head; struct wfq_pkt *tail; uint32_t backlog; /* 队列中累积字节数 */ }; struct wfq_sched { double virtual_time; /* 全局虚拟时间 */ int active_cnt; /* 非空队列数量 */ struct wfq_flow flows[WFQ_MAX_FLOWS]; };参数说明finish_time就是 2.2 里的last_finish_i只不过它记录的是队列尾巴的虚拟完成时间。新包入队时队列非空就直接在这个基础上累加。WFQ_MAX_FLOWS按业务流上限设定如果流数量动态变化最好换成哈希表加淘汰策略。active_cnt用于快速判断调度器是否整体空闲这是在入队时决定要不要重置虚拟时间的关键。3.2 入队函数区分队列空与不空入队时最需要注意的是“调度器完全空闲”的恢复。如果所有队列都空了虚拟时间应当冻结但代码里很难“冻结”不如在下一个包到来时重置。void wfq_enqueue(struct wfq_sched *s, uint16_t flow_id, uint32_t len) { struct wfq_flow *f s-flows[flow_id]; struct wfq_pkt *p alloc_pkt(len); /* 从内存池取包 */ p-flow_id flow_id; p-len len; p-next NULL; if (s-active_cnt 0) { /* 调度器完全空闲重置虚拟时间和所有队列残留 */ s-virtual_time 0; for (int i 0; i WFQ_MAX_FLOWS; i) s-flows[i].finish_time 0; } if (f-backlog 0) { /* 队列空开始时间取全局虚拟时间与残留值的大者 */ double start (s-virtual_time f-finish_time) ? s-virtual_time : f-finish_time; p-finish start (double)len / f-weight; } else { /* 队列非空排在上一个包后面 */ p-finish f-finish_time (double)len / f-weight; } f-finish_time p-finish; if (f-backlog 0) s-active_cnt; /* 队列由空转非空 */ f-backlog len; if (f-tail) f-tail-next p; else f-head p; f-tail p; }逻辑说明入队开头的active_cnt 0分支非常关键。WFQ 的虚拟时间只在有服务时推进如果所有队列都空了还保留历史虚拟时间那么空闲多时后新到的包会用巨大的 start 把自己的 finish 推到天上永远不会被调度。重置后新会话从零开始。finish 的计算里(double)len / f-weight把整数长度提升为浮点运算避免整数除法截断。参数说明alloc_pkt需要实现为从预分配缓冲池取包避免频繁 malloc/free。如果暂时没有池子可以先malloc对应长度并返回结构体指针。注意active_cnt必须在backlog变化前判断否则误判。入队后队列由空转非空active_cnt。3.3 出队选择线性扫描与最小堆流数量不大时线性扫描是最简单可靠的选择struct wfq_pkt *wfq_dequeue_linear(struct wfq_sched *s) { int best -1; double best_finish 1e18; for (int i 0; i WFQ_MAX_FLOWS; i) { struct wfq_flow *f s-flows[i]; if (f-backlog 0 f-head-finish best_finish) { best_finish f-head-finish; best i; } } if (best 0) return NULL; /* 先计算活跃权重和注意必须在摘除包之前 */ double active_w 0; for (int i 0; i WFQ_MAX_FLOWS; i) if (s-flows[i].backlog 0) active_w s-flows[i].weight; struct wfq_flow *f s-flows[best]; struct wfq_pkt *p f-head; f-head p-next; if (f-head NULL) f-tail NULL; f-backlog - p-len; if (f-backlog 0) s-active_cnt--; s-virtual_time (double)p-len / active_w; return p; }逻辑说明这里有两个要点。第一active_w 必须在摘包前计算因为在本次服务期间被选中的流仍然“活跃”它的权重应当计入分母。如果摘包后再扫描这个流已经成为空队列active_w 偏小虚拟时间增量偏大调度顺序就会失真。第二虚拟时间增量公式里的active_w是“本次发送前所有非空队列权重之和”这对应 GPS 模型里虚拟时间推进速率与活跃队列权重总和成反比的定义。即使这是最后一个包active_w 也至少包含该流自身权重不会除零。参数说明1e18作为无穷大阈值在虚拟时间被重置的前提下是安全的。如果不做重置虚拟时间涨到 1e18 量级double 精度会丢失比较结果开始“翻车”所以在 3.2 里那个重置逻辑不能省。流数量多时O(N) 扫描会成为热点。我给出 C 版本用std::set存储活跃流集合键是“队头包的 finish, flow_id”#include set #include utility #include vector struct wfq_sched_cpp { std::setstd::pairdouble, int active; // 队头finish - flow_id double virtual_time 0; std::vectorwfq_flow flows; int active_cnt 0; }; void wfq_enqueue_cpp(wfq_sched_cpp *s, int flow_id, uint32_t len) { wfq_flow *f s-flows[flow_id]; if (s-active_cnt 0) { s-virtual_time 0; for (auto fl : s-flows) fl.finish_time 0; } double finish; if (f-backlog 0) { double start std::max(s-virtual_time, f-finish_time); finish start (double)len / f-weight; } else { finish f-finish_time (double)len / f-weight; } f-finish_time finish; wfq_pkt *p alloc_pkt(len); p-finish finish; p-len len; p-flow_id flow_id; if (f-tail) f-tail-next p; else f-head p; f-tail p; bool was_empty (f-backlog 0); f-backlog len; if (was_empty) { s-active_cnt; s-active.insert({finish, flow_id}); // 队列空时新包的finish就是队头finish } } struct wfq_pkt *wfq_dequeue_cpp(wfq_sched_cpp *s) { if (s-active.empty()) return nullptr; auto it s-active.begin(); int flow_id it-second; wfq_flow *f s-flows[flow_id]; wfq_pkt *p f-head; double active_w 0; for (auto fl : s-flows) if (fl.backlog 0) active_w fl.weight; f-head p-next; if (f-head nullptr) f-tail nullptr; f-backlog - p-len; if (f-backlog 0) { s-active.erase(it); s-active_cnt--; } else { s-active.erase(it); s-active.insert({f-head-finish, flow_id}); } s-virtual_time (double)p-len / active_w; return p; }逻辑说明std::set按 pair 排序finish 相同就按 flow_id 排保证键唯一。队列非空时队头的 finish 不因新包入队而变化所以只需要在“队列由空转非空”时插入出队后如果队列仍非空队头已经变了要删除旧键并插入新键。红黑树插入删除 O(log N)流数量上千时比线性扫描快得多。参数说明std::set每个节点开销较大活跃流上万时缓存命中率下降。真要做高性能可以换成 d-ary 堆加游标但学习期先用 set 把逻辑跑通别在数据结构上过早优化。3.4 权重参数怎么设归一化、非法值和动态调整WFQ 的调度比例只取决于权重相对值所以weight100和weight1在只有这两个流时没有区别。我一般直接用带宽比例配置比如接口带宽 1Gbps给三类业务分别配 500Mbps、300Mbps、200Mbps权重就填 500、300、200。必须注意权重为 0 的流不能参与调度入队时需要校验否则除零。动态改权重时已入队包的 finish 还是旧权重算出来的改完后新包按新权重计算两者会混排一段时间。这不一定是 bug而是算法定义如此。如果要求严格可以清空该流重新排队但转发面通常不做。如果包长差异大64 字节控制包和 1500 字节数据包按权重调度会让小包更频繁地插队带宽比例仍符合预期但延迟抖动会变大。这个问题在 4.5 里展开。4. WFQ实现常见的5个坑从虚拟时间溢出到活跃集合算错WFQ 看起来只有几行公式但落地时我翻过好几次车。下面这 5 个坑都是实际代码里会遇到的写完调度后建议逐一检查。4.1 权重比例始终不对低速流反而被高速流“带偏”现象三个流权重设成 1:2:1各发同样多的包最后统计完成时间比例是 1.8:1:1.8而不是理论上的 2:1:2。原因虚拟时间更新时使用了错误的活动集合。常见错误是摘包之后再扫描活跃队列此时刚出队的流已经 backlog0被排除在 active_w 之外导致分母变小、虚拟时间增量变大。虚拟时间被“注水”后后续所有包的 finish 都被撑大低权重流更容易被误判为不落后。解决把 active_w 的计算放到摘包之前即选定 best 后、修改 backlog 前遍历一次非空队列求和。排查时可以在每次出队时打印virtual_time增量、active_w和期望值偏差一眼可见。4.2 虚拟时间越堆越大调度顺序开始“玄学”现象程序跑了几个小时突然某个流的包一直排不上重启后又正常。原因double 虽然范围大但精度有限。虚拟时间增长到 1e12 量级时相邻两个可表示浮点数之间的间隔变大两个本来不同的 finish 被舍入成同一个值导致 set 里的顺序不稳定。没有空闲重置是另一个推手。解决双管齐下。一是调度器完全空闲时重置虚拟时间和队列残留见 3.2二是维护一个base偏移比较时用finish - base。每当 virtual_time 超过阈值如 1e9把队列中所有包的 finish、队列的 finish_time 和 virtual_time 都减去base再把base加上阈值。注意同步修改活跃集合里的键否则旧键会指向错误对象。这个“定期归零”要写进实现规范。4.3 空闲很久后新流入队第一个包被“发配”到天边现象两个流流 A 很忙流 B 偶尔发一个包。流 B 的包入队后迟迟不被发出哪怕权重设成和流 A 一样。原因流 B 队列空闲时全局 virtual_time 一直在增长。新包 start 取 max(virtual_time, finish_time) 得到一个大值finish 也就异常大自然永远排在最后。严格说这是 GPS 模型下的“补服务”逻辑但新会话的第一包延迟高得离谱用户无法接受。解决允许“新会话红包”。队列由空转非空时如果历史 finish_time 与 virtual_time 的差超过一个阈值比如最长包的服务时间就把 finish_time 重置为 virtual_time 再计算 start。这样新流不会永远垫底同时老流因为历史积累还能保持优势。阈值要可配置默认取max_pkt_len / max_weight的服务时间量纲。4.4 用整数计算虚拟时间导致小包和小权重流饿死现象把 len 和 weight 都定义为int计算len / weight。权重大的流带宽正常权重小的流发送字节数远低于理论值。原因整数除法截断。包长 64、权重 100 时64/1000finish 增量变成 0相当于这个包被“零成本”发送小权重流的虚拟完成时间停滞不前饿死其他包。解决要么用 double计算时先转类型(double)len / weight要么用定点数把虚拟时间单位缩放 1024 倍finish len * 1024 / weight。定点数要小心uint64_t溢出仍需定期归零。调试阶段建议先用 double逻辑确认后再优化。4.5 包长差异大时瞬时带宽比例振荡得像“心电图”现象真实流量跑起来一秒钟内统计每流带宽几个流一会儿快一会儿慢和权重对不上。原因WFQ 按包算虚拟时间包长越大 finish 增量越大。假设流 A 权重 10、流 B 权重 1同样发 1500 字节大包A 的 finish 增量 150B 是 1500。如果一瞬只有两个包A 发一次 B 可能还没轮到但下一轮 B 发一次长期平均还是 10:1。短窗口统计自然噪声大。解决验证时不看瞬时窗口只看“每流发送总字节数/总时间”的稳态值。产品里若要保证瞬时带宽需要在 WFQ 后加令牌桶整形。这不是 WFQ 的 bug而是算法特性别在这上面浪费排查时间。5. 验证WFQ实现构造流量样本、统计带宽比例5.1 一个可编译的最小测试台把前面实现放在wfq.h后写一个单文件测试台。思路三个流权重 1:2:1每个流入队 1000 个随机长度包然后循环出队直到空统计各流总字节数和“最后一个包出队时”的虚拟时间。#include cstdio #include cstdlib #include vector #include wfq.h int main() { struct wfq_sched s {0}; s.flows[0].weight 1.0; s.flows[1].weight 2.0; s.flows[2].weight 1.0; srand(42); std::vectoruint32_t lens; for (int i 0; i 1000; i) { lens.push_back(64 rand() % 1437); // 64~1500字节 } for (int i 0; i 1000; i) { wfq_enqueue(s, 0, lens[i]); wfq_enqueue(s, 1, lens[i]); wfq_enqueue(s, 2, lens[i]); } double total_bytes[3] {0, 0, 0}; double finish_vt[3] {0, 0, 0}; struct wfq_pkt *p; while ((p wfq_dequeue_linear(s)) ! NULL) { total_bytes[p-flow_id] p-len; finish_vt[p-flow_id] s.virtual_time; free_pkt(p); } for (int i 0; i 3; i) { printf(flow %d: bytes%.0f, finish_vt%.3f\n, i, total_bytes[i], finish_vt[i]); } return 0; }逻辑说明三个流总字节数相同入队顺序完全对称。权重 2 的流应该比权重 1 的流更早完成所以它的finish_vt最后一个包出队时的虚拟时间应该更小。如果 WFQ 实现正确流 1 的 finish_vt 约是流 0 的一半。参数说明rand()固定种子保证可复现。包长随机是为了避免“所有包等长”掩盖虚拟时间更新里的整数精度问题。free_pkt对应alloc_pkt在示例里用free(p)代替也行。5.2 用一个小工具统计每流带宽只看最终完成时间不够还要看调度过程中的稳定性。可以按虚拟时间窗口切片比如每累计 10000 虚拟单位算一个窗口统计窗口内各流字节数double window_size 10000; double next_window window_size; uint64_t win_bytes[3] {0}; while ((p wfq_dequeue_linear(s)) ! NULL) { if (s.virtual_time next_window) { printf(window end at vt%.1f: bytes %llu %llu %llu\n, next_window, (unsigned long long)win_bytes[0], (unsigned long long)win_bytes[1], (unsigned long long)win_bytes[2]); win_bytes[0] win_bytes[1] win_bytes[2] 0; next_window window_size; } win_bytes[p-flow_id] p-len; }逻辑说明virtual_time 增长的总量可以理解为总服务量按它切片相当于按时间等分。窗口越大三个流的字节比例越接近 1:2:1。第一个窗口内偏差大很正常至少跑 10 个窗口再看趋势。把输出重定向到 CSV用任何画图工具都能看出收敛过程。参数说明window_size选择要和平均包长匹配。如果平均包长 500 字节一个窗口 20 个包噪声大建议设成平均包长的 100 倍以上。实际测试我用的是 100000 虚拟单位相当于约 200 个包。5.3 对比表格WFQ、FIFO、DRR选哪个调度算法公平性时延上界实现复杂度适用场景FIFO无易队头阻塞依赖队列深度O(1)高吞吐、无QoS要求DRR按权重比例包级公平有界但较大O(1)固定流数链路聚合、普通QoSWFQ按虚拟时间近似GPS有界小于DRRO(logN)用堆语音视频、运营商QoSDRRDeficit Round Robin通过累计赤字来按权重发送实现简单且不依赖虚拟时间但轮询粒度会导致延迟抖动比 WFQ 大。如果只要求“带宽比例”DRR 更省 CPU如果要求“带宽 时延上界”WFQ 更稳。产品里也有混用DRR 做大类调度WFQ 做大类内部的流调度。测试 WFQ 时和 FIFO 对比能看出公平性提升和 DRR 对比能看出延迟抖动差异。我实际验证过在 1000 个包的批量测试里WFQ 的完成时间比例与理论偏差在 1% 以内。6. 进阶把WFQ做成可诊断、可调参的调度模块6.1 用函数指针把调度器从网络框架里解耦不要让调度器硬依赖网卡驱动或内存池接口。我习惯定义一组操作接口struct wfq_sched_ops { void (*enqueue)(struct wfq_sched *, uint16_t, uint32_t); struct wfq_pkt *(*dequeue)(struct wfq_sched *); };测试时用仿真实现部署时用 DPDK 实现核心调度代码完全不变。换平台只需要改alloc_pkt和free_pkt两个函数。6.2 加一个诊断开关别让虚拟时间变成黑匣子调试 WFQ 最痛苦的是看不见虚拟时间的演化。我在 dequeue 里加一行可选日志打印flow_id, len, finish, virtual_time_after, active_cnt。异常时把日志拉出来画图问题一目了然。很多玄学 bug 都是靠这个日志定位的。日志用#ifdef WFQ_DEBUG包起来默认关闭不影响线上性能。6.3 多核场景的锁粒度建议如果多个 CPU 共享一个调度器虚拟时间更新就是全局临界区。我用过最省心的是 per-CPU 调度器通过 RSS 或流哈希把同一个流固定到一个 CPU每个 CPU 维护自己的 WFQ 实例锁竞争消失。如果必须全局共享只能加自旋锁保护整个调度循环吞吐会明显下降。这种取舍在 NFV 场景很常见可以按 CPU 数做分区。我自己最常踩的坑是忘了处理空闲重置导致虚拟时间越来越大调度表现完全不可理解。后来我把“空闲重置”和“定期归零”写进实现规范再没复发。希望这些经验能帮到在这条路上 debug 的你。本文还有配套的精品资源点击获取