为什么把缓冲区从 8KB 调到 1MB,拆分快了,内存却从五十几 MB 涨到两百多 MB?答案不在磁盘,在托管堆:1MB 的 byte[] 越过了 85,000 字节这条线,被丢进大对象堆(LOH)。LOH 不参与压缩,只在 Gen 2 回收时处理——微软官方文档的原话。你以为在优化 IO,其实在给 GC 加压。
这篇不写界面、不写拖控件,那段我之前那篇 C# WinForm 大文件拆分教程已经讲完了。这次只盯流式读写与内存控制:缓冲区定多大、进度条为什么把界面拖死、中途取消留下什么烂摊子。
起因:工具早就跑通了,麻烦才刚开始
那个运维系统每天凌晨吐一个接近 2GB 的访问日志。第一版按"能跑就行"写:8KB 缓冲、按块更进度条,1.2GB 十几秒拆完,内存稳在 50MB 上下。
直到有人丢了个 8GB 数据库备份过来。进度条卡在第 3 块不动,界面点什么都没反应,内存爬到六百多 MB。
两件事是分开的:不崩只是及格线,可控才算完。
过程:把循环重写成"一个缓冲、两个流"
改动只有三条:缓冲区只 new 一次并挪到循环外;给 FileStream 显式传 bufferSize;进度改按字节算千分比。
const int BUF = 64 * 1024; // 65536,刻意压在 85000 以下
var buffer = new byte[BUF];
using (var src = new FileStream(srcPath, FileMode.Open, FileAccess.Read,
FileShare.Read, BUF, FileOptions.SequentialScan))
{
long remaining = src.Length;
int part = 1;
while (remaining > 0)
{
long toWrite = Math.Min(ChunkSize, remaining);
long written = 0;
using (var dst = new FileStream(
Path.Combine(outDir, $"part_{part:D4}.dat"),
FileMode.Create, FileAccess.Write, FileShare.None,
BUF, FileOptions.SequentialScan))
{
while (written < toWrite)
{
int want = (int)Math.Min(BUF, toWrite - written);
int read = src.Read(buffer, 0, want);
if (read == 0) break;
dst.Write(buffer, 0, read);
written += read;
}
}
remaining -= written;
part++;
}
} bufferSize 必须大于 0。官方说明:bufferSize 是"一个大于 0 的正 Int32 值,指示缓冲区大小,默认 4096",传负数或零直接抛 ArgumentOutOfRangeException。网上流传的"BufferSize = 0 关掉内部缓冲"是 .NET 6 之后 FileStreamOptions 的玩法,别抄进 4.8 工程。
SequentialScan 是提示不是保证。官方枚举页原话:"指示从头到尾顺序访问文件,系统可将其用作优化文件缓存的提示……指定此标志在某些情况下可以提高性能"。注意"可以""某些情况"两个限定词。要按 offset 跳读就换 RandomAccess。
踩坑:缓冲区调大之后,内存不降反升
开头那个坑,官方文档写得很清楚:大于 85,000 字节(约 83 KB)的对象放到 LOH,不压缩,且在 Gen 2 回收时才处理。不压缩意味着回收后留的是空洞;Gen 2 又是三代里最贵的。"缓冲区越大越快"一半情况下是反的——IO 省下的时间,可能被 GC 加倍收回去。
阈值具体在哪?官方口径 85,000 字节,扣掉对象头,x64 下 byte[] 临界约在 84,976 字节(社区用 GC.GetGeneration 逼近测出的值,非官方)。64KB(65,536)稳在小对象堆,256KB(262,144)已经在 LOH 里。我上一篇提过"可以调到 64KB 或 256KB",那句要打补丁:256KB 只在全程复用同一个数组时划算,每块 new 一个就是在 LOH 上反复开洞。
第二个坑是进度条。ProgressBar.Maximum 与 Value 都是 int,把 Maximum 设成文件字节数,超过 2,147,483,647 就溢出。用"总块数"能绕开,代价是块越大进度条越久不动。我的做法:Maximum 固定 1000,Value = (int)(written * 1000L / total),中间量走 long。另外别在每次 Read 后 Invoke,节流到 200ms 一次,UI 压力立刻下来。
第三个坑最阴,因为它不报错。取消时只 Dispose 不清理,会留下写了一半的 part_0013.dat;合并端 GetFiles("part_*.dat") 加 Array.Sort 会把残片当完整块拼进去,合成的文件能打开、内容却是错的。批量重命名工具那套"失败尽力回滚"思路在这里直接用:出错就删半成品并提示。
解法:把四个开关一个个拧紧
缓冲区定 64KB 并全局复用:65,536 稳在小对象堆,不给 Gen 2 添堵,IO 次数相比默认 4096 少一个数量级。
要更大缓冲就走池化而不是 new:ArrayPool<byte>.Shared.Rent() 借、Return() 还(4.8 需引 System.Buffers)。要么小,要么池化,千万别"又大又频繁 new"。
进度改千分比 + 200ms 节流 + BeginInvoke;取消用 CancellationToken 并在 catch 里删当前分片;最后算一次哈希写进 manifest。三件加起来不到五十行,却把"能跑"和"敢在生产跑"分开了。
还有个边界别当 bug:最后一块几乎必然小于设定值,那是除不尽的余数。做训练集划分工具时也是这个体会——边界那份必须单独兜底,否则要么出空文件,要么静默少一块。
验证:三行代码自证,别靠感觉
进没进 LOH 不用猜,也不用装 PerfView:
foreach (int n in new[] { 65536, 81920, 84976, 262144 })
{
var b = new byte[n];
Console.WriteLine($"{n} -> Gen {GC.GetGeneration(b)}");
} 输出 Gen 0 说明还在小对象堆,Gen 2 就说明进了 LOH。三十秒跑一次,比在评论区争"到底多大合适"有用。
不想自己维护这些,站里那个大文件拆分组装工具已把分块、合并、校验做进去了;这篇讲的是你想自己写一套时该躲什么。
工具本身不复杂,难的是把"能跑"往前推一步。决定你能坚持多久的,是容错。
常见问题
缓冲区是不是越大越快?
不是。超过 85,000 字节就进大对象堆,不压缩且只在 Gen 2 回收,IO 省下的时间可能被 GC 收回去。64KB 是最稳的默认档,更大就走池化。
FileStream 的 bufferSize 能传 0 关掉缓冲吗?
4.8 里不能。官方要求这个值是"大于 0 的正 Int32",传 0 抛 ArgumentOutOfRangeException。BufferSize = 0 是 .NET 6+ 的 FileStreamOptions 才有的写法。
进度条按字节设 Maximum 为什么有时是负数?
Maximum 和 Value 都是 int,文件超过 2GB 按字节赋值会溢出。把 Maximum 固定成 1000,用 long 中间量算千分比即可。
拆到一半取消,输出目录里那些文件要手动删吗?
要,而且应该写进代码。残留的半个分片会被合并端的排序逻辑当成完整块拼进去,合成文件不报错但内容是坏的。
FileOptions.SequentialScan 加了就一定快吗?
官方原话是"可以""在某些情况下"提高性能,它只是给系统的缓存优化提示,不是性能保证。随机跳读的场景反而应该用 RandomAccess。
相关阅读:C# WinForm 大文件拆分开发教程|C# .NET Framework 4.8 指令式文件助手|WinForm 嵌入 WebView2 踩坑实录|退出码与容错设计

