首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

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

摘要

请注意,在.NET中隐藏着一个竞争条件(Race Condition),当终结器(Finalizers)被执行时,将会触发,它会影响所有代码,甚至是单线程模式代码也会被影响。

原因在于:终结器由.NET在独立的线程上调用,并且可能”访问.NET JIT编译器在较新版本的.NET运行时中激进的生命周期判定策略而已经被垃圾回收的对象”。

(引号中的句子较长,请反复阅读,搞明白)。

解决此问题的一种方法是,在编译器中自动生成对System::GC::KeepAlive的调用。此实现已经在Microsoft C++编译器的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字段只是一个位模式,没有附加其他含义。 .NET JIT 编译器分析了代码并确定 dd 对象的最后一次使用是访问 ptr(第 52 行),然后将其值作为 File::Write 的隐式对象参数传递。 遵循 JIT 编译器的这一推理,一旦从对象中获取 ptr 的值,对象 dd 就不再需要并且有资格进行垃圾回收。 ptr 指向活动的本机对象这一事实对 .NET 来说是不透明的,因为它不跟踪本机指针。

4. 从这里开始,事情可能会出错。 对象 dd 被安排用于收集,并且作为进程的一部分,终结器通常在第二个线程上运行。 现在,我们可能有两件事同时发生,它们之间没有任何顺序,一个经典的竞争条件:Write 成员函数正在执行,终结器 !DataOnDisk 也在执行,后者将删除 ptr 引用的文件对象,而 File::Write 可能仍在运行,这可能会导致崩溃或其他不正确的行为。

等等,发生了什么?

有一些问题马上就冒出来了:

> 这是一个新Bug吗?是的,但又不是。这个问题从.NET 2.0开始就已经存在了。

> 新版本中改变了什么吗?.NET JIT 编译器在 .NET 4.8 终于开始更为激进地判定对象的生命周期。 从托管代码的角度来看,它正在做正确的事情。

> 但是,这会影响核心C++/CLI本机互操作场景。 我们还可以做些什么呢? 请继续阅读。

解决方案

很容易看出,当对 Write 的调用发生时(第 52 行),如果它保持活动状态,那么竞争条件就会消失,因为在对 Write 的调用返回之前将不再收集 dd。 这可以通过几种不同的方式完成:

> 将 JIT 编译器行为的更改视为错误并恢复到旧行为。 执行此操作需要针对 .NET 进行系统更新,并且可能会禁用优化。 将 .NET 框架冻结在 4.7 版也是一种选择,但不是长期有效地选择,特别是因为相同的 JIT 行为也可能发生在 .NET Core 中。

> 在需要的地方手动插入System::GC::KeepAlive(this)调用。 这有效但容易出错并且需要检查源代码并修改每一处地方,因此这对于大型工程来说不是一个可行的解决方案。

> 在需要时让编译器注入 System::GC::KeepAlive(this) 调用。 这是我们在 Microsoft C++ 编译器中实现的解决方案。

实现细节

我们可以通过在每次看到对原生函数的调用时发出对 KeepAlive 的调用来强制实施解决方案,但出于性能原因,我们希望更加智能化。 我们想在可能出现竞争条件的地方发出这样的调用,但在其他地方没有。 以下是 Microsoft C++ 编译器用来确定是否要在代码中的某个点发出隐式 KeepAlive 调用的算法,其中:

> 我们在返回语句或从托管类的成员函数中隐式返回;

> 托管类具有“非托管类型的引用或指针”类型的成员,包括其直接或间接基类中的成员,或嵌入在类层次结构中任何位置的类类型成员中;

> 在当前(托管成员)函数中发现对函数 FUNC 的调用,它满足以下一个或多个条件:

1. FUNC 没有 __clrcall 调用约定,或者

2. FUNC 不将此作为隐式或显式参数,或

3. 对此的引用不跟在对 FUNC 的调用之后

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

如何得到此更新

在 Visual Studio 16.10 及更高版本中,默认情况下上述 Microsoft C++ 编译器行为处于启用状态,但在由于新的隐式调用 KeepAlive 调用而出现不可预见的问题的情况下,Microsoft C++ 编译器提供了两个额外的选项:

> 使用开关/clr:implicitKeepAlive-,它关闭翻译单元中的所有此类调用。 此开关在项目系统设置中不可用,但必须明确添加到命令行选项列表(属性页 > 命令行 > 附加选项)。

> 使用#pragma implicit_keepalive,它在函数级别提供对此类调用的发出的细粒度控制。

总结

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

最后

Microsoft Visual C++团队的博客是我非常喜欢的博客之一,里面有很多关于Visual C++的知识和最新开发进展。大浪淘沙,如果你对Visual C++这门古老的技术还是那么感兴趣,则可以经常去他们那(或者我这)逛逛。

本文来自:《A Race Condition in .NET Finalization and its Mitigation for C++/CLI》

最近我写了个东西

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

我想:你值得拥有。

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20211018A025RA00?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券