线程同步。完全锁定如何使内存访问“正确”?

时间:2011-08-20 13:20:56

标签: c# multithreading locking memory-barriers

首先,我知道lock{}Monitor类的合成糖。 (哦,句法糖)

我正在玩简单的多线程问题并发现无法完全理解锁定某些任意WORD的内存如何保护整个其他内存不被缓存的是寄存器/ CPU缓存等。使用代码示例更容易解释我在说什么约:

for (int i = 0; i < 100 * 1000 * 1000; ++i) {
    ms_Sum += 1;
}

最后ms_Sum将包含100000000,这当然是预期的。

现在我们要执行相同的循环但是在2个不同的线程上并且上限减半。

for (int i = 0; i < 50 * 1000 * 1000; ++i) {
    ms_Sum += 1;
}

由于没有同步,我们得到的结果不正确 - 在我的4核计算机上,它的随机数接近52 388 219,略大于100 000 000的一半。如果我们将ms_Sum += 1;括在lock {}中,那么我们会得到绝对正确的结果100 000 000。但是对我来说真正有趣的是(真的说我 期待相似的行为),在lock行之前添加ms_Sum += 1;会使回答几乎正确:< / p>

for (int i = 0; i < 50 * 1000 * 1000; ++i) {
    lock (ms_Lock) {}; // Note curly brackets

    ms_Sum += 1;
}

对于这种情况,我通常得到ms_Sum = 99 999 920,这非常接近。

问题:为什么lock(ms_Lock) { ms_Counter += 1; }使程序完全正确但lock(ms_Lock) {}; ms_Counter += 1;几乎正确;锁定任意ms_Lock变量如何使整个记忆稳定?

非常感谢!

P.S。已阅读有关多线程的书籍。

类似问题(S)

How does the lock statement ensure intra processor synchronization?

Thread synchronization. Why exactly this lock isn't enough to synchronize threads

5 个答案:

答案 0 :(得分:15)

  

为什么lock(ms_Lock) { ms_Counter += 1; }确实使程序完全正确,但lock(ms_Lock) {}; ms_Counter += 1;几乎只是正确的?

好问题!理解这一点的关键是锁做两件事:

  • 它会导致任何争用锁定的线程暂停,直到可以执行锁定
  • 它会导致记忆障碍,有时也被称为“全围栏”
  

我不完全理解锁定一些任意对象如何阻止其他内存缓存在寄存器/ CPU缓存等中

正如您所知,在寄存器或CPU缓存中缓存内存可能会导致多线程代码中发生奇怪的事情。 (See my article on volatility for a gentle explanation of a related topic.。)简单地说:如果一个线程在之前在CPU缓存中复制了一页内存,则另一个线程更改该内存,然后第一个线程从缓存中读取,然后有效地第一个线程向后移动读取。同样,对内存的写入似乎是及时向前移动

内存屏障就像一个栅栏及时告诉CPU“做你需要做的事情,以确保随时间移动的读写操作不能越过栅栏”。

一个有趣的实验是代替空锁,在那里调用Thread.MemoryBarrier(),看看会发生什么。你得到相同或不同的结果吗?如果你得到相同的结果,那么它就是帮助的内存障碍。如果不这样做,那么线程几乎正确同步的事实正在减慢它们的速度,足以阻止大多数比赛。

我的猜测是它是后者:空锁正在减慢线程,使得他们不会将大部分时间花在具有竞争条件的代码上。强内存模型处理器通常不需要内存屏障。 (你是在x86机器上,还是Itanium,或者什么?x86机器有一个非常强大的内存模型,Itaniums有一个需要内存障碍的弱模型。)

答案 1 :(得分:1)

你没有说你使用了多少个线程,但我猜两个 - 如果你用四个线程运行,我希望解锁版本能够达到合理接近单个1/4的结果 - 线程版“正确”的结果。

当你不使用lock时,你的四进程机器会为每个CPU分配一个线程(为了简单起见,这个语句可以对其他应用程序的存在进行折扣,这些应用程序也将依次进行调度)并且它们运行于全速,互不干扰。每个线程从内存中获取值,递增它并将其存储回内存。结果会覆盖那里的内容,这意味着,由于您有2个(或3个或4个)线程同时以全速运行,因此其他内核上的线程所产生的某些增量会被有效地丢弃。因此,您的最终结果低于单线程的结果。

当您添加lock语句时,这会告诉CLR(这看起来像C#?)以确保在任何可用核心上只有一个线程可以执行该代码。这是从上面的情况发生的重大变化,因为多线程现在互相干扰,即使你意识到这个代码不是线程安全的(只是接近那个是危险的)。这种不正确的序列化结果(作为副作用)在随后的增量中同时执行的次数较少 - 因为隐含的解锁需要昂贵的,就此代码和您的多核CPU而言,至少唤醒任何线程等待锁定。由于这种开销,这个多线程版本也将比单线程版本运行得慢。线程并不总能使代码更快。

当任何等待线程从等待状态唤醒时,锁定释放线程可以继续在其时间片中运行,并且通常会在唤醒线程之前获取,增加并存储变量 有机会从内存中获取变量的副本以获得自己的增量操作。因此,您最终得到一个接近单线程版本的最终值,或者如果你lock - 你在循环内部增加的内容。

查看Interlocked类,了解以原子方式处理某种类型变量的硬件级方法。

答案 2 :(得分:1)

如果您没有锁定共享变量ms_Sum,则两个线程都能够访问ms_Sum变量并无限制地增加该值。在双核机器上并行运行的2个线程将同时对变量进行操作。

Memory: ms_Sum = 5
Thread1: ms_Sum += 1: ms_Sum = 5+1 = 6
Thread2: ms_Sum += 1: ms_Sum = 5+1 = 6 (running in parallel).

以下是我可以解释的事情正在发生的粗略细分:

1: ms_sum = 5.
2: (Thread 1) ms_Sum += 1;
3: (Thread 2) ms_Sum += 1;
4: (Thread 1) "read value of ms_Sum" -> 5
5: (Thread 2) "read value of ms_Sum" -> 5
6: (Thread 1) ms_Sum = 5+1 = 6
6: (Thread 2) ms_Sum = 5+1 = 6

有意义的是,没有同步/锁定,你得到的结果大约是预期总数的一半,因为2个线程可以“差不多”两倍的速度做事。

通过适当的同步,即lock(ms_Lock) { ms_Counter += 1; },订单更改为更像:

 1: ms_sum = 5.
 2: (Thread 1) OBTAIN LOCK. ms_Sum += 1;
 3: (Thread 2) WAIT FOR LOCK.
 4: (Thread 1) "read value of ms_Sum" -> 5
 5: (Thread 1) ms_Sum = 5+1 = 6
 6. (Thread 1) RELEASE LOCK.
 7. (Thread 2) OBTAIN LOCK.  ms_Sum += 1;
 8: (Thread 2) "read value of ms_Sum" -> 6
 9: (Thread 2) ms_Sum = 6+1 = 7
10. (Thread 2) RELEASE LOCK.

至于为什么lock(ms_Lock) {}; ms_Counter += 1;“几乎”正确,我觉得你很幸运。锁定会强制每个线程减速并“等待轮到”获取并释放锁定。算术运算ms_Sum += 1;如此微不足道(运行速度非常快)这一事实可能就是为什么结果“差不多”正常。当线程2执行获取和释放锁的开销时,线程1可能已经完成了简单的算术,因此您接近期望的结果。如果你做了一些更复杂的事情(花费更多的处理时间),你会发现它不会接近你想要的结果。

答案 3 :(得分:1)

我们一直在与deafsheep进行讨论,我们当前的想法可以表示为以下架构

enter image description here

时间从左到右运行,2个线程由两行表示。

其中

  • 黑匣子代表获取,持有和释放的过程 锁
  • plus表示添加操作(架构表示我的缩放比例) PC,锁定比添加的时间长约20倍)
  • 白框表示试图获取锁定的时间段, 并进一步等待它可用

黑匣子的顺序总是这样,它们不能重叠,它们应该始终非常紧密地相互跟随。因此,它变得非常符合逻辑,即从不重叠,我们应该精确地达到预期的总和。

在此question中探讨了现有错误的来源:

答案 4 :(得分:1)

这是答案。

我没有完全阅读所有其他答案,因为它们太长了,我看到了不正确的东西,答案不需要那么久。也许Sedat的答案是最接近的。它与锁定语句'减慢'程序的速度没有任何关系。

它与2个线程之间的ms_sum的缓存同步有关。每个线程都有自己的ms_sum缓存副本。

第一个示例中,因为您没有使用'lock',所以您要将操作系统留给何时进行同步(何时将更新后的缓存值复制回主数据库)内存或何时从主内存读取到缓存中)。因此,每个线程基本上更新它自己的ms_sum 副本。现在,同步确实会不时发生,但不会在每个线程上下文切换上发生,这会导致结果略高于50,000,000。如果它发生在每个线程上下文切换中,您将获得10,000,000。

第二个示例中,每次迭代都会同步ms_sum。这保持了ms_sum#1&amp; ms_sum#2很好地同步。所以,你将获得近10,000,000。但它不会一直到10,000,000,因为每次线程上下文切换时,ms_sum可以关闭1,因为你在锁外发生了+ =。

现在,一般来说,在调用锁时,各种线程缓存的确切部分是同步的,对我来说有点不为人知。但是由于你的第二个例子中你的结果差不多是10,000,000,我可以看到你的锁定调用导致ms_sum被同步。