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

时间:2011-09-01 00:00:05

标签: c# .net multithreading locking

  

可能重复:
  Threads synchronization. How exactly lock makes access to memory 'correct'?

这个问题的灵感来自this one.

我们有一个以下测试类

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%正确)

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

enter image description here

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

,其中

  • 黑框表示获取,保留和释放

  • 的过程
  • lock plus表示添加操作(架构表示缩放 我的电脑,锁定的时间大约是添加的20倍

  • 白框表示试图获取锁定的时间段, 并进一步等待它可用

此外,lock还提供完整的内存栅栏。

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

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

5 个答案:

答案 0 :(得分:7)

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

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

  1. 线程在时间片执行一行代码。
  2. 线程调度以严格的循环方式(A-B-A-B)完成。
  3. Monitor.Enter / Exit的执行时间比算术时间长得多。 (比方说说长3倍。我用Nop s填充了代码,这意味着前一行仍在执行。)
  4. Real +=需要3个步骤。我将它们分解为原子的。
  5. 在左栏显示在线程(A和B)的时间片处执行哪一行。 在右栏 - 程序(根据我的模型)。

    A   B           
    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              
    
    A   B           
    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;是交错的,并导致持续的数据损坏。实际上,线程调度是随机的,但这意味着线程A有更多更改以在另一个线程到达之前完成增量。

答案 1 :(得分:6)

这是一个非常紧凑的循环,里面没有多少内容,所以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 )。

答案 2 :(得分:2)

如上所述:lock(ms_Lock) { }锁定一个空块,所以什么都不做。您仍然遇到ms_Sum += 1;的竞争条件。你需要:

lock( ms_Lock )
{
  ms_Sum += 1 ;
}

[编辑注:]

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

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

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

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

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

你的竞争状况。

答案 3 :(得分:2)

如声明所述

lock(ms_Lock) {}

将导致完全内存屏障。简而言之,这意味着ms_Sum的值将在所有缓存之间刷新,并在所有线程中更新(“可见”)。

但是,ms_Sum += 1 仍然不是原子,因为它只是ms_Sum = ms_Sum + 1的简写:读取,操作和赋值。在这个构造中,仍然存在竞争条件 - ms_Sum的计数可能略低于预期。我还希望在没有内存障碍的情况下,更多的区别。

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

A: read ms_Sum -> a
B: read ms_Sum -> b
A: write a + 1 -> ms_Sum
B: write b + 1 -> ms_Sum // change from A "discarded"

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

快乐的编码。


正如其他人所说,使用锁定所建立的关键区域或提供的原子增量之一,使其真正具有线程安全性。

答案 4 :(得分:0)

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