Flier's Sky

天空,蓝色的天空,眼睛看不到的东西,眼睛看得到的东西

导航

Finalization [1] 现有机制的原理和问题

Posted on 2004-07-08 11:54  Flier Lu  阅读(1104)  评论(0编辑  收藏  举报
http://www.blogcn.com/user8/flier_lu/index.html?id=2203965&run=.08E8850

Finalization 机制是 CLR 中完成显式资源释放的地方,将之与 IDisposable 接口机制配合,能够完成在 CLR 中对资源显式管理。但因为设计上的一些问题,导致正确编写 finalizer 是一件非常困难的事情,cbrumme 在其 BLog 上的一篇文章,Finalization,中详细介绍了为什么 finalizer 可能编写出错,以及在 Whidbey 中是如何尝试去解决这些问题。
    下面我将根据我的理解简述这篇重要文章的内容,备忘。

    在 CLR 中使用 Finalization 机制可能需要付出较为昂贵的代价:

    首先,创建一个 finalizable 对象时,需要将此对象放入系统 RegisteredForFinalization 队列中,以便在执行 GC 操作时能够区别对待这些对象。而这个工作相当于给每个 finalizable 对象增加并初始化一个指针域,这种代价在高速创建小对象时尤为明显。

    堆的实现类 GCHeap (vmgc.h:150) 在其 Alloc 方法 (vmgcsmp.cpp:5140, 5270) 创建一个对象时,会检查此对象是否实现了 finalizer (检查调用的标志参数 glags 是否包含 GC_ALLOC_FINALIZE),如果有则调用堆的 CFinalize 类实例的 RegisterForFinalization 方法 (vmgcsmp.cpp:5808),将此对象加入到 RegisteredForFinalization 队列中。 RegisteredForFinalization 队列实现上是一个初试大小为 100 的对象指针数组 m_Array (vmgcsmppriv.h:777),由堆内多个分代共享,m_FillPointers 数组 (vmgcsmppriv.h:778) 保存了每个分代所用的 RegisteredForFinalization 队列位置。初始化的代码片断如下:
以下为引用:

#define NUMBERGENERATIONS   5               //Max number of generations

class CFinalize
{
private:
  Object** m_Array;
  Object** m_FillPointers[NUMBERGENERATIONS+2];
  Object** m_EndArray;
public:
  CFinalize::CFinalize()
  {
    m_Array = new(Object*[100]);
    m_EndArray = &m_Array[100];

    for (unsigned int i =0; i < NUMBERGENERATIONS+2; i++)
    {
        m_FillPointers[i] = m_Array;
    }
  }
};


    因此在 CFinalize::RegisterForFinalization 执行时,需要逆向检索到对象所在分代的位置,并将对象指针插入到此位置。频繁分配的 finalizable 小对象可能会造成此队列迅速增长。(此队列的增长速度为原有长度的 1.2 倍,详情见 CFinalize::GrowArray vm:gcsmp.cpp:6094)

    其次,每次 GC 操作都需要扫描 RegisteredForFinalization 队列,将可回收的 finalizable 对象放入 ReadyToFinalize 队列中。

    CFinalize 使用 m_FillPointers[NUMBERGENERATIONS] 指向的区域保存 ReadyToFinalize 队列内容,在 CFinalize::ScanForFinalization 函数 (vmgcsmp.cpp:6011) 中完成对 RegisteredForFinalization 队列的扫描。

    而所有 ReadyToFinalize 队列的对象,以及通过他们能够触及 (reachable) 的对象,都将在此次 GC 过程中被标记 (Masked),并被提升 (Promoted) 到下一个分代中,以便完成 finalizer 的调用工作。而 ReadyToFinalize 队列的对象以及通过他们能够触及的对象,可能是一簇非常大的对象集。也就是说如果一个复杂的引用很多对象的类实现了 finalizer,将比简单的 Unmanaged 资源包装类更大地造成性能和垃圾回收率的损失。

    CFinalize::ScanForFinalization 函数在发现 ReadyToFinalize 队列有内容后,调用 CFinalize::GcScanRoots 函数 (gcsmp.cpp:5992) 遍历 ReadyToFinalize 队列,对每个可回收的 finalizable 对象调用 GCHeap::Promote 函数 (vmgcsmp.cc:4917),提升其分代。

    因为更老分代代将以比较新分代更少的比例被垃圾收集处理,所以因为 Finalizaion 造成的 finalizable 对象提升会显著增加本应被回收的对象的生命时间。

    最后,CLR 目前版本只使用了一个高优先级 Finalizer 线程负责遍历 ReadyToFinalize 队列,此线程顺序处理 ReadyToFinalize 队列中的每个对象的 Finalize 函数。在某些情况下,如终端服务中,多个进程的 finalizer 线程可能造成性能上的瓶颈;在某些情况,如多个 CPU 同时告诉创建 finalizable 对象时,可能造成性能瓶颈。最终这个单独的 Finalizer 线程可能变成稀缺资源,因为某些情况下它会被不确定地阻塞,这会造成进程资源不足并最终崩溃。cbrumme 在其 Apartments and Pumping in the CLR 一文中讨论了可能造成这种问题的原因。

    此外 Finalization 机制对 managed 代码开发者来说存在概念成本,很难编写完全正确的 Finalize 方法。

    要解决这些问题,CLR 的后续版本可能会通过使用线程池完成 Finalizer 线程工作减少潜在性能瓶颈;还可以通过修剪 finalizable 对象的引用对象树,降低提升此类对象带来的损耗。但因为修剪引用对象树时,只能通过 finalizable 对象的 Finalize 方法进行静态分析,而不能处理通过虚函数或接口就行调用的间接引用,这造成这种方法并不实用。

    要完全弄清楚这里的问题,需要考察对象可触性(Reachability)、析构顺序(Ordering)和部分信任(Partial Trust)等多方面因素。

    可触性(Reachability)

    编写正确 Finalize 方法的一个很重要的原则是不能在 Finalize 方法中使用其他对象,因为 Finalize 方法的调用顺序是无序的,不能假设一个外部对象在 Finalize 方法被调用时还存在并可访问。唯一例外的情况是只被此对象私有引用的外部对象,可以在 Finalize 方法中安全调用。因为正如上面分析的那样,这些私有引用的对象只有在此 finalizable 对象正确释放后,才会被垃圾收集,故而不存在访问问题。
    也可以通过设计上的技巧解决这个问题,如一个文件和一个缓冲区对象可以互相引用。则一旦他们被回收,肯定是在一代中进行,并按不定顺序调用其 Finalize 方法。在绑定的对象对的某个对象的 Finalize 方法被调用时,通知另外一个对象采取相应行动,可以让析构操作的顺序与 Finalize 方法调用的顺序无关,并最终解决这个问题。示例代码如下:
以下为引用:

class File
{
  private FileBuffer _buf;

  internal bool _finalized;

  public void CloseHandle()
  {
    // ...
  }

  internal void DoClose()
  {
    _buf.Flush();

    CloseHandle();

    _finalized = true;
    _buf._finalized = true;
  }

  public File() : _finalized(false)
  {
    // ...
  }

  public ~File()
  {
    if(!_finalized)
    {
      DoClose();
    }
  }
}

class FileBuffer
{
  private File _file;

  internal bool _finalized;

  public FileBuffer() : _finalized(false)
  {
    // ...
  }

  public ~FileBuffer()
  {
    if(!_finalized)
    {
      _file.DoClose();
    }
  }

  public void Flush()
  {
    // ...
  }
}



    析构顺序(Ordering)

    一个常见的问题是为什么 Finalization 的过程中对象必须是无序被调用的。如果能通过增加一些装饰性的对象引用来指导析构的顺序,则使用起来可以较为简单。但在实现上,必须使用很复杂的跨分代对象析构顺序排序,并很可能造成无法解决的环状互相引用的问题,这就跟通过引用计数实现垃圾收集一样,存在致命的理论上的硬伤。同时,无序的析构过程可以让处理 RegisteredForFinalization 和 ReadyToFinalize 队列的过程更有效,并容易向多线程处理移植。

    部分信任(Partial Trust)

    因为编写 finalizable 对象并不受安全权限的限制,所以通过 Finalize 方法对系统进行 DoS 攻击是非常现实的问题,即使是在部分信任情况下,也无法杜绝。虽然部分信任的代码可能无法直接访问 unmanaged 资源,但他们可以通过其他被信任的代码或方法获取此类资源。同时对 managed 资源也存在需要使用 Finalize 方法的情况,如对象池和缓冲区等等。例如 SQL Server (Yukon) 通过部分信任的 Assembly 实现数据库的 constraints,这些对象不使用 unmanaged 资源但必须能够回收。

    在考察了这些因素后,cbrumme 解释了为什么完美的 Finalize 方法很难编写:

    Finalize 方法必须能够容忍部分构造的对象

    一个从完全信任类型中继承出来的部分信任类型,可能在调用基类的构造函数之前就抛出异常,造成 Finalize 方法必须处理一个对象内容被全部置零,但没有初始化的实例;此外异步异常,如StackOverflowException、OutOfMemoryException 或 AppDomainUnloadException ,也可能造成构造函数的中断。

    对象可能在 Finalization 后变得可调用

    因为对象在 Finalize 方法中,可以将自己重新放入 GC 的根节点中重新可用(也就是 CLR 中的 resurrected 概念),所以对象必须保存是否已经被析构的信息。以便在 Finalize 方法被调用后,能够抛出 ObjectDisposedException 异常,防止错误的调用(或者重新构造自己)。

    对象可能在 Finalization 过程中变得可调用

    因为对象的 Finalize 方法是在单独的 Finalizer 线程中被调用,而引用此对象的其他对象可能在 Finalize 方法被调用时重生,这就造成对象在 Finalization 过程中,可能同时被应用程序和 Finalizer 线程使用。如果对象包装了一个操作系统的句柄,则还会在某些竞争条件下出现 handle recycling 攻击的可能性,具体讨论见 cbrumme 的 Lifetime, GC.KeepAlive, handle recycling 一文。

    Finalize 方法可能被多次调用

    与取消 Finalize 方法调用的 GC.SuppressFinalize 方法对应,GC.ReRegisterForFinalize 强制进行对象的 Finalize 方法调用,这就造成一个 Finalize 方法可能被调用多次。

    Finalizer 线程工作在不同的安全上下文(security context)中

    与 ThreadPool.QueueUserWorkItem 或 Control.BeginInvoke 类似,Finalize 方法被调用时,Finalizer 线程处于此对象构造时不同的安全上下文中。也就是说,如果 Finalize 函数编写不当,可能造成潜在的安全漏洞。如一个不恰当的例子中,一个完全信任的对象在构造函数中接受一个文件名参数,并在 Finalize 函数中打开处理这个文件,这就可能造成安全隐患。

    由此可见,编写一个真正完美的 Finalize 函数实在是太麻烦了,呵呵,反正我看完是头大了一圈 :P

待续...Whidbey 中对的改进