终结器在其对象仍在使用时启动
本文关键字:启动 对象 | 更新日期: 2023-09-27 17:47:20
摘要:C#/.NET应该是垃圾回收。C# 有一个析构函数,用于清理资源。当对象 A 被垃圾回收与我尝试克隆其变量成员之一的同一行时会发生什么?显然,在多处理器上,有时垃圾收集器会获胜......
问题所在
今天,在一次关于 C# 的培训课程中,老师向我们展示了一些代码,这些代码只有在多处理器上运行时才会包含错误。
我总结一下,有时编译器或 JIT 会在从其调用方法返回之前调用 C# 类对象的终结器来搞砸。
Visual C++ 2005 文档中给出的完整代码将作为"答案"发布,以避免提出非常非常大的问题,但基本内容如下:
下面的类有一个"Hash"属性,它将返回内部数组的克隆副本。在构造时,数组的第一项的值为 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
}
}
显然,每 1,000,000 次 DoWork 执行一次,垃圾收集器就会施展它的魔力,并试图回收"ex",因为它不再在函数的 remning 代码中被引用,这一次,它比"Hash"get 方法更快。因此,我们最终得到的是一个零字节数组的克隆,而不是正确的一个(第 1 项位于 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.aspxhttp://blogs.msdn.com/clyon/archive/2004/09/21/232445.aspx
每个人的回答都很有趣,但我无法选择一个比另一个更好。所以我给了你们+1...
不好意思
:-)
编辑 2
我无法在 Linux/Ubuntu/Mono 上重现该问题,尽管在相同的条件下使用相同的代码(多个相同的可执行文件同时运行、发布模式等)。
这只是代码中的一个错误:终结器不应访问托管对象。
实现终结器的唯一原因是释放非托管资源。 在这种情况下,您应该仔细实现标准 IDisposable 模式。
使用此模式,您可以实现受保护的方法"受保护的处置(布尔处置)"。 从终结器调用此方法时,它会清理非托管资源,但不尝试清理托管资源。
在您的示例中,您没有任何非托管资源,因此不应实现终结器。
你所看到的是完全自然的。
您不保留对拥有字节数组的对象的引用,因此该对象(而不是字节数组)实际上可供垃圾回收器收集。
垃圾收集器真的可以那么咄咄逼人。
因此,如果在对象上调用一个方法,该方法返回对内部数据结构的引用,并且对象的终结器弄乱了该数据结构,则还需要保留对该对象的实时引用。
垃圾回收器看到该方法不再使用 ex 变量,因此它可以,并且正如您注意到的那样,将在适当的情况下(即时间和需要)对其进行垃圾回收。
执行此操作的正确方法是调用 GC。在 ex 上保持活力,因此将这行代码添加到方法的底部,一切都应该很好:
GC.KeepAlive(ex);
我通过阅读Jeffrey Richter的《Applied .NET Framework Programming》一书了解到这种攻击性行为。
看起来像是工作线程和GC线程之间的竞争条件;为了避免这种情况,我认为有两种选择:
(1) 将 if 语句更改为使用 ex。哈希[0] 而不是 res,这样 ex 就不能过早地被 GC'd,或者
(2) 在调用哈希期间锁定 ex
这是一个非常精彩的例子 - 老师的观点是 JIT 编译器中可能存在仅在多核系统上出现的错误,或者这种编码可能会与垃圾收集产生微妙的竞争条件?
我认为您看到的是合理的行为,因为事情在多个线程上运行。这就是GC的原因。KeepAlive() 方法,在这种情况下应该使用它来告诉 GC 该对象仍在使用中,并且它不是清理的候选对象。
查看"完整代码"响应中的 DoWork 函数,问题是紧跟在这行代码之后:
byte[] res = ex.Hash;
该函数不再对 ex 对象进行任何引用,因此此时它符合垃圾回收的条件。将调用添加到 GC。KeepAlive可以防止这种情况发生。
是的,这是以前出现过的问题。
更有趣的是,您需要运行发布才能发生这种情况,而您最终会脑袋碎裂:"咦,这怎么可能是空的?
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。
在您的 do 工作方法中调用终结器来说,这是完全正确的,就像在前任。哈希调用,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
}
气相色谱。保持活力确实...没有:)这是一个空的不可内联的/jittable方法,其唯一目的是欺骗GC认为在此之后将使用对象。
警告:如果 DoWork 方法是托管C++方法,您的示例完全有效...如果不希望从另一个线程中调用析构函数,则必须手动使托管实例手动保持活动状态。即。将引用传递给一个托管对象,该对象将在最终确定时删除非托管内存的 Blob,并且该方法使用相同的 Blob。如果您不使实例保持活动状态,则 GC 和方法的线程之间将出现争用条件。
这最终会流泪。并管理堆损坏...
完整代码
您将在下面找到从 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();
}
}
对于那些感兴趣的人,我可以通过电子邮件发送压缩项目。