.NET C#中的unsafe/fixed语法无法固定传递的数组元素?

3

我有一些并发代码,它偶尔会出现故障。我已将问题缩小到两个看起来相同但一个失败而另一个不失败的情况。

我已经花费了太多时间尝试创建一个最小化且完整的失败示例,但没有成功。因此,我只发布了失败的行,以便任何人都可以看到明显的问题。

Object lock = new Object();

struct MyValueType { readonly public int i1, i2; };
class Node { public MyValueType x; public int y; public Node z; };
volatile Node[] m_rg = new Node[300];

unsafe void Foo()
{
    Node[] temp;
    while (true)
    {
        temp = m_rg;
        /* ... */
        Monitor.Enter(lock);
        if (temp == m_rg)
            break;
        Monitor.Exit(lock);
    }

#if OK                                      // this works:
    Node cur = temp[33];
    fixed (MyValueType* pe = &cur.x)
        *(long*)pe = *(long*)&e;
#else                                       // this reliably causes random corruption:
    fixed (MyValueType* pe = &temp[33].x)
        *(long*)pe = *(long*)&e;
#endif

    Monitor.Exit(lock);
}

我研究了IL代码,看起来发生的情况是尽管我们持有一个值类型的指针,但在数组位置33处的Node对象(在极少数情况下)会移动。
好像CLR没有注意到我们通过堆(可移动)对象——数组元素——来访问值类型。 'OK'版本在8路机器上进行了长时间测试,从未失败过,但备选路径每次都很快失败。
这永远不应该起作用,而“OK”版本太简化,以至于在压力下不会失败吗?
我需要使用GCHandle自己固定对象吗(我注意到在IL中,“fixed”语句本身并没有这样做)?
如果此处需要手动固定,则编译器为什么允许以这种方式(无需固定)通过堆对象访问?
请注意:除非直接涉及问题,否则本问题不涉及重新解释混合值类型的优雅性,请勿批评代码方面,谢谢。
[编辑:Jitted Asm]感谢汉斯的回复,我更好地理解了jit在何时将事物放在堆栈上,似乎是空洞的asm操作。例如,请参见[rsp + 50h],以及如何在“fixed”区域之后将其清零。剩下的未解决问题是,堆栈上的[cur + 18h](第207-20C行)是否足以保护对值类型的访问,而[temp + 33 * IntPtr.Size + 18h](第24A行)则不足。
[编辑]
结论摘要,最小示例
比较下面的两个代码片段,我现在相信#1不行,而#2是可以接受的。
(1.)以下内容失败(至少在x64 jit上);如果您尝试通过数组引用“in-situ”修复它,则GC仍然可以移动MyClass实例。没有地方在堆栈上发布特定对象实例的引用(需要固定的数组元素),以供GC注意到。
struct MyValueType { public int foo; };
class MyClass { public MyValueType mvt; };
MyClass[] rgo = new MyClass[2000];

fixed (MyValueType* pvt = &rgo[1234].mvt)
    *(int*)pvt = 1234;

(2.) 但是,如果您在堆上提供了一个显式的引用,并且可以向垃圾收集器广告,即使没有固定(pinning),您也可以使用 fixed 访问(可移动)对象内部的结构:

struct MyValueType { public int foo; };
class MyClass { public MyValueType mvt; };
MyClass[] rgo = new MyClass[2000];

MyClass mc = &rgo[1234];              // <-- only difference -- add this line
fixed (MyValueType* pvt = &mc.mvt)    // <-- and adjust accordingly here
    *(int*)pvt = 1234;

这就是我能提供的,除非有人可以提供更正或更多信息...

我认为我的问题可能归结于“fixed”语句的细节。它实际上保证了什么,以及会将什么放入IL代码中?我应该在IL中看到对GCHandle.Alloc的显式调用吗? - Glenn Slayden
2个回答

3
通过固定指针修改托管类型的对象可能导致未定义的行为。(C#语言规范,第18.6章)
好吧,你正在做这件事。尽管规范和MSDN库中有很多措辞,但实际上,fixed关键字并没有使对象不可移动,也没有被固定。您可能是从查看IL得出的结论。它使用了一个聪明的技巧,通过生成指针+偏移量,并让垃圾收集器调整指针来实现。我不知道为什么在一个情况下会失败而在另一个情况下不会失败。我没有看到生成的机器代码中有根本性的区别。但是我可能也没有复制您的确切机器代码,片段不是很好。
据我所知,由于结构成员访问,它应该在两种情况下都失败。这将导致指针+偏移量折叠为带有LEA指令的单个指针,从而防止垃圾收集器识别引用。结构一直是Jitter的问题。线程计时可能解释了差异。
您可以在connect.microsoft.com上发布以获得第二个意见。然而,要绕过规范违规将会很困难。如果我的理论正确,那么读取也可能失败,但更难以证明。
通过使用GCHandle实际固定数组来修复它。

1
非常好的回复,谢谢。我刚回到我的帖子,开始意识到GC需要在堆栈上窥探活动引用,而上面的代码没有暴露它们。我认为立即失败的原因是数组版本在未受保护的间隙中有更多的指令,因为它检查索引是否超出范围。只有3个指令加上运气可能让另一个工作。 - Glenn Slayden
顺便说一下,就我所知,我正在测试gcServer(8-way x64)上的发布版本。 - Glenn Slayden
@Mehrdad:是的,请查看我刚刚重新编辑的结论和新的代码片段;我现在相信第二个例子应该可以工作 - 这确实是“fixed”的目的。至于第一个例子是否应该工作,似乎还是个未知数,但出于某种原因 - 也许是我提到的猜测 - 它在x64上不起作用。 - Glenn Slayden
@Mehrdad:我可以同意这个。 - Glenn Slayden
C#规范中引用的句子有点含糊。如果T是可平坦化类型,.NET数组原语T[]算作“托管类型的对象”吗?这不可能是他们的意思,因为它会使fixed无用。所以我认为它指的是那些非可平坦化的托管类型--甚至fixed都不允许使用,因此它的说法变得毫无意义。您似乎把它视为第三个选项,即“托管类型的对象”的可平坦化对象引用的字段,但这与实际存在的单词相距甚远。即使是这样,该字段(在我的OP中的mvt)也没有像它所说的那样被(修改)。 - Glenn Slayden
显示剩余2条评论

0
我猜测,这个编译器正在使用 &temp(指向 tmp 数组的固定指针),然后用 [33] 对其进行索引。因此,你是在固定 temp 数组,而不是节点。尝试一下...
fixed (MyValueType* pe = &(temp[33]).x)
    *(long*)pe = *(long*)&e;

谢谢您的建议。我尝试了,但结果仍然相同;它仍然会立即崩溃。 - Glenn Slayden

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接