控制台应用程序和单元测试方法之间的垃圾收集行为不同

本文关键字:应用程序 单元 测试方法 之间 控制台 | 更新日期: 2023-09-27 18:10:09

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

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

我的单元测试显示事情按照我说的那样工作。然而,我的同事编写了一个控制台应用程序,显示事情按照他的方式工作,这意味着GC没有像我期望的那样发生在Main方法中的本地对象上。我能够通过简单地将我的测试代码移到控制台应用程序项目的Main方法中来重现他所看到的行为。

我想知道的是为什么GC在控制台应用程序的Main方法中运行时似乎没有像预期的那样收集对象。通过提取方法,使对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方法中,并将Assert调用转换为Trace.Assert调用。两个相等断言都失败,然后失败。生成的Main方法的代码在这里,如果你想要它。

我确实认识到GC发生的时间应该被视为不确定的,并且通常应用程序不应该关注它确切发生的时间。在所有情况下,代码都是在发布模式下编译的,目标是。net 4.5。

Edit: Other things I tried

  • 使测试方法static,因为NUnit支持;
  • 我还尝试将整个Main方法提取到程序的实例方法中并调用它。两个断言仍然失败。
  • Main归属于[STAThread][MTAThread],以防产生差异。两个断言仍然失败。
  • 根据@Moo-Juice的建议:
    • 我把NUnit引用到控制台应用中,这样我就可以使用NUnit断言了,它们失败了。
    • 我尝试了对测试、测试的类、Main方法和包含Main方法静态的类的可见性的各种更改。没有变化。
    • 我尝试使测试类静态和类包含Main方法静态。没有变化。

控制台应用程序和单元测试方法之间的垃圾收集行为不同

如果将下面的代码提取到一个单独的方法中,那么测试将更有可能按照您的预期进行。编辑:请注意,c#语言规范的措辞并不要求通过此测试,即使您将代码提取到单独的方法中。

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

规范允许,但不要求publisher在此块结束时立即有资格进行GC,因此您不应该以这样的方式编写代码,假设它可以在这里收集。

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

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

问题不在于它是一个控制台应用程序-问题是您可能通过Visual Studio运行它- 并附带调试器!并且/或者你正在编译控制台应用程序作为调试构建。

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

这也是Eric Lippert在《c#性能基准测试错误,第一部分》中提醒你不要在调试器中运行任何性能基准测试的原因。

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

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

相关文章: