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

The*_*ias 10 .net c# garbage-collection iterator memory-leaks

我有一个生成 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();
}
Run Code Online (Sandbox Code Playgroud)

此类使用迭代器生成 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]}");
    }
}
Run Code Online (Sandbox Code Playgroud)

输出:

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();
}
Run Code Online (Sandbox Code Playgroud)

在 Fiddle 上尝试一下

我的期望是所有生成的 DNA 序列都适合垃圾回收,因为它们没有被我的程序引用。我持有的唯一引用是对实例本身的引用DnaGenerator,它并不意味着包含任何序列。该组件仅生成序列。然而,无论我的程序生成多少序列,在完全垃圾回收后总会分配大约 20 MB 的内存。

我的问题是:为什么会发生这种情况?我怎样才能防止这种情况发生?

.NET 6.0、Windows 10、64 位操作系统、基于 x64 的处理器、发布版本。


更新:如果我替换它,问题就会消失:

public IEnumerator<string> GetEnumerator() => _enumerable.GetEnumerator();
Run Code Online (Sandbox Code Playgroud)

...有了这个:

public IEnumerator<string> GetEnumerator() => Iterator().GetEnumerator();
Run Code Online (Sandbox Code Playgroud)

但我不喜欢每次需要枚举器时创建一个新的枚举。我的理解是一个IEnumerable<T>可以创建多个IEnumerator<T>s。AFAIK 这两个接口并不意味着具有一对一的关系。

Mat*_*son 6

该问题是由使用自动生成的代码实现引起的yield

您可以通过显式实现枚举器来稍微缓解这种情况。

.Reset()您必须通过调用from来稍微修改它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();
}
Run Code Online (Sandbox Code Playgroud)


Gur*_*ron 5

请注意,10_000_000 个字符(16 位)将占用大约 20 MB。如果您看一下,decompilation您会注意到生成的yeild return内部<Iterator>类的结果,该内部类又具有一个用于current存储字符串的字段(以实现IEnumerator<string>.Current):

\n
[CompilerGenerated]\nprivate sealed class <Iterator>d__2 : IEnumerable<string>, IEnumerable, IEnumerator<string>, IEnumerator, IDisposable\n{\n\xe2\x80\x8b    ...\n    private string <>2__current;\n    ...\n}\n
Run Code Online (Sandbox Code Playgroud)\n

并且Iterator方法内部将被编译为如下所示:

\n
[IteratorStateMachine(typeof(<Iterator>d__2))]\nprivate IEnumerable<string> Iterator()\n{\n    return new <Iterator>d__2(-2);\n}\n
Run Code Online (Sandbox Code Playgroud)\n

这导致当前字符串始终存储在内存中以供_enumerable.GetEnumerator();实现(迭代开始后),而DnaGenerator实例本身并未被GC。

\n

UPD

\n
\n

我的理解是,单个 IEnumerable 可以创建许多 IEnumerator。AFAIK 这两个接口并不意味着具有一对一的关系。

\n
\n

是的,在为可枚举生成的情况下,yield return它可以创建多个枚举器,但在这种特殊情况下,实现具有“一对一”关系,因为生成的实现是IEnumerableIEnumerator

\n
[IteratorStateMachine(typeof(<Iterator>d__2))]\nprivate IEnumerable<string> Iterator()\n{\n    return new <Iterator>d__2(-2);\n}\n
Run Code Online (Sandbox Code Playgroud)\n
\n

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

\n
\n

但这实际上是你调用时发生的事情_enumerable.GetEnumerator()(这显然是一个实现细节),如果你检查已经提到的反编译,你会发现它_enumerable = Iterator()实际上是new <Iterator>d__2(-2)这样<Iterator>d__2.GetEnumerator()的:

\n
IEnumerator<string> IEnumerable<string>.GetEnumerator()\n{\n    if (<>1__state == -2 && <>l__initialThreadId == Environment.CurrentManagedThreadId)\n    {\n        <>1__state = 0;\n        return this;\n    }\n    return new <Iterator>d__2(0);\n}\n
Run Code Online (Sandbox Code Playgroud)\n

因此,除了第一个枚举之外,它实际上应该每次都创建一个新的迭代器实例,所以你的public IEnumerator<string> GetEnumerator() => Iterator().GetEnumerator();方法就很好。

\n