15

首先,我知道那lock{}Monitor上课用的合成糖。(哦,语法糖)

我正在处理简单的多线程问题,发现无法完全理解如何锁定某些任意内存字来保护整个其他内存不被缓存是寄存器/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;行使答案几乎正确:

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变量如何使整个内存稳定?

非常感谢!

PS 去阅读有关多线程的书籍。

类似问题

lock 语句如何确保处理器内同步?

线程同步。为什么这个锁不足以同步线程

4

5 回答 5

15

为什么确切地lock(ms_Lock) { ms_Counter += 1; }使程序完全正确但lock(ms_Lock) {}; ms_Counter += 1;几乎正确?

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

  • 它会导致任何争夺锁的线程暂停,直到可以获取锁为止
  • 它会导致内存屏障,有时也称为“完整围栏”

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

正如您所注意到的,在寄存器或 CPU 缓存中缓存内存可能会导致在多线程代码中发生奇怪的事情。(有关相关主题的温和解释,请参阅我关于易变性的文章。。)简而言之:如果一个线程在另一个线程更改该内存之前在 CPU 缓存中复制一页内存,然后第一个线程从缓存,那么实际上第一个线程已经及时向后移动了读取。类似地,对内存的写入似乎是及时向前移动的

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

一个有趣的实验是在其中调用 Thread.MemoryBarrier() 来代替空锁,然后看看会发生什么。你得到相同的结果还是不同的结果?如果你得到相同的结果,那么它是有帮助的记忆障碍。如果你不这样做,那么线程几乎正确同步的事实就是使它们减慢到足以阻止大多数竞争的原因。

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

于 2011-08-20T14:08:49.917 回答
1

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

当您不使用lock时,您的 quad-proc 机器会为每个 CPU 分配一个线程(为简单起见,此语句不考虑也将依次安排的其他应用程序的存在)并且它们全速运行,不受每个 CPU 的干扰其他。每个线程从内存中获取值,将其递增并将其存储回内存。结果会覆盖那里的内容,这意味着,由于您有 2 个(或 3 个或 4 个)线程同时全速运行,因此其他内核上的线程产生的一些增量实际上会被丢弃。因此,您的最终结果低于您从单个线程获得的结果。

当您添加lock语句时,这会告诉 CLR(这看起来像 C#?)以确保在任何可用内核上只有一个线程可以执行该代码。这是与上述情况相比的一个关键变化,因为多个线程现在相互干扰,即使您意识到此代码不是线程安全的(只是足够接近危险)。这种不正确的序列化结果(作为副作用)导致随后的增量并发执行的频率降低 - 因为隐含的解锁需要昂贵的,至少就这段代码和您的多核 CPU 而言,唤醒任何线程等待锁。由于这种开销,这个多线程版本的运行速度也会比单线程版本慢。线程并不总是让代码更快。

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

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

于 2011-08-20T13:37:28.603 回答
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 可能已经完成了简单的算术运算,因此您接近所需的结果。如果您正在做一些更复杂的事情(花费更多的处理时间),您会发现它不会接近您想要的结果。

于 2011-08-20T13:38:19.497 回答
1

我们一直在与deafsheep讨论这个问题,我们目前的想法可以表示为以下模式

在此处输入图像描述

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

在哪里

  • 黑框代表获取、持有和释放锁的过程
  • plus 表示加法操作(schema 表示我的 PC 上的比例,lock 大约比 add 长 20 倍)
  • 白框表示由尝试获取锁和进一步等待它变得可用组成的时间段

黑匣子的顺序总是这样,它们不能重叠,并且它们应该总是非常紧密地相互跟随。因此,它变得非常合乎逻辑,加号永远不会重叠,我们应该精确地得出预期的总和。

在这个问题中探讨了现有错误的来源:

于 2011-08-31T21:30:19.823 回答
1

这是答案。

我没有一直阅读所有其他答案,因为它们太长了,而且我看到了不正确的东西,而且答案不需要那么长。也许 Sedat 的答案是最接近的。它实际上与 lock 语句“减慢”程序的速度没有任何关系。

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

在您的第一个示例中,因为您没有使用“锁定”,所以您将由操作系统决定何时进行同步(何时将更新的缓存值复制回主内存或何时将其从主内存读取到缓存)。因此,每个线程基本上都在更新它自己的 ms_sum 副本。现在,同步确实不时发生,但不是在每个线程上下文切换时发生,这导致结果略多于 50,000,000。如果它发生在每个线程上下文切换上,您将获得 10,000,000。

第二个示例中,每次迭代都会同步 ms_sum。这使 ms_sum #1 和 ms_sum #2 保持同步。因此,您将获得近 10,000,000 个。但它不会一直达到 10,000,000,因为每次线程上下文切换时,ms_sum 都可以关闭 1,因为 += 发生在锁之外。

现在,一般来说,当调用 lock 时,各种线程缓存的哪些部分被同步,我有点不知道。但是由于您在第二个示例中的结果接近 10,000,000,我可以看到您的锁定调用导致 ms_sum 被同步。

于 2016-08-30T00:15:22.973 回答