CodeGym /課程 /C# SELF /同步與非同步生成器在 C# 中( yield...

同步與非同步生成器在 C# 中( yield)

C# SELF
等級 62, 課堂 1
開放

1. 介紹

想像你要處理一個巨大的日誌檔案,裡面有上百萬行,或是要生成一個非常長的數字序列。沒有生成器,你會怎麼做?

傳統做法會像這樣:


// 問題:會一次性把整個集合生成到記憶體
List<int> GenerateAllNumbersSync(int count)
{
    List<int> numbers = new List<int>();
    for (int i = 0; i < count; i++)
    {
        numbers.Add(i);
    }
    return numbers; // 當全部準備好才返回
}

// 使用:
var myNumbers = GenerateAllNumbersSync(1_000_000); // 一次把 100 萬個數字放進記憶體!
foreach (var num in myNumbers) { /* 處理 */ }

這裡的問題是什麼?

  1. 記憶體消耗:如果 count 非常大,整個集合會被建立在記憶體中,可能會導致 OutOfMemoryException。
  2. 延遲:使用者或程式的下一部分必須等到所有資料完全生成並載入記憶體後才能繼續。
  3. 無限序列:如果序列可能是無限的,這種做法根本行不通。

這時候就有 生成器 幫忙了!它們實現了延遲計算(Lazy Evaluation)和串流處理(Streaming)。生成器不是一次生成所有資料,而是按需一次產生一個元素。

2. 生成器基礎

在 C# 裡,生成器是用特別的關鍵字 yield 來寫的。

什麼是生成器?

生成器可以是含有一個或多個 yield return 表達式的方法、屬性 get 區塊或運算子。

yield return

這是生成器的核心。當編譯器遇到 yield return 時:

  1. yield return 之後的元素會傳給呼叫端。
  2. 生成器方法的執行會被暫停,並且其當前狀態(在哪個迴圈位置、區域變數的值)會被保存。
  3. 當下一次請求元素(例如下一次 foreach 的迭代)時,該方法會從暫停的地方恢復執行。

回傳類型:生成器方法應該回傳 IEnumerable<T> 或 IEnumerator<T>。編譯器會替你生成必要的「魔法」類別。


// 範例 2.1:簡單的數字生成器
IEnumerable<int> GenerateNumbers(int count)
{
    Console.WriteLine("開始生成...");
    for (int i = 0; i < count; i++)
    {
        Console.WriteLine($"生成中: {i}");
        yield return i; // 暫停並返回元素
    }
    Console.WriteLine("生成完成。");
}

// 使用:
// 注意 "開始生成..." 只有在第一次迭代時才會出現!
// 而 "生成中:X" 則會在每次新迭代時出現。
foreach (var num in GenerateNumbers(3))
{
    Console.WriteLine($"在 foreach 中取得:{num}");
}

yield break

用來提前結束迭代。執行到 yield break 後不會再返回任何元素。如果方法走到結尾,則不必特別寫 yield break。


// 範例 2.2:有退出條件的生成器
IEnumerable<string> GetFirstNElements(List<string> source, int n)
{
    int count = 0;
    foreach (var item in source)
    {
        if (count >= n)
        {
            yield break; // 退出生成器
        }
        yield return item;
        count++;
    }
}

// 使用:
// var fruits = new List<string> { "Apple", "Banana", "Orange", "Grape" };
// foreach (var fruit in GetFirstNElements(fruits, 2))
// {
//     Console.WriteLine(fruit); // 會輸出 "Apple", "Banana"
// }

3. 狀態機

那在「底層」是怎麼運作的?其實沒什麼魔法——只是編譯器做了聰明的轉換。

當你寫一個含有 yield 的方法時,C# 編譯器會把它轉換成一個實作了 IEnumerator<T> 和 IEnumerable<T> 的類別。這個自動生成的類別就是所謂的 狀態機。

  • 保存狀態:狀態機會保存一個狀態編號(表示暫停的位置)以及在暫停時的所有區域變數值。
  • 迭代:當透過 foreach 遍歷時,會呼叫 MoveNext() 並讀取屬性 Current。MoveNext() 會恢復執行直到下個 yield return/yield break,而 Current 則回傳目前元素。

實際上,編譯器幫你實作了 Iterator Pattern。

4. 生成器的應用

範例:處理大量資料

逐行讀檔而不把整個檔案載入記憶體。


// 模擬讀取大檔案
IEnumerable<string> ReadBigFileLines(string filePath)
{
    Console.WriteLine($"打開檔案: {filePath}");
    // 真實應用會用 StreamReader
    yield return "資料行 1";
    yield return "資料行 2";
    yield return "資料行 3";
    Console.WriteLine("模擬讀檔結束。");
}

// 使用:
Console.WriteLine("開始處理。");
foreach (var line in ReadBigFileLines("my_huge_log.txt"))
{
    Console.WriteLine($"已處理行: {line}");
    if (line.Contains("2")) break; // 想停止就可以停止
}
Console.WriteLine("處理完成。");

注意結束訊息只會在迭代結束後出現。

範例:無限序列


IEnumerable<long> FibonacciSequence()
{
    long a = 0;
    long b = 1;
    while (true) // 潛在的無限序列
    {
        yield return a;
        long temp = a;
        a = b;
        b = temp + b;
    }
}

// 使用:
int count = 0;
foreach (var num in FibonacciSequence())
{
    Console.WriteLine(num);
    count++;
    if (count >= 10) break; // 我們要限制輸出,否則會無限迴圈
}

範例:資料處理管線

建立一連串的方法,每一步都在「飛行中」(on-the-fly)處理資料。


IEnumerable<int> GetNumbers()
{
    yield return 1; yield return 2; yield return 3; yield return 4; yield return 5;
}

IEnumerable<int> FilterEven(IEnumerable<int> source)
{
    foreach (var num in source)
    {
        if (num % 2 == 0) yield return num;
    }
}

IEnumerable<int> Square(IEnumerable<int> source)
{
    foreach (var num in source)
    {
        yield return num * num;
    }
}

// 使用:
foreach (var result in Square(FilterEven(GetNumbers())))
{
    Console.WriteLine(result); // 4, 16
}

這個行為很像很多 LINQ 運算子的運作(例如 Where, Select, Take, Skip)。

5. 非同步生成器

同步生成器很棒,但如果序列的每個元素都需要非同步操作(例如網路請求),那在 C# 8.0 之前就比較難處理。

問題:非同步資料流

你不能在同步的生成器方法裡使用 await。


// 這個不會編譯!
IEnumerable<string> GetStringsAsyncProblem()
{
    await Task.Delay(100); // 錯誤:await 只能在 async 方法中使用
    yield return "Hello";
}

解法:IAsyncEnumerable<T> 與 await foreach

  • IAsyncEnumerable<T> 是 IEnumerable<T> 的非同步對應。
  • await foreach 是遍歷非同步序列的方便語法(內部會呼叫 MoveNextAsync() 並處理非同步狀態)。

async yield return

現在可以在回傳 IAsyncEnumerable<T> 的 async 方法中使用 yield return。編譯器會為你建立非同步的狀態機。


// 範例 5.1:非同步數字生成器
async IAsyncEnumerable<int> GenerateNumbersAsync()
{
    Console.WriteLine("開始非同步生成...");
    for (int i = 0; i < 5; i++)
    {
        await Task.Delay(100); // 模擬非同步工作(例如網路請求)
        Console.WriteLine($"非同步生成: {i}");
        yield return i; // 返回元素
    }
    Console.WriteLine("非同步生成完成。");
}

// 使用:
async Task ConsumeAsyncNumbers()
{
    Console.WriteLine("開始非同步處理...");
    await foreach (var number in GenerateNumbersAsync())
    {
        Console.WriteLine($"非同步取得:{number}");
    }
    Console.WriteLine("非同步處理完成。");
}

// 啟動:
await ConsumeAsyncNumbers(); // 從 async Main 或類似上下文呼叫

IAsyncDisposable 和 await using(在生成器上下文)

如果生成器開啟了需要非同步釋放的資源(實作了 IAsyncDisposable,例如實際的 StreamReader),就用 await using。當 await foreach 結束時,若內部的迭代器實作了 IAsyncDisposable,會自動呼叫 DisposeAsync()。


// 範例 5.2:使用 await using 的非同步讀檔
// 真實的 StreamReader 實作了 IAsyncDisposable
async IAsyncEnumerable<string> ReadFileLinesAsync(string filePath)
{
    Console.WriteLine($"[Generator] 非同步打開檔案: {filePath}");
    // await using 保證離開區塊後會呼叫 DisposeAsync()
    await using var reader = new StreamReader(filePath); 
    
    string? line;
    while ((line = await reader.ReadLineAsync()) != null) // 非同步讀取行
    {
        yield return line;
    }
    Console.WriteLine($"[Generator] 讀檔結束: {filePath}");
}

// 使用:
async Task ProcessFileAsync()
{
    Console.WriteLine("[Handler] 開始處理檔案。");
    await foreach (var line in ReadFileLinesAsync("path_to_some_file.txt")) // 換成真實路徑
    {
        Console.WriteLine($"[Handler] 取得行: {line}");
        // 可以在這裡對每行做非同步處理
        await Task.Delay(50); 
    }
    Console.WriteLine("[Handler] 檔案處理完成。");
}

// 啟動:
await ProcessFileAsync(); // 從 async Main 呼叫

取消非同步生成器:CancellationToken

在非同步生成器中加入 CancellationToken,讓呼叫端可以取消生成流程。


// 範例 5.3:支援取消的非同步生成器
async IAsyncEnumerable<int> GenerateCancelableSequence(
    int start, int count, 
    [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default)
{
    for (int i = 0; i < count; i++)
    {
        token.ThrowIfCancellationRequested(); // 檢查取消 token
        await Task.Delay(100, token); // Task.Delay 也支援取消
        yield return start + i;
    }
}

使用方式


var cts = new CancellationTokenSource();
Task.Run(async () =>
{
    await Task.Delay(300); // 讓生成器先工作一下
    cts.Cancel(); // 發出取消!
});

try
{
    await foreach (var num in GenerateCancelableSequence(0, 100, cts.Token))
    {
        Console.WriteLine($"取得: {num}");
    }
}
catch (OperationCanceledException)
{
    Console.WriteLine("生成已取消。");
}

6. 限制與何時謹慎使用

yield 的限制:

  • 不能在含有 catch 或 finally 且這些區塊也包含 yield 的 try 區塊中使用 yield return。
  • 含有 yield 的方法不能是 unsafe。
  • 不能在 async void 方法中使用 yield(應該使用 async Task 或 IAsyncEnumerable<T>)。

效能:對於非常小或固定的集合,狀態機的開銷可能比直接返回 List<T> 稍高。但對於大量資料,延遲與串流處理帶來的好處通常遠大於這點開銷。

錯誤處理:生成器內拋出的例外會像一般方法一樣被傳到呼叫端,並可以在呼叫端捕獲。

2
任務
C# SELF, 等級 62, 課堂 1
上鎖
非同步區間數字產生器
非同步區間數字產生器
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION