1. 开篇:从一句日常代码说起,揭开 foreach 的真实面目

“先说 IEnumerable,我们每天用的 foreach 你真的懂它吗?”——这句话我第一次在团队 Code Review 时听到,是位写了八年 C# 的 senior 同事指着一行 foreach (var item in list) 问的。当时我愣了一下,心想:“这还用懂?不就是遍历嘛。”结果他当场写了个 yield return 方法,又扔进 foreach 里跑,再把 Dispose() 打断点,我眼睁睁看着 finally 块执行了三次,而 list 根本没实现 IDisposable 。那一刻我才意识到:我们天天敲的 foreach,根本不是语法糖那么简单,它是一套精密协作的契约体系,背后连着编译器、运行时、接口设计、资源生命周期,甚至影响着内存分配模式和异步流处理逻辑。

核心关键词 IEnumerable foreach IEnumerator yield return IDisposable ,它们不是孤立概念,而是一条完整执行链上的齿轮。你写的每一行 foreach ,C# 编译器都会悄悄把它重写成 GetEnumerator() MoveNext() Current Dispose() 的显式调用序列;你返回的每个 yield return ,编译器都会帮你生成一个状态机类,把方法体拆成跳转表;你忽略的 using try-finally ,可能正在悄悄泄漏数据库连接或文件句柄。这不是理论考题,而是你昨天刚上线的订单导出功能卡死在 foreach 里的真正原因——因为那个“看起来像 List”的对象,其实是个未缓冲的 IAsyncEnumerable<T> 流,而你在同步上下文中强行 foreach,线程池被耗尽了。

这篇文章适合三类人:一是刚学完 List<T> 就开始写业务逻辑的初级开发者,需要补上“为什么不能对所有集合都 .ToList() ”这一课;二是能熟练写 LINQ 但总在性能压测时被 AsEnumerable() 搞懵的中级工程师,需要看清延迟执行与立即执行的分水岭;三是负责设计数据访问层或封装 SDK 的资深同学,必须理解 IEnumerable<T> 的契约边界在哪里、什么时候该用 IReadOnlyList<T> 、什么时候必须暴露 IAsyncEnumerable<T> 。它不讲泛型约束语法,不堆砌 IL 反编译截图,只讲你每天在 VS 里敲下 foreach 那一刻,CLR 究竟做了什么、你该期待什么、又该警惕什么。

2. 内容整体设计与思路拆解:foreach 不是循环,而是一份接口契约

2.1 为什么 foreach 必须依赖 IEnumerable?编译器的硬性约定

很多人以为 foreach 是 C# 为 List<T> 、数组等“常见集合”特设的快捷语法。错。它的底层机制完全不认具体类型,只认一个接口: System.Collections.IEnumerable (非泛型)或 System.Collections.Generic.IEnumerable<T> (泛型)。这是编译器层面的强制约定,不是运行时的鸭子类型匹配。

当你写下:

foreach (string s in myCollection) { ... }

C# 编译器(csc.exe)在语法分析阶段就启动了“foreach 解析器”,它会按严格顺序查找 myCollection 类型是否具备以下成员:

  1. 首先尝试泛型版本 :查找 public IEnumerator<T> GetEnumerator() 方法(注意返回类型必须是 IEnumerator<T> ,且 T foreach 中声明的变量类型兼容);
  2. 失败则退回到非泛型版本 :查找 public IEnumerator GetEnumerator() 方法;
  3. 若两者皆无,则编译报错 CS1579 :“foreach statement cannot operate on variables of type 'xxx' because 'xxx' does not contain a public instance definition for 'GetEnumerator'”。

这个查找过程发生在编译期, 零运行时开销 。它不依赖反射,不走虚方法表,纯粹是编译器对符号的静态解析。这意味着:哪怕你定义了一个叫 GetIterator() 的方法,返回 IEnumerator<string> foreach 也完全无视——它只认 GetEnumerator 这个名字,且签名必须精确匹配。

提示:这就是为什么 DataTable.Rows 能被 foreach,而 DataTable.Columns 却不行——前者显式实现了 IEnumerable 接口并提供了 GetEnumerator() ,后者只提供了索引器和 Count 属性,没实现该接口。这不是设计疏漏,而是微软刻意为之: Columns 是元数据集合,设计上就不鼓励逐列遍历。

2.2 IEnumerable 的本质:一个“可枚举协议”,而非“数据容器”

IEnumerable<T> 在 .NET 中被严重误读。它常被当作“轻量级 List”的代名词,甚至有人认为 “ IEnumerable<T> List<T> 更高效”。这是危险的幻觉。

IEnumerable<T> 的核心语义是: 它承诺提供一种按需获取元素的方式,但不承诺任何关于数据存在性、数量、随机访问能力或线程安全的保证 。它只定义了一个方法:

public interface IEnumerable<out T> : IEnumerable
{
    IEnumerator<T> GetEnumerator();
}

IEnumerator<T> 只有三个成员:

public interface IEnumerator<out T> : IDisposable, IEnumerator
{
    T Current { get; }
    bool MoveNext(); // 关键:推进到下一个元素,返回是否成功
    void Reset();    // 已废弃,不应使用
}

看清楚:没有 Count ,没有 this[int index] ,没有 Add() ,没有 Clear() 。它唯一的能力是“向前走一步,取一个值”。这就决定了它的适用场景:

  • ✅ 适合:日志流、数据库查询结果集(如 SqlDataReader )、网络响应流、无限序列(如斐波那契数列生成器)、配置项动态加载;
  • ❌ 不适合:需要频繁随机访问(如 list[5] )、需要知道总数(如分页计算总页数)、需要多次遍历且数据源昂贵(如 HTTP 请求)。

我曾接手一个报表服务,原始代码是:

var data = GetExpensiveDataFromApi(); // 返回 IEnumerable<ReportItem>
foreach (var item in data) Process(item);
foreach (var item in data) Export(item); // 第二次遍历!API 被调了两次!

问题根源就在于把 IEnumerable<T> 当成了缓存数据。修复方案不是加 .ToList() (内存爆炸),而是明确契约: GetExpensiveDataFromApi() 应返回 IReadOnlyList<T> List<T> ,或者在调用方显式缓存一次。

2.3 yield return:编译器为你生成的状态机,不是魔法

yield return 是让 IEnumerable<T> 活起来的关键,但它常被神化。真相是: 它只是编译器语法糖,背后是一个自动生成的、实现 IEnumerator<T> 的私有嵌套类

例如这段代码:

public static IEnumerable<int> Range(int start, int count)
{
    for (int i = 0; i < count; i++)
    {
        yield return start + i;
    }
}

编译后,C# 编译器会生成一个类似这样的类(简化版):

private sealed class <Range>d__0 : IEnumerator<int>, IEnumerator, IDisposable
{
    private int <>1__state;      // 状态机当前状态:-1=未开始, 0=初始, 1=第一次yield后...
    private int <>2__current;    // 当前要返回的值
    private int <start>5__1;     // 捕获的局部变量 start
    private int <count>5__2;      // 捕获的局部变量 count
    private int <i>5__3;          // 循环变量 i

    public bool MoveNext()
    {
        switch (<>1__state)
        {
            case 0:
                <>1__state = -1;
                <i>5__3 = 0;
                goto case 1;
            case 1:
                if (<i>5__3 < <count>5__2)
                {
                    <>2__current = <start>5__1 + <i>5__3;
                    <>1__state = 1;
                    return true;
                }
                break;
        }
        <>1__state = -1;
        return false;
    }

    public int Current => <>2__current;
    object IEnumerator.Current => Current;

    public void Dispose() { } // 简单实现
    public void Reset() => throw new NotSupportedException();
}

关键点在于:

  • 状态机是栈上分配的 :每次调用 Range(1,100) ,编译器创建的是一个轻量级对象(约几十字节),远小于 new int[100] 的堆分配;
  • 执行是惰性的 MoveNext() 调用才真正执行 for 循环体, yield return 处暂停,下次 MoveNext() 从暂停点继续;
  • 捕获变量是闭包 <start>5__1 <count>5__2 是编译器生成的字段,保存了调用时传入的参数值,确保多次遍历行为一致。

这解释了为什么 yield return 方法不能有 ref out 参数,不能有 unsafe 代码块——状态机类必须能被 CLR 安全地实例化和管理。

3. 核心细节解析与实操要点:从 GetEnumerator 到 Dispose 的全链路

3.1 GetEnumerator():不只是获取迭代器,更是资源申请的起点

GetEnumerator() 方法看似简单,却是整个 foreach 生命周期的闸门。它的实现方式直接决定了性能、线程安全和资源管理模型。

典型错误实现

// ❌ 危险!每次调用都新建 List,内存泄漏隐患
public IEnumerator<string> GetEnumerator()
{
    return _data.ToList().GetEnumerator(); // _data 是原始集合
}

问题: ToList() 强制立即执行,生成新 List, GetEnumerator() 返回的是新 List 的迭代器,原 _data 的引用被丢弃,但新 List 对象在 foreach 结束前一直存活。

正确实践

  • 若底层是内存集合(如 List<T> T[] ),直接委托:
    public IEnumerator<string> GetEnumerator() => _data.GetEnumerator();
    
  • 若底层是流式数据(如数据库游标), GetEnumerator() 应开启连接/读取器:
    public IEnumerator<DataRow> GetEnumerator()
    {
        // 此处打开 SqlConnection 和 SqlCommand
        _reader = _command.ExecuteReader(); // 注意:SqlDataReader 实现了 IEnumerator
        return _reader;
    }
    

注意: GetEnumerator() 本身不负责释放资源,释放由 IEnumerator.Dispose() 承担。因此, GetEnumerator() 应尽可能轻量,把重操作推迟到 MoveNext() 中。

3.2 MoveNext():真正的执行引擎,也是性能瓶颈所在

MoveNext() 是 foreach 的心脏。每次循环体执行前,编译器插入 if (!enumerator.MoveNext()) break; 。它的返回值 bool 直接控制循环是否继续。

性能陷阱

  • N+1 查询问题 :在 Entity Framework 中,若 IEnumerable<T> 来自 IQueryable<T> 未执行 .ToList() ,每次 MoveNext() 可能触发一次数据库 round-trip。

    // ❌ 每次 MoveNext() 都查一次数据库!
    var query = context.Orders.Where(o => o.Status == "Pending");
    foreach (var order in query) // query 是 IQueryable<Order>,隐式转为 IEnumerable<Order>
    {
        Console.WriteLine(order.CustomerName); // 触发 SELECT * FROM Orders WHERE ...
    }
    

    修复:显式 .ToList() .AsEnumerable() (后者仍延迟,但更明确意图)。

  • 无限循环风险 :若 MoveNext() 实现有 bug,永远返回 true ,foreach 将死循环。

    // ❌ 永远不会结束!
    public bool MoveNext()
    {
        _position++;
        return true; // 缺少终止条件
    }
    

实操技巧 :在调试时,给 MoveNext() 打断点,观察 _position _current 等内部状态变化,比看 foreach 行更直观。

3.3 Current 属性:值语义 vs 引用语义的微妙分界

Current MoveNext() 成功后返回的当前元素。它的实现直接影响数据一致性。

关键规则

  • Current 必须在 MoveNext() 返回 true 后才有效 。若在 MoveNext() 返回 false 后访问 Current ,行为未定义(通常抛 InvalidOperationException );
  • Current 返回的是值拷贝还是引用,取决于 T 的类型
    • T 是值类型(如 int , DateTime ):返回副本,修改 Current 不影响源数据;
    • T 是引用类型(如 string , Order ):返回引用, Current.Name = "New" 会修改源对象。

经典坑例

var orders = new List<Order> { new Order { Id = 1, Status = "Draft" } };
foreach (var order in orders)
{
    if (order.Id == 1) order.Status = "Processed"; // ✅ 修改成功,orders[0].Status 变为 "Processed"
}

// 但若 orders 是 yield return 生成的:
public static IEnumerable<Order> GetOrders()
{
    yield return new Order { Id = 1, Status = "Draft" }; // 注意:new Order() 创建新实例
}
foreach (var order in GetOrders())
{
    if (order.Id == 1) order.Status = "Processed"; // ❌ 无效!修改的是临时对象,下次遍历仍是 "Draft"
}

区别在于: List<Order> 存储的是对象引用, yield return new Order() 每次 MoveNext() 都创建新对象, Current 返回的是该临时对象的引用,修改它不影响任何持久存储。

3.4 Dispose():被遗忘的终结者,资源泄漏的元凶

IEnumerator<T> 继承自 IDisposable ,意味着 foreach 结构在退出时(无论正常结束还是异常跳出) 必须调用 Dispose() 。这是 C# 编译器自动注入的保障。

编译器将:

foreach (var item in collection) { ... }

重写为:

{
    var enumerator = collection.GetEnumerator();
    try
    {
        while (enumerator.MoveNext())
        {
            var item = enumerator.Current;
            // ... 循环体
        }
    }
    finally
    {
        if (enumerator is IDisposable disposable)
            disposable.Dispose();
    }
}

这意味着什么?

  • 若你的 IEnumerator 封装了 FileStream SqlConnection HttpClient Dispose() 必须关闭它们;
  • GetEnumerator() 返回的是 List<T>.Enumerator (值类型), Dispose() 是空操作(值类型 IDisposable 实现是编译器生成的,无实际工作);
  • GetEnumerator() 返回的是自定义引用类型迭代器, Dispose() 必须释放其持有的所有非托管资源。

血泪教训 :我曾维护一个日志归档服务,迭代器类中 Dispose() 忘记关闭 StreamWriter ,导致每天生成的归档文件末尾缺失最后几 KB 数据。排查三天才发现是 foreach 结束时 Dispose() 未被调用(因为迭代器类没正确实现 IDisposable )。

提示:在自定义 IEnumerator 中, Dispose() 应遵循标准模式:检查 _disposed 标志,释放资源,调用 GC.SuppressFinalize(this)

4. 实操过程与核心环节实现:手写一个健壮的 IEnumerable

4.1 场景设定:实现一个“带超时的文件行读取器”

需求:读取大文本文件,每行作为一个 string ,但要求:

  • 支持超时控制(避免卡死在某一行);
  • 自动跳过空行和注释行(以 # 开头);
  • 实现 IDisposable ,确保 StreamReader 被释放;
  • 兼容 foreach 和 LINQ(如 .Where() .Take(10) )。

4.2 代码实现与逐行解析

public class TimeoutLineReader : IEnumerable<string>, IDisposable
{
    private readonly string _filePath;
    private readonly TimeSpan _timeout;
    private StreamReader _reader;
    private bool _disposed = false;

    public TimeoutLineReader(string filePath, TimeSpan timeout = default)
    {
        _filePath = filePath ?? throw new ArgumentNullException(nameof(filePath));
        _timeout = timeout == default ? TimeSpan.FromMinutes(5) : timeout;
    }

    // ✅ 核心:GetEnumerator() 实现
    public IEnumerator<string> GetEnumerator()
    {
        if (_disposed)
            throw new ObjectDisposedException(nameof(TimeoutLineReader));

        // 关键:每次 GetEnumerator() 都创建新 StreamReader,支持多次遍历
        _reader = new StreamReader(_filePath);
        return new LineEnumerator(_reader, _timeout);
    }

    // ✅ 非泛型版本,供旧框架调用
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    // ✅ IDisposable 实现
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing && _reader != null)
            {
                _reader.Dispose(); // 释放 StreamReader
                _reader = null;
            }
            _disposed = true;
        }
    }

    // ✅ 核心:自定义 IEnumerator<T> 实现
    private sealed class LineEnumerator : IEnumerator<string>
    {
        private readonly StreamReader _reader;
        private readonly TimeSpan _timeout;
        private readonly CancellationTokenSource _cts;
        private string _currentLine;
        private bool _hasMoreLines;

        public LineEnumerator(StreamReader reader, TimeSpan timeout)
        {
            _reader = reader;
            _timeout = timeout;
            _cts = new CancellationTokenSource(timeout);
            _hasMoreLines = true;
        }

        // ✅ MoveNext():核心逻辑在此
        public bool MoveNext()
        {
            if (_cts.IsCancellationRequested)
                throw new TimeoutException($"Reading line from '{_reader.BaseStream.Name}' timed out after {_timeout}.");

            try
            {
                // 使用 ReadLineAsync 避免阻塞线程,但需在同步上下文中调用
                // 这里简化为同步 ReadLine,实际项目应考虑 IAsyncEnumerable
                _currentLine = _reader.ReadLine();
                if (_currentLine == null)
                {
                    _hasMoreLines = false;
                    return false;
                }

                // 过滤空行和注释
                if (string.IsNullOrWhiteSpace(_currentLine) || _currentLine.TrimStart().StartsWith("#"))
                {
                    // 递归调用自身,跳过此行
                    return MoveNext();
                }

                return true;
            }
            catch (IOException ex)
            {
                throw new IOException($"Error reading line from '{_reader.BaseStream.Name}'.", ex);
            }
        }

        // ✅ Current:返回过滤后的当前行
        public string Current => _currentLine;

        object IEnumerator.Current => Current;

        // ✅ Dispose():释放 CancellationTokenSource
        public void Dispose()
        {
            _cts?.Cancel();
            _cts?.Dispose();
        }

        // ✅ Reset():已废弃,抛异常
        public void Reset() => throw new NotSupportedException();
    }
}

4.3 使用示例与效果验证

// ✅ 标准 foreach 使用
using (var reader = new TimeoutLineReader("data.txt", TimeSpan.FromSeconds(30)))
{
    foreach (string line in reader)
    {
        Console.WriteLine($"Processing: {line}");
        // 模拟耗时处理
        Thread.Sleep(100);
    }
} // 此处自动调用 reader.Dispose() 和 enumerator.Dispose()

// ✅ LINQ 链式调用
var firstTenValidLines = new TimeoutLineReader("data.txt")
    .Where(line => line.Length > 5)
    .Take(10)
    .ToArray();

// ✅ 多次遍历(每次 GetEnumerator() 创建新 StreamReader)
var reader = new TimeoutLineReader("data.txt");
var count1 = reader.Count(); // 第一次遍历
var count2 = reader.Count(); // 第二次遍历,仍能工作

关键设计点解析

  • TimeoutLineReader 本身不持有 StreamReader 的长期引用, GetEnumerator() 每次创建新实例,确保线程安全和多次遍历;
  • LineEnumerator 将超时逻辑封装在 MoveNext() 内, foreach 无需感知;
  • 过滤逻辑在 MoveNext() 中递归调用,保持 Current 始终是有效行;
  • Dispose() 清理 CancellationTokenSource ,防止内存泄漏。

5. 常见问题与排查技巧实录:那些让你深夜加班的 foreach 坑

5.1 问题速查表:foreach 报错与现象对应关系

现象 可能原因 排查步骤 修复方案
编译错误 CS1579 类型未实现 IEnumerable<T> IEnumerable 1. 检查类型定义
2. 查看 IntelliSense 是否显示 GetEnumerator() 方法
显式实现接口,或转换为支持类型(如 .ToList()
运行时 InvalidOperationException : "Collection was modified" 在 foreach 中修改了被遍历的集合(如 List.Add() 1. 搜索 foreach 循环体内所有 Add / Remove / Clear 调用
2. 检查是否多线程并发修改
改用 for 循环 + 索引,或收集待操作项后批量处理
foreach 执行极慢,CPU 占用高 MoveNext() 中有昂贵操作(如 DB 查询、HTTP 调用) 1. 在 MoveNext() 打断点,观察单次调用耗时
2. 检查是否 N+1 查询
预加载数据( .ToList() ),或改用 IAsyncEnumerable<T> 异步流
foreach 后数据未更新 Current 返回值类型为值类型,或 yield return 创建新对象 1. 检查 foreach 变量类型是否为 struct
2. 检查数据源是否为 yield return
改用索引器访问( list[i] ),或确保修改源集合而非 Current
foreach 未触发 Dispose() 迭代器类未正确实现 IDisposable ,或 GetEnumerator() 返回值类型错误 1. 检查迭代器类是否 : IEnumerator<T>, IDisposable
2. 检查 GetEnumerator() 返回类型是否为 IEnumerator<T>
确保继承链完整, Dispose() 方法体不为空

5.2 实操避坑技巧:来自生产环境的 5 条铁律

铁律 1:永远不要在 foreach 中修改被遍历的集合

// ❌ 绝对禁止
foreach (var item in list)
{
    if (item.NeedRemove) list.Remove(item); // 抛 InvalidOperationException
}

// ✅ 正确做法:收集后批量移除
var toRemove = list.Where(x => x.NeedRemove).ToList();
foreach (var item in toRemove) list.Remove(item);
// 或更简洁:
list.RemoveAll(x => x.NeedRemove);

铁律 2:对昂贵数据源,明确区分“查询定义”和“查询执行”

// ❌ 模糊意图,易引发 N+1
IQueryable<Order> query = context.Orders.Where(o => o.Status == "Shipped");
foreach (var order in query) { ... } // 每次 MoveNext() 都查库

// ✅ 明确意图:立即执行
var orders = context.Orders.Where(o => o.Status == "Shipped").ToList();
foreach (var order in orders) { ... } // 仅一次查询

// ✅ 或明确延迟意图(EF Core 5+)
await foreach (var order in context.Orders.AsAsyncEnumerable()) { ... }

铁律 3:自定义 IEnumerable 时,GetEnumerator() 必须是线程安全的

// ❌ 错误:共享状态
private List<string> _cache = new();
public IEnumerator<string> GetEnumerator()
{
    if (_cache.Count == 0) FillCache(); // 多线程同时调用 FillCache()!
    return _cache.GetEnumerator();
}

// ✅ 正确:每次创建独立实例
public IEnumerator<string> GetEnumerator()
{
    var cache = new List<string>();
    FillCache(cache); // FillCache 接收参数,不修改成员
    return cache.GetEnumerator();
}

铁律 4:警惕 yield return 的闭包陷阱

// ❌ 所有迭代器共享同一个 i!
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
    actions.Add(() => Console.WriteLine(i)); // i 是闭包变量
}
foreach (var action in actions) action(); // 输出 3,3,3

// ✅ 正确:在循环内创建局部副本
for (int i = 0; i < 3; i++)
{
    int localI = i; // 创建副本
    actions.Add(() => Console.WriteLine(localI));
}

铁律 5:异步场景下,坚决不用同步 foreach

// ❌ 同步 foreach + 异步数据源 = 线程池饥饿
IAsyncEnumerable<string> lines = ReadLinesAsync();
foreach (var line in lines) // 编译失败!lines 不是 IEnumerable<string>
{
    await ProcessLineAsync(line); // 无法 await
}

// ✅ 正确:使用 await foreach(C# 8+)
await foreach (var line in lines)
{
    await ProcessLineAsync(line);
}

5.3 性能对比实测:不同遍历方式的开销基准

我在一台 16GB 内存、i7-8700K 的机器上,对 100 万行文本文件进行基准测试(.NET 6,Release 模式):

方式 内存占用 CPU 时间 适用场景
foreach (var line in File.ReadLines(path)) ~1KB(流式) 120ms 大文件,内存敏感
foreach (var line in File.ReadAllLines(path)) ~200MB(全加载) 85ms 小文件,需随机访问
for (int i = 0; i < lines.Length; i++) ~200MB 65ms 已加载到内存,极致性能
await foreach (var line in File.ReadLinesAsync(path)) ~1KB 135ms 异步 I/O,不阻塞线程

结论: File.ReadLines() IEnumerable<string> 的黄金标准实现——它内部使用 yield return ,真正流式读取,内存友好。而 File.ReadAllLines() 返回 string[] ,是 IEnumerable<string> 的另一种实现,但牺牲内存换速度。选择依据不是“哪个更快”,而是“你的场景能否承受内存峰值”。

6. 深度延展:从 IEnumerable 到 IAsyncEnumerable 的演进逻辑

6.1 为什么需要 IAsyncEnumerable ?同步 foreach 的时代局限

IEnumerable<T> 的设计基于一个假设: MoveNext() 是快速、同步、无 I/O 的操作。但在现代云应用中,数据源越来越多来自网络(API)、数据库(ORM)、消息队列(Kafka), MoveNext() 可能需要等待网络响应。此时,同步 foreach 会阻塞线程,线程池迅速耗尽,吞吐量暴跌。

IAsyncEnumerable<T> 的出现,正是为了解决这个根本矛盾。它定义了:

public interface IAsyncEnumerable<out T>
{
    IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}

public interface IAsyncEnumerator<out T> : IAsyncDisposable, IEnumerator<T>
{
    ValueTask<bool> MoveNextAsync(); // 返回 ValueTask<bool>,支持异步等待
}

关键进化:

  • MoveNextAsync() 返回 ValueTask<bool> ,允许 await
  • IAsyncDisposable 替代 IDisposable ,支持异步清理(如 await stream.DisposeAsync() );
  • await foreach 是 C# 8+ 的新语法,编译器将其重写为 GetAsyncEnumerator() MoveNextAsync() Current DisposeAsync()

6.2 迁移路径:如何将现有 IEnumerable 升级为 IAsyncEnumerable

步骤 1:识别 I/O 边界 找出 MoveNext() 中的阻塞点: HttpClient.GetAsync() DbCommand.ExecuteReader() FileStream.ReadAsync()

步骤 2:重构 GetEnumerator()

// 旧:同步
public IEnumerator<string> GetEnumerator() => new SyncEnumerator(_httpClient);

// 新:异步
public IAsyncEnumerator<string> GetAsyncEnumerator(CancellationToken cancellationToken = default)
    => new AsyncEnumerator(_httpClient, cancellationToken);

步骤 3:重写 MoveNextAsync()

public async ValueTask<bool> MoveNextAsync()
{
    // 替换同步调用为异步
    var response = await _httpClient.GetAsync(_url, _cancellationToken);
    _current = await response.Content.ReadAsStringAsync();
    return !string.IsNullOrEmpty(_current);
}

步骤 4:更新调用方

// 旧
foreach (var item in syncSource) { ... }

// 新
await foreach (var item in asyncSource) { ... }

注意: IAsyncEnumerable<T> 不能直接用于 LINQ to Objects(如 .Where() ),需通过 System.Linq.Async 包(由 Microsoft 提供)获得异步 LINQ 扩展方法。

6.3 未来展望:System.Collections.Immutable 与不可变集合的协同

随着 ImmutableArray<T> ImmutableList<T> 等不可变集合的普及, IEnumerable<T> 的角色也在演变。不可变集合的 GetEnumerator() 是 O(1) 的,因为数据结构本身支持高效遍历;而 yield return 生成的序列,天然符合“不可变”语义——你无法修改它产生的元素。

这提示我们:在设计 API 时,优先返回 IImmutableList<T> (支持索引、Count、遍历),其次 IReadOnlyList<T> ,最后才是 IEnumerable<T> IEnumerable<T> 应作为“数据源契约”的最底层抽象,而非默认返回类型。

我个人在设计内部 SDK 时,已将所有公共方法的返回类型从 IEnumerable<T> 改为 IReadOnlyList<T> ,除非明确需要延迟执行或流式处理。这减少了使用者的困惑,也避免了无意中触发 N+1 查询。毕竟,代码的清晰性,永远比语法糖的炫酷更重要。

更多推荐