[英]C# volatile variable: Memory fences VS. caching
所以我现在研究这个主题很长一段时间了,我想我理解最重要的概念,如发布和获取内存栅栏 。
但是,我还没有找到令人满意的解释,因为volatile
和主存储器的缓存之间的关系。
因此,我理解每次对volatile
字段的读写操作都会强制执行读取的严格排序以及之前和之后的写入操作(读取 - 获取和写入 - 释放)。 但这只能保证操作的顺序 。 它没有说明这些更改对其他线程/处理器可见的时间 。 特别是,这取决于刷新缓存的时间(如果有的话)。 我记得曾读过Eric Lippert的评论,他说“存在volatile
字段会自动禁用缓存优化”。 但我不确定这究竟是什么意思。 这是否意味着整个程序的缓存完全被禁用,因为我们在某处有一个volatile
字段? 如果不是,禁用缓存的粒度是多少?
另外,我读了一些关于强和弱易失性语义的东西,并且C#遵循强大的语义,即每次写入都是直接进入主存储器,无论它是否是一个volatile
字段。 我对这一切感到非常困惑。
我先解决最后一个问题。 Microsoft的.NET实现在写入1上具有发布语义。 它不是C#本身,因此在不同的实现中,相同的程序,无论语言,都可能具有弱的非易失性写入。
副作用的可见性涉及多个线程。 忘记CPU,核心和缓存。 相反,想象一下,每个线程都有一个快照,其中包含堆上的内容,需要某种同步来传递线程之间的副作用。
那么,C#说什么呢? C#语言规范 ( 较新的草案 )与公共语言基础结构标准(CLI; ECMA-335和ISO / IEC 23271 )基本相同,但存在一些差异。 我稍后会谈论它们。
那么,CLI说什么呢? 只有挥发性操作才是可见的副作用。
请注意,它还表示堆上的非易失性操作也是副作用,但不保证可见。 同样重要的是2,它不会他们保证不可见的任一状态。
易变操作究竟发生了什么? 易失性读取具有获取语义,它位于任何后续内存引用之前。 易失性写入具有释放语义,它遵循任何前面的内存引用。
获取锁执行易失性读取,释放锁执行易失性写入。
Interlocked
操作具有获取和释放语义。
还有另一个需要学习的重要术语,即原子性 。
对于32位体系结构中高达32位的原始值以及64位体系结构上高达64位的原始值,保证读取和写入(无论是否为volatile)。 它们也保证是原子的参考。 对于其他类型,例如long struct
,操作不是原子操作,它们可能需要多个独立的内存访问。
但是,即使使用volatile语义,读取 - 修改 - 写入操作(例如v += 1
或等效的++v
(或v++
,就副作用而言))也不是原子的。
互锁操作保证了某些操作的原子性,通常是加法,减法和比较交换(CAS),即当且仅当当前值仍然是某个预期值时写入一些值。 .NET还有一个64位整数的原子Read(ref long)
方法,即使在32位架构中也能工作。
我将继续将获取语义称为易失性读取和释放语义作为易失性写入,并将其中一个或两者作为易失性操作。
这在订单方面意味着什么?
易失性读取是在没有存储器引用可以交叉之前的点,并且易失性写入是在语言级别和机器级别之后没有存储器引用可以交叉的点。
如果在两者之间没有易失性写入,则非易失性操作可以在跟随易失性读取之后交叉,并且如果在它们之间没有易失性读取,则交叉到之前的易失性写入之前。
线程内的易失性操作是顺序的,可能不会重新排序。
线程中的易失性操作以相同的顺序对所有其他线程可见。 但是,没有来自所有线程的易失性操作的总顺序,即如果一个线程执行V1然后执行V2,而另一个线程执行V3然后执行V4,则任何在V4之前具有V1之前的任何顺序都可以由任何线程执行。线。 在这种情况下,它可以是以下任一种:
也就是说,观察到的副作用的任何可能顺序对于单次执行的任何线程都是有效的。 对总排序没有要求,因此所有线程仅观察一次执行的可能订单之一。
事情是如何同步的?
从本质上讲,它归结为:同步点是易失性写入后发生的易失性读取。
实际上,您必须检测在另一个线程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;
}
意思是,您还必须分析解决方案并与当前状态进行比较。 并注意ABA问题 。
还有SpinLock
,你必须真正对基于监视器的锁进行分析,因为虽然它们可能使当前线程产生,但它们不会使当前线程进入休眠状态,类似于SpinWait
的显示用法。
切换到易失操作就像玩火。 您必须通过分析证明确保您的代码是正确的,否则您可能会在您最不期望的时候被烧毁。
通常,在高争用情况下优化的最佳方法是避免争用。 例如,要在并行的大列表上执行转换,通常最好将问题划分并委托给生成结果的多个工作项,这些工作项在最后一步中合并,而不是让多个线程锁定列表以进行更新。 这有内存成本,因此它取决于数据集的长度。
有关易变操作的C#规范和CLI规范之间有什么区别?
C#指定副作用,不提及它们的线程间可见性,作为易失性字段的读取或写入,对非易失性变量的写入,对外部资源的写入以及抛出异常。
C#指定线程之间保留这些副作用的关键执行点:对volatile字段的引用, lock
语句以及线程创建和终止。
如果我们将关键执行点作为副作用可见的点 ,它会向CLI规范添加线程创建和终止是可见的副作用,即new Thread(...).Start()
具有当前的释放语义线程并在新线程的开头获取语义,并且退出线程在当前线程和线程上具有释放语义thread.Join()
在等待线程上获取语义。
C#一般不提及volatile操作,例如由System.Threading
的类执行,而不是仅通过使用声明为volatile
字段并使用lock
语句。 我相信这不是故意的。
C#声明捕获的变量可以同时暴露给多个线程。 CIL没有提到它,因为闭包是一种语言结构。
1。
Microsoft(前)员工和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编译器执行,即在同一个非易失性字段上进行多次读取,由不写入该字段且不执行volatile操作或等效的指令分隔,可能会折叠为单个读取。 就好像一个线程永远不会与其他线程同步,所以它会一直观察到相同的值:
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()
会阻止工作人员。 Microsoft的.NET实现保证stop = true;
是一个可见的副作用,但它不能保证Work()
内的stop
读取不会被忽略:
public void Work()
{
bool localStop = stop;
while (!localStop)
{
// TODO: actual work without volatile operations
}
}
这个评论说了很多。 要执行此优化,编译器必须证明没有任何易失性操作,无论是直接在块中,还是间接在整个方法和属性调用树中。
对于这种特定情况,一个正确的实现是将stop
声明为volatile
。 但是有更多选项,例如使用等效的Volatile.Read
和Volatile.Write
,使用Interlocked.CompareExchange
,使用围绕访问的lock
语句来stop
,使用等效于锁的东西,例如Mutex
,或Semaphore
和SemaphoreSlim
如果你不想锁具有线程亲和力,即你可以释放它在不同的线程比获得它的人,或者使用ManualResetEvent
或ManualResetEventSlim
,而不是stop
在这种情况下,你可以Work()
与睡眠在下一次迭代之前等待停止信号时超时等
3。
与Java的易失性同步相比,.NET的易失同步的一个显着差异是Java要求您使用相同的易失性位置,而.NET只要求在发布(易失性写入)后发生获取(易失性读取)。 因此,原则上您可以使用以下代码在.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)
花费精确的时间。 如果是这样,它会正确同步,因为DoWork2
在DoWork1
执行易失性写入(释放)后执行易失性读取(获取)。
在Java中,即使满足这些超现实的期望,也不能保证同步。 在DoWork2
,您必须从您在DoWork1
中DoWork1
的相同volatile字段中DoWork1
。
我阅读了规范,他们没有说明另一个线程是否会观察到易失性写入(无论是否为易失性读取)。 这是正确与否?
让我重新解释一下这个问题:
规范在这个问题上没有说什么是正确的吗?
不。规范在这个问题上非常明确。
是否保证在另一个线程上观察到易失性写入?
是的,如果另一个线程有一个关键的执行点 。 保证观察到关键执行点的 特殊副作用 。
易失性写入是一种特殊的副作用,许多事情都是关键的执行点,包括启动和停止线程。 请参阅规范以获取此类列表。
假设例如线程Alpha将volatile int field v
为1并启动线程Bravo,它读取v
,然后加入Bravo。 (也就是说,Bravo上的块完成了。)
在这一点上,我们有一个特殊的副作用 - 写 - 一个关键的执行点 - 线程开始 - 和第二个特殊的副作用 - 一个易失性读。 因此,Bravo 需要从v
读取一个。 (假设当然没有其他线程写过它。)
Bravo现在将v
增加到2并结束。 这是一个特殊的副作用 - 写入和关键执行点 - 线程的结束。
当线程Alpha现在恢复并且执行v
的易失性读取时,它需要读取两个。 (假设当然没有其他线程写入它。)
必须保留Bravo写入和Bravo终止的副作用的顺序; 很明显Alpha直到Bravo终止后才会再次运行,因此需要观察写入。
是的, volatile
是关于围栏和围栏是关于订购。 那么什么时候 不在范围内,实际上是所有层(编译器,JIT,CPU等)的实现细节,但每个实现都应该对问题有一个体面和实际的答案。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.