1. 介绍
当你处理小文件时,通常不会注意太多:写了、读了——就忘了。但一旦文件大小开始超过大约 100–500 MB,更别说几个 GB,就会出现有趣的问题:
- 操作变慢,尤其是直接“一口气”处理时——比如 File.ReadAllBytes() 或 File.WriteAllText() 可能会拖慢整个程序。
- 可能会耗尽内存,出现 OutOfMemoryException。
- 系统会开始使用 swap(分页),这会导致整个系统变慢。
- 并行操作可能会给磁盘造成过大负载。
在真实场景中这很常见:
- 服务器日志(一天几个 GB)。
- 处理大的 CSV 或 XML 文件的导入导出。
- 视频、音频、压缩包、二进制文件的处理。
- 复制大型程序集或备份。
2. 处理大文件的优化策略
在动手优化之前——先明确我们想要加速或改进什么。以下是最常见的目标:
- “尽可能快地读/写文件,并且不搞崩溃系统”。
- “分块处理文件,以免占满内存”。
- “不在内存中制造多余的数据副本”。
- “并行处理大文件(如果可行)”。
通用做法:
- 流式读写:使用流和缓冲,按块读/写(FileStream, BufferedStream)。
- 直接在磁盘上操作文件——不要在内存中做中间拷贝。
- 谨慎管理缓冲和内存:不要把整个文件放到内存里。
- 异步操作——如果需要不阻塞主线程(下一讲会详细讲)。
3. 流式读写:基本模式
核心原则:按块处理文件! 在现代 C# 中可以很容易地用 FileStream、BufferedStream 等流类来实现。
// 打开一个用于读取的流:
using FileStream fs = new FileStream("bigfile.bin", FileMode.Open, FileAccess.Read);
byte[] buffer = new byte[1024 * 1024]; // 1 MB
int bytesRead;
// 一直读到文件末尾
while ((bytesRead = fs.Read(buffer, 0, buffer.Length)) > 0)
{
// 在这里处理读取到的数据!
// 例如,计算所有字节的和(纯练习用)
long sum = 0;
for (int i = 0; i < bytesRead; i++)
sum += buffer[i];
Console.WriteLine($"读取了 {bytesRead} 字节,和: {sum}");
}
建议:缓冲区大小(比如 64 KB、128 KB、1 MB)需要通过试验来选择。太小会有大量磁盘访问,太大并不总是更快,却会占用更多内存。
为什么这样? 使用 File.ReadAllBytes() 或 File.ReadAllText() 这类不分块的读取会尝试把整个内容加载到内存。如果文件很大——结果显而易见:你会遇到 OutOfMemoryException。
4. BufferedStream:为什么以及何时使用
在上一讲我们见过这个类,值得再提醒:BufferedStream 是对任意流的包装,可以让读/写不是按单字节,而是按块进行。
使用示例:
using var fileStream = new FileStream("bigfile.bin", FileMode.Open, FileAccess.Read);
using var bufferedStream = new BufferedStream(fileStream, 1024 * 128);
byte[] buffer = new byte[1024 * 128];
int bytesRead;
while ((bytesRead = bufferedStream.Read(buffer, 0, buffer.Length)) > 0)
{
// 处理数据
}
在某些情况下,使用 BufferedStream 会加快速度,尤其当你以少量字节频繁读取时,而文件系统偏好块访问。
有趣的事实:在新版 .NET 的 FileStream 中已经内置了缓冲,所以手动包一层 BufferedStream 在处理“原始”流(比如网络流或非标准设备)时收益更明显。
5. 读取和写入大型文本文件
对二进制文件很直观:按块读写。但文本文件怎么办,尤其是大的 CSV、日志、JSON 文件?
这里推荐使用 StreamReader 来读取,StreamWriter 来写入。
逐行读取:
using var reader = new StreamReader("biglog.txt");
string? line;
while ((line = reader.ReadLine()) != null)
{
// 处理这一行
if (line.Contains("ERROR"))
Console.WriteLine("发现 ERROR: " + line);
}
为什么好?
- 我们不会把整个文件保存在内存中。
- 而且 StreamReader 内部的缓冲通常已经调好了。
按行写入:
using var writer = new StreamWriter("output.txt");
for (int i = 0; i < 1000000; i++)
writer.WriteLine($"这是第 {i} 行");
流式读取的架构
[磁盘上的文件]
|
[FileStream]
|
[BufferedStream (可选)]
|
[StreamReader/StreamWriter (用于文本)]
|
[你的代码:处理数据]
6. 实践应用
继续扩展我们的日志处理应用。假设我们要把超过 7 天的旧日志归档,不是简单移动文件,而是把它们压缩到一个归档里。如果文件很大——就按块读写,避免“压垮”内存。
简化起见,这里用流拷贝的标准方式:
void CopyLargeFile(string sourcePath, string destPath)
{
using var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read);
using var destStream = new FileStream(destPath, FileMode.Create, FileAccess.Write);
byte[] buffer = new byte[1024 * 256]; // 256 KB
int bytesRead;
while ((bytesRead = sourceStream.Read(buffer, 0, buffer.Length)) > 0)
{
destStream.Write(buffer, 0, bytesRead);
// 这里可以加进度条!
}
}
适用场景:
- 备份拷贝
- 按天或按月合并日志
- 文件预处理(例如行过滤)
7. 如何评估缓冲效果?
有时候想知道:“到底快了多少?” 可以用计时来对比:
var watch = System.Diagnostics.Stopwatch.StartNew();
CopyLargeFile("source.bin", "dest.bin");
watch.Stop();
Console.WriteLine($"拷贝时间: {watch.Elapsed.TotalSeconds} 秒");
常见的缓冲值:
- 4 KB —— 文件系统的最小块。
- 64 KB / 128 KB —— 实践中几乎总是表现不错。
- 1 MB 及以上 —— 只有在非常快的 SSD 和超大文件时才有意义。
你可以自己尝试不同的缓冲大小!
8. 在大文件中查找和处理数据
如果需要的不只是拷贝,而是查找某行、某个数字、某个短语?在大文件中分块处理效率最高。
示例:在超大日志中查找所有错误行
using var reader = new StreamReader("server.log");
using var writer = new StreamWriter("errors.txt");
string? line;
while ((line = reader.ReadLine()) != null)
{
if (line.Contains("ERROR"))
writer.WriteLine(line); // 只把需要的行写入结果
}
这种方式可以处理 GB 级别甚至更多的日志,而不会占用大量内存。
9. 处理超大文件(>2 GB)的注意事项
如果用流式读取,.NET 和 Windows 对任意大小(甚至几十 TB)的文件支持很好。但还是有些细节要注意!
- 32 位应用受限于 2 GB 地址空间 —— 请使用 x64!
- 处理大文件时务必使用 64 位平台(AnyCPU 或 x64)。
- 对于大于 4 GB 的文件,文件系统 FAT32 不适合——需要 NTFS/exFAT。
对大文件的迭代处理
+------------------+
| Start |
+------------------+
|
v
+------------------------------+
| 打开读取流 |
+------------------------------+
|
v
+------------------------------+
| 当未到达文件末尾 |
+------------------------------+
|
v
+---------------------------+
| 读取一块数据 |
+---------------------------+
|
v
+---------------------------+
| 处理该块 |
+---------------------------+
|
v
+--------------------------+
| 下一个块 |
+--------------------------+
|
v
+--------------------+
| 关闭流 |
+--------------------+
10. 有用的细节
把文件当作流:FileStream 的关键方法
| 方法 | 描述 |
|---|---|
|
把文件的一部分读到缓冲区 |
|
把缓冲区的一部分写入文件 |
|
允许跳转到文件中的某个位置 |
|
文件的字节长度 |
|
当前文件位置 |
Seek() 的使用示例:
using var stream = new FileStream("bigfile.bin", FileMode.Open);
// 向前跳 1 GB!
stream.Seek(1024L * 1024 * 1024, SeekOrigin.Begin);
byte[] buffer = new byte[1024];
int bytesRead = stream.Read(buffer, 0, buffer.Length);
// 现在从文件中间读取数据!
什么时候有用?
- 文件索引化
- 快速访问需要的块(例如在大数据库中)
在多个线程中处理文件(多线程)
如果场景允许,可以并行处理大文件:比如把文件分成块,分别读/写不同片段。但要注意,机械硬盘(HDD)在随机访问时性能差,而 SSD 要快得多。
在简单的家庭场景中,如果不确定,还是顺序处理。并行更适合同时处理多个文件,而不是把单个文件并行化(否则收益可能不明显)。
11. 处理大文件时常见错误
错误 №1:把整个文件读到内存。
新手常用 File.ReadAllBytes() 或 File.ReadAllText() 来处理超大文件。若文件有 GB 级别,程序会因内存不足而崩溃。要使用流式读取。
错误 №2:使用太小的缓冲。
用非常小的缓冲读取,就像用茶匙喝汤。程序会花大量时间做磁盘访问,效率低下。选择合理的缓冲大小。
错误 №3:忘记关闭流。
如果用完不关闭流,文件句柄会被占用,影响其他程序并可能引发操作系统错误。始终使用 using——更安全、更干净。
错误 №4:同时访问同一个文件。
尝试在程序不同部分同时读写同一文件,很容易碰到 IOException。即便有时“能用”,也不可靠。
GO TO FULL VERSION