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

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

文章图片

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

文章图片

【小米科技|存在于.NET终结器中的竞争条件及缓解措施】小米科技|存在于.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 可能仍在运行 , 这可能会导致崩溃或其他不正确的行为 。