C#中的volatile变量:内存屏障VS缓存

12

我已经研究了这个主题相当长的时间,我认为我理解了最重要的概念,例如发布和获取内存屏障

然而,我还没有找到一个令人满意的解释关于volatile和主存缓存之间的关系。

我明白每次读取和写入与volatile字段相关的数据时,都会强制执行其前后的读取和写入操作的严格顺序(读取-获取和写入-释放)。但是这仅仅保证了操作的顺序。它并未说明其他线程/处理器何时可以看到这些更改。特别是这依赖于缓存刷新的时间(如果有的话)。我记得曾经看过Eric Lippert的评论,大意是“volatile字段的存在会自动禁用缓存优化”之类的话。但我不确定这具体意味着什么。这是否意味着只要我们在某个地方有单个的volatile字段,整个程序的缓存就会被完全禁用?如果不是,那么缓存被禁用的粒度是多少?

此外,我了解了一些关于强和弱volatile语义的内容,以及C#遵循强语义的情况,在这种情况下,每次写入都会直接到达主存储器,无论它是volatile字段还是非volatile字段。所有这些让我感到非常困惑。


4
我并没有说过那个话;实际上你更有可能在我发表的评论中看到我说,由于volatile对缓存的影响只是一种实现细节,而不是保证。C#规范说明了volatile的预期行为;超出规定行为的任何行为都是实现细节,不能依赖它们。 - Eric Lippert
2
C# 规范还指出,读取和写入的全局一致可观察顺序明确不保证存在。例如,两个线程可能无法达成一致,即它们对于一个变量的 volatile 读取是在另一个变量的 volatile 写入之前还是之后发生的。 - Eric Lippert
5
你对此感到非常困惑是正确的。我也非常困惑。这就是为什么我从不使用 volatile 的原因。 - Eric Lippert
2
这是一个非常好的问题,我也被困扰了一段时间。我的理解是,内存屏障(包括 volatile 提供的半屏障)需要被内存子系统所尊重;否则,它们就没有什么意义了。因此,acquire 意味着缓存被无效化,而 release 意味着缓存被刷新。但我看到很多人坚称这不是这样的情况。 - Douglas
2
“不完整”具体指什么?C#规范并未涉及其运行处理器的实现细节,这也是不可能的。它只是说明符合规范的实现必须在多线程程序中表现出某些最小可观察行为,例如volatile写入、线程启动、异常等方面的排序。 - Eric Lippert
显示剩余17条评论
3个回答

21
我将首先回答最后一个问题。Microsoft的.NET实现在写入时具有发布语义1。这不是C#本身的问题,所以相同的程序,无论使用哪种语言,在不同的实现中都可能具有弱非易失性写入。
副作用的可见性涉及多个线程。忘记CPU、核心和缓存。相反,想象每个线程都有一个堆上的快照,需要某种同步才能在线程之间通信副作用。
那么,C#说了什么?C#语言规范新版本草案)基本上与公共语言基础设施标准(CLI; ECMA-335ISO/IEC 23271)说的一样,只是有些差异。我稍后会谈到它们。
那么,CLI说了什么?只有易失性操作是可见的副作用。
请注意,它还说堆上的非易失性操作也是副作用,但不能保证可见。同样重要的是2,它也没有声明它们保证可见。

volatile操作到底会发生什么?volatile读具有获取语义,它在任何后续内存引用之前。volatile写具有释放语义,它在任何先前的内存引用之后。

获取锁执行volatile读取,释放锁执行volatile写入。

Interlocked操作具有获取和释放语义。

还有一个重要的术语需要学习,那就是原子性

对于32位架构上的基本值和64位架构上的基本值,无论是否为volatile,读取和写入都保证是原子的。对于引用,它们也保证是原子的。对于其他类型,例如长结构体,操作不是原子的,可能需要多个独立的内存访问。

然而,即使使用volatile语义,读取-修改-写入操作,例如v += 1或等效的++v(或v++,就副作用而言),也不是原子的。

Interlocked操作保证某些操作的原子性,通常是加法、减法和比较交换(CAS),即如果当前值仍然是某个期望值,则写入某个值。.NET还具有针对64位整数的原子Read(ref long)方法,即使在32位架构上也可以使用。

我将继续将获取语义称为volatile读取,将释放语义称为volatile写入,将二者或其中任何一个称为volatile操作。

所有这些都意味着什么,就是顺序

volatile读取是一个点,在该点之前,没有内存引用可以跨越,而volatile写入是一个点,在该点之后,没有内存引用可以跨越,这两个点在语言级别和机器级别都是如此。

如果中间没有volatile写入,则非volatile操作可以跨越到以下volatile读取之后,如果中间没有volatile读取,则可以跨越到先前的volatile写入之前。

线程内的volatile操作是顺序的,不能被重新排序。

线程中的volatile操作按照相同的顺序对所有其他线程可见。但是,没有所有线程的volatile操作的总顺序,即如果一个线程执行V1然后执行V2,另一个线程执行V3然后执行V4,则任何具有V1在V2之前和V3在V4之前的顺序都可以被任何线程观察到。在这种情况下,可以是以下任意一个:

  • V1 V2 V3 V4 V1 V2 V3 V4

  • V1 V3 V2 V4 V1 V3 V2 V4

  • V1 V3 V4 V2 V1 V3 V4 V2

  • V3 V1 V2 V4 V3 V1 V2 V4

  • V3 V1 V4 V2 V3 V1 V4 V2

(注:该文本为HTML代码,无法直接翻译,已按原样返回。)
  • V3 V4 V1 V2 V3 V4 V1 V2

也就是说,对于单个执行过程中的任何线程,观察到的副作用可能的顺序都是有效的。没有总排序的要求,因此所有线程观察到单个执行过程中可能的顺序之一。

如何进行同步?

基本上,它归结为这样:同步点是指在一个易失性写入之后发生的易失性读取。

实际上,您必须检测一个线程中的易失性读取是否发生在另一个线程的易失性写入之后 3 。以下是一个基本示例:

public class InefficientEvent
{
    private volatile bool signalled = false;

    public Signal()
    {
        signalled = true;
    }

    public InefficientWait()
    {
        while (!signalled)
        {
        }
    }
}

然而,虽然效率通常较低,但您可以运行两个不同的线程,一个调用InefficientWait(),另一个调用Signal(),并且当后者从Signal()返回时的副作用在前者从InefficientWait()返回时变得可见。
易失访问不如交错访问一般有用,交错访问也不如同步原语一般有用。我的建议是,首先使用同步原语(锁、信号量、互斥量、事件等)开发安全代码,并在需要时使用它们,如果您发现根据实际数据(例如分析)有理由提高性能,那么只有在这种情况下才看看是否可以改进。
如果您曾经达到快速锁的高竞争状态(仅用于少量读写而不会阻塞),则根据竞争的数量,切换到交错操作可能会提高或降低性能。特别是当您不得不诉诸比较和交换循环时,例如:
var currentValue = Volatile.Read(ref field);
var newValue = GetNewValue(currentValue);
var oldValue = currentValue;
var spinWait = new SpinWait();
while ((currentValue = Interlocked.CompareExchange(ref field, newValue, oldValue)) != oldValue)
{
    spinWait.SpinOnce();
    newValue = GetNewValue(currentValue);
    oldValue = currentValue;
}

意思是,你需要对解决方案进行分析并与当前状态进行比较。并且要注意A-B-A问题
还有SpinLock,你必须真正地针对基于监视器的锁进行分析,因为虽然它们可能使当前线程让出,但它们不会将当前线程置于睡眠状态,类似于SpinWait的使用方式。
转换为易失性操作就像玩火。您必须通过分析证明代码是正确的,否则您可能会在最不希望的时候受到伤害。
通常,在高争用情况下优化的最佳方法是避免争用。例如,要并行地对大型列表执行转换,通常最好将问题分解和委派给多个工作项,生成合并在最后一步的结果,而不是让多个线程锁定列表以进行更新。这具有内存成本,因此它取决于数据集的长度。
C#规范和CLI规范在volatile操作方面有什么区别?
C#规定副作用,但未提及它们在线程间的可见性,包括对volatile字段的读或写、对非volatile变量的写、对外部资源的写以及抛出异常。
C#指定关键执行点,在这些点上,这些副作用在线程之间得到保留:对volatile字段的引用、lock语句以及线程的创建和终止。
如果我们将关键执行点视为副作用变得“可见”的点,则它增加了CLI规范,即线程的创建和终止是“可见”的副作用,即new Thread(...).Start()在当前线程上具有释放语义,在新线程的开始处具有获取语义,并且退出线程在当前线程上具有释放语义,而thread.Join()在等待线程上具有获取语义。
C#没有一般的volatile操作的提及,例如由System.Threading中的类执行,而不仅仅是通过声明为volatile的字段和使用lock语句来执行。我认为这不是故意的。
C#说明捕获的变量可以同时暴露给多个线程。CIL没有提及它,因为闭包是一种语言结构。

1.

有一些地方的微软(前)员工和MVP声称写操作具有发布语义:

在我的代码中,我忽略了这个实现细节。我假设非易失性写入不保证可见。


2.

有一个普遍的误解,认为在C#和/或CLI中允许引入读取操作。

然而,这仅适用于局部参数和变量。

对于静态和实例字段、数组或堆上的任何内容,您不能合理地引入读取操作,因为这样的引入可能会从当前执行线程的角度打破执行顺序,无论是来自其他线程的合法更改还是通过反射进行的更改。

也就是说,您不能将此代码:

object local = field;
if (local != null)
{
    // code that reads local
}

变成这样:

if (field != null)
{
    // code that replaces reads on local with reads on field
}

如果你能够分辨它们的区别。具体来说,当访问local的成员时,会抛出NullReferenceException异常。
对于C#中的捕获变量,它们相当于实例字段。
需要注意的是CLI标准:
  • 表示非易失性访问不能保证可见性

  • 没有表示非易失性访问一定不可见

  • 表示易失性访问会影响非易失性访问的可见性

但你可以将其转换为:
object local2 = local1;
if (local2 != null)
{
    // code that reads local2 on the assumption it's not null
}

变成这样:

if (local1 != null)
{
    // code that replaces reads on local2 with reads on local1,
    // as long as local1 and local2 have the same value
}

您可以将此转换为:
var local = field;
local?.Method()

转换为:

var local = field;
var _temp = local;
(_temp != null) ? _temp.Method() : null

或者这个:
var local = field;
(local != null) ? local.Method() : null

因为你永远无法分辨它们的区别。但是,你不能将其转换为这样:

(field != null) ? field.Method() : null

我认为在两个规范中都明确指出,优化编译器可以重新排列读写操作,只要单个执行线程将它们视为已写入,而不是普遍引入和完全消除它们。
请注意,C#编译器或JIT编译器都可以执行读取消除,即对于同一非易失性字段的多个读取,如果这些读取之间没有写入该字段且未执行易失性操作或其等效操作,则可以折叠为单个读取。就像线程从未与其他线程同步一样,因此它继续观察相同的值:
public class Worker
{
    private bool working = false;
    private bool stop = false;

    public void Start()
    {
        if (!working)
        {
            new Thread(Work).Start();
            working = true;
        }
    }

    public void Work()
    {
        while (!stop)
        {
            // TODO: actual work without volatile operations
        }
    }

    public void Stop()
    {
        stop = true;
    }
}

不能保证Stop()会停止工作线程。微软的.NET实现保证stop=true;是一个可见的副作用,但它不能保证在Work()内对stop的读取不会被省略为以下内容:

    public void Work()
    {
        bool localStop = stop;
        while (!localStop)
        {
            // TODO: actual work without volatile operations
        }
    }

那条评论说了很多。为了执行此优化,编译器必须证明在该块中没有任何volatile操作,或者在整个方法和属性调用树中间接存在任何volatile操作。
对于这种情况,一个正确的实现是将“stop”声明为“volatile”。但还有更多选项,例如使用等效的Volatile.Read和Volatile.Write,使用Interlocked.CompareExchange,在访问“stop”的访问上使用lock语句,使用类似于锁定的东西,例如Mutex,如果您不希望锁定具有线程关联性,即可以在获取它的线程之外的不同线程上释放它,则可以使用Semaphore和SemaphoreSlim,或者在代替stop时使用ManualResetEvent或ManualResetEventSlim,在这种情况下,您可以使Work()在等待停止信号之前的下一次迭代之前睡眠并设置超时。

3.

.NET的volatile同步与Java的volatile同步的一个显著区别是,Java要求您使用相同的volatile位置,而.NET只需要在release(volatile write)之后发生acquire(volatile read)。因此,原则上您可以使用以下代码在.NET中进行同步,但是您无法使用Java中的等效代码进行同步:

using System;
using System.Threading;

public class SurrealVolatileSynchronizer
{
    public volatile bool v1 = false;
    public volatile bool v2 = false;
    public int state = 0;

    public void DoWork1(object b)
    {
        var barrier = (Barrier)b;
        barrier.SignalAndWait();
        Thread.Sleep(100);
        state = 1;
        v1 = true;
    }

    public void DoWork2(object b)
    {
        var barrier = (Barrier)b;
        barrier.SignalAndWait();
        Thread.Sleep(200);
        bool currentV2 = v2;
        Console.WriteLine("{0}", state);
    }

    public static void Main(string[] args)
    {
        var synchronizer = new SurrealVolatileSynchronizer();
        var thread1 = new Thread(synchronizer.DoWork1);
        var thread2 = new Thread(synchronizer.DoWork2);
        var barrier = new Barrier(3);
        thread1.Start(barrier);
        thread2.Start(barrier);
        barrier.SignalAndWait();
        thread1.Join();
        thread2.Join();
    }
}

这个超现实的例子期望线程和Thread.Sleep(int)需要精确地花费一定的时间。如果是这样,它将正确同步,因为DoWork1执行了一个易失性写操作(release)后,DoWork2执行了一个易失性读操作(acquire)。
在Java中,即使满足这种超现实的期望,也不能保证同步。在DoWork2中,您必须从与DoWork1写入的相同易失性字段中读取。

10
我阅读了规范,它没有说明一个volatile写操作是否一定会被另一个线程(无论是否使用volatile读)观察到。这是正确的吗?
让我重新表述一下这个问题:
规范上没有涉及此问题吗?
不是的。规范对此很明确。
一个volatile写操作是否保证在另一个线程中被观察到?
是的,如果另一个线程有一个“关键执行点”。一个特殊的副作用保证被排序,以便与关键执行点相关。
volatile写是一种特殊的副作用,有许多关键执行点,包括线程的启动和停止。请参见规范以获取此类列表。
例如,假设线程Alpha将volatile int字段v设置为1,并启动线程Bravo,后者读取v,然后加入Bravo(即阻塞等待Bravo完成)。
此时我们有一个特殊的副作用--写入--一个关键执行点--线程启动--和第二个特殊副作用--一个volatile读取。因此,必须从v中读取1。 (当然,假设没有其他线程在此期间写入它。)
现在,Bravo将v递增到2并结束。这是一个特殊的副作用--写入--和一个关键执行点--线程结束。
当线程Alpha恢复并对v进行volatile读取时,必须读取2。 (当然,假设没有其他线程在此期间写入它。)
Bravo的写入和Bravo的终止的顺序必须保持不变;显然,Alpha直到Bravo结束后才运行,因此必须观察到写入。

谢谢您的回答,尤其是提供了一个例子。然而,在阅读规范中关于“执行顺序”的部分后,我更加困惑了。老实说,我认为规范在这个问题上不够清晰和精确。现在到底是什么被保留了,以及从谁的角度来看?这是关于特殊副作用相对于特殊执行点的排序,还是关于特殊效果本身的保留?在这种情况下,“保留”究竟意味着什么?我希望这能更正式一些!;) - domin
我仍然会接受你的答案,因为我认为它给了我足够的指引以供进一步搜索! - domin
好的,现在我能够提出一个更具体的问题:假设两个线程A和B已经启动并正在运行。它们都保持执行某些循环,并且没有停止。A写入一些易失性字段v,而B偶尔读取v,基于读取值进行一些逻辑操作。现在:规格的哪个部分保证B会读取A写入的值? - domin
@MightyNicM:好吧,在你的情况下,有什么保证线程B会运行吗?规范对线程调度算法没有任何说明。如果选择这样做,调度程序可能会使B饥饿并花费所有时间在A中。 - Eric Lippert
@MightyNicM:现在,你开始询问缓存实现细节。关于这个问题,可以看看Joe的文章,特别是最后一段关于volatile和新鲜度的部分。http://joeduffyblog.com/2008/06/13/volatile-reads-and-writes-and-timeliness/(在Joe的例子中,m_state是一个字段,如果资源正在使用,则为1,否则为0;interlocked操作本质上是产生内存屏障的自旋锁。) - Eric Lippert
显示剩余2条评论

0

是的,volatile 关乎栅栏,而栅栏关乎顺序。 因此何时不在范围内,实际上是所有层次(编译器、JIT、CPU 等)结合起来的实现细节, 但每个实现都应该对这个问题有一个合理且实用的答案。


这意味着 volatile 不能用于实现同步机制,因为其规范没有提供任何关于可见性的保证? - domin
当您使用“volatile”时,.NET会为您提供保证。 “volatile”本身仅定义排序。 - OmariO
好的,假设其他线程最终能够看到它们,这在规范中没有说明。也许这是默认的,我不知道。易失性和原子性有什么关系?关于易失性,什么是原子操作? - domin
2
@MightyNicM: 在C#中原子性和易失性之间的关系在于,只有已经保证以原子方式读取和写入的变量才允许声明为易失性变量。你可以声明一个易失性int变量,因为int类型被保证具有原子性读写。但是double类型没有这种保证,在C#中也没有易失性double变量。 - Eric Lippert
请注意,原子性仅适用于分裂读取和写入。对于易失性引用,无法保证原子测试和设置。对于易失性整数,无法保证原子递增等操作。 - Eric Lippert
显示剩余2条评论

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