【问题标题】:We need to lock a .NET Int32 when reading it in a multithreaded code?在多线程代码中读取 .NET Int32 时,我们需要锁定它吗?
【发布时间】:2008-12-27 18:13:00
【问题描述】:

我正在阅读以下文章: http://msdn.microsoft.com/en-us/magazine/cc817398.aspx “解决多线程代码中的 11 个可能问题”,作者:Joe Duffy

它向我提出了一个问题: “在多线程代码中读取 .NET Int32 时,我们需要锁定它吗?”

我知道如果它是 32 位 SO 中的 Int64,它可能会撕裂,正如文章中所解释的那样。但是对于 Int32,我想象了以下情况:

class Test
{
  private int example = 0;
  private Object thisLock = new Object();

  public void Add(int another)
  {
    lock(thisLock)
    {
      example += another;
    }
  }

  public int Read()
  {
     return example;
  }
}

我认为没有理由在 Read 方法中包含锁。你呢?

更新根据答案(由 Jon Skeet 和 ctacke 提供),我知道上面的代码仍然容易受到多处理器缓存的影响(每个处理器都有自己的缓存,与其他处理器不同步)。下面的所有三个修改都解决了这个问题:

  1. 在“int example”中添加“volatile”属性
  2. 插入 Thread.MemoryBarrier();在实际阅读“int example”之前
  3. 在“lock(thisLock)”中读取“int example”

而且我也认为“volatile”是最优雅的解决方案。

【问题讨论】:

    标签: c# .net multithreading locking


    【解决方案1】:

    锁定完成了两件事:

    • 它充当互斥体,因此您可以确保一次只有一个线程修改一组值。
    • 它提供内存屏障(获取/释放语义),确保一个线程进行的内存写入在另一个线程中可见。

    大多数人理解第一点,但不理解第二点。假设您从两个不同的线程使用问题中的代码,一个线程重复调用Add,另一个线程调用Read。原子性本身将确保您最终只能读取 8 的倍数 - 如果有两个线程调用 Add 您的锁将确保您不会“丢失”任何添加。但是,您的 Read 线程很可能只会读取 0,即使在多次调用 Add 之后也是如此。在没有任何内存屏障的情况下,JIT 可以将值缓存在寄存器中,并假设它在读取之间没有改变。内存屏障的意义在于确保某些内容真正写入主存,或者真正从主存中读取。

    内存模型可能会变得非常复杂,但如果您遵循每次想要访问共享数据(用于读取 写入)时取出锁的简单规则,您会没事的。有关详细信息,请参阅我的线程教程的volatility/atomicity 部分。

    【讨论】:

    • 你是说我必须读取锁内的 Int32 或者我必须插入一个 Thread.MemoryBarrier();在实际阅读之前?
    • @Vernicht:是的。或者最好使用互锁、易失变量或锁。使用 Thread.MemoryBarrier 真的应该是最后的手段。
    • @Jon: volatile 关键字解决了两种缓存(CPU 和 JIT)引起的问题?
    • @Vernicht:是的,但它对诸如增加变量之类的事情没有帮助 - 它不会使这三个操作(读取、递增、写入)成为原子操作。
    • @Buu:有效地重新排序关于可见性的。它可以用各种方式来描述,但这确实是它的意义所在。不幸的是,整个主题令人困惑,并且在文档中没有很好地解释:(
    【解决方案2】:

    这一切都取决于上下文。在处理整数类型或引用时,您可能希望使用 System.Threading.Interlocked 类的成员。

    典型用法如:

    if( x == null )
      x = new X();
    

    可以替换为调用 Interlocked.CompareExchange()

    Interlocked.CompareExchange( ref x, new X(), null);
    

    Interlocked.CompareExchange() 保证比较和交换作为原子操作发生。

    Interlocked 类的其他成员,例如 Add()Decrement()Exchange()Increment( )Read() 都以原子方式执行各自的操作。阅读 MSDN 上的documentation

    【讨论】:

    • 只是想指出,如果构造函数有任何重要的逻辑量,则不建议使用此示例,因为它会在您每次调用该行时调用构造函数 - 而不仅仅是在需要时。
    • 你是对的。在我的代码中,我总是用 if 包围对 CompareExchange 的调用。由于这不能保证重量级构造函数或工厂方法的单次执行,我养成了使用并行扩展库中的 LazyInit 的习惯。
    【解决方案3】:

    这完全取决于您将如何使用 32 位数字。

    如果你想执行如下操作:

    i++;
    

    这隐含地分解为

    1. 读取i的值
    2. 添加一个
    3. 正在存储i

    如果另一个线程在 1 之后但在 3 之前修改了 i,那么在 i 为 7 时会出现问题,你将其添加到它,现在它是 492。

    但如果你只是读取 i,或者执行单个操作,比如:

    i = 8;
    

    那么你就不需要锁定 i。

    现在,您的问题是,“...阅读时需要锁定 .NET Int32...” 但您的示例涉及读取然后写入到 Int32。

    所以,这取决于你在做什么。

    【讨论】:

    • 这取决于您在从线程 B 读取时是否需要查看线程 A 写入的最新值。通常这是可取的,这需要锁定、Interlocked.*、易失性或显式内存屏障。
    • 波动性足以看到最新的价值,不是吗?
    • (我的问题是关于读取最新值,而不是读取增量写入,这应该通过互锁或(我更喜欢)使用锁来完成)
    • 是的,它是配置器。特别是对于值类型的变量。
    【解决方案4】:

    只有 1 个线程锁什么也做不了。锁的目的是阻塞其他个线程,但如果没有其他人检查锁,它就不起作用了!

    现在,您无需担心 32 位 int 的内存损坏,因为 write 是原子的 - 但这并不一定意味着您可以无锁。

    在您的示例中,可能会出现有问题的语义:

    example = 10
    
    Thread A:
       Add(10)
          read example (10)
    
    Thread B:
       Read()
          read example (10)
    
    Thread A:
          write example (10 + 10)
    

    这意味着 ThreadB 开始读取示例的值 线程 A 开始更新 - 但读取了预更新的值。我想这是否是一个问题取决于这段代码应该做什么。

    由于这是示例代码,因此可能很难看出问题所在。但是,想象一下规范的计数器函数:

     class Counter {
        static int nextValue = 0;
    
        static IEnumerable<int> GetValues(int count) {
           var r = Enumerable.Range(nextValue, count);
           nextValue += count;
           return r;
        }
     }
    

    那么,下面的场景:

     nextValue = 9;
    
     Thread A:
         GetValues(10)
         r = Enumerable.Range(9, 10)
    
     Thread B:
         GetValues(5)
         r = Enumerable.Range(9, 5)
         nextValue += 5 (now equals 14)
    
     Thread A:
         nextValue += 10 (now equals 24)
    

    nextValue 正确递增,但返回的范围会重叠。从未返回 19 - 24 的值。您可以通过锁定 var r 和 nextValue 分配来解决此问题,以防止任何其他线程同时执行。

    【讨论】:

    • 这是一个经典的联锁案例。
    【解决方案5】:

    如果你需要它是原子的,锁定是必要的。由于缓存,读取和写入(作为配对操作,例如当您执行 i++ 时)保证是原子的。此外,个人读取或写入不一定直接进入寄存器(易失性)。如果您希望修改整数(例如,读取、递增、写入操作),使其 volatile 不会给您任何原子性保证。对于整数,互斥锁或监视器可能太重(取决于您的用例),这就是 Interlocked class 的用途。它保证了这些类型操作的原子性。

    【讨论】:

    • 32 位读写保证是原子的,但不是易失的。它们是不同的东西。
    • (原子性保证在 ECMA 335 分区 1 第 12.6.6 节中。)
    • 我已经澄清了我措辞不当的回应,尽管它确实值得更多关注,它是原子的正确对齐并且所有访问位置大小相同。使用不安全代码时,这些都会受到质疑。
    • 同意不安全的代码 - 但是当你没有任何明确的布局时(当然,对于适当大小的值),对齐等是有保证的。
    • ctacke,你的回答对我有帮助
    【解决方案6】:

    一般来说,只有在修改值时才需要锁

    编辑:Mark Brackett 的精彩总结更贴切:

    “当您希望其他非原子操作成为原子操作时,需要锁定”

    在这种情况下,在 32 位机器上读取 32 位整数可能已经是一个原子操作......但也许不是!也许volatile 关键字可能是必要的。

    【讨论】:

    • 不同意。当您希望其他非原子操作成为原子操作时,需要锁定。由于对复杂类型的写入不是原子的,因此需要锁定 - 但还有许多其他场景也需要锁定。
    • @[Mark Brackett]: 是的,但在这种情况下有点过头了——读取 Int32 值在 32 位机器上已经是原子操作了
    • 原子性是不够的。如果您想确保看到最新的值,您也需要波动性 - 或锁定。
    • 或者,更恰当的说法是联锁。
    • @ctacke:对于简单的情况,可能。就我个人而言,我什至很少尝试无锁编程。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2011-08-18
    • 1970-01-01
    • 1970-01-01
    • 2017-01-11
    • 1970-01-01
    • 2011-08-16
    • 2021-06-21
    相关资源
    最近更新 更多