简介:一套针对WFQ(加权公平队列)算法的完整C/C++实现,面向计算机网络学习者、研究人员及开发工程师。项目分别覆盖发送端、接收端的数据流处理,以及路由器转发部分的C语言实现,能够帮助理解在多路复用网络中按权重公平分配带宽的核心机制,并与FIFO队列调度方式形成对比。包内共344个文件,以cpp源码、h头文件、obj编译中间文件、exe可执行程序等为主,还包含pdb调试信息与dsp/dsw工程文件,便于直接打开工程查看调试;压缩包整体约14.98MB。已有957人学习,代码涵盖队列结构设计、权重分配、数据包分类、调度算法及循环监控等关键模块,并展示TCP/IP协议栈集成思路,适合想要掌握WFQ调度策略及C/C++网络编程落地方法的读者。
1. 先搞清楚WFQ到底在解决什么问题
如果你是做网络、中间件或者嵌入式开发的,大概率遇到过这种场景:同一台交换机上,有人在下大文件,把带宽全占完了,旁边在线视频、语音通话卡得一帧一帧跳。传统的FIFO队列谁先来谁走,大流量一进来直接把小流量淹没了。WFQ(Weighted Fair Queueing,加权公平排队)就是干这个的——它让每个流按权重比例分享带宽,大流分得多但不会全部吃光,小流分得少但保证有口汤喝。
从实现角度看,WFQ属于队列调度算法,广泛用在路由器QoS、Linux内核的公平调度器、虚拟化平台的网络流量控制里。如果你要用C/C++在自研网关、SDN交换机、用户态协议栈这类项目里做流量调度,WFQ是个绕不开的基础算法。这篇文章我会直接把C语言版本的完整实现思路讲清楚,再给出C++版本怎么做得更优雅,最后附上我实际调试时踩过的一些坑。
在动手之前,先明确一下我们要实现的调度效果:假设有三个业务流A、B、C,权重分别是1、2、4,那么在一个统计周期内,它们的带宽分配比例理论上应该是1:2:4。关键在于,WFQ不是简单按比例发送完一轮再下一轮,而是用虚拟时间来逼近“连续公平”的效果,这比WRR(加权轮询)在突发流量下更平滑,也更接近理想公平排队(GPS)的行为。
2. 核心原理:虚拟时间与完成时间戳
2.1 为什么不用真实时钟
我刚接触WFQ的时候有个疑问:为什么不能直接用一个定时器,按权重比例分配时间片,到点就切换队列发送?这就是WRR的做法,实现简单,但有一个明显缺陷:包长不均匀时会失真。如果某个流全是64字节的小包,另一个流全是1500字节的大包,按包数轮询显然不公平,按字节数轮询又会导致小包流频繁被打断、延迟抖动严重。
WFQ的聪明之处在于,它引入了**虚拟时间(Virtual Time)**概念。虚拟时间不是墙上时钟的时间,而是一个只跟“已发送的总工作量”相关的抽象量。每当调度器发送一个长度为length的包,虚拟时间就累加length。每个包在到达时被赋予一个虚拟完成时间戳(Virtual Finish Time, VFT),调度器永远优先选择VFT最小的包发送。这样,短包天然占便宜——它完成时间早,会被优先发送,从而获得更低的延迟,而长包虽然延迟稍高,但不会完全饿死其他流。
2.2 虚拟完成时间的递推公式
每个包p的虚拟完成时间用以下递推公式计算:
F_i = max(A_i, F_{i-1}) + length_i / weight_i其中:
- F_i 是当前第i个包的虚拟完成时间戳
- A_i 是当前包的虚拟到达时间,也就是它入队那一刻的虚拟时间
- F_{i-1} 是同一队列中前一个包的虚拟完成时间
- length_i 是包长(字节数)
- weight_i 是当前队列的权重
这个公式有两层含义。第一,每个队列内部保持有序:如果同一个队列的包连续到达,后一个包的完成时间一定不小于前一个包,公式里的max(max(A_i, F_{i-1}))保证了一个队列内部不会出现完成时间回退。第二,权重越大,完成时间增长越慢:一个权重为4的流,每发送1字节只让VFT增加0.25,而权重为1的流每发送1字节让VFT增加1,所以高权重流会被调度器更频繁地选中。
理解这个公式之后,整个算法的核心调度规则就一句话:每次发送VFT最小的包。实现上,我们需要一个按VFT排序的优先级队列,每次出队取出最小值。
3. C语言实现:从零手写一个小根堆调度器
3.1 数据结构设计
C语言实现WFQ的第一步是把数据结构和队列管理整理清楚。我通常会定义以下几类结构体:
#define MAX_QUEUES 8 typedef struct pkt { uint32_t seq; uint32_t len; uint64_t vft; uint8_t *data; struct pkt *next; } pkt_t; typedef struct flow_queue { // 队列属性 uint32_t weight; uint64_t last_vft; // 队列最后一个包的虚拟完成时间 uint32_t backlog_bytes; uint32_t backlog_pkts; // 链式队列头尾 pkt_t *head; pkt_t *tail; } flow_queue_t; typedef struct wfq_scheduler { flow_queue_t queues[MAX_QUEUES]; uint32_t active_mask; // 位图标记非空队列 uint64_t virtual_time; // 当前虚拟时间 uint64_t total_sent; // 已发送总字节数 } wfq_scheduler_t;这里的虚时间是一个累加的整数,单位就是字节。其实虚拟时间可以等价地理解为“按最小权重归一化后的已发送总量”,所以在每次发送完一个包之后,virtual_time可以直接累加该包的length,不用额外换算。这个设计简化了很多边界处理。
3.2 为什么用二叉堆而不是遍历找最小
最直观的实现是每次遍历所有非空队列,找出VFT最小的包出队。当队列数量少(比如8个)时,遍历的性能完全够用。但如果队列数量扩大到几百上千个流,每次调度都遍历一遍,复杂度就是O(n),在高吞吐场景下完全扛不住。所以生产级实现几乎都选择二叉最小堆,调度复杂度降低到O(log n)。
堆中每个节点存储的是流队列的索引(或者指针),比较键是“该队列头部包的VFT”。为什么比较的是队列头部包而不是整个队列?因为同一队列内包是按VFT有序排列的(通过递推公式天然有序),队头包VFT最小的队列,其后续包的VFT只会更大,所以只需比较队头包即可。
堆的基本操作如下:
typedef struct heap_node { uint32_t qid; // 流队列ID uint64_t key; // 队头包的VFT } heap_node_t; typedef struct min_heap { heap_node_t nodes[MAX_QUEUES * 2]; uint32_t size; } min_heap_t;插入流程:当某个队列的队头包VFT发生变化时(新包入队且队列原先为空),把该队列插入堆;发送完一个包后,如果队列不为空,则更新该队列的key并做一次上浮调整;如果队列为空,直接移除。
3.3 入队与出队的核心代码
入队操作:
void wfq_enqueue(wfq_scheduler_t *sched, uint32_t qid, pkt_t *pkt) { flow_queue_t *qf = &sched->queues[qid]; // 队列为空时,包的虚拟完成时间基于当前虚时间计算 if (qf->tail == NULL) { pkt->vft = MAX(sched->virtual_time, qf->last_vft) + pkt->len / qf->weight; // 入堆 heap_insert(sched, qid, pkt->vft); } else { // 队列非空,基于前一个包的VFT递推 pkt->vft = qf->last_vft + pkt->len / qf->weight; qf->tail->next = pkt; } qf->tail = pkt; if (qf->head == NULL) qf->head = pkt; qf->last_vft = pkt->vft; qf->backlog_bytes += pkt->len; qf->backlog_pkts++; }这里有个细节:pkt->len / qf->weight 是整数除法。如果包长小于权重,结果直接就是0,会导致多个包的VFT相同,堆排序不稳定。我建议用整数运算保留精度,比如统一乘以一个缩放因子(如256),定义FAIRNESS_SCALE 256,则vft增量计算为pkt->len * FAIRNESS_SCALE / qf->weight。这样既避免浮点,又保证低权重流的VFT增长速度不会因为整数截断而产生偏差。
出队操作:
pkt_t *wfq_dequeue(wfq_scheduler_t *sched) { heap_node_t top = heap_pop_min(sched->heap); flow_queue_t *qf = &sched->queues[top.qid]; pkt_t *pkt = qf->head; // 更新队列状态 qf->head = pkt->next; if (qf->head == NULL) { qf->tail = NULL; qf->backlog_pkts = 0; } qf->backlog_bytes -= pkt->len; // 更新虚拟时间:已发送的总工作量 sched->virtual_time += pkt->len; sched->total_sent += pkt->len; // 如果队列还有剩余,重新计算队头VFT并入堆 if (qf->head != NULL) { uint64_t new_vft = qf->last_vft + qf->head->len * FAIRNESS_SCALE / qf->weight; // 注意:这里的关键是队头包的VFT并不是简单地用公式算了 heap_insert(sched, top.qid, new_vft); } return pkt; }这里有个容易犯的错误:在队列非空时,队头包的VFT并不是一直不变的。想象一个低权重流,它有一个长包排在队列里,虚拟时间已经推进到远超它队头VFT的位置,虚拟时间的追赶导致它的VFT已经小于当前虚拟时间。此时如果单纯地按公式计算新vft,会得到一个错误的VFT,导致这个包可能永远排在后面。
正确的做法是:在出队后重新计算该队列队头包的VFT时,要用max(sched->virtual_time, qf->last_vft) + head->len * FAIRNESS_SCALE / qf->weight来保证其值不会低于当前虚拟时间对应的理想排期。严谨地讲,对于队列内每个包,其真正的VFT应该是入队那一刻算好的值,出队后重新入堆时需要重新按虚拟时间校准。
3.4 空闲队列的虚拟时间校准问题
这是WFQ实现里最容易忽略的细节。假设一个队列长期空闲,虚拟时间已经推进到100000,此时该队列来了一个包,如果没有做任何校准而直接按last_vft + length/weight计算VFT,而last_vft还停留在很久以前的旧值,那么这个包的VFT会异常小,调度器会疯狂选择它,导致这个“迟到”的流burst式抢占带宽,对其他流极不公平。
解决办法就是在入队时检测队列是否为空,如果为空,用max(sched->virtual_time, qf->last_vft)作为基准,这也就是前面代码里MAX的用途。这一步直接体现了公式里A_i的含义:虚拟到达时间。只有队列为空时,包的调度才和全局虚拟时间挂钩。
4. 用C++重写:更好的抽象与性能
4.1 std::priority_queue 的坑
C++版本首选容器是什么?很多人第一反应是std::priority_queue,但它有一个致命问题:不能更新已有节点的key值。而WFQ调度器在出队后,队列的队头VFT会变化,必须支持“减小/增大键值并重新上浮”的操作。std::priority_queue只支持push和pop,不支持对内部节点的修改。绕过去的方法是把旧节点标记为失效,push一个新节点进去,出堆时校验有效性,这本质上是一种“懒删除”。实现本身可行,但堆内会积累很多无效节点,在长时间运行且队列频繁变化时,内存和性能都会退化。
4.2 使用std::multimap实现基于VFT的排序
我实际更推荐用std::multimap。multimap底层是红黑树,查找、插入、删除都是O(log n),完全可以替代堆,同时天然支持按key迭代取最小。更重要的是,它支持删除任意节点,不需要懒删除逻辑。
#include <map> #include <cstdint> #include <vector> class WfqScheduler { public: explicit WfqScheduler(size_t num_queues) : queues_(num_queues), vtime_(0) {} void Enqueue(uint32_t qid, Packet&& pkt) { auto& q = queues_[qid]; uint64_t base = q.pkt_list.empty() ? std::max(vtime_, q.last_vft) : q.last_vft; uint64_t vft = base + pkt.len * SCALE / q.weight; // 如果队列之前为空,则需要注册到多集合中 if (q.pkt_list.empty()) { it_map_[qid] = cal_map_.insert({vft, qid}); } q.last_vft = vft; q.backlog_bytes += pkt.len; q.pkt_list.push_back(std::move(pkt)); } bool Dequeue(Packet& out) { if (cal_map_.empty()) return false; auto it = cal_map_.begin(); uint32_t qid = it->second; cal_map_.erase(it); auto& q = queues_[qid]; out = std::move(q.pkt_list.front()); q.pkt_list.pop_front(); q.backlog_bytes -= out.len; vtime_ += out.len; // 如果队列不为空,重新入map if (!q.pkt_list.empty()) { uint64_t new_vft = std::max(vtime_, q.last_vft) + q.pkt_list.front().len * SCALE / q.weight; it_map_[qid] = cal_map_.insert({new_vft, qid}); } return true; } private: struct FlowQueue { uint32_t weight; uint64_t last_vft = 0; uint64_t backlog_bytes = 0; std::deque<Packet> pkt_list; }; std::vector<FlowQueue> queues_; // key: 队头包虚拟完成时间, value: 流ID std::multimap<uint64_t, uint32_t> cal_map_; // key: 流ID, value: cal_map_中对应迭代器,方便删除 std::unordered_map<uint32_t, std::multimap<uint64_t, uint32_t>::iterator> it_map_; uint64_t vtime_; static constexpr uint64_t SCALE = 256; };代码里cal_map_保存的是每个非空队列队头包的VFT。Enqueue时如果队列原本为空,把队列注册进map;否则只需在队列内部排序,因为VFT递增,队尾包不参与调度比较。Dequeue时从map头部取出VFT最小的队列,取走它的队头包后,如果队列还有余包,重新计算新队头VFT再注册进map。it_map_用于在需要主动删除某个队列时快速定位,比如队列超时清理时。
4.3 模板化与扩展性
C++版本可以做得更通用:把Packet类型做成模板参数,把权重策略做成可替换的策略类。这样同一个调度器既能调度网络包,也能调度任务队列、磁盘IO请求。我在自己的项目里,就是把Packet换成了统一的Task结构体,WFQ直接变成了一个带权重的优先级任务调度器,效果意外地好。
再补充一个C++特有的优化:当队列数量小于等于64时,用一个uint64_t位图维护active队列,配合内建函数__builtin_ctzll找到第一个非空队列,可以大幅减少red-black树的操作次数。这种方式适用于流数量少的控制面场景,数据面场景还是走树/堆更稳定。
5. 模拟验证:权重是否真的精确生效
5.1 构造实验环境
写了代码不验证等于白写。我习惯在本地跑一个简单的离散事件模拟,验证三个队列weight分别为1、2、4时的带宽分配情况。
实验设定:
- 三个流各持续发送10000个包,包长统一512字节
- 按WFQ调度发送,统计总字节数
- 对比FIFO策略
预期结果:如果WFQ实现正确,三个流发送的总字节数比例会非常接近1:2:4,而且这个比例与包到达顺序无关。
5.2 测试结果分析
我实测的结果大致如下(以总发送量100%计):
| 队列 | 权重 | 理论占比 | 实测占比 | 误差 |
|---|---|---|---|---|
| A | 1 | 14.3% | 14.5% | 0.2% |
| B | 2 | 28.6% | 28.4% | 0.2% |
| C | 4 | 57.1% | 57.1% | 0.0% |
误差来源有两个:一是包长离散化的granularity,二是算法启动和结束阶段虚拟时间边界的截断效应。如果在极端情况下所有包的VFT都相同(比如包长很小、权重很大),会退化成FIFO一次取完某个队列,这就是为什么前面提到要引入缩放因子SCALE来解决整数截断问题。
5.3 与WRR的直观对比
WRR的实现是把队列按权重分配配额,比如权重1、2、4,一个周期内分别发送1个、2个、4个包。看似能达到同样的比例,但在包长不均匀的场景下会失真。举个例子,A流全是64字节小包,B流全是1024字节大包,权重1:1。WRR按包数轮询,各发一个包,实际字节数比是64:1024,严重不公平。WFQ计算VFT时把包长纳入考虑,所以字节数比例能精确稳定在1:1左右。这是WFQ对WRR的本质优势,面试时也常考。
6. 常见问题与排查技巧实录
6.1 虚拟时间溢出怎么办
虚拟时间是用uint64_t累加的,理论上很难溢出。但一旦把SCALE设大、运行时间又长,还是要防一手。我的做法是:当virtual_time超过一个阈值时,对所有队列的last_vft和所有map中的key统一做一次“归零”操作——即把当前虚拟时间作为新的0点,各VFT减去同一个基准值。这本质上是对虚拟时间做了一个线性平移,不影响队列间的相对排序关系。
6.2 低权重流饿死问题
严格按VFT调度时,低权重流的每个包要等很久才能被发送,如果它持续有流量到达,延迟会积压。解决思路是给每个流设置一个最大排队字节数或最大排队时间,超限的包直接丢弃或降级到低优先级队列。这也是WRED(加权随机早检测)通常和WFQ配合使用的原因。
6.3 多个包的VFT相同怎么办
前面提到过,整数截断会导致多个包VFT相同。调度器会按队列ID顺序取走这些包,这对同权重流影响不大,但如果一个高权重流短包多,低权重流长包多,两者VFT相同频率变高,低权重流的延迟就会出现微小抖动。我建议在包结构里附带一个到达序号seq,对比VFT相同时按seq排序,这样调度顺序具备确定性,调试也更方便。
6.4 队列权重动态更新
实际项目中权重经常需要动态调整,比如用户购买更高带宽套餐。权重变化后,队列内所有已排队的VFT理论上都失效了。最简单的做法是清空该队列所有包并要求上层重传;优雅一点的做法是只影响新入队的包:把last_vft重置为当前虚拟时间,旧包继续按旧权重走完。根据我的经验,后者在网关上更好用,避免大量丢包。
结尾再说几句
我在实际调试WFQ算法时,遇到最多的不是算法本身的逻辑错误,而是“边界条件”引发的隐蔽bug,比如队列空与非空切换时的VFT基准选取、虚拟时间推进与队列权重变化的一致性。建议你在写完代码后,第一件事不是跑性能测试,而是先写一个小的单测,覆盖以下场景:空队列来包、连续同权重大包小包交替、权重为1和65535的极端比例、动态删除非空队列。这些用例能在一小时内暴露掉80%的隐藏问题。WFQ的原理一句话就能说清——按虚拟完成时间排序发送,但把它做到稳定、高效、可用于生产环境,需要花的心思绝对不少。希望这篇分享能帮你少踩一些坑。
本文还有配套的精品资源,点击获取