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); // 一百万个数一次性在内存里!
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() 并读取 CurrentMoveNext() 会恢复执行直到下一个 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; // 我们需要限制迭代以免卡死
}

示例:数据处理管道

创建一系列方法链,每一步对数据进行“在线”处理。


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 或类似上下文中调用

IAsyncDisposableawait using(在生成器上下文)

如果生成器打开了需要异步释放的资源(实现了 DisposeAsync()),就用 await using。当 await foreach 结束时,如果内部迭代器实现了 IAsyncDisposable,会自动调用 DisposeAsync()


// 示例 5.2:使用 await using 的异步文件读取
// 真实的 StreamReader 实现了 IAsyncDisposable
async IAsyncEnumerable<string> ReadFileLinesAsync(string filePath)
{
    Console.WriteLine($"[生成器] 异步打开文件: {filePath}");
    // await using 保证在离开块后调用 DisposeAsync()
    await using var reader = new StreamReader(filePath); 
    
    string? line;
    while ((line = await reader.ReadLineAsync()) != null) // 异步读取行
    {
        yield return line;
    }
    Console.WriteLine($"[生成器] 文件读取结束: {filePath}");
}

// 使用:
async Task ProcessFileAsync()
{
    Console.WriteLine("[处理器] 开始处理文件。");
    await foreach (var line in ReadFileLinesAsync("path_to_some_file.txt")) // 换成真实路径
    {
        Console.WriteLine($"[处理器] 收到行: {line}");
        // 这里可以对每行进行异步处理
        await Task.Delay(50); 
    }
    Console.WriteLine("[处理器] 文件处理完成。");
}

// 启动:
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(); // 检查取消令牌
        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 的限制:

  • 不能在包含 catchfinally 且这些块中也使用 yieldtry 块中使用 yield return
  • yield 的方法不能是 unsafe 的。
  • 不能在 async void 方法中使用 yield(应使用 async TaskIAsyncEnumerable<T>)。

性能:对于非常小或固定的集合,状态机的开销可能比直接返回 List<T> 略高。但是在大数据场景下,惰性和流式处理带来的好处通常更重要。

错误处理:在生成器内部抛出的异常会正确传播到调用方,调用方可以像处理普通方法抛出的异常那样捕获它们。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION