【问题标题】:Hazards of not protection shared variables in a threaded environment线程环境中不保护共享变量的危害
【发布时间】:2010-11-10 21:58:21
【问题描述】:

我试图了解在线程(或共享内存)环境中不锁定共享变量的危害。很容易争辩说,如果您对一个变量执行两个或多个相关操作,那么首先持有一些锁很重要。典型的例子是自增操作,先读取当前值,然后加一再写回。

但是,如果您只有一个写入器(和很多读取器)并且写入不依赖于先前的值怎么办。所以我有一个线程每秒存储一次时间戳偏移量。偏移量保存本地时间和其他时间基准之间的差异。许多读者使用这个偏移量来为事件添加时间戳,并且每次获得一个读锁有点昂贵。在这种情况下,我不在乎读者是在写入之前还是之后获得值,只要读者没有得到垃圾(这是一个从未设置的偏移量)。

假设变量是一个 32 位整数。是否有可能在写入过程中对变量进行垃圾读取?还是写一个 32 位整数是一个原子操作?它取决于操作系统或硬件吗? 32 位系统上的 64 位整数怎么样?

共享内存而不是线程呢?

【问题讨论】:

  • “共享内存”不能替代“线程”。可能您唯一的选择是消息传递而不是共享内存,在这种情况下,每个线程都有自己的时间戳,每当他们收到消息时都会更新。消息传递有很多线程安全算法(AFAIK,每个操作系统都提供一个)。

标签: multithreading thread-safety


【解决方案1】:

在 32 位系统上写入 64 位整数不是原子操作,如果不加锁,可能会得到不正确的数据。

例如,如果你的整数是

0x00000000 0xFFFFFFFF

你要按顺序写下一个int,你要写:

0x00000001 0x00000000

但是,如果您在写入其中一个 int 之后读取另一个 int 之前的值,那么您可以读取

0x00000000 0x00000000

0x00000001 0xFFFFFFFF

这与正确的值大不相同。

如果您想在没有锁的情况下工作,您必须非常确定在您的操作系统/CPU/编译器组合上构成原子操作的内容。

【讨论】:

  • 在某些语言中也存在其他问题。例如,Java 不保证不同的线程会看到相同的数据,除非该数据受到synchronized 块的保护。即使在写入线程完成其操作后,读取线程仍然可以看到陈旧值。
  • 这是我的问题的一部分。我怎么知道在我的 OS/CPU/编译器上哪些操作是原子的。例如,我想在 32 位系统上为一个用 c 编写并在 Linux 上运行 gcc 编译的程序编写一个 32 位整数。
  • @Cameron Skinner:在这种情况下,我真的不在乎读者是否看到过时的数据。陈旧的数据是可以的,但垃圾(如本答案中所述,使用 64 位整数)不是。
  • @Vegar 够公平的。我只是想指出,有时还有其他问题可能适用于其他读者。
【解决方案2】:

除了上述 cmets 之外,还要注意稍微更通用的设置中的寄存器组。您最终可能只更新 cpu 寄存器,而不是立即将其真正写回主存。或者在内存中的原始值已更新时使用缓存寄存器副本的另一种方式。某些语言有一个 volatile 关键字来将变量标记为“read-always-and-never-locally-register-cache”。

您的语言的记忆模型很重要。它准确描述了在什么条件下给定值在多个线程之间共享。这要么是您正在执行的 CPU 架构的规则,要么是由运行该语言的虚拟机决定的。例如,Java 有一个单独的内存模型,您可以查看它来弄清楚究竟会发生什么。

【讨论】:

    【解决方案3】:

    如果 8 位、16 位或 32 位读/写与其大小对齐(在 486 和更高版本上)并且未对齐但在高速缓存行内(在 P6 和更高版本上),则可以保证它是原子的。大多数编译器会保证堆栈(本地,假设为 C/C++)变量是对齐的。

    如果对齐(在 Pentium 和更高版本上),64 位读/写保证是原子的,但是,这依赖于编译器生成单个指令(例如,从 FPU 弹出一个 64 位浮点数或使用 MMX)。我预计大多数编译器将使用两个 32 位访问来实现兼容性,尽管当然可以检查(反汇编)并且可能会强制执行不同的处理。

    下一个问题是缓存和内存防护。但是,忽略这些的影响是一些线程可能会看到旧值,即使它已被更新。该值不会无效,只是过时(可能是微秒)。如果这对您的应用程序至关重要,您将不得不更深入地挖掘,但我怀疑它是。

    (来源:Intel Software Developer Manual Volume 3A

    【讨论】:

    • 显然不同的处理器可能有不同的行为,因此请尝试找到与系统编程指南等效的文档,无论您使用什么。
    • 内存对齐是我认为的重要线索。由此看来,如果变量是对齐的(可以在代码中显式完成),那么在 x86 系统上写入 32 位整数是原子的,然后只要我不使用锁就可以安全地访问变量而不需要锁定'不介意由于寄存器缓存而获得可能的陈旧数据。
    • 基本上就是这样,尽管正如stackoverflow.com/questions/4149524/… 指出的那样,您可以通过在定义中添加volatile(在C/C++ 中)来覆盖自己。
    【解决方案4】:

    这在很大程度上取决于硬件以及您如何与它交谈。如果您正在编写汇编程序,您将确切地知道您得到了什么,因为处理器手册会告诉您哪些操作是原子的以及在什么条件下。例如,在 Intel Pentium 中,如果地址对齐,则 32 位读取是原子的,否则不是。

    如果您在高于此级别的任何级别上工作,这将取决于最终如何将其转换为机器代码。无论是编译器、解释器还是虚拟机。

    【讨论】:

      【解决方案5】:

      您运行的平台决定了原子读/写的大小。通常,32 位(寄存器)平台仅支持 32 位原子操作。因此,如果您正在编写超过 32 位的数据,您可能必须使用其他机制来协调对该共享数据的访问。

      一种机制是双倍或三倍缓冲实际数据并使用共享索引来确定“最新”版本:

      write(blah)
      {
          new_index= ...; // find a free entry in the global_data array.
          global_data[new_index]= blah;
          WriteBarrier(); // write-release
          global_index= new_index;
      }
      
      read()
      {
          read_index= global_index;
          ReadBarrier(); // read-acquire
          return global_data[read_index];
      }
      

      您需要内存屏障来确保在您读取global_index 之前不会从global_data[...] 读取,并且在您写入global_data[...] 之前不会写入global_index

      这有点糟糕,因为你也可以通过抢占来遇到 ABA 问题,所以不要直接使用它。

      【讨论】:

        【解决方案6】:

        平台通常提供对原始值(如您的示例中的 32 位或 64 位)的原子读/写访问(在硬件级别强制执行) - 请参阅 the Interlocked* APIs on Windows

        这可以避免对线程安全变量或成员访问使用较重的锁,但不应与同一实例或成员上的其他类型的锁混用。换句话说,不要在一个地方使用Mutex 来调解访问,而在另一个地方使用Interlocked* 来修改或读取它。

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 2014-08-07
          • 2020-02-26
          • 2017-09-12
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2019-06-30
          相关资源
          最近更新 更多