【问题标题】:Thread synchronization. Why exactly this lock isn't enough to synchronize threads [duplicate]线程同步。为什么这个锁不足以同步线程[重复]
【发布时间】:2011-09-01 00:00:04
【问题描述】:

可能重复:
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% 正确)

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

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

在哪里

  • 黑框代表获取、持有和释放的过程

  • lock plus 表示加法操作(schema 表示 scale on 我的电脑,加锁的时间大约是加锁时间的 20 倍)

  • 白框表示由尝试获取锁组成的周期, 并进一步等待它可用

锁还提供完整的内存围栏。

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

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

【问题讨论】:

  • 首先它不是重复的,其次该问题还没有确定的答案,并且有一个标记的答案,可以停止可能的讨论以获取实际原因
  • 如果您阅读了接受的答案,它会告诉您为什么此代码有机会产生正确的结果但不能保证正确的结果......它还会告诉您正确的代码需要什么以及为什么它会产生正确的结果...
  • 使用Interlocked.Increment(ref ms_Sum); 并同时解除锁定可能会解决您的问题。
  • @vcsjones 就像我说的,你建议采用 100% 同步方法,我在问这个小错误的来源是什么。
  • Nobody explained the residual error. It is caused by the Windows thread scheduler

标签: c# .net multithreading locking


【解决方案1】:

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

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

  1. 线程在时间片执行一行代码。
  2. 线程调度以严格的循环方式 (A-B-A-B) 完成。
  3. Monitor.Enter/Exit 的执行时间比算术要长得多。 (假设要长 3 倍。我用 Nops 填充代码,这意味着前一行仍在执行。)
  4. 真正的+= 需要3 个步骤。我把它们分解成原子的。

左列显示在线程(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 有更多的 change 来完成增量,然后另一个线程到达那里。

【讨论】:

  • 这不是问题的答案,每个人都明白空锁是一个奇怪的同步结构。问题是为什么它会导致这么小的错误
  • 不是没有意义,虽然这里没有正确使用。即使没有使用关键区域,它仍然会导致完整的内存屏障。
  • 如果对整个带锁的字符串进行了注释,结果将是相同的,但该锁提供了所需的 99.99% 的同步。
  • @Valentin Kuzub 打算赌 0.01% 不会发生在已发货的产品上? ;-)
  • @pst 就像我说的那样,很明显它是一个错误和不正确的同步 theads 的方法。谁在质疑这个?唯一的问题是,因为它看起来毫无意义,它提供了奇妙的同步。可能是因为写入永远不会在不同的线程上同时发生。所以问题是,为什么它们有时仍然会发生。至于生产,显然将 += 锁定可以解决所有问题。
【解决方案2】:

这是一个非常紧凑的循环,里面没有太多事情发生,所以ms_Sum += 1 有合理的机会在“只是错误的时刻”被并行线程执行。

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

为什么不:

lock(ms_Lock) { 
    ms_Sum += 1;
}

或者只是:

Interlocked.Increment(ms_Sum);

?

-- 编辑 ---

关于为什么你会看到错误,尽管锁有内存屏障方面的问题......想象以下场景:

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

【讨论】:

  • 这显然是一个理论问题。实际上,这样的代码是一场灾难。
  • 是的,你可能是对的,它到底是怎么回事。没有理由它不能在所有这些周期的过程中发生。您在编辑中首先提供了答案。
【解决方案3】:

如声明所述

lock(ms_Lock) {}

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

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

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

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

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

编码愉快。


正如其他人所指出的,使用由锁建立的临界区或提供的原子增量之一使其真正成为线程安全的。

【讨论】:

  • 是的,架构上的整个 [+] 块代表读取和写入。它比获取-持有-释放锁少20倍。如果它们可以在不同的线程上同时发生,那将是缺失值的原因。如果我们在没有锁定的情况下运行它,我们只会看到预期的 100% 中的大约 75%。但是实际上损失很少。这意味着 [+] 实际上很少同时发生在不同的线程上。我问的是什么时候,在什么情况下,出于什么原因?
  • @Valentin Kuzub 如果每个循环有 许多 ms_Sum += 1 怎么办?我预计漂移会增加——我怀疑lock 可能是让线程“排队”的一个因素,从而可以最大限度地减少交错。如果是这种情况,那么在每个 lock 之间执行更多操作会增加观察到的错误是有意义的。
  • 合乎逻辑的是,与获取-持有-释放锁定持续时间相比,锁定指令之间的时间越长,发生的冲突就越多,问题是当前何时会发生冲突。
  • 就是这样。内存屏障大大减少了“错误”,因为它使 CPU 缓存更加同步,主要是通过轮询。没有障碍,缓存可以不同步。这可以通过使用显式屏障而不是 lock() {} 来测试。同样对于增量,您总是可以只使用 InterlockedIncrement,这使得 +=1 原子。
  • @Chris Smith 这不是因为内存屏障。这是因为我在相关图表上描述的情况正在发生。如果您花时间并用 Thread.MemoryBarrier() 调用实际围绕 ms_Sum++,您会对将产生的实际错误大小感到惊讶。 (它很大)
【解决方案4】:

如前所述: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 的 [same] 值,增加它并将新值存储回 ms_Sum,然后被挂起。第一个线程增加其现在过期的值并存储它。

这是你的比赛条件。

【讨论】:

  • 尝试用空锁运行代码,没有它,看看它完成了这里需要的 99.99% 的同步工作
  • 这可能是正确的,但布兰科是第一个
【解决方案5】:

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

【讨论】:

    猜你喜欢
    • 2016-09-11
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-10-12
    • 2014-05-02
    • 1970-01-01
    • 2016-09-27
    • 1970-01-01
    相关资源
    最近更新 更多