7

Main我偶然发现了这样一种情况,即垃圾收集似乎在作为单元测试编写的相同代码与以控制台应用程序的方法编写的代码之间表现不同。我想知道这种差异背后的原因。

在这种情况下,我和一位同事就注册事件处理程序对垃圾收集的影响存在分歧。我认为演示会比简单地向他发送一个高度评价的 SO 答案的链接更好地接受。因此,我写了一个简单的演示作为单元测试。

我的单元测试表明事情按我说的那样工作。但是,我的同事编写了一个控制台应用程序,显示事情按他的方式工作,这意味着 GC 没有像我预期的那样在Main方法中的本地对象上发生。Main只需将代码从我的测试移到控制台应用程序项目的方法中,我就能够重现他看到的行为。

我想知道的是,为什么在Main控制台应用程序的方法中运行时,GC 似乎没有按预期收集对象。通过提取方法,使调用GC.Collect和超出范围的对象发生在不同的方法中,恢复了预期的行为。

这些是我用来定义我的测试的对象。只有一个带有事件的对象和一个为事件处理程序提供合适方法的对象。两者都有终结器设置一个全局变量,以便您可以知道它们何时被收集。

private static string Log;
public const string EventedObjectDisposed = "EventedObject disposed";
public const string HandlingObjectDisposed = "HandlingObject disposed";

private class EventedObject
{
    public event Action DoIt;

    ~EventedObject()
    {
        Log = EventedObjectDisposed;
    }

    protected virtual void OnDoIt()
    {
        Action handler = DoIt;
        if (handler != null) handler();
    }
}

private class HandlingObject
{

    ~HandlingObject()
    {
        Log = HandlingObjectDisposed;
    }

    public void Yeah()
    {
    }
}

这是我的测试(NUnit),它通过了:

[Test]
public void TestReference()
{
    {
        HandlingObject subscriber = new HandlingObject();

        {
            {
                EventedObject publisher = new EventedObject();
                publisher.DoIt += subscriber.Yeah;
            }

            GC.Collect(GC.MaxGeneration);
            GC.WaitForPendingFinalizers();
            Thread.MemoryBarrier();

            Assert.That(Log, Is.EqualTo(EventedObjectDisposed));
        }

        //Assertion needed for foo reference, else optimization causes it to already be collected.
        Assert.IsNotNull(subscriber);
    }

    GC.Collect(GC.MaxGeneration);
    GC.WaitForPendingFinalizers();
    Thread.MemoryBarrier();

    Assert.That(Log, Is.EqualTo(HandlingObjectDisposed));
}

我将上面的主体粘贴到Main新控制台应用程序的方法中,并将调用转换AssertTrace.Assert调用。两个相等断言都失败然后失败。如果需要,生成的 Main 方法的代码在这里。

我确实认识到应该将 GC 何时发生视为非确定性的,并且通常应用程序不应该关心它何时发生。在所有情况下,代码都是在发布模式下编译并针对 .NET 4.5。

编辑:我尝试过的其他事情

  • 制作测试方法static,因为 NUnit 支持它;测试仍然有效。
  • 我还尝试将整个 Main 方法提取到程序上的实例方法中并调用它。两个断言仍然失败。
  • 归因Main[STAThread][MTAThread]以防万一有所作为。两个断言仍然失败。
  • 基于@Moo-Juice 的建议:
    • 我将 NUnit 引用到控制台应用程序以便我可以使用 NUnit 断言,但它们失败了。
    • 我尝试对测试、测试的类、Main方法和包含Main静态方法的类的可见性进行各种更改。没变。
    • 我尝试将测试类设为静态,并将包含该Main方法的类设为静态。没变。
4

2 回答 2

6

如果将以下代码提取到单独的方法中,则测试更有可能按照您的预期运行。编辑:请注意,C# 语言规范的措辞并不要求通过此测试,即使您将代码提取到单独的方法中也是如此。

        {
            EventedObject publisher = new EventedObject();
            publisher.DoIt += subscriber.Yeah;
        }

规范允许但不要求publisher在此块的末尾立即有资格进行 GC,因此您不应以假设可以在此处收集的方式编写代码。

编辑:来自 ECMA-334(C# 语言规范)§10.9 自动内存管理(强调我的)

如果除了运行终结器之外,任何可能的继续执行都无法访问对象的任何部分,则该对象被视为不再使用并且它有资格进行终结。[注意:实现可能会选择分析代码以确定将来可以使用对对象的哪些引用。例如,如果作用域内的局部变量是对对象的唯一现有引用,但从过程中的当前执行点开始的任何可能的继续执行中从未引用该局部变量,则实现可能(但不是需要)将对象视为不再使用。尾注]

于 2013-04-21T20:41:06.117 回答
1

问题不在于它是一个控制台应用程序 - 问题在于您可能通过 Visual Studio 运行它 -附加了调试器!和/或您正在将控制台应用程序编译为调试版本。

确保您正在编译发布版本。然后转到Debug -> Start Without Debugging,或按 Ctrl+F5,或从命令行运行控制台应用程序。垃圾收集器现在应该按预期运行。

这也是 Eric Lippert 在C# Performance Benchmark Mistakes, Part One中提醒您不要在调试器中运行任何性能基准测试的原因。

jit 编译器知道附加了一个调试器,它会故意对它生成的代码进行反优化,以使其更易于调试。垃圾收集器知道附加了一个调试器;它与 jit 编译器一起工作,以确保清理内存的积极性较低,这在某些情况下会极大地影响性能。

Eric 系列文章中的许多提醒都适用于您的场景。如果您有兴趣阅读更多内容,这里是第二第三第四部分的链接。

于 2014-05-13T08:11:39.450 回答