5

摘要: C#/.NET 应该是垃圾收集。C# 有一个析构函数,用于清理资源。当对象 A 在我尝试克隆其变量成员之一的同一行被垃圾收集时会发生什么?显然,在多处理器上,有时垃圾收集器会获胜......

问题

今天,在 C# 培训课程中,老师向我们展示了一些代码,这些代码只有在多处理器上运行时才会包含错误。

我会总结说,有时,编译器或 JIT 在从其调用的方法返回之前调用 C# 类对象的终结器会搞砸。

Visual C++ 2005 文档中给出的完整代码将作为“答案”发布,以避免提出非常大的问题,但基本内容如下:

下面的类有一个“哈希”属性,它将返回一个内部数组的克隆副本。在构造函数中,数组的第一项值为 2。在析构函数中,它的值设置为零。

关键是:如果您尝试获取“示例”的“哈希”属性,您将获得数组的一个干净副本,其第一项仍然是 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
    }
}

显然,每执行 1,000,000 次 DoWork,垃圾收集器就会施展魔法,并尝试回收“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 上重现该问题

4

8 回答 8

9

这只是代码中的一个错误:终结器不应该访问托管对象。

实现终结器的唯一原因是释放非托管资源。在这种情况下,您应该仔细实现标准 IDisposable 模式

使用这种模式,您可以实现一个受保护的方法“protected Dispose(bool disposing)”。当从终结器调用此方法时,它会清理非托管资源,但不会尝试清理托管资源。

在您的示例中,您没有任何非托管资源,因此不应实现终结器。

于 2008-09-27T12:53:23.077 回答
3

你所看到的非常自然。

您不保留对拥有字节数组的对象的引用,因此该对象(不是字节数组)实际上是免费的,可供垃圾收集器收集。

垃圾收集器真的可以那么激进。

因此,如果您在对象上调用一个方法,该方法返回对内部数据结构的引用,并且对象的终结器弄乱了该数据结构,那么您还需要保持对该对象的实时引用。

垃圾收集器看到 ex 变量不再在该方法中使用,因此它可以,并且正如您所注意到的,将在正确的情况下(即时间和需要)将其垃圾收集。

正确的方法是在 ex 上调用 GC.KeepAlive,所以将这行代码添加到方法的底部,一切都应该很好:

GC.KeepAlive(ex);

我通过阅读Jeffrey Richter的Applied .NET Framework Programming一书了解了这种攻击性行为。

于 2008-09-25T17:37:01.767 回答
1

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

(1) 将您的 if 语句更改为使用 ex.Hash[0] 而不是 res,以便 ex 不能过早地被 GC,或者

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

这是一个非常漂亮的例子 - 老师的观点是 JIT 编译器中可能存在仅在多核系统上表现出来的错误,或者这种编码可能具有垃圾收集的微妙竞争条件?

于 2008-09-25T17:27:29.590 回答
1

我认为你看到的是合理的行为,因为事情是在多个线程上运行的。这就是 GC.KeepAlive() 方法的原因,在这种情况下应该使用该方法来告诉 GC 该对象仍在使用中并且它不是清理的候选对象。

查看您的“完整代码”响应中的 DoWork 函数,问题是紧接在这行代码之后:

byte[] res = ex.Hash;

该函数不再对ex对象进行任何引用,因此此时它有资格进行垃圾收集。添加对 GC.KeepAlive 的调用可以防止这种情况发生。

于 2008-09-25T17:29:29.540 回答
1

在你的 do work 方法中调用终结器是完全正常的,因为在 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 做...什么都不做 :) 它是一个空的不可内联 /jittable 方法,其唯一目的是诱使 GC 认为该对象将在此之后使用。

警告:如果 DoWork 方法是托管 C++ 方法,则您的示例完全有效...如果您不希望从另一个线程中调用析构函数,则必须手动手动保持托管实例处于活动状态IE。您传递对托管对象的引用,该对象将在最终确定时删除非托管内存的 blob,并且该方法正在使用相同的 blob。如果您不保持实例处于活动状态,那么您将在 GC 和您的方法的线程之间出现竞争条件。

而这会以泪水告终。并管理堆损坏......

于 2008-09-25T17:36:27.493 回答
1

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

更有趣的是,您需要运行 release 才能发生这种情况,而您最终会抬起头来“嗯,这怎么可能是空的?”。

于 2008-09-25T18:46:01.447 回答
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 中并不存在。它们是您和您的语言编译器之间的句法契约。 一旦启动对 aC.m() 的调用,Other.work() 就可以自由地停止报告 aC。

于 2008-09-25T19:53:07.057 回答
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();
    }
}

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

于 2008-09-25T17:24:11.050 回答