CodeGym /課程 /C# SELF /與文字檔的非同步操作

與文字檔的非同步操作

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

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() 等。
讀取 寫入
ReadLineAsync()
WriteLineAsync()
ReadToEndAsync()
WriteAsync()
File.ReadAllXAsync()
File.WriteAllXAsync()

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 宣告來保證關閉和清空緩衝區。

緩衝區大小(bufferSizeStreamReader/StreamWriter 已經做了優化,但在特別需求下可以調整。預設值通常就很合適(例如 FileStream 常見的是 4096 位元組)。

錯誤處理:非同步方法同樣會拋出例外。把操作包在 try-catch 中。例外會在對應的 await 時「冒出」。

ConfigureAwait:在函式庫和不需要同步上下文(比如 GUI)的網頁場景中,使用 await SomeAsync().ConfigureAwait(false) 可以降低在切換上下文時的額外開銷。在主控台和許多 UI 應用中通常可以省略它。

多練習 — 很快你會發現 async TaskConsole.WriteLine 一樣順手。

9. 常見錯誤與非同步檔案操作的重要細節

如果不使用 await(只是呼叫帶 Async 的方法),你會得到一個 Task,但結果不會自動等待。你需要透過 await 或顯式等待它(通常不建議後者)。

不能從同步方法直接等待非同步方法而不把 async 向上「抬升」。使用 .Result.GetAwaiter().GetResult() 可能導致死鎖 — 最好把呼叫鏈改成 async

不要在同一時間對同一個檔案做讀和寫(即使是非同步的)。這會造成競爭條件和資料損毀的風險。

非同步會釋放呼叫執行緒,但不會讓 I/O 變快:如果磁碟或網路慢,非同步下也一樣慢 — 不過至少不會阻塞 UI 或工作執行緒。

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