简介:这是一份以C/C++实现WFQ(加权公平队列)算法的完整工程源码,适合学习网络调度、服务质量(QoS)或准备相关课程设计/毕设的读者。资源覆盖发送端、接收端与路由器转发三部分,核心展示了如何按源地址、目的地址和服务类型完成数据包分类,并结合权重分配与循环调度保证高优先级流和低延迟业务的带宽公平性。压缩包共344个文件,约14.98MB,其中55个cpp与5个c承担主要算法实现,62个h提供接口声明,其余obj、pch、exe、dsp、dsw等为编译中间产物和工程配置,便于复现构建与对比调试。已有958人学习,适合需要结合源码理解WFQ与传统FIFO差异的读者。除调度主流程外,包内还包含doc说明、rc资源脚本及Visual C++工程文件,可由此梳理队列结构、权重更新、收发线程协作等细节,也可作为网络实验或二次开发的基础。
1. WFQ 算法实现(C/C++):限速场景里绕不开的那杆公平秤
做网关限速、本地网络仿真或给多租户做带宽隔离时,第一个被点名的调度算法通常就是 WFQ(Weighted Fair Queuing,加权公平排队)。它的核心思路很朴素:把流量按流拆成多个队列,再按你设定的权重和虚拟完成时间决定先发谁的包,结果是大流拿大带宽、小流不饿死,整体比例还能受控。适合准备自己写流量整形模块、在模拟环境里验证 QoS、或想在 C/C++ 项目里摆脱对现成队列库依赖的人。这一篇不讲 PPT,直接给可编译的最小实现,参数怎么定、坑在哪都会讲清楚,新手能照敲,熟手能直接拿去改。
2. 先弄懂 WFQ 在排什么:虚拟时间与完成时间的来龙去脉
2.1 从 FIFO 到按流排队:公平队列想解决的两件事
动手写代码之前,得先明白 WFQ 到底替代了什么。最常见的 FIFO 先进先出队列,所有流量混在一个队里,排队延迟由队里的总量决定。一旦某条大流持续占满出口,后面每一条小流的包都得跟着等,延迟和抖动会一起放大;出口拥塞严重时,小流的包还可能被直接挤掉。这就是第一类问题:共享队列让大流污染小流。
有人会想用严格优先级队列来救场,高优先级先走。但它的前提是“事先能准确判断谁高谁低”,一旦低优先级流里混着重要业务,或者高优先级流发包太猛,低优先级会长时间得不到服务,这就是调度里常说的饿死(starvation)。所以公平队列换了思路:每条流有自己的队列,调度器按权重比例决定服务顺序,而不是让一个队排到底。WFQ 把“分类”和“调度”拆成两件事——分类模块决定包属于哪个流队列,调度模块决定轮到哪个队列发包,权重则决定每个队列分到的出口带宽比例。这两件事解耦后,队列之间互不污染,大流小流都有了兜底。
2.2 虚拟时间:公平排队这杆秤怎么走
WFQ 的理论根基是 GPS(Generalized Processor Sharing,广义处理器共享)模型:理想情况下,所有活动流同时被服务,服务速率严格与权重成正比,像流体一样各走各的。但真实的网络包是离散的,一次只能发一个,所以要把流体模型离散化。离散化的抓手就是“虚拟完成时间”——给每个包算一个数值,谁小谁先发。
虚拟时间的落地方式有两个层次。严格 WFQ 的虚拟时间要按 GPS 的瞬时速率积分推进,每来一个包都要维护全局活动流权重和,代码复杂不少。我一般建议第一版先用自钟近似(SCFQ):系统虚拟时间直接用当前堆顶、也就是所有排队包中最小完成时间的那个值来推进。入队新包时,用max(本流上次完成时间, 当前虚拟时间) + 包长 / 权重作为本包完成时间。SCFQ 行为稳定、代码量小,代价是端到端延迟上界比严格 WFQ 略大,对常见的整形场景这个差别基本感知不到。等跑通后再考虑升级到严格版本。
还需要强调的是,这个虚拟时间不是现实秒,它是一个单调递增的排序计数器,唯一的用途是让不同流的完成时间可以相互比较。计数器怎么推进、什么时候归零,是后面最容易翻车的地方。
2.3 完成时间公式拆解与整数化处理
完成时间公式长这样:
F(i,k) = max( F(i,k-1), V(a(i,k)) ) + L(i,k) / w(i)
四个符号分别代表:本流上一个包的完成时间、当前虚拟时间、当前包长、本流权重。max的语义很关键——队列非空时,新包排在本流旧包后面;队列空了,新包就跟全局虚拟时间对齐,而不是继承一个过期很久的旧值。C 语言里没有浮点型权重这一说,落地时要把除法整数化,否则权重为 3 这类数值会截断出离谱结果。最小实现是这样的:
#define FIX_SHIFT 20 /* 按固定点方式计算一个包的服务时长(虚拟单位) */ static uint64_t wfq_service_time(uint32_t len, uint32_t weight) { /* 先左移再除, 保证 weight 较大时结果不为 0 */ return ((uint64_t)len << FIX_SHIFT) / weight; } static uint64_t wfq_finish_time(uint64_t last_finish, uint64_t vtime, uint32_t len, uint32_t weight) { uint64_t start = (last_finish > vtime) ? last_finish : vtime; return start + wfq_service_time(len, weight); }代码里的FIX_SHIFT是把包长先放大 2^20 倍再做除法,相当于把小数点藏进整数里。这样权重为 1 的流也能得到非零的服务时长,不会因为整数除法直接截成 0。两个函数是纯计算,不涉及任何状态修改,建议单独放一个文件,后续做单元测试也方便。这个公式是整个 WFQ 的核心,后面所有代码都是围绕它转的。
3. 用 C 把 WFQ 调度器写出来:从结构体到入队出队
3.1 包与流队列的结构体设计
调度器操作的对象是“流队列”和“包”,结构体先定下来。包里需要带长度和完成时间,流里需要带权重、队头队尾指针、当前积压量以及本流最近一次完成时间:
typedef struct wfq_pkt { uint32_t len; uint32_t flow_id; /* 属于哪条流, 统计和测试要用 */ uint64_t finish_ts; /* 虚拟完成时间, 固定点整数 */ void *data; struct wfq_pkt *next; } wfq_pkt_t; typedef struct wfq_flow { uint32_t flow_id; uint32_t weight; /* 权重, 越大分到的带宽比例越高 */ uint64_t last_finish; /* 本流最近一个包的完成时间 */ wfq_pkt_t *head, *tail; /* 流内 FIFO 队列 */ uint32_t qbytes, qpkts; /* 当前排队字节数和包数, 用于限长 */ uint32_t drop_cnt; } wfq_flow_t;注意这里没有“优先级”字段。WFQ 的服务顺序完全由finish_ts决定,权重已经体现在完成时间的计算里了,不需要再单独排优先级。流内队列用普通的单向链表就能满足先入先出,没必要上复杂结构。qbytes和qpkts是为限长服务的,章节 4.4 会讲到怎么用它们做丢包决策。
3.2 流量分类:五元组 Hash 到流队列
调度器要处理多条流,第一步是把进来的包归类到某个流。常见做法是拿五元组做散列:源 IP、目的 IP、源端口、目的端口、协议号。为了让位与代替取模,流表大小必须取 2 的幂:
#define MAX_FLOWS 1024 /* 必须是 2 的幂 */ static wfq_flow_t *g_flow_table[MAX_FLOWS]; static uint32_t wfq_flow_hash(uint32_t sip, uint32_t dip, uint16_t sp, uint16_t dp, uint8_t proto) { uint32_t h = sip; h = h * 31 + dip; h = h * 31 + sp; h = h * 31 + dp; h = h * 31 + proto; h ^= h >> 16; /* 高位混入低位, 减少规律端口的干扰 */ return h & (MAX_FLOWS - 1); } wfq_flow_t *wfq_flow_lookup(uint32_t sip, uint32_t dip, uint16_t sp, uint16_t dp, uint8_t proto) { return g_flow_table[wfq_flow_hash(sip, dip, sp, dp, proto)]; }这个哈希函数用的是 31 倍乘法,是经典的选择;h ^= h >> 16是为了把高位信息混进低位。很多流量的端口号是有规律的,比如固定从 53 或者 443 发起,不加这一步会把大量连接压到少数几个槽里。MAX_FLOWS取 1024 在演示里够用,真实环境按预期并发流数来定,后面章节会给建议。这里假设每个槽只有一条流在跑,冲突的后果留到第 5 章专门说。
3.3 入队:算完成时间、挂队尾、维护活动堆
入队要干四件事:算完成时间、挂到流队列尾部、更新流的累计状态、把流放进活动堆。有一个细节很容易被漏掉——队列原本非空时,队头没变,堆的键也不用动,只有“从空变非空”才需要入堆:
static struct wfq_sched { uint64_t vtime; /* 当前虚拟时间 */ } g_sched; void wfq_enqueue(wfq_flow_t *f, wfq_pkt_t *pkt) { uint64_t svc = ((uint64_t)pkt->len << FIX_SHIFT) / f->weight; uint64_t start = (f->last_finish > g_sched.vtime) ? f->last_finish : g_sched.vtime; int was_empty = (f->head == NULL); pkt->finish_ts = start + svc; /* 核心: 完成时间掐在这里 */ pkt->flow_id = f->flow_id; pkt->next = NULL; if (f->tail) f->tail->next = pkt; else f->head = pkt; f->tail = pkt; f->last_finish = pkt->finish_ts; f->qbytes += pkt->len; f->qpkts++; if (was_empty) { /* 队列从空变非空, 该流进入活动堆, 键为队头 finish_ts */ wfq_heap_insert(f); } }was_empty必须在挂链之前取,否则会被自己改成非空。入堆的键是“队头包的完成时间”,因为调度器只比较每个流最前面的那个包。流原本有包时,新包排在队尾,队头没变,堆键自然不用更新——很多实现在这里不分青红皂白地更新堆,白白多一次 O(log N) 操作,流多的时候性能差异就出来了。
3.4 出队:堆顶调度、虚拟时间推进、空队列重置
出队逻辑正好是入队的镜像。从堆里取出完成时间最小的流,弹掉它的队头,然后把虚拟时间推进到刚发出的这个包的完成时间——这是 SCFQ 近似的关键一步。队列变空时要移出活动堆,并且把last_finish清零,这是高频 bug 点:
wfq_pkt_t *wfq_dequeue(void) { wfq_flow_t *f = wfq_heap_min(); if (!f) return NULL; wfq_pkt_t *pkt = f->head; f->head = pkt->next; if (!f->head) f->tail = NULL; f->qbytes -= pkt->len; f->qpkts--; g_sched.vtime = pkt->finish_ts; /* SCFQ: 虚拟时间推进到刚发出的包 */ if (f->head) { /* 队列还有包, 用新队头的 finish_ts 更新堆键 */ wfq_heap_update(f, f->head->finish_ts); } else { /* 队列空了, 移出活动堆并重置完成时间 */ wfq_heap_remove(f); f->last_finish = 0; } return pkt; }为什么空队列必须重置last_finish?因为如果不重置,某条流断流 10 秒后重新发包,新包的start = max(过期的 last_finish, vtime),会被那个旧值直接拉高,排到所有老流后面,表现就是“重连流量半天抢不到带宽”。堆用标准二叉堆数组实现就行,每个活动流占一个槽,复杂度 O(log N)。活动流少于 64 条时,线性扫描一个活跃链表反而比堆更快,因为 cache 友好;超过这个量再上堆不迟。
4. 把参数调到能上线:权重、流表容量与队列长度的取舍
4.1 权重代表带宽比例,不是优先级
权重决定的是拥塞时各流占用出口带宽的比例。链路容量为 C、活动流权重之和为 S 时,某条流的期望占比约为w / S * C。注意“拥塞时”这个前提——空闲时段流量可以超过自己的比例,这是 work-conserving 调度器的特性,别把它理解成硬性限速。典型配置参考下表:
| 配置 | 流A权重 | 流B权重 | 拥塞时流A占比 |
|---|---|---|---|
| 平均分 | 1 | 1 | 50% |
| 视频优先 | 4 | 1 | 80% |
| 租户SLA | 8 | 1 | 约 88.9% |
实测比例不会那么精确,因为调度粒度是整包而不是字节。一个 1500 字节的大包和一个 64 字节的小包,完成时间计算是按字节算的,但服务动作是整包发出的,包的边界会带来量化偏差。包越大,偏差越明显;所以在真实部署时,权重比例留 10% 左右的余量是常态。
4.2 FIX_SHIFT 的选择:精度和溢出怎么权衡
固定点位数是 WFQ 整数化里最需要算计的参数。FIX_SHIFT太小,最小权重对应的服务时长可能截成 0,完成时间不增长,调度退化成近似先来先服务;FIX_SHIFT太大,累加值接近 64 位上限,跑一段时间就溢出。经验公式:FIX_SHIFT <= 64 - log2(预期排队包总数 * 最大包长 / 最小权重)。大多数场景取 20 到 24 就够用。
/* 溢出上限估算示例 */ #define FIX_SHIFT 20 uint64_t max_svc_per_pkt = ((uint64_t)65535 << FIX_SHIFT) / 1; /* 约 2^36 */这里最坏情况是单包 64KB、权重为 1,单个包服务时长约 2^36。只要排队包总量不超 2^27(约 1.3 亿个),64 位完全撑得住。真要跑极端长时,配合第 5 章的虚拟时间归一化一起用,双保险。
4.3 流表容量:2 的幂、冲突检测与扩容
流表大小必须保持 2 的幂,否则h & (MAX_FLOWS - 1)就不成立。建议值是预期并发流数的 2 到 4 倍。比如一台边缘网关预期撑 500 条并发流,MAX_FLOWS取 1024 或 2048 都合理。哈希冲突导致两个活跃流落进同一个槽是 WFQ 失效的一个隐蔽原因——调度器把两条流当成一条,权重求和后按一个队列服务,公平性直接归零。
我一般会在流条目里加一个引用计数做监控,当某个槽同时命中了不同五元组且都有流量时,把后到的流挪到一个公共低优先级队列,或者返回一个CONFLICT标记让上层决定是否扩容。最省事的错误做法是冲突后让两条流共用队列、权重取两者之和,结果就是大流吞掉小流,表面没报错,实测权重完全失真。
4.4 单流队列长度与丢包策略
WFQ 只负责调度,不负责拥塞控制,所以每条流的队列长度必须限制。常见做法是按字节设上限,比如 2 倍带宽时延积或者 MTU 的固定倍数。超限后的丢包策略直接影响 TCP 表现,参数建议如下:
| 参数 | 建议起点 | 说明 |
|---|---|---|
| 单流限长 | 32~128 个包 | 太短丢包多,太长放大时延 |
| high_th | 队列上限的 80% | 占用率超过后开始丢包 |
| 随机丢包概率 | 5%~10% | 按 TCP 流数量微调 |
纯粹的尾丢会把同一条 TCP 流的多个包一起丢掉,触发全局同步,吞吐出现锯齿。改成超过high_th后随机丢新包,相当于给 TCP 一个温和的拥塞信号,整体吞吐会平滑很多。
5. 常见问题与排坑:实现 WFQ 最容易翻车的五个点
5.1 跑久了虚拟时间溢出,所有新包都排在最后
现象:调度器连续跑几小时后,新到的包完成时间全部大于堆内旧值,老包迟迟清不掉,出队越来越慢,最后看起来像死锁。
原因:全局vtime单调递增,叠加固定点放大后累加更快;超出 64 位有效范围后,比较结果失真。
解决:做基准归一化。设定一个阈值(比如 2^60),当堆顶完成时间超过它时,把全局vtime、所有流的last_finish、所有排队包的finish_ts同时减掉同一个基准值。注意必须整体平移,只调vtime会导致排序关系错乱。
5.2 权重差太大,小权重流的服务时长被截成 0
现象:配置 1000:1 的权重比,权重为 1 的那条流几乎拿不到带宽,但流量本身很小,看着像被饿死。
原因:(len << FIX_SHIFT) / weight是整数除法,当weight大于左移后的len数值时结果就是 0。完成时间不长,这个流等于每轮都在插队,加权公平直接失效。
解决:调整FIX_SHIFT让最小权重时结果仍不小于 1,或者对权重做下限钳制(比如最小 4)。运算全部用uint64_t,不要先把len转成int。
5.3 五元组哈希冲突,两条大流被并成一个队列
现象:配置 4:1 权重,实测流量比变成 1:1,两条大流的速率还各自抖动。
原因:MAX_FLOWS太小或者哈希分布不好,两条活跃流落入同一槽位,调度器把它们当成一条流,权重相加后混着服务。
解决:流表按并发流数的 2 到 4 倍配置;加冲突检测,发现不同五元组落在同一槽且都有流量时,走二级链表或公共队列。排查时打印每个包的flow_id和五元组,看是否有混流。
5.4 断流后重连,第一个包被旧完成时间“记旧账”
现象:某条流停止发送 10 秒后重新上线,前几个包延迟巨大,要等老一批流清空才轮到。
原因:队列变空时last_finish没重置。新包入队时start = max(旧 last_finish, vtime),旧值远大于当前虚拟时间,新包被排到所有老流后面。
解决:出队时队列为空必须把last_finish清零;入队时用max(last_finish, vtime)而不是直接取last_finish,两处一起改才保险。
5.5 队列超限就尾丢,TCP 吞吐呈锯齿
现象:大流测试吞吐忽高忽低像锯齿,小流时延也大幅波动。
原因:单流限长到顶后直接丢新包,多条 TCP 同时超时重传,形成空窗,再一起恢复,周而复始。
解决:队列占用率超过high_th后随机丢一部分新包,而不是全部丢弃。参数调整参考章节 4.4 的表,跑一轮吞吐测试再微调丢包概率。
6. 用最小实验验证公平性:三流四权重能跑出什么结果
调度器写完,先用一个纯内存的最小实验验证,别急着接网卡。构造三条流,权重 4:2:1,各自灌入 1000 个 1500 字节的包,然后一口气出队,统计每条流实际出队的字节数:
int main(void) { uint32_t w[3] = { 4, 2, 1 }; uint64_t out[3] = { 0, 0, 0 }; for (int i = 0; i < 3; i++) { wfq_flow_t *f = wfq_flow_get(i); f->weight = w[i]; for (int j = 0; j < 1000; j++) { wfq_pkt_t *p = wfq_alloc_pkt(1500); wfq_enqueue(f, p); } } wfq_pkt_t *p; while ((p = wfq_dequeue()) != NULL) { out[p->flow_id] += p->len; /* 按包归属的流累计 */ wfq_free_pkt(p); } printf("out bytes: %llu %llu %llu\n", (unsigned long long)out[0], (unsigned long long)out[1], (unsigned long long)out[2]); return 0; }同尺寸包、无到达间隔的理想输入下,输出会接近 4:2:1,但不会完全精确,因为调度粒度是整包。混入不同大小的包之后,比例会更接近按字节的加权比值,这是正常现象。验证方法很简单:把权重改成 8:1 和 1:1 各跑一次,看输出比是否随权重单调变化;再加一条最小权重 1 的配置,确认没有被第 5.2 节的截断坑到。
更接近真实网络的做法是给包加到达时间戳,用泊松到达注入,统计每条流的吞吐和时延分布,这时候第 5 章那几个坑才会真正暴露。我的习惯是每次改动调度器先跑这个最小实验,纯模拟里问题会被放大,不用真去抓包就能定位。这样迭代几版之后,接入真实流量时翻车的概率会小很多,踩过的坑也能在几分钟内复现验证,希望帮到你。
本文还有配套的精品资源,点击获取