当工作线程对局部或类变量进行非竞争性写入时,需要锁定或易失性

本文关键字:易失性 锁定 线程 工作 局部 类变量 非竞争性 | 更新日期: 2023-09-27 18:24:13

对于下面的情况,当工作线程之间没有写入竞争时,是否仍然需要锁或volatile?如果在"G"处不需要"Peek"访问,则答案有任何差异。

class A 
{
   Object _o; // need volatile (position A)?
   Int _i;    // need volatile (position B)?
   Method()
   {
      Object o;
      Int i;
      Task [] task = new Task[2]
      {
         Task.Factory.StartNew(() => { 
              _o = f1();   // use lock() (position C)?
              o  = f2();   // use lock() (position D)?
         } 
         Task.Factory.StartNew(() => { 
              _i = g1();   // use lock() (position E)?
              i  = g2();   // use lock() (position F)?
         }          
      }
      // "Peek" at _o, _i, o, i (position G)?
      Task.WaitAll(tasks);
      // Use _o, _i, o, i (position H)?
}

当工作线程对局部或类变量进行非竞争性写入时,需要锁定或易失性

安全的做法是首先不要这样做。不要先在一个线程上写一个值,然后在另一个线程中读取该值。制作一个Task<object>Task<int>,将值返回给需要它们的线程,而不是制作跨线程修改变量的任务。

如果您一心想跨线程写入变量,那么您需要保证两件事。首先,抖动没有选择会导致读写在时间上移动的优化,其次,引入了内存屏障。内存屏障限制处理器以某些方式及时移动读写操作。

正如Brian Gideon在回答中指出的那样,WaitAll会产生内存障碍,但我不记得这是一个文档化的保证还是一个实现细节。

正如我所说,我一开始不会这么做。如果我被迫这样做,我至少会把我要写的变量标记为不稳定的。

对引用类型(即Object)和字大小值类型(即32位系统中的int)的写入是原子的。这意味着,当你查看这些值(位置6)时,你可以确保你得到的是旧值或新值,但不是其他值(如果你有一个类型,比如一个大结构,它可以被拼接,并且你可以在写到一半时读取值)。您不需要lockvolatile,只要您愿意接受读取过时值的潜在风险。

请注意,由于此时没有引入内存屏障(lockvolatile的使用都增加了一个),因此变量可能已在另一个线程中更新,但当前线程没有观察到这种变化;在另一个线程中更改了一个"过时"值之后,它可能会在相当长的一段时间内读取该值。volatile的使用将确保当前线程能够更快地观察到变量的更改。

您可以确信,在调用WaitAll之后,即使没有lockvolatile,也会有适当的值。

还要注意的是,虽然您可以确保对引用类型的引用是原子编写的,但您的程序不能保证对引用所引用的实际对象的任何更改的观察顺序。即使从后台线程的角度来看,对象在分配给实例字段之前已经初始化,也可能不会按该顺序发生。因此,另一个线程可以观察引用对该对象的写入,但随后跟随该引用并找到处于初始化或部分初始化状态的对象。引入内存屏障(即通过使用volatile变量)可能会阻止运行时进行这种重新排序,从而确保不会发生这种情况。这就是为什么最好一开始就不这样做,只让这两个任务返回它们生成的结果,而不是操纵一个封闭的变量。

WaitAll除了确保这两个任务实际完成外,还将引入内存屏障,这意味着您知道变量是最新的,不会有旧的过时值。

在位置G,您可以观察到值_o_i可能分别保留其初始化值null和0,或者它们可能包含任务写入的值。在这个位置上是不可预测的。

然而,在H位置,你以两种不同的方式强行提出这个问题。首先,您已经保证两个任务都已完成,因此写入操作也已完成。其次,Task.WaitAll将生成一个内存屏障,以保证主线程能够观察到任务发布的新值。

因此,在这个特定的例子中,在技术上不需要显式锁或存储器屏障生成器(volatile)。