.NET JIT编译器的volatile优化

14

考虑轮询循环模式:

https://msdn.microsoft.com/en-us/magazine/jj883956.aspx
private bool _flag = true; 
public void Run() 
{
    // Set _flag to false on another thread
    new Thread(() => { _flag = false; }).Start();
    // Poll the _flag field until it is set to false
    while (_flag) ;
    // The loop might never terminate! 
} 
在这种情况下,.NET 4.5 JIT编译器可能会将循环重写为以下内容:
if (_flag) { while (true); } 
在单线程情况下,这种转换完全合法,通常将读取移出循环是一个很好的优化。但是,如果在另一个线程上将_flag设置为false,则优化会导致挂起。
请注意,如果_flag字段是易失性的,则JIT编译器不会将读取移出循环。 (有关此模式的更详细说明,请参见12月文章中的“轮询循环”部分。)
如果我锁定_flag,JIT编译器是否仍会像上面显示的那样优化代码,还是仅使其易失性可以停止优化?
Eric Lippert对易失性有以下看法:
坦率地说,我建议您永远不要创建易失性字段。易失性字段意味着您正在做一些非常疯狂的事情:您正在尝试在两个不同的线程上读取和写入相同的值,而没有放置锁定。锁定保证在锁定内部读取或修改的内存被观察为一致,锁定保证只有一个线程同时访问给定的内存块等。需要使用锁定的情况非常少,因为您不了解确切的内存模型而不能正确编写代码的可能性非常大。除了最简单的Interlocked操作之外,我不尝试编写任何低锁代码。我将“易失性”使用给真正的专家。
总结一下:谁保证上述优化不会破坏我的代码?只有易失性吗?还是锁定语句?还是其他东西?
由于Eric Lippert不鼓励使用易失性,因此可能会有其他方法。一个布尔变量不是线程同步原语。这个问题是一个普遍的问题。编译器什么情况下不会进行优化?

1
@Mgetz,谢谢您的评论。我既不是在寻找互锁操作,也不是在寻找特定的取消令牌。我只想知道如何在这种特定情况下停止上述优化。用锁包装字段是否管用?还有其他方法吗?我该如何防止代码在优化后出现故障/防止优化? - NtFreX
4
一个 bool 变量不是线程同步原语,它永远不会成为这样。只要你正确使用,就不必担心微软在处理 volatile 关键字时出错的具体方式。ManualResetEventSlim 是 Interlocked 的一个很好的封装,你可以通过在 if 语句中使用 Volatile.Read() 来使 bool 变量工作,并通过 Volatile.Write() 来设置它。只要不忽略异常,Task 和 CancellationToken 就可以提高抽象级别并带来少量劣势。 - Hans Passant
2
@Mgetz 这个问题明确是关于优化的。你提供的链接并没有提到优化。 - NtFreX
1
@Mgetz:“当字段没有交错锁时,优化是在JIT而不是主编译器中发生的。” 我只是想询问关于优化的事情。 如果您可以帮助我阐明它,我会非常感激。 - NtFreX
2
@Mgetz 嗯,谢谢。我现在已经明白了。这就是为什么我在你的句子后面加上了“当字段未被锁定时”的原因。如果有人把它作为答案发布,我会接受的。而且更多的细节总是很好的。 - NtFreX
显示剩余9条评论
4个回答

12

让我们回答被问到的问题:

如果我锁定_flag,JIT编译器是否仍会像上面展示的那样优化代码?或者只有将其设为volatile才能阻止优化?

好的,我们不回答被问到的问题,因为那个问题太复杂了。让我们将它分解成一系列较简单的问题。

如果我锁定_flag,JIT编译器是否仍会像上面展示的那样优化代码?

简短的回答是:lock 提供比 volatile强大的保证,所以如果在读取 _flag 的时候使用了锁,则JIT编译器将不会允许将该读取提升出循环。当然,锁也必须放在写入周围。只有在所有地方都使用锁时,锁才有效。

private bool _flag = true; 
private object _flagLock = new object();
public void Run() 
{
  new Thread(() => { lock(_flaglock) _flag = false; }).Start();
  while (true)
    lock (_flaglock)
      if (!_flag)
        break;
} 

(当然,我要注意这是一种非常糟糕的等待线程信号的方式。绝不要坐在一个紧密的循环中轮询标志!像明智的人一样使用等待句柄。)

你说锁比volatile更强大;这是什么意思?

对volatile变量的读取会防止某些操作在时间上的移动,对volatile变量的写入也会防止某些操作在时间上的移动。而锁则可以防止 更多的 操作在时间上的移动。这些防止的语义被称为“内存屏障”,基本上,volatile会引入半栅栏,而锁会引入全栅栏。

具体详情请参考C#规范中关于特殊副作用的部分。

与往常一样,我要提醒你,volatile并不能给你全局的最新数据保证。在多线程的C#编程中不存在“最新”的变量值,因此,volatile读取并不能给你“最新”的值,因为它不存在。认为有“最新”的值意味着读取和写入总是以时间上的全局一致顺序被观察到,这是错误的。线程仍然可能在volatile读写的顺序上发生分歧。

锁可以防止这种优化;volatile也可以防止这种优化。还有其他什么可以防止这种优化吗?

有。你也可以使用Interlocked操作,或者显式引入内存屏障。

我是否理解足够多,以正确使用volatile?

不是。

那我该怎么办?

首先不要编写多线程程序,将控制权交给多个线程是一个糟糕的想法。

如果必须这样做,不要在线程之间共享内存。将线程用作低成本进程,并只在有空闲CPU可以执行CPU密集型任务时使用它们。对于所有I/O操作,请使用单线程异步。

如果必须在线程之间共享内存,请使用可用的最高级别编程结构,而不是最低级别的。使用CancellationToken来表示在异步工作流中其他地方被取消的操作。


1
救星已经到来。非常感谢您的时间! - NtFreX
或者Windows无法处理多线程文件,只有在任务协程/状态机/模式下才能获得性能优势?理论上,现代数据驱动器可以同时在多个位置物理读取吗? - NtFreX
1
@NtFreX:是的,我的建议是只使用volatile来构建更高级别的组件。如果您想要延迟初始化,请使用Lazy<T>。它在内部使用了volatile,并且是由知道其含义的专家编写的。如果您想要取消操作,请使用取消标记。如果您想要等待,请使用等待句柄。不要自己编写。 - Eric Lippert
1
不,你永远不想使用多线程来进行文件IO,这就是我的意思。把文件IO看成是寄信并在两周后收到回复。在这种情况下,无论你发送和接收多少封信,你都不需要雇佣任何人来代表你发送或接收。让某人坐在你的信箱旁边并不会使邮政服务更快或更有效率。 - Eric Lippert
1
IO是由硬件执行的,它独立于CPU和任何操作系统级别的概念,如“线程”。 CPU和硬件使用中断进行通信。在SO问题的评论中,不是提供有关硬件工作原理的教程的好地方;进行一些研究,如果您仍然有问题,请提出新问题 - Eric Lippert
显示剩余13条评论

2

这个问题明确涉及到优化。

这不是正确的观点。重要的是语言被规定如何运作。JIT只会在不违反规范的约束下进行优化。因此,优化对程序来说是不可见的。这个问题的关键不在于它被优化了,而在于规范中没有强制要求程序是正确的。为了解决这个问题,你不需要关闭优化或与编译器进行通信。你需要使用能够保证所需行为的基元。


你不能锁定_flaglockMonitor类的语法。该类基于堆上的对象进行锁定。_flag是一个无法锁定的布尔值。

现在,我会使用CancellationTokenSource来取消循环。它在内部使用易失性访问,但将其隐藏在您的视线之外。循环轮询CancellationToken,通过调用CancellationTokenSource.Cancel()来取消循环。这非常容易实现,并且具有自我记录文档的特点。

您还可以在对_flag的任何访问中包装一个锁。代码如下:

object lockObj = new object(); //need any heap object to lock
...

while (true) {
 lock (lockObj) {
  if (_flag) break;
 }
 ...
}

...

lock (lockObj) _flag = true;

你也可以使用 volatile。Eric Lippert非常正确,如果不必要最好不要碰硬核线程编程。


@Mgetz,您所说的不一致是什么意思?volatile在实践中是完全定义清晰且可用的。例如,在此循环中,它将正常工作。Interlocked执行的是不同于volatile的其他计算或操作。Interlocked操作执行某些其他计算或操作,而不仅仅是加载或存储。不能完全比较。 - usr
1
@NtFreX 是的。该锁包含内存屏障,强制更新对其他线程可见。 - usr
根据规范,@usr 中的“volatile”并不能保证顺序一致且无数据竞争的结果。只有 Interlocked 操作和完全锁定才可以做到。对于原语,Interlocked 操作通常被认为是“无锁”的,并保证了所有这些功能。 - Mgetz
@Mgetz 这里我们不需要序列一致性。存储必须释放,加载必须获取。volatile正是实现了这一点。我同意最好避免使用它。 - usr
1
@NtFreX 错误行为取决于具体情况。有时它看起来是随机的,有时它是确定性错误,有时它在你的寻呼机在凌晨4点响起之前完全正确。 - usr
显示剩余5条评论

2
当你使用lockInterlocked Operations时,你告诉编译器该块或内存位置存在数据竞争,并且它不能对这些位置的访问进行假设。因此,编译器会在一个有数据竞争的环境中放弃一些它本来可以执行的优化操作。这个隐含的契约也意味着你告诉编译器你将以适当的无数据竞争方式访问这些位置。

我在问题下面留了一些评论。lock和Interlocked并不表示数据存在数据竞争。它们请求非常特定的行为。它们不是禁用优化的开关。例如,JIT仍然可以将volatileField = 1; volatileField = 2;转换为volatileField = 2;并进行优化。 - usr
@usr,虽然我没有提到我知道这一点。要求是向编译器指示数据竞争,这就是问题所在。 - Mgetz

0

C# 中的 volatile 关键字实现了所谓的获取和释放语义,因此完全可以将其用于简单的线程同步。任何标准兼容的 JIT 引擎都不应对其进行优化。

当然,由于 C/C++ 具有不同的语义,这是一种虚假的语言特性,大多数程序员可能已经习惯了它。因此,C# 特定和 Windows 特定 (除 ARM 架构外) 的 "volatile" 使用有时会令人困惑。


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