锁语句的内存屏障

18

最近我了解了关于内存屏障和重排序问题的知识,现在我对此有些困惑。

考虑以下情况:

private object _object1 = null;    
private object _object2 = null;
private bool _usingObject1 = false;

private object MyObject
{
    get 
    {
        if (_usingObject1)
        {
            return _object1;
        }
        else
        {
            return _object2;
        }
    }
    set 
    {
        if (_usingObject1)
        {
           _object1 = value;
        }
        else
        {
           _object2 = value;
        }
    }
}

private void Update()
{
    _usingMethod1 = true;
    SomeProperty = FooMethod();
    //..
    _usingMethod1 = false;
}
  1. Update方法中,_usingMethod1 = true语句是否总是在获取或设置属性之前执行?还是由于重新排序的问题,我们无法保证这一点?

  2. 我们应该像这样使用volatile

private volatile bool _usingMethod1 = false;
如果我们使用lock;,那么能否保证锁内的每个语句都会按顺序执行,例如:
private void FooMethod()
{
    object locker = new object();
    lock (locker)
    {
        x = 1;
        y = a;
        i++;
    }
}
2个回答

35

内存屏障的主题相当复杂,即使是专家有时也会被绊倒。当我们谈论内存屏障时,我们实际上结合了两个不同的想法。

  • 获取屏障:在该屏障之前,其他读取和写入不允许移动。
  • 发布屏障:在该屏障之后,其他读取和写入不允许移动。

只创建这两种屏障中的一种称为“半屏障”。同时创建这两种屏障的称为“全屏障”。

volatile关键字创建半屏障。对volatile字段的读取具有获取语义,而写入具有发布语义。这意味着没有任何指令可以在读取之前或写入之后移动。

lock关键字在两个边界(进入和退出)上都创建全屏障。这意味着任何指令都不能在每个边界之前或之后移动。

但是,如果我们只关注一个线程,则所有这些都无用。就该线程所感知的排序方式而言,它始终得到保留。实际上,如果没有这个基本的保证,没有任何程序将能够正常工作。真正的问题在于其他线程如何感知读取和写入。这就是您需要关注的地方。

因此,回答您的问题:

  1. 从单个线程的角度来看...是的。从另一个线程的角度来看...不是。

  2. 这取决于情况。可能会起作用,但我需要更好地了解您要实现的目标。

  3. 从另一个线程的角度来看...不是。读取和写入可以在锁定的边界内自由移动。它们只是不能超出这些边界。这就是为什么其他线程也需要创建内存屏障的重要性。


谢谢提供的信息,它确实帮助我更好地理解了这个概念。我的目标是确保在执行下一个指令 SomeProperty = FooMethod() 之前,_usingMethod1 = true 指令总是会被执行。在多线程场景下,如何实现呢?是通过以下方式:_usingMethod1 = true; Thread.MemoryBarrier(); SomeProperty = FooMethod(); 还是使用锁定以进行完整的屏障,以防止重排序:lock (locker) { _usingMethod1= true; } SomeProperty = FooMethod(); 或者只需将_usingMethod1设置为volatile变量即可。感谢您的帮助。 - Jalal Said
4
我会把Update方法的全部内容都用锁进行包裹。除了内存屏障,这也确保了原子性,同样是十分重要的。此外,使用无锁编程的技巧(比如使用volatile、Thread.MemoryBarrier等)非常难以正确实现。 - Brian Gideon
你对获取和释放栅栏的定义并不完全正确。获取还可以防止栅栏之前的读取操作移动,而释放则可以防止栅栏之后的写入操作移动。否则,你可以将获取之前或释放之后的所有内容移动到栅栏上方或下方,这样栅栏就无法提供任何保证了。 - relatively_random
另一个微妙之处在于,volatile并不能保证产生实际的acquire/release屏障。Volatile读取保证所有后续内存访问不会在该读取之前重新排序,但是对其他变量的任何先前读取都可以移动到volatile读取之下。Acquire屏障也将防止所有先前的读取移动到任何后续内存访问之下。对于volatile写入也是类似的 - 它不是一个release屏障,只是一个release写入。 - relatively_random
最后,据我所知,锁只需要在进入时获取语义,在退出时释放语义 - 甚至不需要获取/释放栅栏。换句话说,它们只需要防止块内的操作逃逸,但不需要影响其外部的任何内容。理论上,在锁定块之前发生的不相关内存访问可能会在其之后重新排序,反之亦然。 - relatively_random

4
volatile关键字在这里没有任何作用。它的保证非常弱,不意味着内存屏障。您的代码没有显示另一个线程被创建,因此很难猜测是否需要锁定。但是,如果两个线程可以同时执行Update()并使用相同的对象,则锁定是硬性要求。
请注意,您发布的锁代码没有锁定任何内容。每个线程都将拥有自己的"locker"对象实例。您必须将其设置为类的私有字段,由构造函数或初始化器创建。因此:
private object locker = new object();

private void Update()
{
    lock (locker)
    {
        _usingMethod1 = true;
        SomeProperty = FooMethod();
        //..
        _usingMethod1 = false;
    }
}

请注意,在 SomeProperty 赋值时也会有一个竞争过程。

volatile关键字具有内存屏障的作用;因此我想知道这个内存屏障是否会抑制重排序,以便确保_usingMethod1 = true在获取或设置SomeProperty属性之前始终得到执行。我提供了一个锁,仅用于内存屏障而不是与其他线程同步的问题,因此我故意将其作为方法中的局部变量,因为我想知道它是否可以避免锁内指令的重排序。 - Jalal Said
1
一个单独的线程总是对其使用的变量具有一致的视图。如果不是这种情况,程序可能无法正常工作。 - Hans Passant

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