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

使用 BufferedStream

C# SELF
等級 41 , 課堂 2
開放

1. 介紹

在開始使用新的類別之前,先搞清楚它到底為什麼存在比較好。讓我們弄清楚當我們用 FileStream「直接」操作檔案時到底發生了什麼。

當你呼叫 ReadWrite 對一個由 FileStream 建立的 stream 時,實際上是在跟磁碟子系統互動。這個過程本身(尤其是在舊式的 HDD 上,但在新的 SSD 上也一樣)比操作 RAM 慢很多。想像一下你在 McDonalds 點薯條,但每次收銀員都要去倉庫拿一袋新的包裝,看看隊伍會有多長!

如果你每次只處理小塊資料,頻繁地訪問磁碟或網路會導致性能大幅下降。資料量越大,這個差異越明顯。

簡短類比

沒緩衝的 stream 大概就像跑去超市十次,每次只買一個優格;緩衝式 stream 則是一次帶一整籃優格,把跑腿次數降到最低。

2. 類別 BufferedStream:第一印象

它的用途

BufferedStream 是包在任何 stream(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(它負責把文字寫進 stream)。
  • 只要緩衝區被填滿,資料就會一次性寫到磁碟。

3. 緩衝的內部運作

我們用個流程圖來拆解:


[你的程式碼] → [StreamWriter] → [BufferedStream] → [FileStream] → [磁碟上的檔案]

當你對 StreamWriter 呼叫 WriteLine() 時,文字會先寫進它自己的內部緩衝,接著透過 BufferedStream 進入另一個緩衝,最後當緩衝滿了或 stream 被關閉時,資料才寫到磁碟。

一個桶能裝多少位元組?

預設緩衝大小是 4096 byte(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. 實驗:比較有無緩衝的速度

為了看清差別,我們寫個測試,用有緩衝與無緩衝的方式來比較寫入速度:


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 + " 毫秒");

sw.Restart();
WriteWithBuffer();
sw.Stop();
Console.WriteLine("使用緩衝: " + sw.ElapsedMilliseconds + " 毫秒");

預期結果:
大多數情況下有緩衝會顯著快很多!尤其是在 HDD 上。如果是 SSD,效果仍然存在,但不會那麼戲劇化。

5. 選用哪種緩衝?比較與實務

.NET 裡有很多用來緩衝的類別,這裡幫你整理清楚:

類別 用途 內建緩衝? 需要使用 BufferedStream
FileStream
處理檔案 是(4 KB) 幾乎不需要(但可以用)
NetworkStream
處理網路 強烈建議
StreamReader/Writer
讀取/寫入文字 是(從 1 KB 起) 通常不需要
GZipStream
壓縮/解壓縮 可/建議用來加速

重要:
如果你在建立 FileStream 時已經在建構子裡指定了合適的 bufferSize,那它本身就已經是有緩衝的了。這種情況下再加一層 BufferedStream 不會帶來太大提升。但對於沒有緩衝的 stream(例如網路流)或自訂的 Stream 實作,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(像是網路 stream,或自訂繼承自 Stream 的實作);
  • 當你在處理大量的二進位資料(例如複製檔案、格式轉換、備份);
  • 當你在優化既有程式,發現瓶頸是大量小的 Write/Read 操作。

關於非同步和緩衝的一點說明

有了非同步操作(ReadAsync/WriteAsync),緩衝依然有用,但記得:當你在緩衝上使用非同步方法時,處理仍然會在記憶體層面做緩衝,實際的磁碟 I/O 會被進一步減少。

在 .NET 8+ 和 .NET 9 裡,緩衝機制越來越深入內建,很多類別預設就有緩衝。但為了跟網路 stream 或你自己的實作相容,手動使用 BufferedStream 仍然很有幫助。

關於非同步的更多內容你會在第 58 級學到 :P

緩衝式 stream 的視覺化示意

flowchart LR
    A[你的程式碼] --> B[StreamReader/Writer]
    B --> C[BufferedStream]
    C --> D[FileStream]
    D --> E[檔案/裝置]
  • A — 你的程式碼,會呼叫 Write/Read。
  • B — 高階的 stream(處理文字或資料)。
  • C — 緩衝(把資料分組以提升速度)。
  • D — 具體的 stream 實作(檔案、網路)。
  • E — 實體裝置(硬碟、SSD、網路等)。

實戰技巧和小貼士

  1. 如果你每次只寫一行到檔案(像是 logging),最好把緩衝大小設定大於單行大小。這樣可以更快地一次性寫出大批資料。
  2. 如果每個操作都必須立即寫入(例如關鍵日誌),寫完就呼叫 Flush()。但要注意這會降低緩衝帶來的效益!
  3. 如果你建立的是暫存檔,而且建立後馬上就刪除,緩衝裡還有沒刷新的資料可能不那麼重要 — 不過如果需要確保檔案完整,還是要小心。
  4. 處理非常大的檔案(比如幾十 GB)時,可以把緩衝提高到 1_048_576 byte(1 MB)或更大 — 只要記憶體允許。

8. 常見錯誤和使用細節

如果你現在很想「到處塞緩衝」——先冷靜一下。凡事適度最重要!

常見錯誤之一是忘了呼叫 Flush() 或在該關閉 stream 時沒有正確關閉。如果程式在 stream 關閉前崩潰,最後的幾個位元組可能還留在記憶體中的緩衝,沒被寫到磁碟。例如寫日誌時程式當掉,最後一條記錄可能就不見了。

BufferedStream 並不理解你的邏輯訊息邊界 — 它只是等到累積到某個資料量才一次性發送。因此對於關鍵性資料(像日誌、備份等),最好定期強制呼叫 Flush()

bufferedStream.Flush(); // 強制把緩衝資料寫到磁碟

如果你在使用 StreamWriter,要記得它也有自己的緩衝!也就是說巢狀使用時會有兩層緩衝(這不一定是好事)。通常只要一層緩衝就夠了,如果你已經使用 StreamWriter,額外再套 BufferedStream 未必必要。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION