Finalizer 比你想象的更棘手(二):深入剖析 .NET 中的 Finalizer
Finalizers are tricker than you might think. Part 2
2025年3月27日
在前一篇文章中,我们讨论了 finalizer 应该只处理非托管资源。在这篇文章中,我想说明这远没有听起来那么简单。
让我们再次重申什么是原生资源(native resource)。原生资源是指不由 CLR 管理的资源。这种资源通常由原生代码处理,并通过以下 API 公开:
- 一个“构造器”,用于分配资源并返回其句柄(handle)
- 一个“析构器”,用于清理资源
- 一组接受句柄以执行操作的方法
例如,RocksDb 是一个著名的键值存储,用 C++ 编写,并提供了 C 绑定,允许非 C++ 应用程序通过互操作 API 使用它。 这是一个简单的 API 示例。
public static class RocksDbNative
{
private static readonly HashSet<IntPtr> ValidHandles = new();
public static IntPtr CreateDb()
{
// Allocating native resource used by the DB.
IntPtr handle = 42;
Trace("Creating Db", handle);
ValidHandles.Add(handle);
return handle;
}
public static void DestroyDb(IntPtr handle)
{
Trace("Destroying Db", handle);
ValidHandles.Remove(handle);
// Cleaning up the resources associated with the handle.
}
public static void UseDb(IntPtr handle)
{
Trace("Starting using Db", handle);
// Just mimic some extra work a method might do.
PerformLongRunningPrerequisite();
// Using the handle
Trace("Using Db", handle);
PInvokeIntoDb(handle);
}
private static void PInvokeIntoDb(IntPtr handle) {}
private static void Trace(string message, IntPtr handle)
{
Console.WriteLine(
$"{message}. Id: {handle}, IsValid: {IsValid(handle)}.");
}
public static bool IsValid(IntPtr handle) => ValidHandles.Contains(handle);
private static void PerformLongRunningPrerequisite()
{
// Skipped for now.
}
}
我们不想直接使用原生资源,所以我们将创建一个包装器——RocksDbWrapper:
public class RocksDbWrapper : IDisposable
{
private IntPtr _handle = RocksDbNative.CreateDb();
public void Dispose() => Dispose(true);
public void UseRocksDb() => RocksDbNative.UseDb(_handle);
private void Dispose(bool disposing) => RocksDbNative.DestroyDb(_handle);
~RocksDbWrapper() => Dispose(false);
}
这个类实现了 IDisposable 接口,用于及时进行资源清理。 并且由于它拥有非托管资源,因此它也有一个 finalizer。
这是使用示例:
static void UseRocksDbWrapper()
{
new RocksDbWrapper().UseRocksDb();
Console.WriteLine("Done using RocksDb!");
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
RocksDbWrapper 是可释放的,但代码没有调用 Dispose,只是创建了实例并调用了 UseRocksDb。 这不是很好,但由于该类有一个 finalizer,我们应该没事吧? 没错!
让我们看看输出:
Creating Db. Id: 42, IsValid: False.
Starting using Db. Id: 42, IsValid: True.
Destroying Db. Id: 42, IsValid: True.
Using Db. Id: 42, **IsValid: False.**
原生句柄在 RocksDbNative.UseDb 中被使用之前就被关闭了,在实际环境中可能会导致崩溃。 但是 为什么??
注意:要重现以下所有示例,您需要在 Release 模式下运行代码,并禁用 NET5+ 的分层编译。 Tier0 编译不跟踪局部变量的生命周期,因此您需要多次运行一个方法以将其传播到更高级别,或者完全禁用分层编译。
首先,让我们检查一下 PerformLongRunningPrerequisite 的主体做了什么。
private static void PerformLongRunningPrerequisite()
{
Thread.Sleep(100);
// Code runs long enough to cause the GC to run twice.
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
它强制执行两个 GC 周期,以一致地重现此问题。 实际上,您不会运行两次 GC,因此该问题不会那么容易重现,而是会偶尔出现崩溃,这使得此类调查非常痛苦。
让我们一步一步地弄清楚为什么原生资源在使用之前就被清理掉了。 为了弄清楚这一点,我们需要了解一些关于 GC 的知识。
局部变量可以在方法调用期间被回收吗?
是的,可以。 这是一个例子:
public static void ShowReachability()
{
object o1 = new object();
object o2 = new object();
var wr1 = new WeakReference(o1);
var wr2 = new WeakReference(o2);
GC.Collect();
Console.WriteLine($"o1 alive: { wr1.IsAlive}, o2 alive: { wr2.IsAlive}");
GC.KeepAlive(o2);
}
o1 alive: false, o2 alive: true
如果一个局部变量是一个 final root reference,那么该实例在最后一次使用后就会被 GC 回收,而不是在方法结束时。 这是可能的,因为 GC 跟踪变量何时不再被使用,并将此信息存储在称为 GCInfo 的内部数据结构中。 这种跟踪只在发布模式下完成,并且仅适用于完全优化的代码。 这就是为什么在调试模式下或当代码由 Tier0 JIT 编译时,行为会有所不同的原因。
有关更多信息,请参阅我的帖子 Garbage collection and variable lifetime tracking.
实例可以在实例方法调用期间被回收吗?
答案仍然是,是的,可以! 这是一个更复杂的例子:
public class InstanceEligibility
{
public void Test()
{
var wr = new WeakReference(this);
GC.Collect();
if (!wr.IsAlive)
{
Console.WriteLine("The instance was collected by the GC!");
}
}
}
如果您运行 new InstanceEligibility().Test(),您会看到 wr.IsAlive 将为 false,并且消息将打印到控制台。
从 CLR 的角度来看,实例方法只是一个静态方法,其中实例通过第一个参数隐式传递。 与前一种情况类似,实例在其方法中最后一次使用后就会被 GC 回收。
整合所有知识
让我们回到我们的例子,并准确地展示在运行时调用 new RocksDbWrapper().UseRocksDb() 时会发生什么:

如果你问自己,这是否可能在现实世界中发生,答案是:当然!
那么,解决方案是什么?
使用 SafeHandle
处理原生资源的正确方法是什么? 将它们包装到 safe handle 中。 SafeHandle 是 BCL 中的一个特殊基类型,专为管理原生资源而设计。 这是我们案例的一个例子:
public class RocksDbSafeHandle : SafeHandle
{
private int _released = 0;
/// <inheritdoc />
private RocksDbSafeHandle(IntPtr handle) : base(handle, ownsHandle: true) { }
public static RocksDbSafeHandle Create()
=> new RocksDbSafeHandle(RocksDbNative.CreateDb());
/// <inheritdoc />
protected override bool ReleaseHandle()
{
if (Interlocked.CompareExchange(ref _released, 1, 0) == 0)
{
RocksDbNative.DestroyDb(handle);
return true;
}
return false;
}
/// <inheritdoc />
public override bool IsInvalid => _released != 0;
/// <inheritdoc />
public override string ToString() => handle.ToString();
internal IntPtr Handle => this.handle;
}
请注意,这里我们没有 finalizer。 相反,我们只是从 SafeHandle 派生并覆盖两个方法:ReleaseHandle 和 IsValid。 就是这样!
我们将使用 RocksDbSafeHandle 而不是 IntPtr,包括 RocksDbNative 类:
public static class RocksDbNative
{
public static void UseDb(RocksDbSafeHandle handle)
{
Trace("Starting using Db", handle);
PerformLongRunningPrerequisite();
// Using the handle
Trace("Using Db", handle);
PInvokeIntoDb(handle.Handle);
// Making sure the ‘handle’ is not collected during PInvoke
GC.KeepAlive(handle);
}
}
现在,这是安全的,因为 handle 实例本身在方法结束之前不会被 GC 回收,这归功于 GC.KeepAlive(并且 GC.KeepAlive 在这里非常重要,以避免可能导致在原生调用期间资源清理的竞争条件)。 是的,代码仍然非常复杂,但这种复杂性隐藏在原生层中,而原生层本身在设计上就很复杂。 至少您的用户是安全的,他们不会因为他们的代码和 GC finalization 之间非常奇怪且难以重现的竞争条件而遇到 heisenbug。
结论
这篇文章的主要目标是吓唬你,并让你相信 .NET 中的原生资源管理非常棘手。 您应该清楚地设计原生到托管层,并依赖 SafeHandle 而不是手动管理原生资源。 您绝对不应该将原生句柄暴露在此层之外,以避免将复杂性暴露到整个应用程序中。
疯狂领域:finalizer 是否可以在构造函数完成之前运行?
这里有一个更疯狂的场景。 是否有可能在“.ctor end”之前看到“.dtor end”?
public class CrazyContructor
{
public CrazyContructor()
{
Console.WriteLine(".ctor start");
Console.WriteLine(".ctor end");
}
~CazyConstructor()
{
Console.WriteLine(".dtor end");
}
}
可能,如果你已经读到这里,你不会惊讶答案是是的! 构造函数对于 GC 来说并不特殊。 如果构造函数没有触及 this 指针,并且 this 指针是实例的最后一个根,那么该实例就可以被 GC 回收。 如果构造函数的其余部分将运行很长时间,并且此时发生两个 GC 周期,那么 GC 将在技术上在完成实例构造之前运行 finalizer!
这是一个完整的例子:
public class CrazyContructor
{
private readonly int _field;
public CrazyContructor()
{
Console.WriteLine(".ctor start");
_field = 42;
Console.WriteLine(_field);
// We’re not touching ‘this’ pointer after this point.
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Console.WriteLine(".ctor end");
}
~CrazyContructor()
{
Console.Write(".dtor end");
}
}
如果您只运行 new CrazyConstructor(),您将得到以下输出:
.ctor start
42
.dtor end.ctor end
Dissecting the Code
-
Sergey Teplyakov
Exploring the Hidden Sides of C# and .NET