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

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

这个问题的灵感来自于这个问题。

我们有一个以下的测试课程

class Test { private static object ms_Lock=new object(); private static int ms_Sum = 0; public static void Main () { Parallel.Invoke(HalfJob, HalfJob); Console.WriteLine(ms_Sum); Console.ReadLine(); } private static void HalfJob() { for (int i = 0; i < 50000000; i++) { lock(ms_Lock) { }// empty lock ms_Sum += 1; } } } 

实际结果非常接近预期值100 000 000(50 000 000 x 2,因为2个循环同时运行),差异大约为600 – 200(我的机器上的误差大约为0.0004%,非常低)。 没有其他同步方式可以提供这种近似方式(它要么是更大的错误%,要么是100%正确)

我们目前了解这种精确程度是因为程序以下列方式运行:

在此处输入图像描述

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

哪里

  • 黑匣子代表获取,持有和释放的过程

  • lock plus表示添加操作(架构表示我的PC上的比例,锁定大约比添加的时间长20倍)

  • 白框表示包含尝试获取锁定并进一步等待其变为可用的时间段

锁也提供完整的内存栅栏。

所以现在的问题是:如果上面的模式代表了正在发生的事情,那么这个大错误的原因是什么(现在它的大原因模式看起来非常强大的同步模式)? 我们可以理解1-10之间的区别,但它显然没有错误的唯一原因? 我们无法看到何时可以同时发生对ms_Sum的写入,从而导致错误。

编辑:很多人喜欢快速得出结论。 我知道同步是什么,如果我们需要正确的结果,那么上面的构造不是真正的或接近好的同步线程的方法。 对海报有一定的信心,或者先阅读相关的答案。 我不需要一种方法来同步2个线程来并行执行添加,我正在探索这种奢侈而又高效的,与任何可能的和近似的替代,同步构造相比(它确实在某种程度上同步,因此它不像建议的那样毫无意义

这是一个非常紧凑的循环,内部没有多少进展,所以ms_Sum += 1有一个合理的机会在并行线程的“错误时刻”执行。

你为什么要在实践中编写这样的代码?

为什么不:

 lock(ms_Lock) { ms_Sum += 1; } 

要不就:

 Interlocked.Increment(ms_Sum); 

– 编辑—

一些评论为什么你会看到错误,尽管锁的内存屏障方面…想象一下以下场景:

  • 线程A进入lock ,离开lock ,然后被OS调度程序抢占。
  • 线程B进入并离开lock (可能一次,可能不止一次,可能数百万次)。
  • 此时,线程A再次被调度。
  • A和B同时命中ms_Sum += 1 ,导致一些增量丢失(因为increment = load + add + store )。

lock(ms_Lock) { }这是无意义的构造。 lock保证在其中独占执行代码。

让我解释为什么这个空lock减少(但不会消除!)数据损坏的可能性。 让我们简化一下线程模型:

  1. 线程在时间片执行一行代码。
  2. 线程调度以严格的循环方式(ABAB)完成。
  3. Monitor.Enter / Exit比算术执行时间要长得多。 (比方说说长3倍。我用Nop s填充代码,这意味着前一行仍在执行。)
  4. Real +=需要3个步骤。 我将它们分解为primefaces的。

在左列显示哪条线在线程的时间片(A和B)处执行。 在右栏 – 程序(根据我的模型)。

 AB 1 1 SomeOperation(); 1 2 SomeOperation(); 2 3 Monitor.Enter(ms_Lock); 2 4 Nop(); 3 5 Nop(); 4 6 Monitor.Exit(ms_Lock); 5 7 Nop(); 7 8 Nop(); 8 9 int temp = ms_Sum; 3 10 temp++; 9 11 ms_Sum = temp; 4 10 5 11 AB 1 1 SomeOperation(); 1 2 SomeOperation(); 2 3 int temp = ms_Sum; 2 4 temp++; 3 5 ms_Sum = temp; 3 4 4 5 5 

正如您在第一个场景中看到的那样,线程B无法捕获线程A而A有足够的时间来完成ms_Sum += 1;执行ms_Sum += 1; 。 在第二种情况下, ms_Sum += 1; 交错并导致持续的数据损坏。 实际上,线程调度是随机的,但这意味着线程A在另一个线程到达之前有更多的更改来完成增量。

正如声明所述

lock(ms_Lock) {}

会造成完全的记忆障碍 。 简而言之,这意味着ms_Sum的值将在所有缓存之间刷新并在所有线程中更新(“可见”)。

但是, ms_Sum += 1 仍然不是primefaces的,因为它只是ms_Sum = ms_Sum + 1 :读取,操作和赋值。 在这种结构中,仍然存在竞争条件 – ms_Sum的计数可能略低于预期。 我也希望在没有内存障碍的情况下,差异更大

这是一个假设的情况,为什么它可能更低(A和B代表线程,a和b代表线程本地寄存器):

 答:读取ms_Sum  - > a
 B:读取ms_Sum  - > b
答:写一个+ 1  - > ms_Sum
 B:写b + 1  - > ms_Sum //改变A“丢弃” 

这取决于非常特定的交错顺序,并且取决于诸如线程执行粒度和在所述非primefaces区域中花费的相对时间之类的因素。 我怀疑lock本身会减少(但不能消除)上面交错的机会,因为每个线程都必须等待才能通过它。 锁定本身在增量上花费的相对时间也可能起作用。

快乐的编码。


正如其他人所指出的那样,使用由锁建立的关键区域或提供的primefaces增量之一来使其真正具有线程安全性。

如上所述: lock(ms_Lock) { }锁定一个空块,因此不执行任何操作。 你仍然有一个ms_Sum += 1;的竞争条件ms_Sum += 1; 。 你需要:

 lock( ms_Lock ) { ms_Sum += 1 ; } 

[编辑注:]

除非您正确地序列化对ms_Sum的访问,否则您将遇到竞争条件。 您编写的代码执行以下操作(假设优化器不会丢弃无用的锁语句:

  • 获得锁定
  • 释放锁定
  • 获取ms_Sum的值
  • 增量值ms_Sum
  • 存储ms_Sum的值

即使在中间指令中,每个线程也可以在任何时候暂停。 除非特别记录为primefaces,否则任何执行时间超过1个时钟周期的机器指令都可能在执行中被中断。

所以我们假设您的锁实际上是在序列化两个线程。 仍然没有任何东西可以阻止一个线程被挂起(从而优先于另一个),而它正处于执行最后三个步骤的中间位置。

因此第一个线程,锁定,释放,获取ms_Sum的值,然后暂停。 第二个线程进入,锁定,释放,获取ms_Sum的[相同]值,递增它并将新值存储回ms_Sum,然后被挂起。 第一个线程递增其now-outdates值并存储它。

这是你的竞争条件。

+ =运算符不是primefaces的,也就是说,首先它读取然后写入新值。 同时,在读取和写入之间,线程A可以切换到另一个B,实际上没有写入值…然后另一个线程B看不到新值,因为它没有被其他线程分配A …当返回到线程A时,它将丢弃线程B的所有工作。