【问题标题】:Is it safe to read an integer variable that's being concurrently modified without locking?读取一个在没有锁定的情况下同时修改的整数变量是否安全?
【发布时间】:2010-11-23 23:21:17
【问题描述】:

假设我在一个类中有一个整型变量,这个变量可能会被其他线程同时修改。写入受互斥体保护。我也需要保护读取吗?我听说有一些硬件架构,如果一个线程修改一个变量,另一个线程读取它,那么读取的结果将是垃圾;在这种情况下,我确实需要保护读取。不过我从未见过这样的架构。

此问题假设单个事务仅包含更新单个整数变量,因此我不担心事务中可能涉及的任何其他变量的状态。

【问题讨论】:

  • 我不认为那个特定的是这个的重复,但我认为之前已经问过了
  • 哎呀,你是对的 - 有这么多可供选择,我想我很困惑。
  • 还有内存屏障的问题:如果一个线程写入变量,另一个线程会看到变化吗? (如果两个线程通过不同的处理器缓存访问内存,这可能是一个问题。)
  • Neil,那是 Java,它有不同的内存模型。

标签: c++ multithreading concurrency


【解决方案1】:

原子读取
如前所述,它取决于平台。在 x86 上,该值必须在 4 字节边界上对齐。通常对于大多数平台,读取必须在单个 CPU 指令中执行。

优化器缓存
优化器不知道您正在读取由不同线程修改的值。声明值 volatile 有助于解决此问题:优化器将为每次访问发出内存读/写,而不是尝试将值缓存在寄存器中。

CPU 缓存
不过,您可能会读到一个陈旧的值,因为在现代架构中,您有多个内核和单独的缓存,这些缓存不会自动保持同步。您需要一个读取内存屏障,通常是特定于平台的指令。

在 Wintel 上,线程同步函数会自动添加一个完整的内存屏障,或者您可以使用 InterlockedXxxx 函数。

MSDN:Memory and Synchronization issuesMemoryBarrier

[edit] 另请参阅 drhirsch 的 cmets。

【讨论】:

  • +1 因为内存屏障/CPU 缓存提及以及优化器缓存,这里似乎没有其他人承认。
  • -1 在 CPU 缓存问题上完全错误。谷歌搜索 MESI 协议。内存屏障用于弱排序加载/存储指令(在 x86 上通常是流式 mmx),这是一个完全不同的主题。
  • (1) 是的。没有缓存一致性就没有多处理器架构,仅仅因为它是必需的。除了 MESI,还有其他协议,但这是最普遍的。 (2) 您似乎混淆了指令的重新排序、内存操作的重新排序和对相同数据的并发访问。这一切都不一样了。在大多数架构上,指令和相应的内存操作不耦合,处理器可以在一定的效率限制内重新排序内存操作(乱序架构)。罕见的反例之一是英特尔凌动。
  • 但这通常与程序员无关,除非您使用具有“弱内存排序”的指令。您需要汇编指令或内在函数才能使用它们,因此如果您使用高级语言,您将永远不必使用内存屏障。到目前为止,这与多处理器及其缓存完全无关。如果现在两个处理器访问位于其中一个处理器缓存中的相同数据,则硬件会确保两个处理器都获得完全相同的数据,这些数据甚至可以从一个处理器缓存传输到另一个处理器。
  • 在这种情况下,“指令的重新排序”并不真正相关,这是处理器可以进行的局部优化。如果您不使用同步并且在您的一个处理器中进行写操作,则结果只是未定义的。 - MSDN 确实不是查找低级指令的正确位置,最好查看 Intel 或 AMD 手册或您正在使用的任何内容。
【解决方案2】:

你问了一个关于读取变量的问题,然后你谈到了更新变量,这意味着读取-修改-写入操作。

假设你真的是指前者,那么读取是安全的如果它是原子操作。对于几乎所有架构,整数都是如此。

有一些(和罕见的)例外:

  • 读取未对齐,例如在奇数地址访问 4 字节 int。通常你需要强制具有特殊属性的编译器做一些错位。
  • int 的大小大于指令的自然大小,例如在 8 位架构上使用 16 位 int。
  • 一些架构人为地限制了总线宽度。我只知道非常老旧的,比如 386sx 或 68008。

【讨论】:

  • 请注意,即使读取是原子的,它仍然可能是当前线程 CPU 缓存中的陈旧值。
  • 没有。阅读有关 MESI 协议的信息。正常读/写操作的缓存一致性始终得到保证并且对软件透明。
  • @drhirsch:啊哈! “不是这样的 MIPS 系统,许多 MIPS 内核都有缓存,没有任何类型的额外“一致性”硬件。” - embedded.com/design/opensource/208800056?_requestid=187872
  • 下一部分 6 确实会讨论 CPU 缓存。无论如何,我碰巧知道有多个 CPU 的系统没有硬件缓存一致性。也许你从未见过,但它们确实存在。如果没有将正确的硬件指令用于共享数据,您遇到严重的错误。我链接的该系列第 6 部分的另一句话是:“但是,一旦您摆脱了围绕单个公共总线构建的系统,就很难确保读取和写入保持相同的相对顺序。”
  • 现在我意识到,当我们说“硬件缓存一致性”时,我们可能在谈论不同的事情。我并不是说硬件不同步缓存和内存。我是说,如果没有被要求这样做,就不会这样做。它并不总是自动完成。
【解决方案3】:

在这种情况下,我建议不要依赖任何编译器或架构
每当您同时拥有读者和作者(而不是只有读者或作者)时,您最好将它们全部同步。想象一下你的代码运行了一个人的人造心脏,你真的不希望它读取错误的值,当然你也不希望你所在城市的发电厂因为有人决定不使用该互斥体而“轰鸣”。从长远来看,让自己睡一觉,同步它们。
如果你只有一个线程读取——你最好只使用一个互斥锁,但是如果你计划多个读取器和多个写入器,则需要一段复杂的代码来同步它。我还没有看到一个很好的读/写锁实现,也是“公平”的。

【讨论】:

  • 这是一个技术问题;它需要一个技术答案。
【解决方案4】:

假设您正在一个线程中读取变量,该线程在读取时被中断,并且变量被写入线程更改。现在读取线程恢复后读取的整数值是多少?

除非读取变量是原子操作,这种情况下只需要一条(汇编)指令,不能保证上述情况不会发生。 (变量可以写入内存,取回值需要多条指令)

共识是您应该单独封装/锁定所有写入,而读取可以与(仅)其他读取同时执行

【讨论】:

  • 读取不需要花费一个时钟周期就可以是原子的。我不清楚你的最后一部分 - 我猜你的意思是“读取可以同时进行,但在写入期间,其他线程读取或写入必须被锁定”
【解决方案5】:

假设我在一个类中有一个整型变量,这个变量可能会被其他线程同时修改。写入受互斥体保护。我也需要保护读取吗?我听说有一些硬件架构,如果一个线程修改一个变量,另一个线程读取它,那么读取的结果将是垃圾;在这种情况下,我确实需要保护读取。不过我从未见过这样的架构。

在一般情况下,这可能是每个架构。每个架构都有这样的情况,即读和写同时会导致垃圾。 然而,几乎每个架构也有例外。

字大小的变量通常是原子读写的,所以读写时不需要同步。正确的值将作为单个操作以原子方式写入,并且线程也会将 current 值作为单个原子操作读取,即使另一个线程正在写入。所以对于整数,你在大多数架构上是安全的。有些人也会将此保证扩展到其他一些尺寸,但这显然取决于硬件。

对于非单词大小的变量,读取和写入通常都是非原子的,必须通过其他方式进行同步。

【讨论】:

    【解决方案6】:

    如果你在写new的时候不使用这个变量的prevous值,那么:

      您可以在不使用互斥锁的情况下读写整数变量。这是因为整数是 32 位架构中的基本类型,并且每次修改/读取值都是通过一次操作完成的。

    但是,如果你做一些诸如增量之类的事情:

    myvar++;
    

      然后你需要使用互斥锁,因为这个构造扩展为 myvar = myvar + 1 并且在 read myvar 和 increment myvar 之间,可以修改 myvar。在这种情况下,您将获得不好的价值。

    【讨论】:

      【解决方案7】:

      虽然在不同步的情况下在 32 位系统上读取整数可能是安全的。我不会冒险的。虽然多个并发读取不是问题,但我不喜欢写入与读取同时发生。

      我建议也将读取放在关键部分,然后在多个内核上对您的应用程序进行压力测试,看看这是否会导致过多的争用。发现并发错误是我宁愿避免的噩梦。如果将来有人决定将 int 更改为 long long 或 double,这样他们可以容纳更大的数字会怎样?

      如果你有一个不错的线程库,比如 boost.thread 或 zthread,那么你应该有读/写锁。这些将非常适合您的情况,因为它们允许在保护写入的同时进行多次读取。

      【讨论】:

        【解决方案8】:

        这可能发生在使用 16 位整数的 8 位系统上。

        如果你想避免锁定,你可以在适当的情况下多次阅读,直到你得到两个相等的连续值。例如,我使用这种方法在 32 位嵌入式目标上读取 64 位时钟,其中时钟滴答被实现为中断例程。在这种情况下,读取 3 次就足够了,因为在读取例程运行的短时间内时钟只能滴答一次。

        【讨论】:

        • 通常你会在读取或写入值时禁用中断,然后你只需要读取一次长字。但有时你不能禁用中断,是的,我在紧要关头使用了多次读取技术。这种技术对于读取日期/时间值对也很有用,其中日期是一次读取,时间是另一次读取(可能通过慢速网络)。在这种情况下,您想先读取日期(最重要的值),然后是时间,然后再次读取日期以确保它没有提前(即在日期更改时读取 23:59 或 00:00 左右的时间)。
        【解决方案9】:

        一般来说,每条机器指令在执行时都会经过几个硬件阶段。由于大多数当前 CPU 是多核或超线程的,这意味着读取变量可能会启动它通过指令流水线移动,但它不会阻止另一个 CPU 内核或超线程同时执行存储指令到同一个地址。两条同时执行的指令,读取和存储,可能会“交叉路径”,这意味着读取将在新值存储之前接收旧值。
        要恢复:您确实需要用于读写的互斥锁。

        【讨论】:

          【解决方案10】:

          对具有并发性的变量的读/写都必须受到临界区(不是互斥体)的保护。除非你想浪费一整天的调试时间。

          我相信关键部分是特定于平台的。在Win32上,临界区非常高效:在没有发生联锁的情况下,进入临界区几乎是空闲的,不影响整体性能。当发生互锁时,它仍然比互斥锁更有效,因为它在挂起线程之前执行了一系列检查。

          【讨论】:

            【解决方案11】:

            取决于您的平台。大多数现代平台为整数提供原子操作:Windows 有 InterlockedIncrement、InterlockedDecrement、InterlockedCompareExchange 等。这些操作通常由底层硬件(读取:CPU)支持,它们通常比使用临界区或其他同步机制便宜。

            参见 MSDN:InterlockedCompareExchange

            我相信 Linux(和现代 Unix 变体)在 pthreads 包中支持类似的操作,但我并不声称自己是那里的专家。

            【讨论】:

            • 这不能回答 OP 的问题
            【解决方案12】:

            如果一个变量用 volatile 关键字标记,那么读/写就变成了原子的,但这对于编译器的作用和行为方式有很多很多其他的影响,不应该仅仅用于此目的。

            在盲目开始使用 volatile 之前,请先了解它的作用:http://msdn.microsoft.com/en-us/library/12a04hfd(VS.80).aspx

            【讨论】:

            • volatile 并不意味着“原子”。 volatile 的真正含义取决于编译器,如今大多数编译器都将其视为“不要对这个变量做聪明的优化技巧”
            • 该标准将“易失性”指定为意味着变量可能会更改或在线程控制之外被访问,因此不应将其缓存在寄存器或类似物中。标记为 volatile 的变量将始终被直接读取和写入主存储器。但是,它没有说明同步。
            • @jalf:我相信标记为“volatile”的变量必须在 C 代码指示读取的任何时候准确读取一次,即使读取变量零次或多次会更有效;并且在代码指示写入的任何时候都只写入一次,即使编写更多代码可能更有效(例如 a=!b; 可能是最有效的 "a=0; if (!b) a=1;"但如果 a 是 volatile 则不允许这样做。这仍然是当前含义吗?
            猜你喜欢
            • 2017-05-15
            • 2021-10-03
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 2021-04-11
            • 2020-06-16
            相关资源
            最近更新 更多