在对象仍在使用时启动终结器

5
概述: C#/.NET 应该是由垃圾收集器进行垃圾回收的。C# 有析构函数,用于清理资源。当一个对象 A 被垃圾收集时,如果我尝试克隆它的某个变量成员,会发生什么?显然,在多处理器上,有时候垃圾收集器会获胜... 问题: 今天,在一次 C# 培训课上,老师给我们展示了一些代码,其中包含一个只在多处理器上运行时才存在错误的漏洞。
简而言之,有时编译器或 JIT 在调用 C# 类对象的终结器方法之前就返回了。
为了避免产生非常大的问题,Visual C++ 2005 文档中提供了完整的代码,将作为一个“答案”发布,但下面是必要的部分:
下面的类具有“哈希”属性,该属性将返回内部数组的克隆副本。在其构造过程中,该数组的第一项的值为 2。在析构函数中,将该值设置为零。
重点是:如果您尝试获取“Example”的“Hash”属性,您将获得数组的干净副本,其第一个项目仍为 2,因为该对象正在被使用(因此不会被垃圾收集/终结):
public class Example
{
    private int nValue;
    public int N { get { return nValue; } }

    // The Hash property is slower because it clones an array. When
    // KeepAlive is not used, the finalizer sometimes runs before 
    // the Hash property value is read.

    private byte[] hashValue;
    public byte[] Hash { get { return (byte[])hashValue.Clone(); } }

    public Example()
    {
        nValue = 2;
        hashValue = new byte[20];
        hashValue[0] = 2;
    }

    ~Example()
    {
        nValue = 0;

        if (hashValue != null)
        {
            Array.Clear(hashValue, 0, hashValue.Length);
        }
    }
}

但是事情并不那么简单......使用这个类的代码在一个线程中运行,当然,为了测试,该应用程序是高度多线程的:

public static void Main(string[] args)
{
    Thread t = new Thread(new ThreadStart(ThreadProc));
    t.Start();
    t.Join();
}

private static void ThreadProc()
{
    // running is a boolean which is always true until
    // the user press ENTER
    while (running) DoWork();
}

DoWork静态方法是出现问题的代码:

private static void DoWork()
{
    Example ex = new Example();

    byte[] res = ex.Hash; // [1]

    // If the finalizer runs before the call to the Hash 
    // property completes, the hashValue array might be
    // cleared before the property value is read. The 
    // following test detects that.

    if (res[0] != 2)
    {
        // Oops... The finalizer of ex was launched before
        // the Hash method/property completed
    }
}

一旦DoWork执行1,000,000次,垃圾回收器会进行清理并试图回收"ex",因为它不再被函数中的剩余代码引用,并且这一次比"Hash"获取方法更快。因此,最终得到的是一个零长度字节数组的克隆,而不是正确的数组(其中第一个项在2处)。我猜测代码存在内联,这本质上替换了DoWork函数中标记为[1]的行,变成了类似以下的内容:
    // Supposed inlined processing
    byte[] res2 = ex.Hash2;
    // note that after this line, "ex" could be garbage collected,
    // but not res2
    byte[] res = (byte[])res2.Clone();

假设Hash2是一个简单的访问器,代码如下:
// Hash2 code:
public byte[] Hash2 { get { return (byte[])hashValue; } }

所以问题是:在C#/.NET中这是否应该按照这种方式工作,或者这可以被视为编译器或JIT的错误? 编辑
请参阅Chris Brumme和Chris Lyons的博客以获取解释。 http://blogs.msdn.com/cbrumme/archive/2003/04/19/51365.aspx
http://blogs.msdn.com/clyon/archive/2004/09/21/232445.aspx 每个人的回答都很有趣,但我无法选择比其他更好的答案。所以我给了你们所有人一个+1 ...
抱歉
:-)
编辑2
我无法在Linux / Ubuntu / Mono上重现该问题,尽管在相同的条件下使用相同的代码(同时运行多个相同的可执行文件,发布模式等)。

这是一个古老的问题,但对于任何想要了解此问题的人来说,有一个提示,那就是确保使用finalizer !Example实现托管C++代码,而不是使用析构函数~Example(在C++/CLI中,这会创建IDisposable实现)。这是C++/CLI的一个怪癖,旨在为C++开发人员提供便利,他们期望在类超出范围或被删除时确定地调用析构函数(在托管情况下,从C#调用,在'using'语句超出范围或已Dispose)。 - Dan Bryant
@Dan Bryant:我不同意:C++/CLI中的~XXX和!XXX符号是如果C#预见到了处理问题将会使用的符号。这不是一个怪癖。这是一种符号,因为:1. C++开发人员对Managed C++终结器荒谬行为(即与C#相同)感到愤慨;2. 这种符号自动化了Dispose()Dispose(bool)方法的逻辑编写,在C#中编写这些方法很麻烦(更不用说“在C#中正确编写”)。RAII不是C++的概念。RAII是一种模式,很少有语言能够正确实现。C#(甚至Java)都没有做到这一点。 - paercebal
我同意,实际上 !XXX 更适合用于 C# 中表示终结器,因为 ~XXX 表示太像 C++ 的析构函数了。它们的行为截然不同(因为终结器在另一个线程上非确定性地执行),这导致了很多混淆。我也认为 Dispose(bool) 模式不适合一般使用;在大多数情况下,我可以将我的类标记为 sealed 并直接实现 Dispose()。 - Dan Bryant
@Dan Bryant:我目前的问题是,由于终结/处理程序崩溃了我们的.NET应用程序,所以在我需要纠正的代码中sealed不是一个选项...这个混乱让我花了几周的时间,而且将再次花费几周的时间,因为修正后的处理程序代码太丑陋了,我不能不戴墨镜来保护我的眼睛看它...也许C#的下一个迭代版本将让我们使用~XXX/!XXX符号。毕竟,如果C++/CLI编译器能够生成正确的FinalizeDispose代码,那么C#也应该能够做到... - paercebal
1
现在是2022年。我遇到了同样的问题:当对象仍在使用时,Finalizer被执行。非常高兴找到了你深入的解释和有用的链接! - liviaerxin
8个回答

9

你的代码中有一个简单的错误:finalizer不应该访问托管对象。

实现finalizer的唯一原因是为了释放非托管资源。在这种情况下,你应该小心地实现标准的IDisposable模式

使用此模式,你可以实现一个受保护的方法"protected Dispose(bool disposing)"。当从finalizer调用此方法时,它会清理非托管资源,但不会尝试清理托管资源。

在你的示例中,没有任何非托管资源,因此不应该实现finalizer。


你比我先一步完成了!我不知道为什么每个人都在强调线程、JIT和“侵略性行为”,而他们都忽略了真正的问题,就像你所描述的那样。 - Jeffrey L Whitledge
尽管其他回答都是正确的(据我所知),但是你的回答对我对这个Dispose模式的看法产生了最大的影响。谢谢。+1 - paercebal
1
这与托管/非托管资源无关 - 非托管资源也存在相同的竞态条件... 对吗?或者我漏掉了什么? - Eamon Nerbonne
@Earmon 你说得完全正确。是的,在这种情况下,终结器不应该触碰托管对象,但托管对象仍然会存在,因为它本身没有终结器(数组没有终结器),并且它仍然是可达的(终结器列表中的对象被认为是可达的)。还可以构造出一个更加牵强的例子来证明他仍然需要保持原始的、基础的对象存活。在这种情况下,正如所述,他应该不要搞乱数组,但我们所知道的是这只是一个演示问题的示例代码,而不是问题本身。 - Lasse V. Karlsen
例如,您可以构建一个类来通过非托管引用管理与某个链接的连接。该类的一个方法返回一个新对象,该对象被赋予访问该设备的非托管引用的副本(这是错误和有缺陷的,我知道,但它已经完成了)。在某些时候,原始对象被完成,"打开"的连接被关闭,而期望仍然存活的返回对象。但我仍然希望看到一个不包含粗心构造的例子,即没有缺陷代码的例子。 - Lasse V. Karlsen

3
你看到的是完全自然的。
你没有保留对拥有字节数组的对象的引用,因此该对象(而不是字节数组)实际上可以被垃圾回收器收集。
垃圾回收器确实可以如此积极。
因此,如果您调用对象上的方法,该方法返回对内部数据结构的引用,并且对象的终结器破坏了该数据结构,则还需要保持对对象的活动引用。
垃圾回收器发现 ex 变量在该方法中不再使用,因此在正确的情况下(即时机和需求),它可以回收该变量,正如您所注意到的那样。
正确的方法是在 ex 上调用 GC.KeepAlive,因此请在方法底部添加此行代码,一切都应该正常:
GC.KeepAlive(ex);

我通过阅读Jeffrey Richter所著的书籍《应用.NET框架编程》了解了这种积极行为。


我了解到这种攻击性行为... 所以,您确认当在多核处理器上启动此代码时可见的这种行为是正常的行为,并且这被描述在您帖子中提到的书籍中? - paercebal
因为使用垃圾回收机制创建的相同类别的相同代码(使用gcnew而不是new)在托管C++上可以正常工作。 - paercebal
这与多核无关,而与多线程有关,在服务器上的 .NET 运行时环境中,如果在繁忙的单核系统上运行,可能会看到相同的行为。 - Lasse V. Karlsen
如果您阅读了paercebal在此问题上的另一个答案,引用了cbrumme的一些文本,您会发现比我的解释好得多。但是,是的,这确实很自然,并且您需要确保对象在调用期间始终存在。 - Lasse V. Karlsen
GC.KeepAlive不是处理这个问题的正确方法。正确的方法是摆脱终结器。 - Joe

1

这看起来像是你的工作线程和GC线程之间的竞争条件;为了避免这种情况,我认为有两个选择:

(1) 将if语句更改为使用ex.Hash[0]而不是res,这样ex就不能过早地被GC回收,或者

(2) 在调用Hash期间锁定ex

这是一个相当棒的例子 - 老师的观点是JIT编译器可能存在 bug,只有在多核系统上才表现出来,还是这种编码方式可能会出现关于垃圾回收的微妙竞争条件?


那种推理在非托管的 C++ 中行得通,因为由开发人员决定何时释放内存。在垃圾回收的世界里,运行时认为 ex 不再被使用,并且可以被回收。 - Scott Dorman
C++代码是经过管理的,而不是非托管的。这就是老师认为有问题的原因。由Manager C++编译器生成的代码在对象被使用时会进行保护,而由C#编译器生成的代码则没有这种保护机制。 - paercebal
我的看法是,当你一方面有垃圾回收,另一方面有析构函数时,这种情况就可能发生。 - paercebal
1
[@paercebal]:检查C#和C++生成的中间语言(MSIL)以查看该函数的差异;可能是C++编译器将中间变量作为不必要的进行了优化。 - Steven A. Lowe
好的...我之前的评论无效了。看一下生成的IL代码,这可能会显示Steven怀疑的内容。另外,请确保C++和C#构建都是“Release”构建,因为“Debug”构建会为GC添加一些额外的好处。 - Scott Dorman
显示剩余2条评论

1

是的,这是一个问题,以前出现过

更有趣的是,你需要运行发布版本才能发现这个问题,然后你会疑惑地问自己:“咦,怎么可能为空呢?”


非常好的答案。上面的链接很有趣,第二个链接提到了文本的作者,网址如下:http://blogs.msdn.com/cbrumme/archive/2004/02/20/77460.aspx - paercebal
是的,竞态条件问题以前就出现过,但我认为这种情况会在任何.NET语言中发生。根据其他帖子上的评论,似乎这只发生在C#示例中,C++/CLI版本的示例没有出现这个问题。 - Scott Dorman
我在想...也许这对于.NET来说是正常的,而是Managed C++添加了额外的代码...明天,我会要求获得VB.NET和Managed C++版本的代码,看看是否存在竞争条件。如果我得到了这些源代码,我将在下面发布它们,并提供所有评论的结果。 - paercebal

1
我认为你所看到的是由于事情在多个线程上运行而导致的“合理”行为。这就是GC.KeepAlive()方法的原因,在这种情况下应该使用它来告诉GC对象仍在使用中,它不是清理的候选对象。
查看你在“完整代码”响应中的DoWork函数,问题在于在此代码行之后立即出现:
byte[] res = ex.Hash;

该函数不再引用ex对象,因此在那一点上它变得可以进行垃圾回收。添加GC.KeepAlive调用将防止这种情况发生。


是的,非常正确。关键是:我能否在使用其get属性之一的同一行中完成我的“ex”对象? - paercebal
如果我理解你的问题...是的。在.NET中,即使对象已被处理,它仍然是“可用”的,因此您仍然可以调用它,尽管结果不能保证。我认为这就是您在这里看到的部分原因。 - Scott Dorman
1
我的问题是,当我在使用“ex”对象时(因此仍然存在引用),同一行代码被垃圾收集器以非常突然的方式“偷走”。我本来以为GC会等到下一行才收集对象。 - paercebal
那么你的意思是第一次运行 byte[] res = ex.Hash; 这一行代码时出现了问题? - Scott Dorman

1

在你的 do work 方法中,finalizer 被调用是完全正常的,因为在 ex.Hash 调用之后,CLR 知道不再需要 ex 实例了...

现在,如果你想保持实例的存活,请这样做:

private static void DoWork()
{
    Example ex = new Example();

    byte[] res = ex.Hash; // [1]

    // If the finalizer runs before the call to the Hash 
    // property completes, the hashValue array might be
    // cleared before the property value is read. The 
    // following test detects that.

    if (res[0] != 2) // NOTE
    {
        // Oops... The finalizer of ex was launched before
        // the Hash method/property completed
    }
  GC.KeepAlive(ex); // keep our instance alive in case we need it.. uh.. we don't
}

GC.KeepAlive什么也不做 :)它是一个空的、不可内联/可JIT化的方法,其唯一目的是欺骗GC认为对象在此之后将被使用。

警告:如果DoWork方法是托管C++方法,你的示例是完全有效的...如果你不想在另一个线程内调用析构函数,你必须手动保持托管实例的存活。即,当你传递一个引用到一个将要删除未管理内存块的托管对象时,并且该方法正在使用这个相同的块。如果你不保持实例的存活,你将在GC和你方法的线程之间产生竞争条件。

这将最终导致泪水和管理堆损坏...


1
你应该调用GC.KeepAlive(ex)而不是GC.SuppressFinalize(ex),因为KeepAlive专门设计用于这些情况,而SupressFinalize则用于不同的上下文中,即防止已经被Dispose的对象进行终结。 - Scott Dorman
现在,我的问题是无论线程上下文如何:在一个函数的主体中,我的对象在我使用它的引用调用其获取属性的同一行被终止。这种行为正常吗? - paercebal
我在考虑给予ROTOR垃圾回收实现的指针,结果ADD接管了 :D这是,谢谢,我已经纠正。 - Palad1

1

Chris Brumme博客中的有趣评论

http://blogs.msdn.com/cbrumme/archive/2003/04/19/51365.aspx

class C {<br>
   IntPtr _handle;
   Static void OperateOnHandle(IntPtr h) { ... }
   void m() {
      OperateOnHandle(_handle);
      ...
   }
   ...
}

class Other {
   void work() {
      if (something) {
         C aC = new C();
         aC.m();
         ...  // most guess here
      } else {
         ...
      }
   }
}

所以我们无法确定在上述代码中“aC”可能存活的时间有多长。JIT 可能会报告引用,直到 Other.work() 完成为止。它可能会将 Other.work() 内联到其他方法中,并且报告 aC 的时间更长。即使您在使用后添加了“aC = null;”,JIT 也可以将此赋值视为死代码并消除它。无论 JIT 何时停止报告引用,GC 可能需要一些时间才能收集它。
更有趣的是担心 aC 可以被收集的最早时间点。如果您像大多数人一样,您会猜测 aC 最早可以在 Other.work() 的“if”子句的结束括号处被回收,我已经添加了注释。实际上,花括号在 IL 中不存在。它们是您和语言编译器之间的语法契约。Other.work() 在启动对 aC.m() 的调用后就可以自由地停止报告 aC。

0

完整代码

以下是完整的代码,从Visual C++ 2008 .cs文件中复制/粘贴而来。由于我现在使用Linux,并且没有任何关于Mono编译器或其使用方法的知识,所以现在无法进行测试。不过,几个小时前,我看到这段代码运行正常,也发现了它的一个错误:

using System;
using System.Threading;

public class Example
{
    private int nValue;
    public int N { get { return nValue; } }

    // The Hash property is slower because it clones an array. When
    // KeepAlive is not used, the finalizer sometimes runs before 
    // the Hash property value is read.

    private byte[] hashValue;
    public byte[] Hash { get { return (byte[])hashValue.Clone(); } }
    public byte[] Hash2 { get { return (byte[])hashValue; } }

    public int returnNothing() { return 25; }

    public Example()
    {
        nValue = 2;
        hashValue = new byte[20];
        hashValue[0] = 2;
    }

    ~Example()
    {
        nValue = 0;

        if (hashValue != null)
        {
            Array.Clear(hashValue, 0, hashValue.Length);
        }
    }
}

public class Test
{
    private static int totalCount = 0;
    private static int finalizerFirstCount = 0;

    // This variable controls the thread that runs the demo.
    private static bool running = true;

    // In order to demonstrate the finalizer running first, the
    // DoWork method must create an Example object and invoke its
    // Hash property. If there are no other calls to members of
    // the Example object in DoWork, garbage collection reclaims
    // the Example object aggressively. Sometimes this means that
    // the finalizer runs before the call to the Hash property
    // completes. 

    private static void DoWork()
    {
        totalCount++;

        // Create an Example object and save the value of the 
        // Hash property. There are no more calls to members of 
        // the object in the DoWork method, so it is available
        // for aggressive garbage collection.

        Example ex = new Example();

        // Normal processing
        byte[] res = ex.Hash;

        // Supposed inlined processing
        //byte[] res2 = ex.Hash2;
        //byte[] res = (byte[])res2.Clone();

        // successful try to keep reference alive
        //ex.returnNothing();

        // Failed try to keep reference alive
        //ex = null;

        // If the finalizer runs before the call to the Hash 
        // property completes, the hashValue array might be
        // cleared before the property value is read. The 
        // following test detects that.

        if (res[0] != 2)
        {
            finalizerFirstCount++;
            Console.WriteLine("The finalizer ran first at {0} iterations.", totalCount);
        }

        //GC.KeepAlive(ex);
    }

    public static void Main(string[] args)
    {
        Console.WriteLine("Test:");

        // Create a thread to run the test.
        Thread t = new Thread(new ThreadStart(ThreadProc));
        t.Start();

        // The thread runs until Enter is pressed.
        Console.WriteLine("Press Enter to stop the program.");
        Console.ReadLine();

        running = false;

        // Wait for the thread to end.
        t.Join();

        Console.WriteLine("{0} iterations total; the finalizer ran first {1} times.", totalCount, finalizerFirstCount);
    }

    private static void ThreadProc()
    {
        while (running) DoWork();
    }
}

对于那些感兴趣的人,我可以通过电子邮件发送压缩的项目文件。


你压缩后的项目和你在这里发布的一样吗? - Scott Dorman
或多或少,我不得不在每行开头添加4个空格,并且由于这些文件是Windows格式的,在我的Linux系统上,\r\n回车符/换行符会添加不必要的空行,我通过结尾删除了它们。 - paercebal

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接