lock 语句中还需要 volatile 吗?

Is volatile still needed inside lock statements?

我在不同的地方读到有人说应该始终使用 lock 而不是 volatile。我发现那里有很多关于多线程的令人困惑的陈述,甚至专家对这里的某些事情也有不同的看法。

经过大量研究,我发现 lock 语句至少也会插入 MemoryBarriers

例如:

    public bool stopFlag;

    void Foo()
    {
        lock (myLock)
        {
          while (!stopFlag)
          {
             // do something
          }
        }
    }

但如果我没有完全错的话,JIT 编译器可以自由地永远不会真正读取循环内的变量,而是它可能只会从寄存器中读取变量的缓存版本. 如果 JIT 对变量进行寄存器分配,AFAIK MemoryBarriers 将无济于事,它只是确保 if 我们从内存中读取值是当前值.

除非有一些编译器魔术说 "if a code block contains a MemoryBarrier, register assignment of all variables after the MemoryBarrier is prevented".

除非声明volatile或用Thread.VolatileRead()读取,如果myBool从另一个Thread设置为false,循环可能仍然运行无限,这是正确的吗? 如果是,这是否适用于线程之间共享的所有变量?

the JIT compiler is free to never actually read the variable inside the loop but instead it may only read a cached version of the variable from a register.

嗯,它会在循环的第一次迭代中读取变量一次,但除此之外,是的,它会继续读取缓存值,除非有一个记忆障碍。任何时候代码越过内存屏障,它都不能使用缓存的值。

使用 Thread.VolatileRead() 添加适当的内存屏障,将字段标记为 volatile 也是如此。还有很多其他的事情也可以隐含地增加内存屏障;其中之一正在进入或离开 lock 语句。`

由于您的循环是在单个 lock 的正文中说而不是进入或离开它,因此可以继续使用缓存的值。

当然,这里的解决方案不是添加内存屏障。如果您想等待另一个线程通知您何时应该继续,请使用 AutoResetEvent(或其他专门设计用于允许线程通信的类似同步工具)。

每当我看到这样的问题时,我的下意识反应就是"assume nothing!".NET内存模型很弱,C#内存模型对于使用只能应用于处理器的语言尤其值得注意使用甚至不再支持的弱内存模型。没有什么可以告诉你这段代码中会发生什么,你可以推理锁和内存屏障,直到你脸色发青,但你一无所获。

x64 抖动非常干净,很少出现意外。但它的日子已经屈指可数了,它将在 VS2015 中被 Ryujit 取代。以 x86 抖动代码库为起点的重写。令人担忧的是,x86 抖动会让您陷入困境。双关语。

最好的办法就是尝试一下,看看会发生什么。稍微重写您的代码并使该循环尽可能紧凑,以便抖动优化器可以做任何它想做的事情:

class Test {
    public bool myBool;
    private static object myLock = new object();
    public int Foo() {
        lock (myLock) {
            int cnt = 0;
            while (!myBool) cnt++;
            return cnt;
        }
    }
}

并像这样测试它:

    static void Main(string[] args) {
        var obj = new Test();
        new Thread(() => {
            Thread.Sleep(1000);
            obj.myBool = true;
        }).Start();
        Console.WriteLine(obj.Foo());
    }

切换到发布版本。 Project + Properties,Build 选项卡,勾选 "Prefer 32-bit" 选项。 Tools + Options,Debugging,General,取消勾选"Suppress JIT optimization"选项。首先 运行 调试版本。工作正常,程序在一秒钟后终止。现在切换到发布版本,运行 并观察它死锁,循环永远不会完成。使用 Debug + Break All 可以看到它在循环中挂了。

要了解原因,请使用 Debug + Windows + Disassembly 查看生成的机器代码。只关注循环:

                int cnt = 0;
013E26DD  xor         edx,edx                      ; cnt = 0
                while (myBool) {
013E26DF  movzx       eax,byte ptr [esi+4]         ; load myBool 
013E26E3  test        eax,eax                      ; myBool == true?
013E26E5  jne         013E26EC                     ; yes => bail out
013E26E7  inc         edx                          ; cnt++
013E26E8  test        eax,eax                      ; myBool == true?
013E26EA  jne         013E26E7                     ; yes => loop
                }
                return cnt;

地址 013E26E8 处的指令讲述了这个故事。请注意 myBool 变量如何存储在 eax 寄存器中, cnt 存储在 edx 寄存器中。抖动优化器的标准职责是使用处理器寄存器并避免内存加载和存储,从而使代码更快。请注意,当它测试该值时,它仍然使用寄存器并且 not 从内存中重新加载。因此这个循环永远不会结束,它总是会挂起你的程序。

当然,代码很假,没有人会写这个。实际上,这往往是偶然工作的,您将在 while() 循环中包含更多代码。太多让抖动完全优化可变方式。但是没有硬性规定会告诉您何时发生这种情况。有时它确实成功了,什么都不假设。永远不应跳过适当的同步。你真的只有为 myBool 或 ARE/MRE 或 Interlocked.CompareExchange() 加一个额外的锁才安全。如果你想削减这样一个不稳定的角落,那么你必须检查。

并在评论中指出,尝试使用 Thread.VolatileRead() 代替。您需要使用 byte 而不是 bool。它仍然挂起,它不是一个同步原语。

这个怎么样

public class Singleton<T> where T : class, new()
{
    private static T _instance;
    private static object _syncRoot = new Object();

    public static T Instance
    {
        get
        {
            var instance = _instance;
            if (instance == null)
            {
                lock (_syncRoot)
                {
                    instance = Volatile.Read(ref _instance);
                    if (instance == null)
                    {
                        instance = new T();
                    }
                    Volatile.Write(ref _instance, instance);
                }
            }
            return instance;
        }
    }
}