English 中文(简体)
我们在多线程代码中读取 .NET Int32 时需要锁定它吗?
原标题:
  • 时间:2008-12-27 18:13:00
  •  标签:

I was reading the following article: http://msdn.microsoft.com/en-us/magazine/cc817398.aspx "Solving 11 Likely Problems In Your Multithreaded Code" by Joe Duffy

And it raised me a question: "We need to lock a .NET Int32 when reading it in a multithreaded code?"

我理解如果在32位操作系统中使用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. Adding to "int example" the "volatile" property
  2. Inserting a Thread.MemoryBarrier(); before the actual reading of "int example"
  3. Read "int example" inside a "lock(thisLock)"

我认为“易挥发”(volatile)是最优雅的解决方案。

最佳回答

锁定能够实现两个目的:

  • It acts as a mutex, so you can make sure only one thread modifies a set of values at a time.
  • It provides memory barriers (acquire/release semantics) which ensures that memory writes made by one thread are visible in another.

大多数人都理解第一个观点,但不理解第二个观点。 假设您在两个不同的线程中使用了问题中的代码,其中一个线程不断调用 Add ,而另一个线程调用 Read 。 仅使用原子性就可以确保您最终只读取了8的倍数 - 如果有两个线程调用 Add ,则锁定将确保您没有“丢失”任何添加操作。 但是,您的 Read 线程可能仅仅读取0,即使之前已经调用了 Add 多次。 没有任何内存栅栏,JIT可以仅在寄存器中缓存值,并假设在读取之间未发生任何更改。内存栅栏的作用是确保确实将某些内容写入主内存或从主内存读取。

记忆模型可能会变得相当复杂,但是如果您遵循每次要访问共享数据(读写)时都要取出锁定的简单规则,那么您将没问题。有关更多详细信息,请参见我的线程教程的易变性/原子性部分。

问题回答

这一切都取决于上下文。当处理整数类型或引用时,您可能想要使用 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上的文档。

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

如果您想要执行像这样的操作:

i++;

那暗含着分解成为

  1. reading the value of i
  2. adding one
  3. storing i

如果1之后但是3之前另一个线程修改了i,那么你会面临一个问题,i最初为7,你将其加1,现在变为492。

但是,如果你只是在阅读它,或执行单个操作,比如:

i = 8;

那么你就不需要锁住它了。

Now, your question says, "...need to lock a .NET Int32 when reading it..." but your example involves reading and then writing to an Int32.

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

只有一条线程锁无法实现任何效果。锁的目的是阻止其他线程,但如果没有其他人检查锁,则无法起作用!

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

在你的例子中,有可能出现可疑的语义。

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)

下一个值会被适当地增加,但返回的范围将重叠。19-24的值从未返回。您可以通过在变量r和nextValue分配周围进行锁定来修复此问题,以防止任何其他线程同时执行。

如果您需要它是原子性的,那么锁定是必要的。由于缓存,操作一个32位数字的读写(作为一对操作,例如在进行i++时)不能保证是原子性的。此外,单个读取或写入不一定直接进入寄存器(易变性)。使其易变不能保证原子性,如果您希望修改整数(例如读取、增量、写入操作)。对于整数,互斥锁或监视器可能太重(取决于您的用例),这就是Interlocked类的作用。它保证了这些类型操作的原子性。

一般情况下,只有在需要修改值时才需要锁定。

编辑:马克·布拉克特的杰出总结更为恰当:

"Locks are required when you want an otherwise non-atomic operation to be atomic"

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





相关问题