☰
WFQ加权公平队列算法C/C++实现:从虚拟时间计算到代码落地与避坑指南
2026/10/17 4:50:04 网站建设 项目流程

简介:这是一份面向网络编程学习者与通信方向开发者的WFQ加权公平队列算法C/C++实现工程,聚焦多路复用网络中数据流公平共享带宽这一核心问题,覆盖发送端、接收端与路由器转发三类角色的代码实现,适合用于课程设计、协议栈实验或调度算法原理验证。压缩包共344个文件,约14.98MB,以cpp与h源文件为主体,配合obj、exe等编译产物,另有c、rc、dsp、dsw等工程与资源文件,以及pdb、ilk等调试信息,完整保留了可编译运行的工程结构。资源已有958人学习下载,具备一定参考热度。工程按队列结构设计、权重分配、数据包分类、调度选择与循环调度等环节组织代码,可与FIFO策略对照理解公平性与服务质量差异,帮助读者掌握队列管理、调度策略与C/C++混合编程的落地思路,并借助现成工程快速调试与二次开发。

1. 从一次队列积压说起:WFQ 到底在调度什么

做过网络设备或者协议栈开发的人,大概率遇到过这种场景:一条出口链路上同时跑着语音、视频和文件传输,带宽被大流量业务吃满之后,语音开始断断续续,视频花屏,而文件传输却跑得欢快。问题不在于总带宽不够,而在于没有一套合理的调度机制去决定“下一个包该发谁的”。WFQ(Weighted Fair Queueing,加权公平队列)就是解决这个问题的经典算法,它给每个流分配一个权重,按权重比例分配带宽,同时保证低权重流不会被高权重流彻底饿死。

这份 WFQ 算法实现(C/C++)资源,核心就是一套可编译、可调试的调度器代码,适合做网络协议栈、QoS 模块、嵌入式网关或者教学实验的从业者。它不依赖特定硬件,纯软件实现,能直接嵌进你现有的包处理流程里。如果你正在找一个能跑通、能改参数、能观察调度效果的 WFQ 参考实现,这份代码值得拆开看看。下面我从数据结构、核心计算、代码落地到避坑,一步步拆。

2. WFQ 的核心机制:虚拟时间与权重怎么算

2.1 为什么不用轮询,而用虚拟时间

轮询(Round Robin)给每个队列相等的机会,但现实中的流对带宽的需求并不相等。WFQ 的思路是:给每个流一个权重 $w_i$,流 $i$ 每发送一个包,其“虚拟时间”就前进 $\frac{L}{w_i}$,其中 $L$ 是包长。调度器每次选择虚拟时间最小的流来发送。这样,权重大的流虚拟时间增长慢,获得更多发送机会;权重小的流虽然机会少,但不会被完全忽略。

这个机制的关键在于“虚拟时间”的维护。常见做法是用一个全局虚拟时间 $V(t)$,每个流维护一个“虚拟开始时间” $S_i$ 和“虚拟完成时间” $F_i$。当一个包到达时,如果队列为空,$S_i = \max(V(t), F_i^{prev})$;否则 $S_i = F_i^{prev}$。然后 $F_i = S_i + \frac{L}{w_i}$。调度器选择 $F_i$ 最小的包发送。这套计算是 WFQ 的灵魂,代码里如果这里写错,调度结果就会完全乱套。

提示:虚拟时间的更新频率直接影响精度。如果每个包都更新一次全局虚拟时间,计算量会比较大;常见优化是只在队列非空时更新,或者用近似算法(如 WF2Q)来降低复杂度。

2.2 权重与包长的实际影响

权重 $w_i$ 的取值通常来自业务优先级映射,比如语音流权重设为 10,视频流设为 5,文件传输设为 1。包长 $L$ 是实际字节数。从公式 $F_i = S_i + \frac{L}{w_i}$ 可以看出,包长越大,虚拟完成时间越大,该流下一次被调度的优先级就越低。这符合直觉:长包占用链路时间长,应该让短包先走,减少小包的排队延迟。

但这里有个容易翻车的地方:如果权重设置差距过大(比如 1000:1),低权重流的虚拟时间会增长极快,可能导致其 $F_i$ 长时间大于全局虚拟时间,调度器会一直不选它,形成“饿死”。实际工程中,权重比例一般控制在 10:1 以内,或者引入最小带宽保证机制。代码里通常会有一个min_bandwidth参数来兜底。

2.3 数据结构选型:堆还是有序链表

WFQ 调度器需要频繁取出 $F_i$ 最小的流,所以数据结构的选择很关键。常见方案有两种:

  • 最小堆:插入和取出最小值的复杂度都是 $O(\log n)$,适合流数量较多的场景(比如上千个流)。但堆不支持高效地更新某个流的 $F_i$,需要额外的索引映射。
  • 有序链表:插入时按 $F_i$ 排序,取出头部即可,复杂度 $O(n)$。流数量少(几十个)时反而更简单,代码量小,调试直观。

这份 C/C++ 实现里,我看到的做法是用了一个小顶堆,同时维护了一个flow_id -> heap_index的映射表,这样当某个流的 $F_i$ 更新时,可以快速调整堆。这个设计在流数量动态变化时比较稳,但映射表的维护需要小心,删除流的时候容易出索引错乱。

3. 代码落地:从结构体到调度循环

3.1 核心结构体定义

先看流控制和包的结构。代码里通常这样定义:

// 流控制块,每个流一个 typedef struct flow { int flow_id; // 流标识 double weight; // 权重 double virtual_finish; // 当前虚拟完成时间 F_i double virtual_start; // 虚拟开始时间 S_i struct packet *queue; // 该流的包队列(链表) int queue_len; // 队列长度 int heap_index; // 在堆中的位置,用于快速更新 } flow_t; // 包结构 typedef struct packet { int pkt_id; int length; // 包长,字节 double arrival_time; // 到达时间 struct packet *next; } packet_t; // 调度器全局状态 typedef struct wfq_scheduler { flow_t **heap; // 小顶堆,按 virtual_finish 排序 int heap_size; int heap_capacity; double global_virtual_time; // 全局虚拟时间 V(t) double link_rate; // 链路速率,用于计算实际发送时间 } wfq_sched_t;

这里virtual_finish是排序依据,heap_index是为了在更新某个流的virtual_finish后能快速调整堆。global_virtual_time的更新策略有两种:一种是每发送一个包就按L / link_rate推进;另一种是只在有包到达且队列为空时更新。代码里用的是第一种,实现简单,但精度受包长影响。

3.2 入队与虚拟时间计算

当一个包到达时,需要做几件事:找到对应流,把包挂到流队列尾部,如果流之前为空,计算新的 $S_i$ 和 $F_i$,然后更新堆。

// 包到达处理 int wfq_enqueue(wfq_sched_t *sched, int flow_id, packet_t *pkt) { flow_t *f = find_flow(sched, flow_id); if (!f) return -1; // 流不存在 // 包挂到流队列尾部 pkt->next = NULL; if (f->queue == NULL) { // 流之前为空,计算 S_i 和 F_i f->virtual_start = fmax(sched->global_virtual_time, f->virtual_finish); f->virtual_finish = f->virtual_start + (double)pkt->length / f->weight; f->queue = pkt; f->queue_len = 1; // 插入堆 heap_insert(sched, f); } else { // 流非空,只挂包,F_i 不变(因为 F_i 是队首包的完成时间) packet_t *tail = f->queue; while (tail->next) tail = tail->next; tail->next = pkt; f->queue_len++; } return 0; }

注意这里的关键逻辑:只有流从空变为非空时,才重新计算 $S_i$ 和 $F_i$。如果流已经有包在排队,新来的包不会改变队首包的虚拟完成时间,因为调度器只关心队首包什么时候发完。这个细节很多初学者会搞错,导致每个包都重算一遍,调度结果完全不对。

参数说明:fmax是取最大值,保证 $S_i$ 不小于全局虚拟时间,避免新流因为历史 $F_i$ 过小而获得不公平的优先权。weight是浮点数,实际工程中可以用整数权重再归一化,避免浮点误差累积。

3.3 出队与堆调整

调度器每次从堆顶取出 $F_i$ 最小的流,发送其队首包,然后更新该流的 $F_i$,再调整堆。

// 出队:选择下一个发送的包 packet_t* wfq_dequeue(wfq_sched_t *sched) { if (sched->heap_size == 0) return NULL; flow_t *f = sched->heap[0]; // 堆顶,F_i 最小 packet_t *pkt = f->queue; if (!pkt) return NULL; // 从流队列移除队首包 f->queue = pkt->next; f->queue_len--; // 更新全局虚拟时间 sched->global_virtual_time += (double)pkt->length / sched->link_rate; if (f->queue == NULL) { // 流变空,从堆中移除 heap_remove_top(sched); } else { // 流还有包,更新 F_i f->virtual_start = f->virtual_finish; f->virtual_finish = f->virtual_start + (double)f->queue->length / f->weight; // 调整堆 heap_update(sched, f->heap_index); } pkt->next = NULL; return pkt; }

这段代码里,global_virtual_time的推进量是pkt->length / link_rate,也就是实际发送这个包占用的时间。这个值会影响后续新流的 $S_i$ 计算。如果链路速率设得不对,虚拟时间推进过快或过慢,都会导致调度偏差。

heap_update是堆调整函数,因为 $F_i$ 变大了,所以需要向下调整堆。这里容易出的 bug 是:更新virtual_finish后忘记调用heap_update,或者heap_index没有正确维护,导致堆结构破坏,后续取出的最小值不是真正的全局最小。

3.4 编译与测试

代码包通常包含wfq.c、wfq.h和一个main.c测试入口。编译命令:

gcc -O2 -Wall -o wfq_test wfq.c main.c -lm

-lm是因为用到了fmax等数学函数。运行后,测试程序会模拟几个流按不同权重发送包,打印每个包的发送顺序和虚拟时间。你可以通过修改main.c里的权重和包长来观察调度变化。

注意:如果编译时报undefined reference to fmax,检查是否漏了-lm,或者把fmax换成手写的比较宏。

4. 避坑与排查:WFQ 实现里最容易翻车的五个点

4.1 虚拟时间只增不减,但浮点误差会累积

现象:跑长时间测试后,调度顺序开始出现微小偏差,低权重流偶尔被连续调度两次。

原因:virtual_finish和global_virtual_time都是double类型,每次加法都会引入舍入误差。当流数量多、包长差异大时,误差累积会放大。

解决:把虚拟时间放大一个固定倍数(比如乘以 1000)后用int64_t存储,或者定期对全局虚拟时间做归一化。代码里我一般会加一个#define VIRTUAL_SCALE 1000,所有计算先乘后除。

4.2 流删除时堆索引错乱

现象:某个流超时被删除后,堆里还残留它的指针,后续调度取到空流,程序崩溃或死循环。

原因:删除流时只从流表里移除,没有从堆里移除,或者移除后没有更新受影响节点的heap_index。

解决:删除流必须走heap_remove,并且把堆尾元素移到被删位置后,同时做向上和向下调整。heap_index要在每次交换时同步更新。测试时专门写一个“反复创建删除流”的用例,跑几千次看有没有内存错误。

4.3 权重为 0 或负数导致除零

现象:配置权重时不小心传了 0,程序直接崩溃或者虚拟完成时间变成inf。

原因:F_i = S_i + L / w_i,权重为 0 时除零。

解决:在流创建时校验权重,小于等于 0 的拒绝或者设一个最小值(比如 0.001)。代码里加一行if (weight <= 0) weight = 0.001;就能兜住。

4.4 包长单位不统一

现象:调度结果和理论计算对不上,高权重流反而拿不到应有带宽。

原因:包长有的地方用字节,有的地方用比特,link_rate的单位也跟着乱。比如包长 1500 字节,链路速率 100 Mbps,如果直接1500 / 100,虚拟时间推进量就错了。

解决:统一单位。建议包长用字节,链路速率用字节/秒,或者包长用比特,链路速率用比特/秒。代码注释里写清楚每个变量的单位,测试用例用已知结果反推验证。

4.5 堆容量固定导致溢出

现象:流数量超过预设上限后,新流无法入堆,调度器静默丢包。

原因:heap_capacity初始化时设了固定值,没有动态扩容。

解决:要么在流数量上限内使用,要么实现heap_expand在容量不足时realloc。我一般会在wfq_enqueue里加一个判断,堆满时返回错误码,让上层决定是丢包还是扩容。

5. 进阶技巧:用权重映射表把 WFQ 接进真实业务

5.1 从 DSCP 到权重的映射

真实网络里,流的优先级通常由 DSCP(差分服务代码点)标记。你可以建一张映射表,把 DSCP 值转成 WFQ 权重。比如:

DSCP 值业务类型建议权重
46 (EF)语音10
34 (AF41)视频6
26 (AF31)信令4
10 (AF11)普通数据2
0 (BE)尽力而为1

这张表放在配置文件里,启动时加载。权重比例不要拉得太开,否则低优先级流容易饿死。实际调优时,先按 10:1 的最大比例设,观察低优先级流的延迟,再决定是否压缩比例。

5.2 用令牌桶做准入控制

WFQ 只管调度,不管流量是否超标。如果某个流疯狂发包,即使权重低,也会因为队列过长而占用大量内存。常见做法是在 WFQ 前面加一个令牌桶,每个流按权重分配令牌速率,超出的包直接丢弃或标记。这样 WFQ 的队列长度可控,调度延迟也更稳定。

代码里可以加一个简单的令牌桶结构:

typedef struct token_bucket { double tokens; // 当前令牌数 double rate; // 令牌生成速率,字节/秒 double burst; // 桶容量 double last_update; // 上次更新时间 } token_bucket_t; // 检查是否有足够令牌发送包 int tb_consume(token_bucket_t *tb, int pkt_len, double now) { double elapsed = now - tb->last_update; tb->tokens = fmin(tb->burst, tb->tokens + elapsed * tb->rate); tb->last_update = now; if (tb->tokens >= pkt_len) { tb->tokens -= pkt_len; return 1; // 允许发送 } return 0; // 令牌不足,丢弃或排队 }

这个令牌桶的rate可以按weight * base_rate来设,保证权重高的流获得更多令牌。burst一般设为最大包长的 2 到 3 倍,避免突发流量被误杀。

5.3 验证调度公平性的方法

写完代码怎么确认 WFQ 真的公平?我一般用两种方法:

方法一:固定包长,统计各流发送包数。设两个流,权重 2:1,包长都是 1000 字节,跑 3000 个包。理论上权重 2 的流应该发 2000 个,权重 1 的发 1000 个。实际结果偏差在 1% 以内算正常。

方法二:变长包,统计各流发送字节数。权重 2:1,包长随机在 64 到 1500 之间。跑 10000 个包后,权重 2 的流发送总字节数应该是权重 1 的 2 倍左右。如果偏差超过 5%,检查虚拟时间计算和堆调整逻辑。

这两种测试我都写成了脚本,每次改完代码跑一遍。从那以后我每次改调度逻辑,都强制走一遍固定包长和变长包两个用例,确认公平性没跑偏。希望这份实现和排查思路能帮到你,少走点虚拟时间算错的弯路。

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

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

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

立即咨询