为什么这段代码不能证明读/写的非原子性?

时间:2010-09-09 17:55:54

标签: c# .net thread-safety double atomic

阅读this question,我想测试一下我是否可以证明对这种操作的原子性无法保证的类型的读写非原子性。

private static double _d;

[STAThread]
static void Main()
{
    new Thread(KeepMutating).Start();
    KeepReading();
}

private static void KeepReading()
{
    while (true)
    {
        double dCopy = _d;

        // In release: if (...) throw ...
        Debug.Assert(dCopy == 0D || dCopy == double.MaxValue); // Never fails
    }
}

private static void KeepMutating()
{
    Random rand = new Random();
    while (true)
    {
        _d = rand.Next(2) == 0 ? 0D : double.MaxValue;
    }
}

令我惊讶的是,即使在执行了整整三分钟之后,断言也拒绝失败。 是什么赋予了?

  1. 测试不正确。
  2. 测试的具体时序特性使断言不太可能/不可能失败。
  3. 概率很低,我必须运行测试的时间更长,才有可能触发。
  4. CLR提供了比C#规范更强的原子性保证。
  5. 我的操作系统/硬件提供比CLR更强的保证。
  6. 别的什么?
  7. 当然,我并不打算依赖规范未明确保证的任何行为,但我希望对此问题有更深入的了解。

    仅供参考,我在两个不同的环境中对Debug和Release(将Debug.Assert更改为if(..) throw)配置文件进行了此操作:

    1. Windows 7 64位+ .NET 3.5 SP1
    2. Windows XP 32位+ .NET 2.0
    3. 编辑:为了排除John Kugelman的评论“调试器不是Schrodinger安全”的可能性,我将someList.Add(dCopy);行添加到KeepReading方法并验证此列表不是从缓存中看到一个陈旧的值。

      编辑: 基于丹·布莱恩特的建议:使用long代替double几乎立即打破它。

4 个答案:

答案 0 :(得分:12)

您可以尝试通过CHESS运行它,看看它是否可以强制进行交错打破测试。

如果您查看x86 diassembly(从调试器中可见),您可能还会看到抖动是否正在生成保持原子性的指令。


编辑:我继续运行反汇编(强制目标x86)。相关的行是:

                double dCopy = _d;
00000039  fld         qword ptr ds:[00511650h] 
0000003f  fstp        qword ptr [ebp-40h]

                _d = rand.Next(2) == 0 ? 0D : double.MaxValue;
00000054  mov         ecx,dword ptr [ebp-3Ch] 
00000057  mov         edx,2 
0000005c  mov         eax,dword ptr [ecx] 
0000005e  mov         eax,dword ptr [eax+28h] 
00000061  call        dword ptr [eax+1Ch] 
00000064  mov         dword ptr [ebp-48h],eax 
00000067  cmp         dword ptr [ebp-48h],0 
0000006b  je          00000079 
0000006d  nop 
0000006e  fld         qword ptr ds:[002423D8h] 
00000074  fstp        qword ptr [ebp-50h] 
00000077  jmp         0000007E 
00000079  fldz 
0000007b  fstp        qword ptr [ebp-50h] 
0000007e  fld         qword ptr [ebp-50h] 
00000081  fstp        qword ptr ds:[00159E78h] 

它使用单个fstp qword ptr在两种情况下执行写操作。我的猜测是英特尔CPU保证了此操作的原子性,但我还没有找到任何文档来支持这一点。任何可以证实这一点的x86大师?


更新:

如果使用Int64,它会按预期失败,后者使用x86 CPU上的32位寄存器而不是特殊的FPU寄存器。您可以在下面看到:

                Int64 dCopy = _d;
00000042  mov         eax,dword ptr ds:[001A9E78h] 
00000047  mov         edx,dword ptr ds:[001A9E7Ch] 
0000004d  mov         dword ptr [ebp-40h],eax 
00000050  mov         dword ptr [ebp-3Ch],edx 

更新:

我很好奇如果我在内存中强制非双字段对齐8字节会失败,那么我把这段代码放在一起:

    [StructLayout(LayoutKind.Explicit)]
    private struct Test
    {
        [FieldOffset(0)]
        public double _d1;

        [FieldOffset(4)]
        public double _d2;
    }

    private static Test _test;

    [STAThread]
    static void Main()
    {
        new Thread(KeepMutating).Start();
        KeepReading();
    }

    private static void KeepReading()
    {
        while (true)
        {
            double dummy = _test._d1;
            double dCopy = _test._d2;

            // In release: if (...) throw ...
            Debug.Assert(dCopy == 0D || dCopy == double.MaxValue); // Never fails
        }
    }

    private static void KeepMutating()
    {
        Random rand = new Random();
        while (true)
        {
            _test._d2 = rand.Next(2) == 0 ? 0D : double.MaxValue;
        }
    }

它没有失败,生成的x86指令与以前基本相同:

                double dummy = _test._d1;
0000003e  mov         eax,dword ptr ds:[03A75B20h] 
00000043  fld         qword ptr [eax+4] 
00000046  fstp        qword ptr [ebp-40h] 
                double dCopy = _test._d2;
00000049  mov         eax,dword ptr ds:[03A75B20h] 
0000004e  fld         qword ptr [eax+8] 
00000051  fstp        qword ptr [ebp-48h] 

我尝试交换_d1和_d2用于dCopy / set,并尝试使用FieldOffset 2.所有生成相同的基本指令(上面有不同的偏移量)并且几秒钟后都没有失败(可能数十亿次尝试) 。鉴于这些结果,我非常自信至少英特尔x86 CPU提供双重加载/存储操作的原子性,无论是否对齐。

答案 1 :(得分:3)

允许编译器优化_d的重复读取。据他所知,只是静态地分析你的循环,_d永远不会改变。这意味着它可以缓存该值,并且永远不会重新读取该字段。

要防止这种情况发生,您需要同步_d的访问权限(例如用lock语句将其包围),或将_d标记为volatile。使其变为volatile会告诉编译器它的值可能随时发生变化,因此它永远不应该缓存该值。

不幸的是(或幸运的是),您无法将double字段标记为volatile,正是因为您尝试测试的一点 - double s无法原子访问!同步对_d的访问是编译器重新读取值的强制,但这也会破坏测试。哦,好吧!

答案 2 :(得分:2)

您可以尝试删除'dCopy = _d'并在断言中使用_d。

这样两个线程同时读/写同一个变量。

您当前的版本制作了一个_d副本,它创建了一个新实例,所有这些都在同一个线程中,这是一个线程安全的操作:

http://msdn.microsoft.com/en-us/library/system.double.aspx

  
    

此类型的所有成员都是线程安全的。似乎修改实例状态的成员实际上返回使用新值初始化的新实例。与任何其他类型一样,读取和写入包含此类实例的共享变量必须通过锁保护,以保证线程安全。

  

但是如果两个线程都在读/写同一个变量实例,那么:

http://msdn.microsoft.com/en-us/library/system.double.aspx

  
    

在所有硬件平台上分配此类型的实例并非线程安全,因为该实例的二进制表示可能太大而无法在单个原子操作中进行分配。

  

因此,如果两个线程都在读取/写入同一个变量实例,则需要一个锁来保护它(或者Interlocked.Read/Increment/Exchange。,不确定它是否适用于双精度数据)

修改

正如其他人所指出的,在Intel CPU上读/写double是一个原子操作。但是,如果程序是为X86编译并使用64位整数数据类型,则操作不是原子操作。如以下程序所示。用双重替换Int64,它似乎工作。

    Public Const ThreadCount As Integer = 2
    Public thrdsWrite() As Threading.Thread = New Threading.Thread(ThreadCount - 1) {}
    Public thrdsRead() As Threading.Thread = New Threading.Thread(ThreadCount - 1) {}
    Public d As Int64

    <STAThread()> _
    Sub Main()

        For i As Integer = 0 To thrdsWrite.Length - 1

            thrdsWrite(i) = New Threading.Thread(AddressOf Write)
            thrdsWrite(i).SetApartmentState(Threading.ApartmentState.STA)
            thrdsWrite(i).IsBackground = True
            thrdsWrite(i).Start()

            thrdsRead(i) = New Threading.Thread(AddressOf Read)
            thrdsRead(i).SetApartmentState(Threading.ApartmentState.STA)
            thrdsRead(i).IsBackground = True
            thrdsRead(i).Start()

        Next

        Console.ReadKey()

    End Sub

    Public Sub Write()

        Dim rnd As New Random(DateTime.Now.Millisecond)
        While True
            d = If(rnd.Next(2) = 0, 0, Int64.MaxValue)
        End While

    End Sub

    Public Sub Read()

        While True
            Dim dc As Int64 = d
            If (dc <> 0) And (dc <> Int64.MaxValue) Then
                Console.WriteLine(dc)
            End If
        End While

    End Sub

答案 3 :(得分:0)

IMO的正确答案是#5。

double是8个字节长。

存储器接口为64位=每个模块每个时钟8个字节(即双通道存储器为16个字节)。

还有CPU缓存。在我的机器上,缓存行是64字节,在所有CPU上,它是8的倍数。

正如上面的评论所说,即使CPU以32位模式运行,也只需加载1个指令即可加载和存储双变量。

这就是为什么只要您的双变量对齐(我怀疑公共语言运行时虚拟机为您做对齐),双读和写是原子的。