CodeGym /课程 /C# SELF /使用 BufferedStream

使用 BufferedStream

C# SELF
第 41 级, 课程 2
可用

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 吗?
FileStream
操作文件 是(默认 4 KB) 几乎不需要(但可以用)
NetworkStream
网络操作 否 强烈建议
StreamReader/Writer
读/写文本 是(从 ~1 KB 起) 通常不需要
GZipStream
压缩/解压 否 可以/需要(用于加速)

重要:
如果你在构造 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、网络等)。

实战小贴士

  1. 如果你每次只写一行(比如日志),最好显式把缓冲大小设得比一行大,这样可以更快地一次性刷出一大批数据。
  2. 如果每次写入都必须立刻持久化(比如关键日志),在每次写后调用 Flush()。但这会削弱缓冲带来的性能优势!
  3. 如果你生成临时文件且写完就删,缓冲里是否有残留可能不重要——但如果文件必须被确确实实写入,请谨慎处理。
  4. 处理非常大的文件(比如几十 GB)时,不妨把缓冲调到 1_048_576 字节(1 MB)或更高——只要内存够用。

8. 常见错误与注意点

如果你现在很激动,想把缓冲“塞满 everywhere”——别急。任何事物都要适度!

常见错误之一是忘记在需要的时候调用 Flush() 或关闭流。如果流还没关闭而程序崩溃,最后几字节可能还留在内存里的缓冲里没写到磁盘。比如你把日志写到文件里,程序挂了,最后一条记录可能就消失了。

BufferedStream 本身并不会“知道”你的逻辑消息边界——它只是等到收集到足够的数据才发起一次写入。因此对于关键数据(日志、备份等)最好定期强制调用 Flush():

bufferedStream.Flush(); // 强制把缓冲数据写到磁盘

如果你使用 StreamWriter,它本身也有自己的缓冲!也就是说在嵌套使用时会有两层缓冲(这并不总是好事)。很多时候只用一层缓冲就够了,如果你已经在用 StreamWriter,额外再套一个 BufferedStream 可能没必要。

2
任务
C# SELF, 第 41 级, 课程 2
已锁定
使用 BufferedStream 复制文件内容
使用 BufferedStream 复制文件内容
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION