1. 介绍
在开始用一个新类之前,先弄清楚它到底为啥存在。我们来看看,当我们直接用 FileStream 操作文件时到底发生了什么。
当你对通过 FileStream 创建的流调用 Read 或 Write 时,实际上是在访问计算机的磁盘子系统。这个过程本身(尤其是在老式机械硬盘上,但在新的 SSD 上也一样)远慢于内存操作。想象每次你在麦当劳点薯条时,收银员都要跑去仓库拿一个新袋子,队伍会有多长!
如果你用很小的块读写数据,频繁访问磁盘或网络会严重影响性能。数据量越大,这个差距越明显。
简短类比
不带缓冲的流就像你去超市十次,每次只买一个酸奶。缓冲流就是你一次拿一整筐酸奶,把来回次数降到最少。
2. 类 BufferedStream:初步认识
它干嘛的
BufferedStream 是对任意流(Stream)的包装,在内存里维护一个中间缓冲区。你写入的数据先进入缓冲区,只有当缓冲区填满时才一次性写到磁盘。读也是类似:第一次读会把一大块数据加载到内存,然后从内存里按需返回小块,直到缓冲区耗尽。
示例代码:创建 BufferedStream
我们来写一个最简单的例子。假设任务是往文件里写入 100 000 行:
string filePath = "big_output.txt";
using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write);
using var bufferedStream = new BufferedStream(fileStream);
using var writer = new StreamWriter(bufferedStream);
for (int i = 0; i < 100_000; i++)
{
writer.WriteLine($"第{i}行");
}
Console.WriteLine("写入完成!");
说明:
- 我们通过 FileStream 打开文件用于写入。
- 然后用 BufferedStream 包一层,再用 StreamWriter(它负责把文本写入流)。
- 缓冲区一旦满了——数据就会被一次性写到磁盘。
3. 缓冲在“内部”怎么工作
用图示解释下:
[你的代码] → [StreamWriter] → [BufferedStream] → [FileStream] → [磁盘上的文件]
当你在 StreamWriter 上调用 WriteLine() 时,文本先写到它的内部缓冲,再通过 BufferedStream 进入另一个缓冲,最后在缓冲满或流关闭时写入磁盘。
一桶能装多少字节?
默认的缓冲大小是 4096 字节(4 KB),但你可以显式指定:
int myBufferSize = 16 * 1024; // 16 KB
using var fileStream = new FileStream(filePath, FileMode.Create);
using var bufferedStream = new BufferedStream(fileStream, myBufferSize);
// ...
实用小技巧: 在现代系统上,使用 8–64 KB 的缓冲通常比较合理。对于超大文件操作可以更大。但别太贪心:如果你在只有 128 KB 可用内存的微控制器上工作,给 64 KB 缓冲就不是个好主意 :)
4. 实验:比较有缓冲和无缓冲的速度
要弄清楚效果有多明显,我们写个测试,比较通过 FileStream 写入时有缓冲和无缓冲的速度:
using System.Diagnostics;
using System.Text;
string data = new string('X', 1000); // 1 000 个字符
void WriteWithoutBuffer()
{
using var fs = new FileStream("no_buffer.txt", FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: false);
for (int i = 0; i < 10_000; i++)
{
byte[] bytes = Encoding.UTF8.GetBytes(data);
fs.Write(bytes, 0, bytes.Length); // 直接写入文件——每次都会访问磁盘
}
}
void WriteWithBuffer()
{
using var fs = new FileStream("with_buffer.txt", FileMode.Create, FileAccess.Write, FileShare.None);
using var bs = new BufferedStream(fs, 16 * 1024);
for (int i = 0; i < 10_000; i++)
{
byte[] bytes = Encoding.UTF8.GetBytes(data);
bs.Write(bytes, 0, bytes.Length);
}
}
// 计时
Stopwatch sw = Stopwatch.StartNew();
WriteWithoutBuffer();
sw.Stop();
Console.WriteLine("无缓冲: " + sw.ElapsedMilliseconds + " ms");
sw.Restart();
WriteWithBuffer();
sw.Stop();
Console.WriteLine("有缓冲: " + sw.ElapsedMilliseconds + " ms");
预期输出:
大多数情况下,有缓冲会明显快很多!尤其是在 HDD 上。如果是 SSD,效果仍然能看出来,但不那么戏剧化。
5. 选哪个缓冲?比较与实践
.NET 里有很多用来缓冲的类。我们来理清下:
| 类 | 用途 | 自带缓冲? | 需要用 BufferedStream 吗? |
|---|---|---|---|
|
操作文件 | 是(默认 4 KB) | 几乎不需要(但可以用) |
|
网络操作 | 否 | 强烈建议 |
|
读/写文本 | 是(从 ~1 KB 起) | 通常不需要 |
|
压缩/解压 | 否 | 可以/需要(用于加速) |
重要:
如果你在构造 FileStream 时传了 bufferSize 参数,那其实 FileStream 本身已经是缓冲的了。如果你自己把缓冲设得够大,额外再套一个 BufferedStream 带来的收益就不大。但如果你用的是其他类型的流(比如网络流),那么 BufferedStream 会很有用。
6. 示例:用 BufferedStream 复制文件
string source = "big_input.dat";
string dest = "big_output.dat";
int bufferSize = 64 * 1024; // 64 KB
using var inputStream = new FileStream(source, FileMode.Open, FileAccess.Read);
using var outputStream = new FileStream(dest, FileMode.Create, FileAccess.Write);
using var bufferedInput = new BufferedStream(inputStream, bufferSize);
using var bufferedOutput = new BufferedStream(outputStream, bufferSize);
byte[] buffer = new byte[bufferSize];
int bytesRead;
while ((bytesRead = bufferedInput.Read(buffer, 0, buffer.Length)) > 0)
{
bufferedOutput.Write(buffer, 0, bytesRead);
}
// 别忘了 Flush —— 否则最后的字节可能不会写到磁盘!
bufferedOutput.Flush();
Console.WriteLine("复制完成!");
说明:
- 我们用 BufferedStream 一次读出较大的块数据(64 KB),然后写到另一个文件。
- 写入也用了缓冲。
- 循环结束后一定要调用 Flush() 来把最后的数据写入磁盘。
7. 有用的细节
建议:什么时候真的需要 BufferedStream
- 当你操作没有内置缓冲的流时(比如网络流,或自定义继承自 Stream 的实现);
- 当你处理大量二进制数据时(如文件复制、格式转换、备份);
- 当你在优化现有代码并发现瓶颈是大量小的 Write/Read 操作时。
关于异步和缓冲
随着异步方法的出现(ReadAsync/WriteAsync),缓冲仍然有用,但要记住:当你在缓冲上面使用异步方法时,处理仍然主要在内存中进行,和磁盘的物理交互被进一步减少。
在 .NET 8+ 和 .NET 9 中,缓冲越来越多地内置到了各种类里,大多数类默认就有缓冲。但为了兼容网络流或你的自定义实现,手动使用 BufferedStream 仍然是有意义的。
关于异步的更多内容你会在第58级学到 :P
缓冲流的可视化工作示意
flowchart LR
A[你的代码] --> B[StreamReader/Writer]
B --> C[BufferedStream]
C --> D[FileStream]
D --> E[文件/设备]
- A — 你的代码,调用 Write/Read。
- B — 高级流(处理文本或数据)。
- C — 缓冲(把数据分组以提高速度)。
- D — 具体的流实现(文件、网络)。
- E — 物理设备(机械硬盘、SSD、网络等)。
实战小贴士
- 如果你每次只写一行(比如日志),最好显式把缓冲大小设得比一行大,这样可以更快地一次性刷出一大批数据。
- 如果每次写入都必须立刻持久化(比如关键日志),在每次写后调用 Flush()。但这会削弱缓冲带来的性能优势!
- 如果你生成临时文件且写完就删,缓冲里是否有残留可能不重要——但如果文件必须被确确实实写入,请谨慎处理。
- 处理非常大的文件(比如几十 GB)时,不妨把缓冲调到 1_048_576 字节(1 MB)或更高——只要内存够用。
8. 常见错误与注意点
如果你现在很激动,想把缓冲“塞满 everywhere”——别急。任何事物都要适度!
常见错误之一是忘记在需要的时候调用 Flush() 或关闭流。如果流还没关闭而程序崩溃,最后几字节可能还留在内存里的缓冲里没写到磁盘。比如你把日志写到文件里,程序挂了,最后一条记录可能就消失了。
BufferedStream 本身并不会“知道”你的逻辑消息边界——它只是等到收集到足够的数据才发起一次写入。因此对于关键数据(日志、备份等)最好定期强制调用 Flush():
bufferedStream.Flush(); // 强制把缓冲数据写到磁盘
如果你使用 StreamWriter,它本身也有自己的缓冲!也就是说在嵌套使用时会有两层缓冲(这并不总是好事)。很多时候只用一层缓冲就够了,如果你已经在用 StreamWriter,额外再套一个 BufferedStream 可能没必要。
GO TO FULL VERSION