【问题标题】:Why doesn't this code demonstrate the non-atomicity of reads/writes?为什么这段代码没有展示读/写的非原子性?
【发布时间】:2011-04-10 09:39:34
【问题描述】:

阅读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. 还有别的吗?

当然,我不打算依赖规范未明确保证的任何行为,但我希望对该问题有更深入的了解。

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

  1. Windows 7 64 位 + .NET 3.5 SP1
  2. Windows XP 32 位 + .NET 2.0

编辑:为了排除 John Kugelman 的评论“调试器不是薛定谔安全”成为问题的可能性,我将行 someList.Add(dCopy); 添加到 KeepReading 方法并验证此列表没有看到一个陈旧的缓存中的值。

编辑: 根据 Dan Bryant 的建议:使用 long 而不是 double 几乎会立即破坏它。

【问题讨论】:

  • 我的猜测(也就是这样)是#3。
  • 我会检查 IL 以确保编译器没有玩任何技巧,但除此之外我什么都没有。我希望这会在 32 位机器上中断。
  • 我会增加写入线程的数量 - 尝试让 N+1 线程在 N 核系统上运行。
  • 尝试使用 Int64 而不是 double 运行它。 FPU 是 Intel CPU 上的一个独立的野兽,它有自己的寄存器堆栈,能够存储 8 字节浮点数。请务必使用跨越两个单词的数字。
  • 该线程中那个可悲地被忽视的答案中提到了它。可怜的家伙。它主要取决于 JIT 编译器在加载程序堆中分配变量的确切位置。这是你无法控制的。不确定加载器堆的对齐规则是什么,尝试在静态双精度之前和之后添加更多不同大小的静态。您可以在调试器的内存窗口中获取它们的地址(如 &d)。

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


【解决方案1】:

您可以尝试通过CHESS 运行它,看看它是否可以强制执行中断测试的交错。

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


编辑:我继续运行反汇编(强制目标 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 来执行写操作。我的猜测是 Intel 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 一起使用,还尝试了 2 的 FieldOffset。所有都生成了相同的基本指令(上面有不同的偏移量),并且在几秒钟后都没有失败(可能是数十亿次尝试) .鉴于这些结果,我非常有信心至少 Intel x86 CPU 提供双重加载/存储操作的原子性,而不管对齐方式。

【讨论】:

    【解决方案2】:

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

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

    不幸(或幸运),您不能将double 字段标记为volatile,正是因为您要测试的点——doubles 不能以原子方式访问!同步访问_d 会强制编译器重新读取该值,但这也会破坏测试。哦,好吧!

    【讨论】:

    • 我可以看到KeepReading方法在调试器中看到_d的不同值。另外,查看 IL,循环 inside 的第一行是 ldsfld float64 Tester.Program::d,因此没有进行编译器优化。
    • 调试器不是薛定谔安全的。您正在尝试测试非常低级别的东西。这有点超出了我的薪酬等级,但我怀疑 JIT 优化器可以在运行时优化读取。很难说,也可能是#2、#3、#4。
    【解决方案3】:

    您可以尝试摆脱 '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 替换为 double 即可。

        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
    

    【讨论】:

    • 顺便说一句,您可以尝试使用“断言”以外的构造。
    • 在发布模式下,我将其替换为if(...) throw
    • 删除 dCopy 什么都不做(除了让线程读取 _d 两次而不是一次)。
    【解决方案4】:

    IMO 正确答案是 #5。

    double 的长度为 8 个字节。

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

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

    正如上面的 cmets 所说,即使 CPU 在 32 位模式下运行,也只需 1 条指令就可以加载和存储双精度变量。

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

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2014-12-01
      • 2019-07-26
      • 2011-10-28
      • 1970-01-01
      • 1970-01-01
      • 2022-06-16
      • 1970-01-01
      • 2016-05-16
      相关资源
      最近更新 更多