Dissecting the Code

Finalizers are tricker than you might think. Part 2

2025年3月27日

前一篇文章中,我们讨论了 finalizer 应该只处理非托管资源。在这篇文章中,我想说明这远没有听起来那么简单。

让我们再次重申什么是原生资源(native resource)。原生资源是指不由 CLR 管理的资源。这种资源通常由原生代码处理,并通过以下 API 公开:

例如,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() 时会发生什么:

Diagram

如果你问自己,这是否可能在现实世界中发生,答案是:当然!

那么,解决方案是什么?

使用 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 派生并覆盖两个方法:ReleaseHandleIsValid。 就是这样!

我们将使用 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

Exploring the Hidden Sides of C# and .NET