小米科技|存在于.NET终结器中的竞争条件及缓解措施( 二 )


等等 , 发生了什么?有一些问题马上就冒出来了:
> 这是一个新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 行仍然可能存在竞争条件 。 为了了解原因 , 想象一下终结器线程和用户代码同时调用终结器 。在这种情况下 , 双重删除的可能性是显而易见的 。解决这个问题需要一个临界区 , 但这超出了本文的范围 , 还是留给读者作为课后练习题吧 。