线程同步。锁是如何使对内存的访问“正确”的?

17
首先,我知道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变量如何使整个内存稳定?

非常感谢!

P.S. 我去读关于多线程的书了。

类似的问题

锁定语句如何确保处理器内部同步?

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


4
你的意思是“语法糖”。 :) - Hugh
1
@Hugh:这里有些人工的感觉。 - H H
没有人解释残差误差。它是由Windows线程调度程序引起的。使用start.exe /affinity 1运行无锁版本以获得可比较的结果。 - Hans Passant
5个回答

16
为什么lock(ms_Lock) { ms_Counter += 1; }可以使程序完全正确,但是lock(ms_Lock) {}; ms_Counter += 1;只能几乎正确呢?
很好的问题!理解这一点的关键在于锁执行了两个操作:
- 它使任何竞争锁的线程暂停,直到可以获取锁。 - 它会导致一个内存屏障(memory barrier),有时也称为“全栅栏”。
我不完全理解锁定某些任意对象如何防止其他内存被缓存在寄存器/CPU缓存等中。
正如您所指出的,将内存缓存在寄存器或CPU缓存中可能会导致多线程代码中发生奇怪的事情。 (请参见我的关于易变性的文章,以获得相关主题的简要解释。)简而言之:如果一个线程在另一个线程更改该内存之前在CPU缓存中复制了一页内存,并且然后第一个线程从缓存中读取,则实际上第一个线程已经将读取“向后移动”了。类似地,对内存的写入似乎会“向前移动”。
内存屏障就像是时间上的一道栅栏,告诉CPU“做必要的事情,以确保在时间上移动的读写不能越过栅栏”。
一个有趣的实验是,不使用空锁,而是在其中调用Thread.MemoryBarrier(),看看会发生什么。你是否得到相同的结果或不同的结果?如果得到相同的结果,则是内存屏障起了帮助作用。如果没有,则几乎正确同步线程的事实足以使它们减速以防止大多数竞争。
我猜这是后者:空锁减慢了线程的速度,使它们不会花费大部分时间在具有竞争条件的代码中。在强内存模型处理器上通常不需要内存屏障。(你在x86机器上还是Itanium上?x86机器具有非常强的内存模型,Itanium具有需要内存屏障的弱模型。)

Eric,我在x86和x64上测试了这段代码,它们的工作方式是一样的。用一些只会减慢线程执行速度的代码替换空锁确实有助于接近100 000 00090 633 072),但效果没有空锁那么好。调用Thread.MemoryBarrier()的效果也不是很明显(68 511 152)。 - Roman
@Roman - 空锁在锁争用时会使线程进入等待状态 - 除非您添加的延迟代码也这样做,否则您看到的差异是完全可以理解的。一旦进入等待状态,要摆脱它是很昂贵的。比执行几个延迟指令/代码行要花费更多代价。 - Steve Townsend
@Steve - "比起执行几个延迟指令/代码行,要昂贵得多" - 即使我加入了几百个延迟指令,以便总循环时间达到比使用“锁”更长的时间,结果仍然无法接近100mln。因此,“锁”不能简单地用适当的“等待代码”替换。 - Roman
@Eric - 关于Thread.MemoryBarrier(),当在单核机器上执行此2线程代码或将ProcessorAffinity设置为两个线程都运行在单核时,它百分之百地有帮助...这是有道理的。 - Roman
@Roman - 我并不是在任何情况下都建议等待代码作为锁的语义替代,除非等待代码执行某些操作来触发CLR的显式线程切换。 - Steve Townsend
@Roman:我怀疑如果你有这种类型的锁,问题将完全得到解决,原因就像Eric提到的那样(尽管这当然是一个愚蠢的解决方案,它只是表明锁使访问更加同步):lock(ms_Lock) { Thread.Sleep(100); } ms_Counter++; - configurator

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

如果没有同步/锁定,你得到的结果大约是预期总数的一半,因为两个线程可以“几乎”以两倍的速度完成任务。

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

1

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

enter image description here

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

其中

  • 黑色方框表示获取、持有和释放锁的过程
  • 加号表示加法操作(模式表示我的电脑上的规模,锁比加法大约慢20倍)
  • 白色方框表示尝试获取锁并等待其可用的时间段

黑色方框的顺序始终如此,它们不能重叠,并且它们应该紧密地跟随在一起。因此,很容易理解加号永不重叠,我们应该得到预期的总和。

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


1

这是答案。

我没有完全阅读其他答案,因为它们太长了,而且我看到了一些不正确的东西,答案并不需要那么长。也许 Sedat 的答案最接近。这实际上与锁定语句“减缓”程序速度没有什么关系。

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

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

在第二个示例中,ms_sum在每次迭代时都会被同步,这使得ms_sum #1和ms_sum #2保持得非常同步。因此,您将获得接近1000万的结果。但它不会完全达到1000万,因为每次线程上下文切换时,ms_sum可能会偏差1,因为+=发生在锁定之外。
现在,一般来说,当调用锁定时,各个线程缓存的确切部分被同步还有点我不太清楚。但是由于您在第二个示例中几乎获得了1000万的结果,我可以看到您的锁调用导致ms_sum被同步。

1

你没有说你使用了多少个线程,但我猜测是两个 - 如果你使用了四个线程,我期望未加锁版本的结果与单线程版本的“正确”结果相差不大。

当你不使用lock时,你的四核机器会为每个CPU分配一个线程(为简单起见,此语句排除了其他应用程序的存在,它们也将轮流调度),它们以全速运行,互不干扰。每个线程从内存中获取值,将其递增并将其存回内存。结果覆盖了原来的值,这意味着,由于你有2(或3、4)个线程同时全速运行,一些在其他核心上运行的线程所做的递增实际上被丢弃了。因此,你的最终结果比单线程得到的结果要低。

添加lock语句后,CLR(这看起来像是C#?)会确保只有一个线程在任何可用的核心上执行该代码。这是与上面情况的关键区别,因为多个线程现在相互干扰,尽管您意识到此代码不是线程安全的(足以危及安全)。这种错误的序列化结果(作为副作用)导致随后的增量执行并发较少-因为暗示的解锁需要在等待锁的任何线程中花费昂贵的时间,在这段代码和您的多核CPU术语中至少唤醒它们。由于这种开销,这个多线程版本也比单线程版本运行得慢。线程并不总是使代码更快。
当任何等待的线程从等待状态中唤醒时,释放锁的线程可以继续在其时间片中运行,并且通常会在唤醒线程获取变量的机会之前,对变量进行增量和存储。因此,您最终得到的值接近单线程版本,或者如果在循环内部锁定增量,则得到的值相同。

请查看Interlocked类,以硬件级别的方式原子性地处理特定类型的变量。


1
+1. 在这里,Interlocked.Increment(ref ms_Sum) 绝对是你想要的。 - Hugh
我知道Interlocked类,但它不能在我问题的主要部分lock(ms_Lock) {}; ms_Counter += 1;中使用,所以我没有提到它。 - Roman

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接