C# volatile 变量:内存栅栏 VS。缓存
Posted
技术标签:
【中文标题】C# volatile 变量:内存栅栏 VS。缓存【英文标题】:C# volatile variable: Memory fences VS. caching 【发布时间】:2017-11-25 08:13:06 【问题描述】:所以我研究了这个话题很长一段时间,我想我理解了最重要的概念,比如释放和获取内存栅栏。
但是,对于volatile
和主存的缓存之间的关系,我还没有找到令人满意的解释。
因此,我了解对volatile
字段的每次读取和写入都会强制执行严格的读取顺序以及其前后的写入操作(读取-获取和写入-释放)。但这只能保证操作的排序。它没有说明这些更改对其他线程/处理器可见的时间。特别是,这取决于刷新缓存的时间(如果有的话)。我记得读过 Eric Lippert 的评论,他说“volatile
字段的存在会自动禁用缓存优化”。但我不确定这到底意味着什么。这是否意味着仅仅因为我们在某处有一个 volatile
字段就完全禁用了整个程序的缓存?如果不是,缓存被禁用的粒度是多少?
另外,我读过一些关于强和弱易失性语义的内容,并且 C# 遵循强语义,每次写入都将始终直接进入主内存,无论它是否是 volatile
字段。我对这一切感到非常困惑。
【问题讨论】:
我没这么说;事实上,你更有可能读过我的评论,说 volatile 对缓存造成的任何影响都是实现细节,而不是保证。 C# 规范说明了您对 volatile 的期望;超出指定行为的任何行为都是您不能依赖的实现细节。 C# 规范还指出,不能明确保证全局一致的可观察读写顺序。例如,对于一个变量的 volatile 读取是发生在对另一个变量进行 volatile 写入之前还是之后,两个线程可能存在分歧。 您对此感到非常困惑是对的。我也很困惑。这就是为什么我从不使用volatile
。
这是一个很好的问题;这也让我困惑了一段时间。我的理解是内存子系统需要尊重内存栅栏(包括volatile提供的半栅栏);否则,它们将毫无意义。因此,获取意味着缓存无效,而释放意味着缓存被刷新。但我看到很多人坚持认为事实并非如此。
“不完整”到底是什么意思? C# 规范没有说明它运行的处理器的实现细节。怎么可能?它只是说,在多线程程序中,符合要求的实现必须具有与某些效果的顺序相关的某些最小可观察行为,例如易失性写入、线程启动、异常等。
【参考方案1】:
我将首先解决最后一个问题。 Microsoft 的 .NET 实现在写入1 上有发布语义。它本身不是 C#,所以同一个程序,无论是什么语言,在不同的实现中都可能有弱的非易失性写入。
副作用的可见性与多线程有关。忘掉 CPU、内核和缓存吧。想象一下,相反,每个线程都有堆上内容的快照,需要某种同步来在线程之间传达副作用。
那么,C# 是怎么说的? C# language specification (newer draft) 与公共语言基础设施标准(CLI;ECMA-335 和 ISO/IEC 23271)基本相同,但存在一些差异。稍后我会谈到它们。
那么,CLI 是怎么说的?只有 volatile 操作是可见的副作用。
请注意,它还表示堆上的非易失性操作也是副作用,但不能保证可见。同样重要的是2,它并没有说明它们也保证不可见。
易失性操作究竟会发生什么?易失性读取具有获取语义,它在任何后续内存引用之前。易失性写入具有释放语义,它遵循任何先前的内存引用。
获取锁执行易失性读取,释放锁执行易失性写入。
Interlocked
操作具有获取和释放语义。
还有一个重要的术语需要学习,即原子性。
读取和写入,无论是否易失,都保证在 32 位架构上最多 32 位和 64 位架构上最多 64 位的原始值上是原子的。它们也保证是原子参考。对于其他类型,例如 long struct
s,操作不是原子的,它们可能需要多次独立的内存访问。
但是,即使使用 volatile 语义,读取-修改-写入操作,例如 v += 1
或等效的 ++v
(或 v++
,就副作用而言)也不是原子的。
互锁操作保证某些操作的原子性,通常是加法、减法和比较和交换 (CAS),即当且仅当当前值仍然是某个预期值时才写入某个值。 .NET 也有一个原子Read(ref long)
方法,用于 64 位整数,即使在 32 位架构中也可以使用。
我将继续将获取语义称为易失性读取,将释放语义称为易失性写入,或者两者都称为易失性操作。
就顺序而言,这一切意味着什么?
在语言级别和机器级别,易失性读取是内存引用不能跨越的点,而易失性写入是内存引用不能跨越的点。
如果两者之间没有易失性写入,非易失性操作可能会交叉到后续易失性读取之后,如果两者之间没有易失性读取,则可以交叉到之前的易失性写入之前。
线程中的可变操作是连续的,不能重新排序。
线程中的 volatile 操作以相同的顺序对所有其他线程可见。但是,所有线程的 volatile 操作没有总顺序,即如果一个线程执行 V1 然后 V2,另一个线程执行 V3 然后 V4,那么任何顺序都可以观察到 V1 在 V2 之前 V3 在 V4 之前线。在这种情况下,它可以是以下任一种:
V1 V2 V3 V4V1 V3 V2 V4
V1 V3 V4 V2
V3 V1 V2 V4
V3 V1 V4 V2
V3 V4 V1 V2
也就是说,观察到的副作用的任何可能顺序对于单次执行的任何线程都是有效的。对总排序没有要求,所有线程在一次执行中只观察一个可能的顺序。
事物是如何同步的?
基本上,它归结为:同步点是在易失性写入之后发生易失性读取的位置。
实际上,您必须检测一个线程中的 volatile 读取是否发生在另一个线程中的 volatile 写入之后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 problem。
还有SpinLock
,您必须真正针对基于监视器的锁进行分析,因为尽管它们可能使当前线程屈服,但它们不会使当前线程进入睡眠状态,类似于SpinWait
的所示用法.
切换到 volatile 操作就像在玩火。你必须通过分析证明你的代码是正确的,否则你可能会在最意想不到的时候被烧毁。
通常,在高争用情况下进行优化的最佳方法是避免争用。例如,要在一个大列表上并行执行转换,通常最好将问题划分并委托给多个工作项,这些工作项生成在最后一步合并的结果,而不是让多个线程锁定列表以进行更新。这有内存成本,因此取决于数据集的长度。
关于易失性操作,C# 规范和 CLI 规范有什么区别?
C# 指定了副作用,但并未提及它们的线程间可见性,例如对 volatile 字段的读取或写入、对非易失性变量的写入、对外部资源的写入以及抛出异常.
C# 指定在线程之间保留这些副作用的关键执行点:对 volatile 字段的引用、lock
语句以及线程创建和终止。
如果我们将关键执行点作为副作用变得可见的点,它会在 CLI 规范中添加线程创建和终止是可见副作用,即new Thread(...).Start()
在当前线程上具有释放语义,在新线程开始时获取语义,退出线程在当前线程上具有释放语义,thread.Join()
在等待线程上具有获取语义。
C# 通常不提及 volatile 操作,例如由 System.Threading
中的类执行,而不是仅通过使用声明为 volatile
的字段和使用 lock
语句来执行。我相信这不是故意的。
C# 声明捕获的变量可以同时暴露给多个线程。 CIL 没有提到它,因为闭包是一种语言结构。
1.
在一些地方,微软(前)员工和 MVP 声称编写的内容具有发布语义:
Memory Model, by Chris Brumme
Memory Models, Understand the Impact of Low-Lock Techniques in Multithreaded Apps, by Vance Morrison
CLR 2.0 memory model, by Joe Duffy
Which managed memory model?, by Eric Eilebrecht
C# - The C# Memory Model in Theory and Practice, Part 2, by Igor Ostrovsky
在我的代码中,我忽略了这个实现细节。我假设非易失性写入不保证可见。
2.
有一个常见的误解是允许您在 C# 和/或 CLI 中引入读取。
The problem with being second, by Grant Richins
评论The CLI memory model, and specific specifications, by Jon Skeet
C# - The C# Memory Model in Theory and Practice, Part 2, by Igor Ostrovsky
但是,这仅适用于局部参数和变量。
对于静态和实例字段,或者数组,或者堆上的任何东西,你不能理智地引入读取,因为这样的引入可能会破坏从当前执行线程看到的执行顺序,或者来自其他线程的合法更改,或者通过反思来改变。
也就是说,你不能转这个:
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
我认为在这两个规范中都表示谨慎的做法是,优化编译器可以重新排序读取和写入,只要单个执行线程按照写入方式观察它们,而不是通常引入 strong> 并完全消除它们。
请注意,读取消除 可能由 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
那条评论说了很多。要执行此优化,编译器必须证明不存在任何易失性操作,无论是直接在块中,还是间接在整个方法和属性调用树中。
对于这种特定情况,一种正确的实现是将stop
声明为volatile
。但是还有更多的选择,例如使用等效的Volatile.Read
和Volatile.Write
,使用Interlocked.CompareExchange
,在访问stop
周围使用lock
语句,使用与锁等效的东西,例如Mutex
, 或 Semaphore
和 SemaphoreSlim
如果您不希望锁具有线程关联性,即您可以在与获取它的线程不同的线程上释放它,或者使用 ManualResetEvent
或 ManualResetEventSlim
代替stop
在这种情况下,您可以让 Work()
在等待下一次迭代之前的停止信号时超时休眠,等等。
3.
.NET 的 volatile 同步与 Java 的 volatile 同步的一个显着区别是 Java 要求您使用相同的 volatile 位置,而 .NET 只要求在释放(volatile 写入)之后发生获取(volatile 读取)。因此,原则上您可以在 .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
中写入的同一易失性字段。
【讨论】:
【参考方案2】:我阅读了规范,但他们没有说明另一个线程是否会观察到易失性写入(易失性读取与否)。这是正确的还是不正确的?
让我重新表述一下这个问题:
规范对此事只字未提是正确的吗?
没有。规范对这个问题很清楚。
是否保证在另一个线程上观察到易失性写入?
是的,如果另一个线程有一个关键执行点。保证观察到特殊副作用相对于关键执行点是有序的。
易失性写入是一种特殊的副作用,许多事情都是关键的执行点,包括启动和停止线程。请参阅规范以获取此类列表。
假设例如线程 Alpha 将 volatile int 字段 v
设置为 1 并启动线程 Bravo,该线程读取 v
,然后加入 Bravo。 (也就是说,Bravo 完成时的块。)
此时我们有一个特殊的副作用——写入——一个关键的执行点——线程启动——以及第二个特殊的副作用——易失性读取。因此,Bravo 需要从v
中读取一个。 (当然,假设同时没有其他线程写过它。)
Bravo 现在将 v
增加到 2 并结束。这是一个特殊的副作用——一次写入——和一个关键的执行点——线程的结束。
当线程 Alpha 现在恢复并对 v
进行易失性读取时,需要读取两个。 (当然,假设同时没有其他线程写入它。)
必须保留 Bravo 写入和 Bravo 终止的副作用的顺序;显然 Alpha 直到 Bravo 终止后才会再次运行,因此需要观察写入。
【讨论】:
感谢您的回答,尤其是示例。但是,在阅读了规范中有关“执行顺序”的部分后,我更加困惑。老实说,我认为规格在这个问题上还不清楚,也不够精确。现在保存的是什么?从谁的角度?是关于特殊副作用w.r.t的排序吗?特殊的执行点?或者关于保存特殊效果本身?在这种情况下,“保留”甚至意味着什么?我希望这会更正式! ;) 我仍然会接受你的回答,因为我认为它为我提供了足够的指导来进一步搜索! 好的,现在我可以提出一个更详细的问题:假设两个线程 A 和 B 已经启动并且正在运行。两者都继续执行一些循环,并且没有停止。 A 写入某个 volatile 字段 v,B 偶尔读取 v,基于读取值的一些逻辑。现在:规范的哪一部分保证 B 曾经读取 A 写入的值? @MightyNicM:嗯,在你的场景中,是什么保证线程 B 完全运行?该规范没有说明线程调度算法。如果调度器愿意,它可能会饿死 B 并将所有时间都花在 A 上。 我明确写了“假设”。所以假设每个线程最终都会取得进展。然后怎样呢? B 最终会读取 A 写入的值吗?【参考方案3】:是的,volatile
是关于围栏的,而围栏是关于订购的。
所以 when? 不在范围内,实际上是所有层(编译器、JIT、CPU 等)组合的实现细节,
但是每个实现都应该对这个问题有体面和实际的答案。
【讨论】:
所以这意味着 volatile 不能用于实现同步机制,因为它的规范没有提供关于可见性的任何保证? 这是.NET在您使用volatile
时给您的保证。 volatile
本身只定义了排序。
所以当另一个线程不能保证看到任何这些操作的效果时,排序保证是非常无用的,对吧? ;)
Ordering 保证保证另一个线程将看到更改的顺序 :) 这是我们有 volatile 的两个原因之一。另一个是原子性。交付更改是平台其他部分的责任。
@MightyNicM:C# 中原子性和易变性之间的关系是只有已经保证可以原子读写的变量才允许声明为易失性。您可以声明一个 volatile int 变量,因为 int 保证是原子读写的。但是双精度数不是,C# 中没有 volatile 双精度数。以上是关于C# volatile 变量:内存栅栏 VS。缓存的主要内容,如果未能解决你的问题,请参考以下文章
从缓存行出发理解volatile变量伪共享False sharingdisruptor
JUC并发编程 共享模型之内存 -- Java 内存模型 & 原子性 & 可见性(可见性问题及解决 & 可见性 VS 原子性 & volatile(易变关键字))(代码