14

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

它向我提出了一个问题:“在多线程代码中读取 .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”是最优雅的解决方案。

4

6 回答 6

25

锁定完成了两件事:

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

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

内存模型可能会变得很麻烦,但是如果您遵循每次想要访问共享数据(用于读取写入)时取出锁的简单规则,那么您会没事的。有关更多详细信息,请参阅我的线程教程的易变性/原子性部分。

于 2008-12-27T19:15:00.670 回答
7

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

于 2008-12-27T19:28:22.297 回答
4

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

如果您想执行如下操作:

i++;

这隐含地分解为

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

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

但是,如果您只是读取 i 或执行单个操作,例如:

i = 8;

那么你不需要锁定我。

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

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

于 2008-12-27T18:40:50.303 回答
2

只有 1 个线程锁无济于事。锁的目的是阻塞其他线程,但如果没有其他人检查锁,它就不起作用!

现在,您无需担心 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)

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

于 2008-12-27T18:43:25.243 回答
2

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

于 2008-12-27T19:29:17.117 回答
0

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

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

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

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

于 2008-12-27T18:16:58.713 回答