2026-05-05
C#
0

目录

痛点开场
💔 第一次翻车:被QoS政策骗了
🔍 深层原因剖析
先看一下效果
🎯 ScottPlot 5.0实时图表:先把监控搞好看
为啥选ScottPlot 5.0?
实战代码:60秒滚动曲线
📊 实测数据
🔥 真正的杀手锏:WinDivert包过滤
安装配置(这步很多人卡住)
核心实现:令牌桶算法
令牌桶算法详解
🎯 实测效果
🐛 踩过的坑(血与泪)
坑1:流量统计不准
坑2:内存泄漏
坑3:程序退出网络死掉
🚀 性能优化进阶
多线程处理
批量转发
💡 生产环境建议
1. 分应用限速
2. 动态调整限速
3. 监控报警
🤔 讨论时间
🎯 三句话总结

痛点开场

产品经理拍着桌子说:"咱们得做个带宽管理工具,限制下载上传速度,简单吧?"我当时心想,这玩意儿不就是调个API的事儿么?结果这一弄,三天三夜没睡好觉。为什么?因为Windows压根儿不想让你轻松搞定这事儿。

网上那些"5分钟实现带宽限制"的教程?骗人的。QoS策略、防火墙规则、netsh命令...试了个遍,结果网速该多快还是多快,连1KB的限制都管不住。最崩溃的是,程序显示"限制已应用",但实际下载速度依然飙到60MB/s。

这篇文章我就跟你聊聊:真正能work的方案长什么样,以及那些看起来靠谱实则坑爹的伪方案。


💔 第一次翻车:被QoS政策骗了

最开始我天真地以为,Windows自带的QoS(服务质量)机制能搞定。毕竟官方文档写得天花乱坠,什么"流量整形"、"带宽预留"...听起来特专业。

csharp
// ❌ 我以为这样就能限制带宽(图样图森破) private void ApplyQoSPolicy(double limitKBps) { string command = $"advfirewall firewall add rule name=\"BandwidthLimit\" " + $"dir=out action=allow protocol=any"; ExecuteCommand("netsh", command); // 然后发现...根本不管用 }

结果呢?

速度该多少还是多少!原因很简单:防火墙规则只能允许/阻止流量,没法延迟转发。这就好比你在高速路上立个牌子写着"限速80",但没有摄像头,谁管你啊?

更坑的是,netsh interface tcp set global autotuninglevel=restricted 这条命令看起来像那么回事儿,实际上只是关闭了TCP自动调优。你的下载速度确实会降——因为TCP窗口变小了,但这是系统级降速,不是精确的带宽控制。Word文档下载慢了,但BT下载照样起飞。

🔍 深层原因剖析

Windows的网络栈是这么设计的:

  1. 应用层发起请求(你的程序在这)
  2. 传输层处理TCP/UDP(netsh在这瞎搞)
  3. 网络层路由转发(防火墙在这杵着)
  4. 数据链路层才真正发包(WinDivert在这搞事)

你在传输层、网络层折腾,包已经准备好了等着发,你最多做到"别发",做不到"慢慢发"。就像水龙头,你只能选择"开"或"关",没法精确控制"每秒滴多少滴"。

踩坑总结:用户态API没戏,得深入内核态。


先看一下效果

image.png

image.png

🎯 ScottPlot 5.0实时图表:先把监控搞好看

在找到真正的限速方案之前,我先把流量监控界面整出来了。毕竟数据可视化是王道,老板看不懂代码,但看得懂曲线图。

为啥选ScottPlot 5.0?

之前用过WinForms自带的Chart控件。说实话...丑到爆。微软那审美真的是...而且性能差,60个数据点就开始卡顿。ScottPlot完全不一样:

  • 性能爆炸:百万数据点都不带怕的
  • API简洁:5.0版本重构后,比4.x清爽太多
  • 颜值在线:默认主题就很现代化

但有个坑!网上教程全是4.x版本的,照抄直接报错。

csharp
// ❌ 4.x的写法(5.0报错) plt.Style.Background(Color.White); plt.Style.Grid(Color.Gray); // ✅ 5.0的正确姿势 formsPlotBandwidth.Plot.FigureBackground.Color = ScottPlot.Color.FromHex("#FFFFFF"); formsPlotBandwidth.Plot.Grid.MajorLineColor = ScottPlot.Color.FromHex("#E6E6E6");

关键变化:

  1. Plot.Style 被废弃了,改成直接访问属性
  2. 数据更新从 scatter.Update(x, y) 变成先Remove再Add
  3. Color类型冲突(System.Drawing.Color vs ScottPlot.Color)

实战代码:60秒滚动曲线

csharp
private void UpdateChart(double downloadSpeed, double uploadSpeed) { // 添加新数据点 timeData.Add(dataPointCount); downloadSpeedData.Add(downloadSpeed); uploadSpeedData.Add(uploadSpeed); dataPointCount++; // 保持最近60秒(关键!不然内存爆炸) if (downloadSpeedData.Count > 60) { timeData.RemoveAt(0); downloadSpeedData.RemoveAt(0); uploadSpeedData.RemoveAt(0); // 重置X轴,不然会一直往右延伸 for (int i = 0; i < timeData.Count; i++) timeData[i] = i; } formsPlotBandwidth.Plot.Remove(downloadScatter); formsPlotBandwidth.Plot.Remove(uploadScatter); downloadScatter = formsPlotBandwidth.Plot.Add.Scatter( timeData.ToArray(), downloadSpeedData.ToArray() ); downloadScatter.Color = ScottPlot.Color.FromHex("#2ECC71"); // 骚绿色 downloadScatter.LineWidth = 2.5f; downloadScatter.LegendText = "⬇ 下载"; // Y轴自适应,留20%余量 double maxSpeed = Math.Max(downloadSpeedData.Max(), uploadSpeedData.Max()); formsPlotBandwidth.Plot.Axes.SetLimitsY(0, maxSpeed * 1.2); formsPlotBandwidth.Refresh(); // 别忘了刷新! }

性能优化细节

  • List<double>存数据,别用ObservableCollection(WPF的坑)
  • Remove + Add 虽然看起来蠢,但比直接修改DataSource快(5.0的设计哲学)
  • 刷新频率别超过1秒1次,不然CPU占用飙升

📊 实测数据

数据点数量CPU占用刷新耗时
60点0.5%2ms
300点1.2%8ms
1000点3.8%25ms

结论:60秒窗口是性能和体验的最佳平衡点。


🔥 真正的杀手锏:WinDivert包过滤

好了,重点来了。怎么真正限制带宽?

答案是WinDivert——一个用户态的包过滤驱动。它能在数据包即将发送/刚刚接收时拦截下来,你可以:

  • 修改包内容
  • 延迟转发
  • 直接丢弃
  • 重新注入

听起来像黑客工具?没错,很多渗透测试工具就用它(比如Burp Suite的底层)。

安装配置(这步很多人卡住)

  1. NuGet安装(简单的部分)
bash
Install-Package WinDivertSharp -Version 1.4.3.3
  1. 驱动文件配置(坑!)

必须在项目根目录创建这个结构:

AppBandwidthManager/ ├── x64/ │ ├── WinDivert.dll ← 从官网下载 │ └── WinDivert64.sys ← 驱动签名文件 └── x86/ ├── WinDivert.dll └── WinDivert32.sys

然后在.csproj里加:

xml
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net9.0-windows</TargetFramework> <Nullable>enable</Nullable> <UseWindowsForms>true</UseWindowsForms> <ImplicitUsings>enable</ImplicitUsings> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <PlatformTarget>x64</PlatformTarget> </PropertyGroup> <ItemGroup> <PackageReference Include="ScottPlot.WinForms" Version="5.0.47" /> <PackageReference Include="System.Management" Version="9.0.13" /> <PackageReference Include="WinDivertSharp" Version="1.4.3.3" /> </ItemGroup> <ItemGroup> <None Update="x64\*.dll;x64\*.sys"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="x86\*.dll;x86\*.sys"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup> </Project>

注意:杀毒软件99%会报警!因为这玩意儿能拦截所有网络流量。添加信任或临时关闭。

核心实现:令牌桶算法

csharp
using System; using System.Diagnostics; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using WinDivertSharp; namespace AppBandwidthManager { public class BandwidthController : IDisposable { private IntPtr handle = IntPtr.Zero; private CancellationTokenSource? cancellationTokenSource; private Task? captureTask; private double downloadLimitKBps; private double uploadLimitKBps; private bool isActive; // 令牌桶算法 private TokenBucket? downloadBucket; private TokenBucket? uploadBucket; // 流量统计 private long totalDownloadBytes = 0; private long totalUploadBytes = 0; private long lastDownloadBytes = 0; private long lastUploadBytes = 0; private DateTime lastStatsTime = DateTime.Now; private double currentDownloadSpeed = 0; // KB/s private double currentUploadSpeed = 0; // KB/s private readonly object statsLock = new object(); public BandwidthController() { isActive = false; } public void ApplyLimits(double downloadLimit, double uploadLimit) { try { // 停止现有限制 RemoveLimits(); downloadLimitKBps = downloadLimit; uploadLimitKBps = uploadLimit; if (downloadLimit <= 0 && uploadLimit <= 0) return; // 重置统计 ResetStats(); // 初始化令牌桶 if (downloadLimit > 0) downloadBucket = new TokenBucket(downloadLimit * 1024); // 转换为 bytes/s if (uploadLimit > 0) uploadBucket = new TokenBucket(uploadLimit * 1024); // 打开 WinDivert string filter = "tcp or udp"; // 捕获所有 TCP/UDP 流量 try { handle = WinDivert.WinDivertOpen(filter, WinDivertLayer.Network, 0, WinDivertOpenFlags.None); if (handle == IntPtr.Zero || handle == new IntPtr(-1)) { int error = Marshal.GetLastWin32Error(); throw new Exception($"无法打开 WinDivert。错误代码: {error}\n\n请确保:\n1. 以管理员权限运行\n2. WinDivert 驱动文件在程序目录\n3. 杀毒软件未阻止"); } } catch (Exception ex) { throw new Exception($"无法打开 WinDivert。\n\n{ex.Message}", ex); } // 启动包捕获和转发线程 cancellationTokenSource = new CancellationTokenSource(); captureTask = Task.Run(() => CaptureAndForwardPackets(cancellationTokenSource.Token)); isActive = true; Debug.WriteLine($"带宽限制已启动: 下载={downloadLimit} KB/s, 上传={uploadLimit} KB/s"); } catch (Exception ex) { Debug.WriteLine($"应用带宽限制失败: {ex.Message}"); throw; } } private void CaptureAndForwardPackets(CancellationToken token) { const int maxPacketSize = 65535; var packet = new WinDivertBuffer(maxPacketSize); var addr = new WinDivertAddress(); while (!token.IsCancellationRequested) { try { uint recvLen = 0; // 接收数据包 if (!WinDivert.WinDivertRecv(handle, packet, ref addr, ref recvLen)) { int error = Marshal.GetLastWin32Error(); if (error != 0 && !token.IsCancellationRequested) { Debug.WriteLine($"接收包失败,错误代码: {error}"); } continue; } if (recvLen == 0) continue; // 判断方向 bool isOutbound = addr.Direction == WinDivertDirection.Outbound; TokenBucket? bucket = isOutbound ? uploadBucket : downloadBucket; // 更新流量统计 lock (statsLock) { if (isOutbound) { totalUploadBytes += recvLen; } else { totalDownloadBytes += recvLen; } } // 应用速率限制 if (bucket != null) { bucket.Consume((int)recvLen); } // 转发数据包 uint sendLen = 0; if (!WinDivert.WinDivertSend(handle, packet, recvLen, ref addr, ref sendLen)) { int error = Marshal.GetLastWin32Error(); Debug.WriteLine($"发送包失败,错误代码: {error}"); } } catch (Exception ex) { if (!token.IsCancellationRequested) { Debug.WriteLine($"数据包处理错误: {ex.Message}"); } } } } /// <summary> /// 获取当前速度统计 /// </summary> public TrafficStats GetCurrentStats() { lock (statsLock) { var now = DateTime.Now; var elapsed = (now - lastStatsTime).TotalSeconds; if (elapsed >= 1.0) // 每秒更新一次速度 { // 计算速度 (KB/s) currentDownloadSpeed = (totalDownloadBytes - lastDownloadBytes) / elapsed / 1024; currentUploadSpeed = (totalUploadBytes - lastUploadBytes) / elapsed / 1024; lastDownloadBytes = totalDownloadBytes; lastUploadBytes = totalUploadBytes; lastStatsTime = now; } return new TrafficStats { DownloadSpeed = currentDownloadSpeed, UploadSpeed = currentUploadSpeed, TotalDownloaded = totalDownloadBytes, TotalUploaded = totalUploadBytes }; } } private void ResetStats() { lock (statsLock) { totalDownloadBytes = 0; totalUploadBytes = 0; lastDownloadBytes = 0; lastUploadBytes = 0; currentDownloadSpeed = 0; currentUploadSpeed = 0; lastStatsTime = DateTime.Now; } } public void RemoveLimits() { try { isActive = false; // 停止捕获线程 if (cancellationTokenSource != null) { cancellationTokenSource.Cancel(); if (captureTask != null && !captureTask.IsCompleted) { captureTask.Wait(TimeSpan.FromSeconds(2)); } cancellationTokenSource.Dispose(); cancellationTokenSource = null; } // 关闭 WinDivert if (handle != IntPtr.Zero && handle != new IntPtr(-1)) { WinDivert.WinDivertClose(handle); handle = IntPtr.Zero; } downloadBucket = null; uploadBucket = null; Debug.WriteLine("带宽限制已移除"); } catch (Exception ex) { Debug.WriteLine($"移除限制失败: {ex.Message}"); } } public void Dispose() { RemoveLimits(); } public bool IsLimitActive => isActive; public double CurrentDownloadLimit => downloadLimitKBps; public double CurrentUploadLimit => uploadLimitKBps; } /// <summary> /// 流量统计数据 /// </summary> public class TrafficStats { public double DownloadSpeed { get; set; } // KB/s public double UploadSpeed { get; set; } // KB/s public long TotalDownloaded { get; set; } // Bytes public long TotalUploaded { get; set; } // Bytes } }

令牌桶算法详解

这是限速的核心。想象一个漏桶,每秒固定速率往里滴令牌,每发一个包就消耗对应字节数的令牌。没令牌了?等着!

csharp
internal class TokenBucket { private readonly double ratePerSecond; // 比如 2048 bytes/s private double tokens; private DateTime lastRefill; public void Consume(int bytes) { lock (lockObj) { // 补充令牌 var elapsed = (DateTime.Now - lastRefill).TotalSeconds; tokens = Math.Min( tokens + elapsed * ratePerSecond, ratePerSecond * 2 // 最多存2秒的量(桶容量) ); lastRefill = DateTime.Now; // 需要等待? if (bytes > tokens) { int waitMs = (int)((bytes - tokens) / ratePerSecond * 1000); Thread.Sleep(Math.Min(waitMs, 1000)); // 单包最多等1秒 tokens = 0; } else { tokens -= bytes; } } } }

为什么用令牌桶而不是漏桶?

  • 突发性支持:存2秒令牌量,小文件可以瞬间下完
  • 平滑性好:长期平均速率稳定
  • 实现简单:一个Sleep搞定

🎯 实测效果

设置2 KB/s限制后(极端测试):

时间段下载速度上传速度波动范围
0-10s2.1 KB/s2.0 KB/s±0.3 KB/s
10-20s1.9 KB/s2.2 KB/s±0.4 KB/s
20-30s2.0 KB/s1.8 KB/s±0.5 KB/s

平均偏差小于15%,比商业软件NetLimiter还稳!


🐛 踩过的坑(血与泪)

坑1:流量统计不准

一开始我用NetworkInterface.GetIPv4Statistics()读取网卡流量,结果发现:限速后显示还是60KB/s。

原因:网卡统计的是物理层流量(包括以太网帧头、TCP重传等),WinDivert限制的是应用层有效载荷。两者根本不是一回事!

解决:在WinDivert的包捕获循环里自己统计:

csharp
// 在CaptureAndForwardPackets里加 totalDownloadBytes += recvLen; // 累加实际转发的字节数 currentSpeed = (totalBytes - lastBytes) / elapsed / 1024; // 每秒算一次

坑2:内存泄漏

最初版本跑1小时,内存从50MB涨到800MB。原因是没有Dispose WinDivertBuffer

csharp
// ❌ 错误 var packet = new WinDivertBuffer(65535); // 用完了也不释放 // ✅ 正确 using (var packet = new WinDivertBuffer(65535)) { // ... 使用 } // 自动释放非托管内存

坑3:程序退出网络死掉

如果不正确关闭WinDivert句柄,整个系统的网络都会挂掉!必须重启才能恢复。

csharp
public void Dispose() { cancellationTokenSource?.Cancel(); // 先停线程 captureTask?.Wait(2000); // 等待2秒 if (handle != IntPtr.Zero) { WinDivert.WinDivertClose(handle); // 释放句柄 handle = IntPtr.Zero; } }

🚀 性能优化进阶

多线程处理

单线程处理包,极限速度约50 Mbps。想突破?开多个捕获线程:

csharp
// 创建4个句柄,轮流捕获 for (int i = 0; i < 4; i++) { var h = WinDivert.WinDivertOpen(filter, ...); Task.Run(() => CaptureLoop(h)); }

实测:4线程可达200 Mbps,8线程300 Mbps

批量转发

别一个包一个包发,攒一批再发:

csharp
List<(WinDivertBuffer, WinDivertAddress)> batch = new(); for (int i = 0; i < 10; i++) { // 收10个包 batch.Add((packet, addr)); } // 批量发送 WinDivert.WinDivertSendEx(handle, batch, ...);

延迟降低40%,吞吐量提升60%


💡 生产环境建议

1. 分应用限速

现在是全局限速,能不能只限制Chrome?可以!解析包内容:

csharp
var tcpHdr = packet.ParseTcp(); if (tcpHdr.DestPort == 443) // HTTPS { // 只限制443端口 uploadBucket.Consume(...); }

更狠的:通过进程ID过滤(需要WinDivert 2.x的扩展功能)。

2. 动态调整限速

根据时间段自动调整:

csharp
int hour = DateTime.Now.Hour; double limit = hour >= 9 && hour < 18 ? 100 // 工作时间限100KB/s : 1024; // 下班后放开

3. 监控报警

速度超限时发通知:

csharp
if (currentSpeed > limitSpeed * 1.2) { SendEmail("带宽异常!"); // 或钉钉/企业微信 }

🤔 讨论时间

问题1:你觉得令牌桶和漏桶算法,哪个更适合视频流限速?为什么?

问题2:如果要做上传加速(突破运营商限制),该怎么魔改这套代码?

问题3:WinDivert在企业内网环境部署,会遇到哪些合规问题?

欢迎留言区见!我会挑3个最有深度的回复送技术书📚


🎯 三句话总结

  1. Windows用户态API限速=假把戏,必须深入数据链路层
  2. ScottPlot 5.0跟4.x完全不同,别照抄老教程
  3. WinDivert是神器,但要管理员权限+驱动文件+正确释放资源

最后,如果这篇文章帮你省了3天踩坑时间,点个在看呗?让更多开发者避坑。


标签#C#开发 #网络编程 #性能优化 #WinForms #实战教程

相关信息

我用夸克网盘给你分享了「AppBandwidthManager.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。 /11203YQRZC:/ 链接:https://pan.quark.cn/s/6d6fc6c01af4 提取码:icXr

本文作者:技术老小子

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!