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

发布于 2024-09-18 01:58:06 字数 1352 浏览 4 评论 0原文

阅读这个问题,我想测试一下我是否可以证明非- 不保证此类操作的原子性的类型上的读取和写入的原子性。

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.Assert 更改为 if(..) throw )配置文件上运行了此配置文件:

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

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

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

Reading this question, I wanted to test if I could demonstrate the non-atomicity of reads and writes on a type for which the atomicity of such operations is not guaranteed.

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;
    }
}

To my surprise, the assertion refused to fail even after a full three minutes of execution.
What gives?

  1. The test is incorrect.
  2. The specific timing characteristics of the test make it unlikely/impossible that the assertion will fail.
  3. The probability is so low that I have to run the test for much longer to make it likely that it will trigger.
  4. The CLR provides stronger guarantees about atomicity than the C# spec.
  5. My OS/hardware provides stronger guarantees than the CLR.
  6. Something else?

Of course, I don't intend to rely on any behaviour that is not explicitly guaranteed by the spec, but I would like a deeper understanding of the issue.

FYI, I ran this on both Debug and Release (changing Debug.Assert to if(..) throw) profiles in two separate environments:

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

EDIT: To exclude the possibility of John Kugelman's comment "the debugger is not Schrodinger-safe" being the problem, I added the line someList.Add(dCopy); to the KeepReading method and verified that this list was not seeing a single stale value from the cache.

EDIT:
Based on Dan Bryant's suggestion: Using long instead of double breaks it virtually instantly.

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(4

日久见人心 2024-09-25 01:58:07

您可以尝试通过 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,这会按预期失败,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。所有指令都生成相同的基本指令(上面具有不同的偏移量),并且在几秒钟后都没有失败(可能是数十亿次尝试)。鉴于这些结果,我谨慎地相信,至少 Intel x86 CPU 提供双加载/存储操作的原子性,无论对齐如何。

You might try running it through CHESS to see if it can force an interleaving that breaks the test.

If you take a look at the x86 diassembly (visible from the debugger), you might also see if the jitter is generating instructions that preserve atomicity.


EDIT: I went ahead and ran the disassembly (forcing target x86). The relevant lines are:

                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] 

It uses a single fstp qword ptr to perform the write operation in both cases. My guess is that the Intel CPU guarantees atomicity of this operation, though I haven't found any documentation to support this. Any x86 gurus who can confirm this?


UPDATE:

This fails as expected if you use Int64, which uses the 32-bit registers on the x86 CPU rather than the special FPU registers. You can see this below:

                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 

UPDATE:

I was curious if this would fail if I forced non-8byte alignment of the double field in memory, so I put together this code:

    [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;
        }
    }

It does not fail and the generated x86 instructions are essentially the same as before:

                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] 

I experimented with swapping _d1 and _d2 for usage with dCopy/set and also tried a FieldOffset of 2. All generated the same basic instructions (with different offsets above) and all did not fail after several seconds (likely billions of attempts). I'm cautiously confident, given these results, that at least the Intel x86 CPUs provide atomicity of double load/store operations, regardless of alignment.

凝望流年 2024-09-25 01:58:07

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

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

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

The compiler is allowed to optimize away the repeated reads of _d. As far as it knows just statically analyzing your loop, _d never changes. This means it can cache the value and never re-read the field.

To prevent this you either need to synchronize access to _d (i.e. surround it with a lock statement), or mark _d as volatile. Making it volatile tells the compiler that its value could change at any time and so it should never cache the value.

Unfortunately (or fortunately), you cannot mark a double field as volatile, precisely because of the point you are trying to test—doubles cannot be accessed atomically! Synchronizing access to _d is the forces the compiler to re-read the value, but that also breaks the test. Oh well!

木槿暧夏七纪年 2024-09-25 01:58:07

您可以尝试去掉 '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

You might try getting rid of the 'dCopy = _d' and simply use _d in your assert.

That way two threads are reading/writing to the same variable at the same time.

Your current version makes a copy of _d which creates a new instance, all in the same thread, which is a thread safe operation:

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

All members of this type are thread safe. Members that appear to modify instance state actually return a new instance initialized with the new value. As with any other type, reading and writing to a shared variable that contains an instance of this type must be protected by a lock to guarantee thread safety.

However if both threads are reading/writing to the same variable instance then:

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

Assigning an instance of this type is not thread safe on all hardware platforms because the binary representation of that instance might be too large to assign in a single atomic operation.

Thus if both threads are reading/writing to the same variable instance you would need a lock to protect it (or Interlocked.Read/Increment/Exchange., not sure if that works on doubles)

Edit

As pointed out by others, on an Intel CPU reading/writing a double is an atomic operation. However, if the program is compiled for X86 and uses a 64 bit integer data type, then the operation would not be atomic. As demonstrated in the following program. Replace the Int64 with double and it appears to work.

    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
遮了一弯 2024-09-25 01:58:07

IMO 正确答案是#5。

double 的长度是 8 个字节。

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

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

正如上面的注释所述,即使 CPU 在 32 位模式下运行,双变量也只需 1 条指令即可加载和存储。

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

IMO the correct answer is #5.

double is 8 bytes long.

Memory interface is 64 bits = 8 bytes per module per clock (i.e. it becomes 16 bytes for double-channel memory).

There're also CPU caches. On my machine, the cache line is 64 bytes, and on all CPUs it's multiple of 8.

As said by the comments above, even when the CPU is running in 32-bits mode, double variables are loaded and stored with just 1 instruction.

That's why as long as your double variable is aligned (I suspect the common language runtime virtual machine does alignment for you), the double reads and writes are atomic.

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文