问题 我可以避免为我很少变化的变量使用锁吗?


我一直在阅读Joe Duffy关于并发编程的书。我有一个关于无锁线程的学术问题。

第一:我知道无锁线程充满了危险(如果你不相信我,请阅读书中有关内存模型的部分)

不过,我有一个问题: 假设我有一个带有int属性的类。

多个线程将非常频繁地读取此属性引用的值

值很少会发生变化,当它发生变化时,它将是一个改变它的线程。

如果它确实发生变化,而另一个使用它的操作正在飞行中,那么任何人都不会失去一个手指(任何人使用它的第一件事就是将它复制到局部变量)

我可以使用锁(或readerwriterlockslim来保持读取并发)。 我可以标记变量volatile(很多例子都是这样做的)

然而,即使是不稳定也可能会带来性能损失。

如果我在更改时使用VolatileWrite,并使读取的访问正常,该怎么办?像这样的东西:

public class MyClass
{
  private int _TheProperty;
  internal int TheProperty
  {
    get { return _TheProperty; }
    set { System.Threading.Thread.VolatileWrite(ref _TheProperty, value); }
  }
}

我不认为我会在现实生活中尝试这一点,但我对答案感到好奇(最重要的是,作为我是否理解我一直在阅读的记忆模型的检查点)。


8332
2018-01-28 21:14


起源



答案:


将变量标记为“volatile”有两个影响。

1)读取和写入具有获取和释放语义,因此对于该存储器位置的读取和写入,其他存储器位置的读取和写入将不会“及时向前和向后移动”。 (这是一个简化,但你明白我的意思。)

2)抖动生成的代码不会“缓存”逻辑上不变的值。

前一点是否与您的场景相关,我不知道;你只描述了一个内存位置。是否只有易失性写入而不是易失性读取是很重要的,由您决定。

但在我看来,后一点非常重要。如果您对非易失性变量进行自旋锁定:

while(this.prop == 0) {}

抖动是在生成此代码的权利范围内,就像您编写的那样

if (this.prop == 0) { while (true) {} }

不管它是否确实如此,我不知道,但它有权利。如果你想要的是代码实际上重新检查每个循环的属性,将它标记为volatile是正确的方法。


6
2018-01-29 00:37



谢谢埃里克,我同意 - 第二点证明,如果你想要无锁,那么做到正确是多么棘手。这对我来说真的是一个学术练习 - 我避免无锁。然而,事后来看,我认为这是一次非常有价值的对话。我有时会被问到为什么我们不应该/不能无法解决这个或那个问题。我通常会回来说“你考虑过xxx”。当这些无锁讨论出现时,这个对话框为我提供了更多证据。谢谢你们。 - JMarsch


答案:


将变量标记为“volatile”有两个影响。

1)读取和写入具有获取和释放语义,因此对于该存储器位置的读取和写入,其他存储器位置的读取和写入将不会“及时向前和向后移动”。 (这是一个简化,但你明白我的意思。)

2)抖动生成的代码不会“缓存”逻辑上不变的值。

前一点是否与您的场景相关,我不知道;你只描述了一个内存位置。是否只有易失性写入而不是易失性读取是很重要的,由您决定。

但在我看来,后一点非常重要。如果您对非易失性变量进行自旋锁定:

while(this.prop == 0) {}

抖动是在生成此代码的权利范围内,就像您编写的那样

if (this.prop == 0) { while (true) {} }

不管它是否确实如此,我不知道,但它有权利。如果你想要的是代码实际上重新检查每个循环的属性,将它标记为volatile是正确的方法。


6
2018-01-29 00:37



谢谢埃里克,我同意 - 第二点证明,如果你想要无锁,那么做到正确是多么棘手。这对我来说真的是一个学术练习 - 我避免无锁。然而,事后来看,我认为这是一次非常有价值的对话。我有时会被问到为什么我们不应该/不能无法解决这个或那个问题。我通常会回来说“你考虑过xxx”。当这些无锁讨论出现时,这个对话框为我提供了更多证据。谢谢你们。 - JMarsch


问题是阅读线程是否会 曾经 看到变化。这不仅仅是它是否看到它的问题 立即

坦率地说,我已经放弃了试图理解波动性 - 我知道这并不意味着我曾经想过的......但我也知道,在阅读线上没有任何记忆障碍,你可能正在阅读永远相同的旧数据。


4
2018-01-28 21:21



我同意放弃理解它!这就是为什么我认为这是一个acedemic问题大声笑。它是否“永远”被看到正是促使我提出问题的原因 - 我并不立即担心,即使你使用锁定,仍然存在竞争(读者会在作者之前获得锁定)。我读了很多Joe的博客,似乎每次我认为我都会遇到波动性问题,我读了一篇新文章,证实它比我想象的还要怪!无论如何,谢谢你发表你的想法! - JMarsch
@Jon快速问题:内存屏障是什么阻止了指令的重新排序,以及导致缓存刷新的易失性读/写是什么?屏障是否意味着冲洗?或者易失性读/写意味着屏障? (或两者都不是?)唉。 - JMarsch
@JMarsch:记忆障碍强加了一个命令 内存访问 指示要求;它是否阻止“重新排序指令”是处理器的实现细节。记忆障碍仅仅为某些操作的可观察后果提供了保证。 - Eric Lippert
@Eric Lippert。是的,好吧,埃里克。内存屏障只是确保任何挂起的读取或写入都将被视为已完成。这个东西变得非常复杂,非常快,当你将L1和L2(有时甚至是)L3缓存投入到混合中时,硬件同步变得几乎复杂化了 - zebrabox


“性能打击” volatile 是因为编译器现在生成代码来实际检查值而不是优化它 - 换句话说,你会  无论你做什么,都要取得这种性能。


2
2018-01-28 21:22



这不是波动的唯一表现。更重要的是波动率如何改变缓存的刷新方式。 - Eric Lippert
@Anon我正在探索的区别是:如果使用volatile关键字,那么读写将是易失性的。 (在这个思想实验中,将有大量的读取,并且很少写入)。所以,我正在研究是否可以进行优化,我们会进行易失性写入,而不是易失性读取。 - JMarsch
@Eric:我同意 - 这就是我的好奇心。今天,它对我来说只是学术性的,但据我所知,随着核心数量(以及因此同步的缓存)的增长,惩罚将会增加。 (乔在他的书中提到过)。因此,今天有4个内核是常态,但如果/当64或128个内核成为常态时,我们会遇到什么样的问题呢? - JMarsch


在CPU级别,是每个处理器都会 终于 看到内存地址的变化。即使没有锁或内存障碍。锁和障碍只会确保所有这些都发生在相对排序中(例如其他说明),以使其对您的程序来说是正确的。

问题不在于缓存一致性(我希望Joe Duffy的书没有犯这个错误)。缓存保持一致 - 只是这需要时间,并且处理器不愿意等待这种情况发生 - 除非你强制执行它。因此,处理器继续前进到下一条指令,该指令可能会或可能不会发生 之前 前一个(因为每个内存读/写make需要不同的时间。具有讽刺意味的是 因为 处理器同意一致性等的时间 - 这会导致一些高速缓存行比其他高速缓存更快(即取决于行是修改,独占,共享还是无效,它需要更多或更少的工作才能进入必要的国家)。)

因此,读取可能看起来很旧或来自过时的缓存,但实际上它只是比预期更早发生(通常是因为前瞻和分支预测)。当它真的  读取,缓存是连贯的,从那时起它刚刚发生了变化。因此,当你阅读它时,它的价值并不老,但现在你需要它的时候。你刚才读完了。 :-(

或者等效地,它的编写时间晚于您编写的代码逻辑。

或两者。

无论如何,如果这是C / C ++,即使没有锁/障碍,你也会 终于 获取更新的值。 (通常在几百个周期内,因为内存耗时很长)。在C / C ++中,您可以使用volatile(弱非线程volatile)来确保不从寄存器中读取值。 (现在有一个非连贯的缓存!即寄存器)

在C#中,我不太了解CLR知道一个值可以在寄存器中保留多长时间,也不知道如何确保从内存中真正重新读取。你已经失去了'弱'的波动。

我怀疑只要变量访问没有完全被编译掉,你最终会用完寄存器(x86没有很多开始)并重新读取。

但我不能保证。如果你可以将你的volatile读取限制在你的代码中的某个特定点,这通常但不常见(即在一段时间(thing_to_do)循环中开始下一个任务)那么这可能是你能做的最好的。


2
2018-01-29 02:23



我同意缓存一致性,但不确定我严格同意你关于处理器继续运行的意见。大多数硬件实现CPU'监听',CPU检查它的存储缓冲区和它的缓存,如果存储缓冲区较新,将忽略不正确的缓存值 - zebrabox
我的意思是,一般来说,例如CPU发出读取请求然后开始处理下一条指令(如果读取没有依赖性 - 这就是为什么它向前看并尽早发出读取)。写作更多。发出写入但不要等待它完成。可怕的一个是无效消息 - CPU被告知它的高速缓存行是无效的,它确定我听到了你,但我会暂时忽略它,如果它不会影响我单线程。这是alpha在读取p之前读取* p'的方式。 - tony
谢谢Tony,感谢您对John的帖子的额外评论。 - JMarsch
这是一个可爱的答案,非常可爱,这对网站来说意外。无论哪种方式,C#/ java中的“volatile”读取都非常便宜,并且在需要时没有真正需要尝试避免易失性,但主要问题是,如果需要“易变”...通常需要CAS并行。 - bestsss


这是我在“最后一个作家获胜”模式适用于该情况时使用的模式。我用过了 volatile 关键字,但在Jeffery Richter的代码示例中看到此模式后,我开始使用它。


1
2018-01-28 21:18



有趣。哪本里希特书涵盖了哪些? - JMarsch
我在2007年的Devscovery Redmond问过他。我以为它也是通过C#在CLR中。 - codekaizen


对于正常事物(如内存映射设备),在CPU / CPU内部/之间进行的缓存一致性协议是为了确保共享该内存的不同线程获得一致的事物视图(即,如果我更改了它是一个CPU中的内存位置  被其缓存中有内存的其他CPU看到。在这方面,volatile将有助于确保优化器不会通过读取寄存器中缓存的值来优化内存访问(无论如何总是通过缓存)。 C#文档似乎很清楚。同样,应用程序员通常不必自己处理缓存一致性。

我强烈建议您阅读免费提供的论文“每个程序员应该了解的内存”。引擎盖下有很多魔法可以阻止自己在脚下射击。


1
2018-01-28 21:49





在C#中, int type是线程安全的。

既然你说只有一个线程写入它,你就不应该争论什么是正确的值,只要你缓存一个本地副本,就不应该得到脏数据。

但是,您可以申报 挥发物 如果OS线程将进行更新。

还要记住一些 操作 不是 原子,如果你有多个作家,可能会导致问题。例如,即使 bool 如果你有多个作家,类型不会腐败,这样的声明:

a = !a;

不是原子的。如果两个线程同时读取,则表示存在竞争条件。


0
2018-01-28 21:18



“永远不会看到从未写过的价值”和“总是看到最新的价值”之间存在差异。没有任何记忆障碍,总有可能看到旧的价值。 - Jon Skeet
@Jon:那只是 如果 你声明它不稳定吧? - John Gietzen
我想这就是为什么Joel Spolsky总是说“没有人足够聪明地做多线程”,呃? - John Gietzen
无论锁定如何,缓存都保持一致。内部锁定也处理订购和可见性。这里唯一的问题是C#运行时 - 如果需要,它可以将事物保存在寄存器中。如果它只是C / C ++,你就会知道 终于即使没有锁,障碍等,内存也会更新。 - tony
C / C ++中的volatile与寄存器相反,但在C#中则不然。在C#中,volatile更强大 - 它增加了内存屏障,以确保其他内存读/写不会被重新排序(从而确保即使跨线程也能正常工作)。问题突出的整个问题是C#因此失去了较弱的volatile ==!寄存器。内存屏障易失性高达100倍(即屏障可以使CPU停顿100秒以等待内存)。 - tony