【问题标题】:Threads synchronization. How exactly lock makes access to memory 'correct'?线程同步。锁定究竟如何使访问内存“正确”?
【发布时间】:2011-08-20 13:20:56
【问题描述】:

首先,我知道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。但是对我来说有趣的是(真的是说我期待类似的行为)在 ms_Sum += 1; 行之前或之后添加 lock 使得答案几乎正确:

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

非常感谢!

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

类似问题

How does the lock statement ensure intra processor synchronization?

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

【问题讨论】:

  • 你的意思是“句法”糖。 :)
  • @Hugh:有一些合成的东西。
  • 没有人解释残差。它是由 Windows 线程调度程序引起的。使用 start.exe /affinity 1 运行无锁定版本以获得可比较的结果。

标签: c# multithreading locking memory-barriers


【解决方案1】:

为什么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 有一个需要内存屏障的弱模型。)

【讨论】:

  • Eric,我在 x86 和 x64 上测试此代码,工作方式相同。用一些只会减慢线程执行速度的代码替换空锁确实有助于接近100 000 000 (90 633 072),但不如空lock 有效。致电 Thread.MemoryBarrier() 并没有多大帮助 (68 511 152)。
  • @Roman - 空锁使线程在锁竞争时进入等待状态 - 除非您添加的延迟代码这样做,否则您看到的差异是完全可以理解的。一旦你处于等待状态,摆脱它的代价是昂贵的。比简单地执行一些延迟指令/代码行要昂贵得多。
  • @Steve - "Much more costly than simply executing a few delaying instructions/lines of code" - 即使我放了几百条延迟指令,所以100mln 的总循环时间比lock 的循环时间长得多,结果与@987654332 不够接近@。所以lock不能简单地替换为适当的“等待代码”。
  • @Eric - 关于Thread.MemoryBarrier(),当这个 2 线程代码在单核机器上执行或使用双线程到单核的 ProcessorAffinity 执行时,它 100% 有帮助......这是有道理的。
  • @Roman - 我并不是说在任何情况下等待代码都是锁的语义替换,除非等待代码执行某些操作来触发 CLR 的显式线程切换。
【解决方案2】:

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

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

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

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

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

【讨论】:

  • +1。 Interlocked.Increment(ref ms_Sum) 绝对是你想要的。
  • 我知道 Interlocked 类,但它不能用于我的问题lock(ms_Lock) {}; ms_Counter += 1; 的主要部分之一,所以我没有提到它。
【解决方案3】:

如果您没有围绕共享变量 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 可能已经完成了简单的算术运算,因此您接近所需的结果。如果您正在做一些更复杂的事情(需要更多的处理时间),您会发现它不会接近您想要的结果。

【讨论】:

  • 您的第一部分是正确的,但对于第二个解释,我认为这更有可能是因为lock 是完整的栅栏并创建了内存屏障,因此它会导致下一条指令是易失性读取..
  • 可能,老实说,我只是在猜测最后一部分。显然,最好建议 OP 在共享变量周围使用适当的锁定。
  • 谢谢,JJ。如果我正确理解,当某个线程看到 ms_Lock 变量被另一个线程锁定时,它会使所有已经缓存的数据(在 L0 和寄存器中)无效。另一方面,离开lock 的线程将所有缓存数据刷新到RAM。这是正确的吗?我知道我离 CPU/RAM 实现细节太近了,但有趣的是 CPU 如何知道缓存内存的哪一部分无效。如果 CPU 使整个缓存失效,这对我来说是有意义的,但我相信 CPU 会做一些更聪明的工作来使缓存的一部分失效。
  • 也许您已经接近实现细节,但了解 .NET 内存模型对于编写正确的并发程序至关重要。也许你会觉得这篇文章很有趣:msdn.microsoft.com/en-us/magazine/cc163715.aspx
【解决方案4】:

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

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

在哪里

  • 黑框表示获取、持有和释放的过程 锁定
  • plus 代表加法操作(schema 代表我的刻度 PC,锁定时间大约是添加时间的 20 倍)
  • 白框表示由尝试获取锁组成的周期, 并进一步等待它可用

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

在这个question中探索了现有错误的来源:

【讨论】:

    【解决方案5】:

    这就是答案。

    我没有一直阅读所有其他答案,因为它们太长了,而且我看到了一些不正确的东西,而且答案不需要那么长。也许 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 被同步。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2020-05-18
      • 2022-01-05
      • 2011-10-12
      • 1970-01-01
      • 1970-01-01
      • 2016-10-31
      • 2012-11-22
      相关资源
      最近更新 更多