我有一个生成 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();
}
此类使用迭代器生成DNA序列。无需一次又一次地调用迭代器,而是在构造过程中创建一个
IEnumerable<string>
实例并将其缓存为私有字段。问题在于,使用此类会导致不断分配相当大的内存块,而“垃圾收集器”无法回收该块。以下是此行为的最小演示:
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
实例本身的引用,它并不意味着包含任何序列。该组件仅生成序列。然而,无论我的程序生成多少序列,在完全垃圾回收后总会分配大约 20 MB 的内存。
我的问题是:为什么会发生这种情况?我怎样才能防止这种情况发生? .NET 6.0、Windows 10、64 位操作系统、基于 x64 的处理器、Release 内置。
如果我更换这个,问题就会消失:
public IEnumerator<string> GetEnumerator() => _enumerable.GetEnumerator();
...用这个:
public IEnumerator<string> GetEnumerator() => Iterator().GetEnumerator();
但我不喜欢每次需要枚举器时创建一个新的枚举。我的理解是,单个
可以创建许多
IEnumerator<T>
。 AFAIK 这两个接口并不意味着具有一对一的关系。
yield
自动生成的代码实现引起的。
您可以通过显式实现枚举器来稍微缓解这种情况。您必须通过从
.Reset()
调用
public IEnumerator<string> GetEnumerator()
来稍微修改一下,以确保每次调用时都会重新启动枚举:class DnaGenerator : IEnumerable<string>
{
private readonly IEnumerator<string> _enumerable;
public DnaGenerator() => _enumerable = new IteratorImpl();
sealed class IteratorImpl : IEnumerator<string>
{
public bool MoveNext()
{
return true; // Infinite sequence.
}
public void Reset()
{
_index = 0;
}
public string Current
{
get
{
var result = new String(_data[_index], 10_000_000);
if (++_index >= _data.Length)
_index = 0;
return result;
}
}
public void Dispose()
{
// Nothing to do.
}
readonly char[] _data = { 'A', 'C', 'G', 'T' };
int _index;
object IEnumerator.Current => Current;
}
public IEnumerator<string> GetEnumerator()
{
_enumerable.Reset();
return _enumerable;
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
,您会注意到
yeild return
会生成内部 <Iterator>
类,该类又具有 current
字段来存储字符串(以实现 IEnumerator<string>.Current
):[CompilerGenerated]
private sealed class <Iterator>d__2 : IEnumerable<string>, IEnumerable, IEnumerator<string>, IEnumerator, IDisposable
{
...
private string <>2__current;
...
}
并且
Iterator
方法内部将被编译为如下所示:
[IteratorStateMachine(typeof(<Iterator>d__2))]
private IEnumerable<string> Iterator()
{
return new <Iterator>d__2(-2);
}
这导致当前字符串始终存储在内存中以用于
_enumerable.GetEnumerator();
实现(迭代开始后),而
DnaGenerator
实例本身并未被GC。UPD我的理解是,单个 IEnumerable 可以创建许多 IEnumerator。 AFAIK 这两个接口并不意味着具有一对一的关系。
是的,如果为
yield return
枚举生成,它可以创建多个枚举器,但在这种特殊情况下,实现具有“一对一”关系,因为生成的实现既是
IEnumerable
又是 IEnumerator
:private sealed class <Iterator>d__2 :
IEnumerable<string>, IEnumerable,
IEnumerator<string>, IEnumerator,
IDisposable
但我不喜欢每次需要枚举器时创建一个新的枚举。但这实际上是当你调用
_enumerable.GetEnumerator()
时发生的事情(这显然是一个实现细节),如果你检查已经提到的反编译,你会发现
_enumerable = Iterator()
实际上是 new <Iterator>d__2(-2)
并且 <Iterator>d__2.GetEnumerator()
看起来像这样: IEnumerator<string> IEnumerable<string>.GetEnumerator()
{
if (<>1__state == -2 && <>l__initialThreadId == Environment.CurrentManagedThreadId)
{
<>1__state = 0;
return this;
}
return new <Iterator>d__2(0);
}
所以它实际上应该每次都创建一个新的迭代器实例,除了第一个枚举,所以你的
public IEnumerator<string> GetEnumerator() => Iterator().GetEnumerator();
方法就很好了。
证明我在这里提出的问题是由于我对 C# 迭代器及其内部实现方式的浅薄理解而造成的。通过将 IEnumerable<string>
存储在我的
DnaGenerator
实例中,我基本上什么也没得到。当请求枚举器时,下面的两行都会导致分配单个对象。它是一个自动生成的具有双重人格的物体。它既是一个IEnumerable<string>
,又是一个IEnumerator<string>
。public IEnumerator<string> GetEnumerator() => _enumerable.GetEnumerator();
public IEnumerator<string> GetEnumerator() => Iterator().GetEnumerator();
通过将
_enumerable
存储在字段中,我只是防止该对象被回收。
尽管如此,我仍在寻找解决这个非问题的方法,以一种允许我保留缓存的_enumerable
字段的方式,而不会导致内存泄漏,并且无需从头开始实现完整的
IEnumerable<string>
如@MatthewWatson 的answer所示。我发现的解决方法是将生成的 DNA 序列包装在
StrongBox<string>
包装器中:
private IEnumerable<StrongBox<string>> Iterator()
{
while (true)
foreach (char c in new char[] { 'A', 'C', 'G', 'T' })
yield return new(new String(c, 10_000_000));
}
然后我必须先
Unwrap
迭代器,然后再将其暴露给外部世界:
private readonly IEnumerable<string> _enumerable;
public DnaGenerator() => _enumerable = Iterator().Unwrap();
这里是
Unwrap
扩展方法:
/// <summary>
/// Unwraps an enumerable sequence that contains values wrapped in StrongBox instances.
/// The latest StrongBox instance is emptied when the enumerator is disposed.
/// </summary>
public static IEnumerable<T> Unwrap<T>(this IEnumerable<StrongBox<T>> source)
=> new StrongBoxUnwrapper<T>(source);
private class StrongBoxUnwrapper<T> : IEnumerable<T>
{
private readonly IEnumerable<StrongBox<T>> _source;
public StrongBoxUnwrapper(IEnumerable<StrongBox<T>> source)
{
ArgumentNullException.ThrowIfNull(source);
_source = source;
}
public IEnumerator<T> GetEnumerator() => new Enumerator(_source.GetEnumerator());
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
private class Enumerator : IEnumerator<T>
{
private readonly IEnumerator<StrongBox<T>> _source;
private StrongBox<T> _latest;
public Enumerator(IEnumerator<StrongBox<T>> source)
{
ArgumentNullException.ThrowIfNull(source);
_source = source;
}
public T Current => _source.Current.Value;
object IEnumerator.Current => Current;
public bool MoveNext()
{
var moved = _source.MoveNext();
_latest = _source.Current;
return moved;
}
public void Dispose()
{
_source.Dispose();
if (_latest is not null) _latest.Value = default;
}
public void Reset() => _source.Reset();
}
}
诀窍是跟踪枚举器发出的最新
StrongBox<T>
,并在处置枚举器时将其
Value
设置为
default
。现场演示