C# 迭代器泄漏的托管内存

问题描述 投票:0回答:4

我有一个生成 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

在 Fiddle 上尝试一下

我的期望是所有生成的 DNA 序列都适合垃圾回收,因为它们没有被我的程序引用。我持有的唯一引用是对

DnaGenerator

实例本身的引用,它并不意味着包含任何序列。该组件仅生成序列。然而,无论我的程序生成多少序列,在完全垃圾回收后总会分配大约 20 MB 的内存。

我的问题是:

为什么会发生这种情况?我怎样才能防止这种情况发生? .NET 6.0、Windows 10、64 位操作系统、基于 x64 的处理器、Release 内置。


更新:

如果我更换这个,问题就会消失: public IEnumerator<string> GetEnumerator() => _enumerable.GetEnumerator();

...用这个:

public IEnumerator<string> GetEnumerator() => Iterator().GetEnumerator();

但我不喜欢每次需要枚举器时创建一个新的枚举。我的理解是,单个 

IEnumerable<T>

 可以创建许多 
IEnumerator<T>
。 AFAIK 这两个接口并不意味着具有一对一的关系。

c# .net memory-leaks iterator garbage-collection
4个回答
6
投票
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();
}



5
投票

decompilation

,您会注意到 
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();

方法就很好了。

    


1
投票


0
投票
answer

证明我在这里提出的问题是由于我对 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

现场演示

© www.soinside.com 2019 - 2024. All rights reserved.