C#迭代器引发托管内存泄漏的原因与解决方案咨询
我有一个用于生成DNA序列的类,DNA序列以长字符串形式表示,该类实现了IEnumerable<string>接口,可生成无限数量的DNA序列,以下是该类的简化实现:
class DnaGenerator : IEnumerable<string> { private readonly IEnumerable<string> _enumerable; public DnaGenerator() => _enumerable = Iterator(); private IEnumerable<string> Iterator() { while (true) foreach (char c in new char[] { 'A', 'C', 'G', 'T' }) yield return new String(c, 10_000_000); } public IEnumerator<string> GetEnumerator() => _enumerable.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); }
该类通过迭代器(iterator)生成DNA序列,我并未反复调用迭代器方法,而是在类构造阶段创建一个IEnumerable<string>实例,将其缓存为私有字段。但使用该类时会出现持续占用大量内存的问题,垃圾回收器(garbage collector)无法回收这部分内存,以下是复现该行为的最小示例:
var dnaGenerator = new DnaGenerator(); Console.WriteLine($"TotalMemory: {GC.GetTotalMemory(true):#,0} bytes"); DoWork(dnaGenerator); GC.Collect(); Console.WriteLine($"TotalMemory: {GC.GetTotalMemory(true):#,0} bytes"); GC.KeepAlive(dnaGenerator); static void DoWork(DnaGenerator dnaGenerator) { foreach (string dna in dnaGenerator.Take(5)) { Console.WriteLine($"Processing DNA of {dna.Length:#,0} nucleotides" + $", starting from {dna[0]}"); } }
运行输出:
TotalMemory: 84,704 bytes Processing DNA of 10,000,000 nucleotides, starting from A Processing DNA of 10,000,000 nucleotides, starting from C Processing DNA of 10,000,000 nucleotides, starting from G Processing DNA of 10,000,000 nucleotides, starting from T Processing DNA of 10,000,000 nucleotides, starting from A TotalMemory: 20,112,680 bytes
可在线运行测试该代码。
我原本预期所有生成的DNA序列都可被垃圾回收,因为程序中没有持有这些序列的引用,我仅持有DnaGenerator实例本身的引用,该组件仅负责生成序列,不应存储任何序列。但无论程序生成多少序列,执行完整垃圾回收后始终有约20MB内存被占用。
我的问题是: 该现象出现的原因是什么?要如何避免该问题?
运行环境:.NET 6.0、Windows 10 64位操作系统、x64架构处理器、Release版本编译。
补充说明: 如果将如下代码:
public IEnumerator<string> GetEnumerator() => _enumerable.GetEnumerator();
替换为以下代码:
public IEnumerator<string> GetEnumerator() => Iterator().GetEnumerator();
上述内存问题就会消失。但我不希望每次获取枚举器时都创建新的可枚举实例,根据我的理解,单个IEnumerable<T>可创建多个IEnumerator<T>实例,二者并非一对一的关系。
这个问题来自C#编译器对yield迭代器块的实现细节,和通用的IEnumerable<T>/IEnumerator<T>接口约定无关:
- 当编写返回
IEnumerable<T>的yield迭代器方法时,C#编译器会自动生成一个隐藏的状态机类,这个类同时实现了IEnumerable<T>和IEnumerator<T>两个接口。 - 这个设计是针对常见单次枚举场景的性能优化:当直接对迭代器方法返回值调用
GetEnumerator()(比如直接foreach遍历方法返回结果),第一次调用时状态机实例会直接把自身作为枚举器返回,省去额外创建新枚举器对象的开销。 - 状态机类会把迭代过程中的所有局部变量、当前执行位置、最后一次
yield return返回的值全部存在自身的实例字段上。即使foreach执行完后调用了枚举器的Dispose()方法,也只会把状态机标记为结束状态,不会主动清空字段上存储的对象引用。 - 你的代码里把
Iterator()返回的状态机实例缓存为DnaGenerator的私有字段,而DnaGenerator实例本身在GC.KeepAlive调用前始终是可达的存活对象,导致这个状态机实例也一直被标记为存活。遍历过程中最后一次生成的1000万字符长度字符串(每个char占2字节,加上少量对象开销刚好约20MB)被存在状态机的字段上,始终存在可达引用,因此GC无法回收这部分内存。 - 你测试的替换写法每次调用
GetEnumerator()都会执行一次Iterator(),生成全新的状态机实例。这个新实例在DoWork方法执行完后就没有任何可达引用,会被GC正常回收,它持有的字符串也会被一并回收,因此不会出现内存残留。 - 你担心的「每次调用Iterator()创建新可枚举实例开销大」是完全没有必要的:迭代器方法是延迟执行的,调用
Iterator()时只会创建体积极小的状态机实例(仅几十个字节,只包含几个状态控制字段),不会执行任何迭代逻辑,也不会提前生成字符串,性能开销可以忽略不计。
按推荐优先级排序:
直接使用你验证过的写法,移除
_enumerable缓存字段,在GetEnumerator()中直接返回Iterator().GetEnumerator()
这是最符合迭代器语义、代码最简洁、性能最优的方案,完全没有额外开销,也从根源上避免了长生命周期状态机持有大对象引用的问题。
修正后的完整代码如下:class DnaGenerator : IEnumerable<string> { private IEnumerable<string> Iterator() { while (true) foreach (char c in new char[] { 'A', 'C', 'G', 'T' }) yield return new String(c, 10_000_000); } public IEnumerator<string> GetEnumerator() => Iterator().GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); }如果你出于某些原因一定要缓存
IEnumerable实例,可以用无操作的LINQ算子包装迭代器结果,强制每次调用GetEnumerator()时生成独立的枚举器,避免直接持有编译器生成的自引用状态机:public DnaGenerator() => _enumerable = Iterator().Select(s => s);这种写法是利用LINQ算子的延迟执行特性绕开问题,可读性和可靠性不如第一种方案,非必要不推荐。
自行实现
IEnumerator<T>接口完成迭代逻辑,在Dispose()方法中主动清空持有的大对象引用。这种方案需要手动编写状态机逻辑,代码量大且容易出错,没有特殊性能需求不建议使用。
内容的提问来源于stack exchange,提问作者Theodor Zoulias

