Wednesday, April 20, 2005

 

Some notes on Finalization

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

By Flier Lu
From http://www.cnblogs.com/flier/archive/2004/07/08/22391.html

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

2.
Finalization [2] Whidbey 中的改进

By Flier Lu
From http://www.cnblogs.com/flier/archive/2004/07/08/22392.html

在了解了 Finalization 存在的问题后,接下来看看 CLR 1.0 和 1.1 中的现状,以及 Whidbey (v2.0) 中是如何尝试解决这些问题的。

在 v1.0 和 v1.1 中,一旦创建一个 finalizable 对象,则此对象被加入到 RegisteredForFinalization 系统队列中,此对象结束前可能出现一下的情况

1.完成正常的 Finalization 操作流程。对象顺利地被 Finalizer 线程调用 Finalize 方法,并最后被 GC 回收。这是最理想的状况 :P

2.不经过 CLR 关闭流程。这种情况发生在进程被通过 TerminateProcess 或 ExitProcess 函数直接停止了,CLR 只是通过 DllMain 函数的 DLL_PROCESS_DETACH 消息获知进程终止,而此时又不能调用 Managed 代码,所以 Finalize 方法不会被调用,只能依赖于操作系统在进程级对资源的回收。所以内存和句柄引用的内核对象将被正确回收,但保存在内存中的缓冲区等“软”资源会丢失。

3.经过 CLR 关闭流程。这种情况发生在进程通过 System.Environment.Exit 方法等 Managed 方法停止,CLR 会被引发相应的处理函数,完成所以线程的终止操作,以及 RegisteredForFinalization 和 ReadyToFinalize 队列的处理工作。这种情况也可以接受。

System.Environment.Exit 方法 (bclsystemEnvironment.cs:83) 调用 System.Environment.ExitNative 外部方法,通过 CLR 在 SystemNative::Exit 函数实现 (vmcomsystem.cpp:833) 中调用 ForceEEShutdown 函数 (vmceemain.cpp:658),强制调用 EEShutDown 函数 (vmceemain.cpp:739) 完成 CLR 执行环境的关闭流程。EEShutDown 函数中分两步调用 GCHeap::FinalizerThreadWatchDog (vmgcee.cpp:975) 完成 Finalization 流程。

4.经过 GC 流程强制被回收。这种情况通常发生在对象类型所在 AppDomain 被 Unload 时,AppDomain 只有在其所有类型的对象所在 RegisteredForFinalization 和 ReadyToFinalize 队列的处理工作完成之后,才会被实际卸载。

System.AppDomain.Unload 方法 (bclsystemAppDomain.cs:696) 调用 System.AppDomain.UnloadWorker.Unload 方法 (bclsystemAppDomain.cs:1702) 间接通过 System.AppDomain.UnloadThreadWorker.Unload 方法 (bclsystemAppDomain.cs:1746) 创建一个后台线程完成实际的 AppDomain 卸载工作。而在 AppDomainNative::Unload 函数 (vmappdomainnative.cpp:471) 中实现的 AppDomain.nUnload 完成真正的卸载工作。而 AppDomainNative::Unload 函数内部调用 AppDomain::Exit 函数 (vmappdomain.cpp:5623) 完成类型卸载、资源回收,以及在调用 GCHeap::GarbageCollect 函数 (vmgcee.cpp:5351) 回收被卸载类型对象后,调用 GCHeap::FinalizerThreadWait 函数 (vmgcee.cpp:934) 释放 finalizable 对象。

此外 cbrumme 还提到了一些细节上需要注意的地方,例如除了 System.Threading.Thread 类的对象外,所有对象都是在其创建的 AppDomain 中被析构,而 Thread 对象在进程终止的过程中不被析构等等。因为这涉及到太多相关内容,这儿就不详细展开解释了,呵呵,每一条都足够单独写篇文章分析了 :P
如果有进一步了解的兴趣,可以参考 cbrumme 的另一篇文章 Startup, Shutdown & related matters。

在提出并分析问题后,cbrumme 给出了 Whidbey 中解决问题的一些思路和方法。

cbrumme 首先列举了一个 v2.0 中新增的 System.Runtime.InteropServices.SafeHandle 类型以及其种种优点。简单来说,这是一个操作安全的对 Win32 句柄进行包装的类,v2.0 的 BCL 从此类中派生出很多对句柄进行维护的类型,如 Win32SafeHandle 以及 Microsoft.Win32.SafeHandles 名字空间下一堆句柄包装类。这些类能够一定程度上解决构造和析构过程原子化等问题,杜绝诸如句柄重用攻击和资源耗尽攻击。而这一神奇类型真正的幕后功臣是 Critical Finalization 概念的出现和应用。

任一从 System.Runtime.Reliability.CriticalFinalizerObject 类型继承的子类,都自动获得 Critical Finalization (CF)的能力,CLR v2.0 将保障这些类型满足一下需求:

1.此类对象构造之前,CLR 将预先准备好调用此对象 Finalize 方法所需的各种资源。这种准备包括预先 JIT 代码,允许类构造函数 (class constructor),以及静态可达的所以其他类型。虽然如前面所述,这种静态分析无法处理通过虚函数或接口间接引用的对象,那些问题需要程序员来解决。

2.CLR 在执行这些类型的 Finalize 函数时不会超时。这就要求此类函数的 Finalze 代码编写非常小心,值得信任,呵呵

3.调用 Finalize 函数时,CLR 会进入一种保护状态,防止因为异步异常中断执行操作。例如 JIT 的 OutOfMemoryExceptions 或调用 .cctors 的 TypeInitializationExceptions 异常将被暂时屏蔽,保障 Finalize 函数运行的原子性。不过应该程序原因导致的异常,如分配对象导致 OutOfMemoryException 异常,这属于程序自身的问题,可以通过 try...catch 保护,不在系统保护范畴之内。

4.所有其它的普通对象在此类对象之前被析构,无论析构成功与否,都能保障在此类对象执行析构操作时,它们已经完成工作。

实际上 CF 的前三点跟 Constrained Execution Regions (CER) 要解决的问题是一致的,也就是对异步异常的处理模型。cbrumme 在另一篇文章 Reliability 中详细讨论了这方面问题,这里就不罗嗦了。而 v2.0 BCL 中 System.Runtime.Reliability 名字空间下增加的几个类型,就是为了解决这方面问题,可惜介绍的资料太少了。

而对于 CF 来说,在普通的程序中其重要意义并不能很好体现出来,毕竟碰到内存耗尽和调用 Thread.Abort 的机会并不多。不过在某些受限情况下,如 SQL Server (Yukon) 中,这种问题的解决就具有重要意义。例如作为 CLR 宿主的 Yukon 经常运行在临界资源状态下,异步 异常和强制用 Thread.Abort 中断时间过长的查询都是经常碰到的。好在 Whidbey 将提供基于策略的执行机制,AppDomain 卸载、普通的中断操作等等都会加入超时策略机制,通过配置来缓解部分问题。

看到这里,偶对 cbrumme 的仰慕之情如滔滔江水连绵不决,又有如黄河泛滥一发而不可收拾...呵呵

3.
Finalization [3] 外部资源对象生命期管理和句柄重用攻击

By Flier Lu
From http://www.cnblogs.com/flier/archive/2004/07/08/22393.html

cbrumme 在 Finalization 一文中多次提到了资源包装类生命期管理和句柄重用攻击的问题,其另外一篇文章 Lifetime, GC.KeepAlive, handle recycling 详细讨论了这个问题。

首先考察一个外部资源包装类及其使用代码

以下为引用:

class C {
IntPtr _handle;

public ~C() { ... }

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 {
...
}
}
}




以 C++ 背景程序员的思路来看,aC 指向对象的生命期应该 if 语句块的末尾结束,无论是否增加一句 aC = null,aC 都会显式被标记为不再使用。如果是 C++ 代码的话,还会在 else 之前由编译器自动加入的 aC.~C() 析构函数调用等等。
但是在 IL 代码一级,这个 "}" 实际上并不存在,它只是 C# 编译器增加的一个逻辑上的范围而已。对 JITer 所看到的 IL 代码这个层面,Other.work 函数中对 aC 的使用,在 aC.m() 调用之后就结束了。也就是说,GC 可以在 aC.m() 调用之后马上开始对 aC 的垃圾收集工作。
有人会进一步猜测,aC 将在 C.m() 函数或 C.OperateOnHandle() 函数调用完成后可被回收。但实际上,在 m 函数中,this 指针在用来获取 _handle 内容后,就失去作用了。也就是说,在实际调用静态函数 OperateOnHandle 之前,aC 保存的对象就可以被回收。

这样一来就会造成一种竞争条件的出现,在用户线程调用 C.OperateOnHandle() 函数处理 _handle 的句柄的同时,后台 Finalizer 线程可能已经在调用 C 类型的 Finalize 函数关闭 _handle 句柄了。出现这种问题的根本原因在于代码打破了对外部资源句柄封装的透明性,封装类 C 和被封装的句柄 _handle 的生命周期被分离开来。导致此问题的原因,还可以由于将 _handle 通过函数返回给最终使用者或者放入某个静态变量中。

现有情况下一个解决办法是在 OperateOnHandle 函数调用后添加一个 GC.KeepAlive(this) 调用,向 JIT 和 GC 标记当前对象的生命期将显式被延续到其所封装外部资源句柄的生命期之后。此函数不进行任何操作,只是 touch 目标对象一下,表示我还需要使用它,呵呵。
而这种将生命期维护工作交给最终用户来完成的策略,某种程度上将大大增加潜在问题出现的可能性,必须以其他替代机制保护这种被分离生命期的外部资源对象。

v1.1 内部处理这种问题的解决方法是通过提供诸如 System.Threading.__HandleProtector 这样的内部类。当外部句柄从包装类的生命期中分离出去时,通过新增保护机制来单独维护外部对象的生命期,从而彻底分离两者。例如 System.Threading.WaitHandle 类型的 Handle 属性就通过

以下为引用:

namespace System.Threading
{
class WaitHandle
{
public virtual IntPtr Handle
{
get
{
if (this.waitHandleProtector != null)
{
return this.waitHandleProtector.Handle;
}
return WaitHandle.InvalidHandle;
}
set
{
this.waitHandleProtector = ((value == WaitHandle.InvalidHandle) ? null : new __WaitHandleHandleProtector(value));
this.waitHandle = value;
}
}
}
}




类似的问题还存在于提供了 IDisposable 接口的包装类。当一个线程在调用 Dispose 方法时,另一个线程可能正在使用这个资源。GC.KeepAlive 是无法完全解决这类问题的,而通过大范围的锁来彻底解决,则在性能和功能上是不现实的。
而这类问题还可能导致前面所说的句柄重用攻击的安全漏洞。如恶意代码可以打开一个具有足够权限的文件,然后同时调用 Read 和 Dispose 方法。而如果恰好发生竞争条件时,另外一个重要文件被打开并重用了此句柄,则本不应被授权的读操作将利用竞争条件被执行。者就是句柄重用攻击。
要完全解决此问题,在现有架构下需要增加自动对资源引用次数的追踪,通过计数器来跟踪句柄的使用情况,防止竞争条件的发生。但这样做会带来性能上的代价,以及复杂的 unmanaged 资源跟踪机制,并且需要由 CLR 自动或用户手工来确定哪些资源是需要跟踪的。这显然并不现实可行,并且会因为另外的如 unsafe 代码等问题导致连锁问题。

为此 Whidbey 提供了上述的 SafeHandle 类型,从其继承出来的子类,如 SafeWaitHandle 类型,防止上述问题的发生。如 CLR 2.0 中 WaitHandle.Handle 属性的处理代码改成下面的形式:

以下为引用:

namespace System.Threading
{
class WaitHandle
{
protected virtual void Dispose(bool explicitDisposing)
{
if (this.safeWaitHandle != null)
{
this.safeWaitHandle.Close();
}
}
public virtual IntPtr Handle
{
get
{
if (this.safeWaitHandle != null)
{
return this.safeWaitHandle.DangerousGetHandle();
}
return WaitHandle.InvalidHandle;
}
set
{
if (value == WaitHandle.InvalidHandle)
{
this.safeWaitHandle.SetHandleAsInvalid();
this.safeWaitHandle = null;
}
else
{
this.safeWaitHandle = new SafeWaitHandle(value, true);
}
this.waitHandle = value;
}
}
}
}

4.
C++/CLI中栈对象的设计问题

By 李建忠
From http://blog.joycode.com/lijianzhong/archive/2005/01/10/42762.aspx

C++/CLI中新推出的自动确定性资源回收(Automatic deterministic destruction)被视为一个优秀的设计。是使用所谓C++/CLI这个“新瓶”来装Bjarne Stroustrup提出的RAII这个“旧酒”。

这的确不错,相对而言,这个比C#中的using 关键字(dispose模式),以及Java中的hard-coded的dispose方法都要好许多。这个特性是由C++/CLI中栈对象(局部对象)来提供的,局部对象本身没错,RAII也是局部对象应有之义。

但问题在于C++/CLI中栈对象的可用性由于许多原因会大打折扣,使用起来已经远远不如ISO-C++中那样流畅。下面列出了损伤其可用性的几大硬伤:

#1。C++/CLI的栈对象并非真的位于栈中

只要类型是ref class,C++/CLI中的栈对象就仍位于托管堆中。仍然使用newobj IL指令来分配。如果R没有定义析构器(~R)(注意:C++/CLI中的析构器和C#中的析构器完全两回事),那么下面两行代码实际上将生成完全一样的IL代码:

R r;
R h=gcnew R;


好像记得Herb Sutter曾经说过他们将来可能会在真正的方法栈中分配r ——说实话恐怕只有C++背景的人敢这么“胡思乱想”:) 他们现在只是想在语法层面让程序员"感觉"就像r是从栈中分配的一样。又一个syntax sugar:)

当然为了对称和语义的完美,有时候还需要在r上应用%——虽然背后仍是什么也没做:)



#2。C++/CLI编译器默认情况下不会自动产生拷贝构造函数和拷贝赋值操作符

这一点非常令人烦恼,几乎让人“望栈对象而却步”。更糟糕的是BCL中的所有类型都没有提供拷贝构造函数和拷贝赋值操作符——因为恐怕只有C++/CLI会用到他们。

话说回来,即使C++/CLI会自动产生拷贝构造函数和拷贝赋值操作符,那么继承自BCL的类型还是会很麻烦。


#3。如果函数要被其他CLI语言调用,那么就不能将其参数设计为栈对象

a. static void add(R r){...}

编译出来有一个modopt元数据,所以可以被其他语言调用,但是如果被其他语言调用,比如C#,那么其他语言将是以传值的方式传递引用,而C++/CLI将是传递对象拷贝(要调用拷贝构造器),所以语义混乱,完全不可以这样做。


b. static void add(R% r){...}

由于编译出来都有一个modreq元数据,所以不能被其他CLI语言调用。



#4。如果函数要被其他CLI语言调用,那么也不能将其返回值设计为栈对象

a. static R add(){...}

b. static R% add(){...}

两者编译出来都有一个modreq元数据,所以都不能被其他CLI语言调用。



#5。使用BCL时,如果要传递栈对象,总要使用“莫名其妙”的%操作符

比如:

String s("abc");
ArrayList list;
list.Add(%s);

实在很不好,还是使用追踪引用比较好:

String^ s="abc";
ArrayList^ list=gcnew ArrayList();
list->Add(s);


总结一下:

#1和#5对栈对象的可用性影响不算大,毕竟从语义层面来理解,还是行得通的。
但是,#2、#3、#4的影响就很大。#3和#4使得我们必须放弃使用栈对象来进行互操作。而#2会让编写C++/CLI代码非常的不方便——除非你以后不想使用栈对象。

现在的问题是,是否C++/CLI中的栈对象只是为了获得自动确定性资源回收而存在?值得这样做吗?

5.
关于“值类型的Finalize不会被调用?”

By 李建忠
From http://blog.joycode.com/lijianzhong/archive/2005/01/13/42991.aspx

ninputer在这里(http://blog.joycode.com/ninputer/archive/2005/01/12/42866.aspx)有一篇blog提出了一个问题“值类型的Finalize不会被调用?”

我曾经对Rotor,也就是sscli(Shared Source Common Language Infrastructure),有过一些粗略的探索——不过现在由于比较忙,慢慢也半途而废了:)

这个问题可以从sscli里得到解释——sscli和目前运行在我们机器上的CLR实现差别主要在效率和扩展层面,因此研究它有助于理解CLR的行为。所有有关底层运作的代码都在目录sscli\clr\src\vm下。结合sscli的源码,下面我来聊聊这个话题。


首先给出一个结论:这是因为CLR对值类型进行了专门的设计,让它不可能进入Freachable Queue 里面。


下面根据sscli源码来对上述结论进行解释:

1。有关CLR类型一个最关键的类就是MethodTable。它的第一个字段m_wFlags(一个DWORD)的第21位 bit用来标示这个类是否有Finalizer。

MethodTable有一个方法为HasFinalizer就做此用:

MethodTable::HasFinalizer()
{
return (m_wFlags & enum_flag_HasFinalizer);
}
其中enum_flag_HasFinalizer = 0x100000,

GC在判断一个类型的实例对象是否需要放到Freachable Queue中,就是采用MethodTable::HasFinalizer()方法来判断。



2。最关键的是EEClass::BuildMethodTable,这个方法负责建立类型的方法表,它会被ClassLoader::LoadTypeHandleFromToken调用,ClassLoader::LoadTypeHandleFromToken又被ClassLoader::LoadTypeHandle和Module::BuildClassForModule调用。

用通俗的语言来解释就是“每一个类型被load到内存中的时候,它都会建立和该类型相关的方法表”,而我们在CLR中的所有对象都有自己的类型。



3。下面就是看EEClass::BuildMethodTable如何设置MethodTable::m_wFlags。

EEClass::BuildMethodTable中和“值类型的Finalize”这个主题相关的动作有以下几个调用(为简便起见我没有在这里写方法的参数):

EEClass::BuildMethodTable
{
...
CheckForValueType
...

CheckForEnumType

...
GetMethodTable()->MaybeSetHasFinalizer

...
}


4。来看CheckForValueType和CheckForEnumType分别做了什么。

HRESULT EEClass::CheckForValueType(bmtErrorInfo* bmtError)
{
if(...) //查看类型元数据
SetValueClass();
}

HRESULT EEClass::CheckForEnumType(bmtErrorInfo* bmtError)
{
if(...) //查看类型元数据
SetValueClass();
}

再来看SetValueClass做了什么:

inline void EEClass::SetValueClass()
{
m_VMFlags |= VMFLAG_VALUETYPE;
}

就是设置EEClass::m_VMFlags的第24位bit来表示这个类为“值类型”。

其中VMFLAG_VALUETYPE = 0x00800000,



5。最后来看MaybeSetHasFinalizer做了什么(我简化了其中很多代码,只展示和本问题相关的代码逻辑)。

void MethodTable::MaybeSetHasFinalizer()
{

if ( !IsValueClass())
{

if(...)
{
m_wFlags |= enum_flag_HasFinalizer;
}

}
}

这段代码的意思是只要IsValueClass()为true,那么MethodTable::m_wFlags的第21位 bit就不会被置1。

那么MethodTable::IsValueClass()做了什么呢?

inline BOOL MethodTable::IsValueClass()
{
return GetClass()->IsValueClass();
}

inline DWORD EEClass::IsValueClass()
{
return (m_VMFlags & VMFLAG_VALUETYPE);
}

判断EEClass::m_VMFlags的第24位bit看看其是否为“值类型”。


至此,整个来龙去脉已经非常清晰——CLR的设计者通过MethodTable::m_wFlags的第21位bit来控制一个类型是否有Finalizer,同时通过EEClass::m_VMFlags的第24位bit来控制一个类型是否为值类型。最后在调用EEClass::BuildMethodTable的时候,判断如果一个类型为值类型,那么就让它不可能具有Finalizer语义。



Ninputer 在随后的回复中还提了一个问题“如果值类型用了非托管资源怎么释放呢?”。

我的回答是:不要这么做,值类型当初就是为象integer这样的轻量级类型而设计的,持有非托管资源的类型天生就是一个“重量级类型”。当然你可以使struct实现IDisposable,但是那是不完整的Dispose模式。

实际上在我的Effective .NET (in C#)一书的draft里就有这样一个条款:

# 如果使用非托管资源,请把它封装在class而不是struct里面。

6.
C#中Finalize方法的问题

By 李建忠
From http://blog.joycode.com/lijianzhong/archive/2005/01/13/43014.aspx

ninputer在关于“值类型的Finalize不会被调用”中(http://blog.joycode.com/lijianzhong/archive/2005/01/13/42991.aspx#FeedBack)评论到“VB对Finalize管的可松呢,可以直接重写、直接调用、允许不调用父类的Finalize,或者多次调用父类的Finalize等等…… 完全不像C#”。

其实C#的Finalize方法看起来只是比VB的好一点,但仍然有非常隐蔽的问题。问题如下。

首先来看如下的代码:

using System;

public class Grandpapa
{
~Grandpapa(){ Console.WriteLine("Grandpapa.~Grandpapa");}
}

public class Parent:Grandpapa
{
~Parent(){ Console.WriteLine("Parent.~Parent");}
}

public class Son:Parent
{
~Son(){ Console.WriteLine("Son.~Son");}
}

public class App
{
public static void Main()
{
Son s=new Son();

GC.Collect();
GC.WaitForPendingFinalizers();
}
}

这段代码的运行结果毫无疑问是:

Son.~Son
Parent.~Parent
Grandpapa.~Grandpapa

这没什么问题。但是如果将Parent类重新定义如下,会出现什么情况呢?

public class Parent:Grandpapa
{
protected void Finalize(){ Console.WriteLine("Parent.Finalize");}
}


运行结果变成了:

Son.~Son
Parent.Finalize

情况已经有些不妙了,我在Parent中定义了一个“普通”的Finalize方法,竟然被它的子类Son的析构器给调用了?

当然Finalize方法在C#中并不是一个“普通”的方法,析构器编译后就是一个有上述签名的Finalize方法。但C#编译器并没有禁止我们定义“普通”的Finalize,

C#规范也没有指出定义这样的Finalize方法就是在定义一个析构器——实际上也不是,只是上述代码的表现如此——甚至还有这样一句诱人犯错的话:The compiler behaves as if this method(Finalize), and overrides of it, do not exist at all。分析IL代码可以看出,Parent中定义的“普通”的Finalize方法实际上“欺骗”了它的子类。它的子类只关心其父类是否定义了Finalize(当然签名要为上述形式)方法,它并不关心那个Finalize方法是否具有“析构器”语义。

如果上述代码的行为通过理性分析还算可以接受的话,那么下面代码的运行结果就令人眩晕了,将Parent类重新定义如下(在上面的基础上添加了一个virtual关键字):

public class Parent:Grandpapa
{
protected virtual void Finalize(){ Console.WriteLine("Parent.Finalize");}
}

编译后运行结果如下:

Grandpapa.~Grandpapa

这一次从IL代码的角度也解释不清了,我怀疑CLR对于析构器的判断是否还有另外的附加条件,但无论如何C#编译器呈现的行为是诡异的,因为这种结果放到哪里都是难以自圆其说的。我曾经为此挖掘了sscli源代码很长时间,但是就是找不到原因。

这一方面是C#编译器的一个bug,另一方面也是CLR的一个bug。这个bug从.NET Framework的1.0版(VS.NET 2002),到1.1版(VS.NET 2003),以及Alpha版本的Longhorn操作系统中自带的1.2版都存在。后来我写信给C#的产品经理Eric Gunnerson(http://blogs.msdn.com/ericgu/)告诉他们这个bug。Eric Gunnerson随后回信告诉我他们会修复这个bug。

我现在使用Visual C# Express 2005编译器编译(version 8.00.41013)上述代码,后面两种修改版都会得到一个warning:

warning CS0465: Introducing a 'Finalize' method can interfere with destructor invocation. Did you intend to declare a destructor?

但是如果不理会这样的警告,得到的exe文件执行行为仍然是非常奇怪。也就是说CLR中的bug仍然没有fix。我个人认为对于C#编译器来说,warning是不够的,应该彻底禁止定义这样的Finalize方法。

实际上在我的Effective .NET (in C#)一书的draft里也有这样一个条款:

# 不要在一个类中有定义任何Finalize方法的念头,因为那样会对你的“析构器链”造成潜在的严重的伤害。



<< Home

This page is powered by Blogger. Isn't yours?