1. 介紹
回想我們載入大圖片的例子。當它在載入時,應用會「卡住」。對文字檔也一樣,尤其是很大的檔案(幾十 GB 的日誌、巨大的 CSV 報表、以文字格式的資料庫備份)。
想像你在寫一個應用,它會:
解析超大日誌檔,去找錯誤。如果你用同步方式讀取,使用者介面會卡住幾秒甚至幾分鐘,直到操作結束。使用者會以為程式當掉了。
邊產生邊寫入報表檔。如果寫入阻塞主執行緒,那麼資料產生和介面都會受到影響。
網頁伺服器 要處理成千上萬的請求。每個請求可能需要讀或寫檔案。如果每次檔案 I/O 都是同步的,伺服器的執行緒會空等磁碟,伺服器很快就會因為請求堆積而「窒息」。
在這些場景下,非同步 I/O 不只是「好用的功能」,而是必需。它讓你的應用在磁碟「思考」時不至於閒置,能做有用的事(比如更新介面、處理其他請求或執行計算)。
基本概念:async/await 和任務
- 關鍵字 async 表示該方法可能包含「等待點」(await)。
- 運算子 await 臨時交出控制權,直到非同步任務(例如讀檔)完成為止。
- 非同步方法在不阻塞當前執行緒的情況下執行 I/O:在資料尚未就緒時 — 執行緒是空閒的。
這些構成了檔案非同步處理的基礎「魔法」。
2. 檔案的非同步方法
在現代 .NET 中,幾乎所有主要的檔案操作類別都有非同步對應方法。對文字檔常用的有:
- StreamReader.ReadLineAsync()
- StreamReader.ReadToEndAsync()
- StreamWriter.WriteLineAsync()
- StreamWriter.WriteAsync()
- 還有靜態方法:File.ReadAllTextAsync()、File.WriteAllTextAsync() 等。
| 讀取 | 寫入 |
|---|---|
|
|
|
|
|
|
3. 非同步讀取整個文字檔
我們先把整個檔案讀成一個字串。小檔(設定檔、小日誌)常這樣做。
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string path = "input.txt";
// 非同步讀取整個檔案
string fileContents = await File.ReadAllTextAsync(path);
Console.WriteLine("檔案內容:");
Console.WriteLine(fileContents);
}
}
注意:方法 Main 現在被標記為 async Task Main()。從 C# 7.1 開始可以這樣寫。只要一個 await — 就能非同步運作!
4. 非同步逐行讀取大型檔案
當檔案真的很大時,把它整個讀進記憶體不是好主意。比較好的做法是逐行讀取:
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string path = "biglog.txt";
// 開啟 StreamReader 做非同步讀取
using StreamReader reader = new StreamReader(path);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
// 在這裡可以處理該行(例如,搜尋錯誤)
Console.WriteLine(line);
}
}
}
這怎麼運作?
每次調用 await reader.ReadLineAsync() 都會釋放執行緒 — 在網路磁碟或雲端檔案特別有用。當面對數萬行且需同時為多個使用者服務(例如在伺服器 API)時,非同步處理就很關鍵。
5. 非同步把行寫入檔案
同樣可以非同步寫資料到檔案(例如在生成報表時):
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string path = "output.txt";
using StreamWriter writer = new StreamWriter(path);
for (int i = 0; i < 5; i++)
{
await writer.WriteLineAsync($"第 {i + 1} 行");
}
// 可以明確呼叫 FlushAsync,以確保資料寫入
await writer.FlushAsync();
Console.WriteLine("資料已非同步寫入!");
}
}
呼叫 FlushAsync() 並非總是必須的 — 關閉 StreamWriter 時緩衝區會被清空。但如果你需要保證「現在就寫入」,就用它。
6. 多個非同步檔案操作之間的互動
比如,需要讀一個文字檔,並同步把處理後的結果寫到另一個檔案:
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string sourcePath = "even_biggerlog.txt";
string destinationPath = "copy_biggerlog.txt";
using StreamReader reader = new StreamReader(sourcePath);
using StreamWriter writer = new StreamWriter(destinationPath);
string? line;
int linesProcessed = 0;
while ((line = await reader.ReadLineAsync()) != null)
{
// 一點小魔法:把所有字母轉成大寫
string processed = line.ToUpperInvariant();
await writer.WriteLineAsync(processed);
linesProcessed++;
}
Console.WriteLine($"處理的行數:{linesProcessed}");
}
}
這裡讀和寫都是非同步的。每個 await 都會釋放控制權,允許應用去做別的事。
7. 實際應用場景:在哪裡會用到?
- 網頁開發 (ASP.NET Core): 上傳/下載檔案不會阻塞其他請求處理;伺服器保持回應能力。
- 桌面應用 (WPF, WinForms): 開啟日誌或儲存報表時,UI 不會「凍結」。
- 遊戲引擎: 非同步載入資源(貼圖、模型)讓動畫和遊戲流程不中斷。
- 大數據處理: 逐行解析巨大的 CSV/JSON/XML,邊讀邊處理,避免過度佔用記憶體。
- 背景服務和 daemon: 記錄日誌、快取、處理佇列時有效利用執行緒與磁碟。
結論:非同步有助於建立現代、回應迅速且易擴展的應用。「阻塞」是壞的,非同步是好的!
8. 注意事項與最佳實踐
別忘了 await! 如果呼叫帶有後綴 Async 的方法不等待,就會得到一個 Task,但程式會繼續往下執行,導致執行順序錯亂。
// 不好:忘記 await
FileManager.ReadTextFileAsync("nonexistent.txt"); // 會開始,但 Main 會繼續往下
Console.WriteLine("我馬上執行了,雖然檔案還在讀(或已經拋錯)!這很糟糕!");
編譯器通常會警告你忘記 await,但不會阻止編譯。
using 用於所有實作了 IDisposable 的東西:所有流(FileStream, StreamReader, StreamWriter)都應該正確釋放。使用 using 區塊或 C# 8+ 的 using 宣告來保證關閉和清空緩衝區。
緩衝區大小(bufferSize):StreamReader/StreamWriter 已經做了優化,但在特別需求下可以調整。預設值通常就很合適(例如 FileStream 常見的是 4096 位元組)。
錯誤處理:非同步方法同樣會拋出例外。把操作包在 try-catch 中。例外會在對應的 await 時「冒出」。
ConfigureAwait:在函式庫和不需要同步上下文(比如 GUI)的網頁場景中,使用 await SomeAsync().ConfigureAwait(false) 可以降低在切換上下文時的額外開銷。在主控台和許多 UI 應用中通常可以省略它。
多練習 — 很快你會發現 async Task 跟 Console.WriteLine 一樣順手。
9. 常見錯誤與非同步檔案操作的重要細節
如果不使用 await(只是呼叫帶 Async 的方法),你會得到一個 Task,但結果不會自動等待。你需要透過 await 或顯式等待它(通常不建議後者)。
不能從同步方法直接等待非同步方法而不把 async 向上「抬升」。使用 .Result 或 .GetAwaiter().GetResult() 可能導致死鎖 — 最好把呼叫鏈改成 async。
不要在同一時間對同一個檔案做讀和寫(即使是非同步的)。這會造成競爭條件和資料損毀的風險。
非同步會釋放呼叫執行緒,但不會讓 I/O 變快:如果磁碟或網路慢,非同步下也一樣慢 — 不過至少不會阻塞 UI 或工作執行緒。
GO TO FULL VERSION