我将首先解决最后一个问题。 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 structs,操作不是原子的,它们可能需要多次独立的内存访问。
但是,即使使用 volatile 语义,读取-修改-写入操作,例如 v += 1 或等效的 ++v(或 v++,就副作用而言)也不是原子的。
互锁操作保证某些操作的原子性,通常是加法、减法和比较和交换 (CAS),即当且仅当当前值仍然是某个预期值时才写入某个值。 .NET 也有一个原子Read(ref long) 方法,用于 64 位整数,即使在 32 位架构中也可以使用。
我将继续将获取语义称为易失性读取,将释放语义称为易失性写入,或者两者都称为易失性操作。
就顺序而言,这一切意味着什么?
在语言级别和机器级别,易失性读取是内存引用不能跨越的点,而易失性写入是内存引用不能跨越的点。
如果两者之间没有易失性写入,非易失性操作可能会交叉到后续易失性读取之后,如果两者之间没有易失性读取,则可以交叉到之前的易失性写入之前。
线程中的可变操作是连续的,不能重新排序。
线程中的 volatile 操作以相同的顺序对所有其他线程可见。但是,所有线程的 volatile 操作没有总顺序,即如果一个线程执行 V1 然后 V2,另一个线程执行 V3 然后 V4,那么任何顺序都可以观察到 V1 在 V2 之前 V3 在 V4 之前线。在这种情况下,它可以是以下任一种:
也就是说,观察到的副作用的任何可能顺序对于单次执行的任何线程都是有效的。对总排序没有要求,所有线程在一次执行中只观察一个可能的顺序。
事物是如何同步的?
基本上,它归结为:同步点是在易失性写入之后发生易失性读取的位置。
实际上,您必须检测一个线程中的 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 声称编写的内容具有发布语义:
在我的代码中,我忽略了这个实现细节。我假设非易失性写入不保证可见。
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
我认为在这两个规范中都表示谨慎的做法是,优化编译器可以重新排序读取和写入,只要单个执行线程按照写入方式观察它们,而不是通常引入 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 中写入的同一易失性字段。