存在于NET终结器中的竞争条件及缓解措

摘要

请注意,在.NET中隐藏着一个竞争条件(RaceCondition),当终结器(Finalizers)被执行时,将会触发,它会影响所有代码,甚至是单线程模式代码也会被影响。原因在于:终结器由.NET在独立的线程上调用,并且可能”访问.NETJIT编译器在较新版本的.NET运行时中激进的生命周期判定策略而已经被垃圾回收的对象”。(引号中的句子较长,请反复阅读,搞明白)。解决此问题的一种方法是,在编译器中自动生成对System::GC::KeepAlive的调用。此实现已经在MicrosoftC++编译器的16.10及更高版本中可用。

简介

C++/CLI主要用于连接原生C++和.NET世界,实现两者之间的互操作。因此,开发者经常采用的一种代码模式是将原生指针封装到一个托管类中,如下图所示:

通常,托管包装类会创建一个NativeClass的实例,它控制和访问系统资源(例如文件),使用资源并确保资源被正确释放,将此任务委托给终结器。为了演示上述流程,请查看下图中的代码:

在上述代码中,File类通过原生C++接口来控制文件对象,而类DataOnDisk则使用原生类File来对文件进行数据的读写。虽然在不再使用文件时可以显式调用Close,但终结器会在DataOnDisk对象被回收时执行此操作。

我们可以看到,上面的代码看起来没啥大问题,但是其中隐藏一个潜在的竞争条件,会导致程序错误。

竞争条件

让我们在上述代码中定义一个类成员函数WriteData,如下图所示:

此函数会在下面的调用场景下被调用,如下图所示:

到目前为止,一切都还顺利,没看出为什么大问题。从test_write开始,让我们好好研究下程序执行的细节。1.第57行,一个DataOnDisk对象被创建,一些测试数据同时被创建,然后WriteData被调用并将测试数据写入到文件中(第57行)。2.WriteData在获取元素的地址并调用底层原生File对象的Write成员函数之前小心地锁定缓冲区数组对象(第51行)。锁定这个操作很重要,因为我们不希望.NET在写入时移动缓冲区。3.但是,由于.NET垃圾收集器对本机原生类型一无所知,因此DataOnDisk的ptr字段只是一个位模式,没有附加其他含义。.NETJIT编译器分析了代码并确定dd对象的最后一次使用是访问ptr(第52行),然后将其值作为File::Write的隐式对象参数传递。遵循JIT编译器的这一推理,一旦从对象中获取ptr的值,对象dd就不再需要并且有资格进行垃圾回收。ptr指向活动的本机对象这一事实对.NET来说是不透明的,因为它不跟踪本机指针。4.从这里开始,事情可能会出错。对象dd被安排用于收集,并且作为进程的一部分,终结器通常在第二个线程上运行。现在,我们可能有两件事同时发生,它们之间没有任何顺序,一个经典的竞争条件:Write成员函数正在执行,终结器!DataOnDisk也在执行,后者将删除ptr引用的文件对象,而File::Write可能仍在运行,这可能会导致崩溃或其他不正确的行为。

等等,发生了什么?

有一些问题马上就冒出来了:这是一个新Bug吗?是的,但又不是。这个问题从.NET2.0开始就已经存在了。新版本中改变了什么吗?.NETJIT编译器在.NET4.8终于开始更为激进地判定对象的生命周期。从托管代码的角度来看,它正在做正确的事情。但是,这会影响核心C++/CLI本机互操作场景。我们还可以做些什么呢?请继续阅读。

解决方案

很容易看出,当对Write的调用发生时(第52行),如果它保持活动状态,那么竞争条件就会消失,因为在对Write的调用返回之前将不再收集dd。这可以通过几种不同的方式完成:将JIT编译器行为的更改视为错误并恢复到旧行为。执行此操作需要针对.NET进行系统更新,并且可能会禁用优化。将.NET框架冻结在4.7版也是一种选择,但不是长期有效的选择,特别是因为相同的JIT行为也可能发生在.NETCore中。在需要的地方手动插入System::GC::KeepAlive(this)调用。这有效但容易出错并且需要检查源代码并修改每一处地方,因此这对于大型工程来说不是一个可行的解决方案。在需要时让编译器注入System::GC::KeepAlive(this)调用。这是我们在MicrosoftC++编译器中实现的解决方案。

实现细节

我们可以通过在每次看到对原生函数的调用时发出对KeepAlive的调用来强制实施解决方案,但出于性能原因,我们希望更加智能化。我们想在可能出现竞争条件的地方发出这样的调用,但在其他地方没有。以下是MicrosoftC++编译器用来确定是否要在代码中的某个点发出隐式KeepAlive调用的算法,其中:我们在返回语句或从托管类的成员函数中隐式返回;托管类具有“非托管类型的引用或指针”类型的成员,包括其直接或间接基类中的成员,或嵌入在类层次结构中任何位置的类类型成员中;在当前(托管成员)函数中发现对函数FUNC的调用,它满足以下一个或多个条件:1.FUNC没有__clrcall调用约定,或者2.FUNC不将此作为隐式或显式参数,或3.对此的引用不跟在对FUNC的调用之后

本质上,我们正在寻找表明在调用FUNC期间没有垃圾收集危险的指标。因此,如果满足上述条件,我们会在调用FUNC之后立即插入System::GC::KeepAlive(this)调用。尽管对KeepAlive的调用看起来很像生成的MSIL中的函数调用,但JIT编译器将其视为一个指令,以在该点考虑当前对象处于活动状态。

如何得到此更新

在VisualStudio16.10及更高版本中,默认情况下上述MicrosoftC++编译器行为处于启用状态,但在由于新的隐式调用KeepAlive调用而出现不可预见的问题的情况下,MicrosoftC++编译器提供了两个额外的选项:使用开关/clr:implicitKeepAlive-,它关闭翻译单元中的所有此类调用。此开关在项目系统设置中不可用,但必须明确添加到命令行选项列表(属性页命令行附加选项)。使用#pragmaimplicit_keepalive,它在函数级别提供对此类调用的发出的细粒度控制。

总结

细心的读者会注意到,在第39行仍然可能存在竞争条件。为了了解原因,想象一下终结器线程和用户代码同时调用终结器。在这种情况下,双重删除的可能性是显而易见的。解决这个问题需要一个临界区,但这超出了本文的范围,还是留给读者作为课后练习题吧。

最后

MicrosoftVisualC++团队的博客是我非常喜欢的博客之一,里面有很多关于VisualC++的知识和最新开发进展。大浪淘沙,如果你对VisualC++这门古老的技术还是那么感兴趣,则可以经常去他们那(或者我这)逛逛。本文来自:《ARaceConditionin.NETFinalizationanditsMitigationforC++/CLI》

最近我写了个东西

正如你们所知道的,拓扑梅尔智慧办公平台(TopomelBox)是一款绿色软件,主要面向经常使用电脑的朋友。它提供了各种提升办公效率的小功能,同时操作上尽可能地简单方便。我想:你值得拥有。




转载请注明:http://www.aierlanlan.com/cyrz/1272.html