【问题标题】:I've heard i++ isn't thread safe, is ++i thread-safe?我听说 i++ 不是线程安全的,++i 是线程安全的吗?
【发布时间】:2010-10-15 08:55:03
【问题描述】:

我听说 i++ 不是线程安全语句,因为在汇编中它简化为将原始值存储为某个地方的临时值,递增它,然后替换它,这可能会被上下文切换中断。

但是,我想知道 ++i。据我所知,这将简化为一条汇编指令,例如“add r1, r1, 1”,并且由于它只有一条指令,因此不会被上下文切换中断。

谁能澄清一下?我假设正在使用 x86 平台。

【问题讨论】:

  • 只是一个问题。两个(或更多)线程访问这样的变量需要什么样的场景?我在这里诚实地问,而不是批评。就在这个时候,我的脑袋想不出来。
  • C++ 类中维护对象计数的类变量?
  • 关于我今天刚刚观看的这件事的好视频,因为另一个人告诉我:youtube.com/watch?v=mrvAqvtWYb4
  • 重新标记为 C/C++ ;这里不考虑 Java,C# 类似,但缺少这种严格定义的内存语义。
  • @Oscar Reyes 假设您有两个线程都使用 i 变量。如果一个线程只在某一点增加线程,另一个只在另一点减少线程,你就不得不担心线程安全。

标签: c++ c multithreading


【解决方案1】:

你听错了。 "i++" 很可能对于特定的编译器和特定的处理器架构是线程安全的,但在标准中根本没有强制要求。事实上,由于多线程不是 ISO C 或 C++ 标准 (a) 的一部分,因此根据您认为它会编译成的内容,您不能认为任何东西都是线程安全的.

++i 可以编译成任意序列,例如:

load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory

在我的(想象的)没有内存增量指令的 CPU 上,这不是线程安全的。或者它可能很聪明,将其编译成:

lock         ; disable task switching (interrupts)
load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory
unlock       ; enable task switching (interrupts)

lock 禁用中断,unlock 启用中断。但是,即便如此,在具有多个这些 CPU 共享内存的架构中,这可能不是线程安全的(lock 可能只禁用一个 CPU 的中断)。

语言本身(或它的库,如果它没有内置在语言中)将提供线程安全的构造,您应该使用这些构造,而不是依赖于您对将生成的机器代码的理解(或可能是误解)。

像 Java synchronizedpthread_mutex_lock()(在某些操作系统下可用于 C/C++)之类的东西是您需要研究的(a)


(a) 这个问题是在 C11 和 C++11 标准完成之前提出的。这些迭代现在已经在语言规范中引入了线程支持,包括原子数据类型(尽管它们以及一般的线程是可选的,至少在 C 中)。

【讨论】:

  • +1 强调这不是特定于平台的问题,更不用说明确的答案了......
  • 恭喜你的 C 银徽章 :)
  • 我认为您应该明确指出,没有现代操作系统授权用户模式程序关闭中断,并且 pthread_mutex_lock() 不是 C 的一部分。
  • @Bastien,没有任何现代操作系统会在没有内存增量指令的 CPU 上运行 :-) 但你的观点是关于 C 的。
  • @Bastien:公牛。 RISC 处理器通常没有内存增量指令。加载/添加/存储三元组是您在例如 PowerPC 上执行此操作的方式。
【解决方案2】:

您不能对 ++i 或 i++ 做出笼统的陈述。为什么?考虑在 32 位系统上增加一个 64 位整数。除非底层机器有四字“加载、递增、存储”指令,否则递增该值将需要多条指令,其中任何一条都可以被线程上下文切换中断。

此外,++i 并不总是“将值加一”。在像 C 这样的语言中,增加指针实际上会增加所指向事物的大小。也就是说,如果i 是一个指向32 字节结构的指针,++i 会增加32 字节。尽管几乎所有平台都有一个原子的“在内存地址递增值”指令,但并非所有平台都有一个原子的“将任意值添加到内存地址的值”指令。

【讨论】:

  • 当然,如果你不局限于无聊的 32 位整数,在像 C++ 这样的语言中,++i 实际上可以调用一个更新数据库中值的 Web 服务。
【解决方案3】:

它们都是线程不安全的。

CPU 不能直接对内存进行数学运算。它通过从内存中加载值并使用 CPU 寄存器进行数学运算来间接做到这一点。

我++

register int a1, a2;

a1 = *(&i) ; // One cpu instruction: LOAD from memory location identified by i;
a2 = a1;
a1 += 1; 
*(&i) = a1; 
return a2; // 4 cpu instructions

++i

register int a1;

a1 = *(&i) ; 
a1 += 1; 
*(&i) = a1; 
return a1; // 3 cpu instructions

对于这两种情况,都存在导致不可预测的 i 值的竞争条件。

例如,假设有两个并发 ++i 线程,每个线程分别使用寄存器 a1、b1。并且,上下文切换执行如下:

register int a1, b1;

a1 = *(&i);
a1 += 1;
b1 = *(&i);
b1 += 1;
*(&i) = a1;
*(&i) = b1;

结果,我没有变成i+2,而是变成了i+1,这是不正确的。

为了解决这个问题,现代 CPU 在禁用上下文切换期间提供了某种 LOCK、UNLO​​CK cpu 指令。

在 Win32 上,使用 InterlockedIncrement() 执行 i++ 以确保线程安全。它比依赖互斥锁要快得多。

【讨论】:

  • “CPU 不能直接用内存做数学运算”——这不准确。有 CPU-s,您可以在其中“直接”对内存元素进行数学运算,而无需先将其加载到寄存器中。例如。 MC68000
  • LOCK 和 UNLOCK CPU 指令与上下文切换无关。他们锁定缓存行。
【解决方案4】:

如果您在多核环境中跨线程共享一个 int,则需要适当的内存屏障。这可能意味着使用互锁指令(例如,请参见 win32 中的 InterlockedIncrement),或使用可确保某些线程安全的语言(或编译器)。对于 CPU 级别的指令重新排序和缓存以及其他问题,除非您有这些保证,否则不要假设跨线程共享的任何内容都是安全的。

编辑:对于大多数架构,您可以假设的一件事是,如果您正在处理正确对齐的单个单词,您最终不会得到一个包含两个混合在一起的值组合的单词。如果两次写入相互重叠,则一个将获胜,另一个将被丢弃。如果你很小心,你可以利用这一点,并看到 ++i 或 i++ 在单写/多读的情况下是线程安全的。

【讨论】:

  • 在 int 访问(读/写)是原子的环境中实际上是错误的。有一些算法可以在这样的环境中工作,即使没有内存屏障可能意味着您有时会处理陈旧的数据。
  • 我只是说原子性并不能保证线程安全。如果你足够聪明,可以设计无锁数据结构或算法,那就继续吧。但是你仍然需要知道你的编译器会给你什么保证。
【解决方案5】:

如果您想要 C++ 中的原子增量,您可以使用 C++0x 库(std::atomic 数据类型)或类似 TBB 的东西。

曾有一段时间,GNU 编码指南说更新适合一个词的数据类型“通常是安全的”,但该建议对于 SMP 机器是错误的,对于某些架构是错误的,使用优化编译器。


澄清“更新单字数据类型”注释:

SMP 机器上的两个 CPU 可以在同一周期内写入同一内​​存位置,然后尝试将更改传播到其他 CPU 和缓存。即使只写入一个字的数据,因此写入只需一个周期即可完成,它们也会同时发生,因此您无法保证哪个写入成功。您不会得到部分更新的数据,但是一次写入会消失,因为没有其他方法可以处理这种情况。

比较和交换在多个 CPU 之间正确协调,但没有理由相信单字数据类型的每个变量分配都会使用比较和交换。

虽然优化编译器不会影响如何加载/存储的编译,但它可以改变何时加载/存储发生,如果您期望的话会造成严重的麻烦您的读取和写入将按照它们在源代码中出现的顺序发生(最著名的是双重检查锁定在 vanilla C++ 中不起作用)。

注意 我原来的回答还说英特尔 64 位架构在处理 64 位数据时被破坏了。这不是真的,所以我编辑了答案,但我的编辑声称 PowerPC 芯片坏了。 That is true when reading immediate values (i.e., constants) into registers(参见清单 2 和清单 4 下名为“加载指针”的两个部分)。但是有一条指令可以在一个周期内从内存中加载数据(lmw),所以我删除了这部分答案。

【讨论】:

  • 如果您的数据自然对齐且大小正确,即使使用 SMP 和优化编译器,读取和写入在大多数现代 CPU 上都是原子操作。不过有很多注意事项,尤其是 64 位机器,因此确保您的数据满足每台机器的要求可能很麻烦。
  • 感谢更新。正确,读取和写入是原子的,因为您说它们不能完成一半,但是您的评论突出了我们在实践中如何处理这个事实。与内存屏障相同,它们不会影响操作的原子性质,而是我们在实践中如何处理它。
【解决方案6】:

即使简化为一条汇编指令,直接在内存中递增值,仍然不是线程安全的。

当在内存中增加一个值时,硬件会执行“读-修改-写”操作:它从内存中读取值,增加它,然后将它写回内存。 x86 硬件无法直接在内存上递增; RAM(和缓存)只能读取和存储值,不能修改它们。

现在假设您有两个独立的内核,或者在不同的插槽上,或者共享一个插槽(有或没有共享缓存)。第一个处理器读取该值,在它可以写回更新的值之前,第二个处理器读取它。在两个处理器写回该值后,它只会增加一次,而不是两次。

有一种方法可以避免这个问题; x86 处理器(以及您会发现的大多数多核处理器)能够检测到硬件中的这种冲突并对其进行排序,从而使整个读-修改-写序列看起来是原子的。但是,由于这非常昂贵,因此仅在代码请求时才完成,在 x86 上通常通过 LOCK 前缀。其他架构可以通过其他方式做到这一点,结果相似;例如,加载链接/存储条件和原子比较和交换(最近的 x86 处理器也有最后一个)。

请注意,在这里使用volatile 没有帮助;它只告诉编译器该变量可能已在外部进行了修改,并且对该变量的读取不得缓存在寄存器中或优化出来。它不会让编译器使用原子原语。

最好的方法是使用原子原语(如果你的编译器或库有它们),或者直接在汇编中进行增量(使用正确的原子指令)。

【讨论】:

    【解决方案7】:

    在 C/C++ 中的 x86/Windows 上,您不应假定它是线程安全的。如果你需要原子操作,你应该使用InterlockedIncrement()InterlockedDecrement()

    【讨论】:

      【解决方案8】:

      如果您的编程语言对线程一无所知,但在多线程平台上运行,那么 任何 语言构造如何是线程安全的?

      正如其他人指出的那样:您需要保护平台特定调用对变量的任何多线程访问。

      那里有一些库可以抽象出平台特异性,而即将到来的 C++ 标准已经调整了它的内存模型来处理线程(因此可以保证线程安全)。

      【讨论】:

        【解决方案9】:

        永远不要假设增量会编译成原子操作。使用 InterlockedIncrement 或目标平台上存在的任何类似函数。

        编辑:我刚刚查看了这个特定的问题,X86 上的增量在单处理器系统上是原子的,但在多处理器系统上不是。使用锁定前缀可以使其原子化,但仅使用 InterlockedIncrement 就更便携。

        【讨论】:

        • InterlockedIncrement() 是一个 Windows 函数;我所有的 Linux 机器和现代 OS X 机器都是基于 x64 的,所以说 InterlockedIncrement() 比 x86 代码“更便携”是相当虚假的。
        • 它更便携,就像 C 比汇编更便携一样。这里的目标是使您自己不依赖特定处理器的特定生成程序集。如果您关心其他操作系统,那么 InterlockedIncrement 很容易包装。
        【解决方案10】:

        根据 x86 上的 assembly lesson,您可以自动将寄存器添加到内存位置,因此您的代码可能会自动执行 '++i' 或 'i++'。 但正如在另一篇文章中所说,C ansi 不会将原子性应用于“++”操作,因此您无法确定编译器会生成什么。

        【讨论】:

          【解决方案11】:

          1998 年的 C++ 标准对线程没有任何规定,尽管下一个标准(今年或明年发布)有。因此,如果不参考实现,您就不能说任何关于操作线程安全的智能。不仅仅是使用的处理器,还有编译器、操作系统和线程模型的组合。

          如果没有相反的文档,我不会假设任何操作都是线程安全的,尤其是对于多核处理器(或多处理器系统)。我也不相信测试,因为线程同步问题很可能只是偶然出现。

          没有什么是线程安全的,除非您有说明它适用于您正在使用的特定系统的文档。

          【讨论】:

            【解决方案12】:

            将 i 放入线程本地存储;它不是原子的,但它并不重要。

            【讨论】:

              【解决方案13】:

              AFAIK,根据 C++ 标准,对 int 的读/写是原子的。

              然而,这一切只是摆脱了与数据竞争相关的未定义行为。

              但是如果两个线程都尝试增加i,仍然会出现数据竞争。

              想象以下场景:

              i = 0最初:

              线程 A 从内存中读取值并存储在自己的缓存中。 线程 A 将值增加 1。

              线程 B 从内存中读取值并存储在自己的缓存中。 线程 B 将值增加 1。

              如果这都是单个线程,您将在内存中获得i = 2

              但是对于两个线程,每个线程都会写入其更改,因此线程 A 将 i = 1 写入内存,线程 B 将 i = 1 写入内存。

              定义明确,没有部分破坏或构造或任何形式的对象撕裂,但它仍然是数据竞赛。

              为了自动增加i,您可以使用:

              std::atomic<int>::fetch_add(1, std::memory_order_relaxed)

              可以使用宽松的排序,因为我们不关心这个操作发生在哪里,我们只关心增量操作是原子的。

              【讨论】:

                【解决方案14】:

                你说“它只是一条指令,它不会被上下文切换中断。” - 这对于单个 CPU 来说很好,但是双核 CPU 呢?那么你真的可以让两个线程同时访问同一个变量,而无需任何上下文切换。

                在不懂语言的情况下,答案是测试一下。

                【讨论】:

                • 您无法通过测试确定某事物是否是线程安全的 - 线程问题可能发生百万分之一。你在你的文档中查找它。如果您的文档不能保证线程安全,则不是。
                • 在这里同意@Josh。只有通过对底层代码的分析才能在数学上证明某些东西才是线程安全的。再多的测试都无法达到这一点。
                • 在最后一句话之前,这是一个很好的答案。
                【解决方案15】:

                我认为如果表达式“i++”是语句中唯一的,它相当于“++i”,编译器足够聪明,不会保留时间值等。所以如果你可以互换使用它们(否则你不会问使用哪一个),无论你使用哪个都没有关系,因为它们几乎相同(美学除外)。

                无论如何,即使增量运算符是原子的,如果您不使用正确的锁,也不能保证其余的计算将是一致的。

                如果您想自己进行实验,请编写一个程序,其中 N 个线程同时递增一个共享变量,每个共享变量 M 次...如果该值小于 N*M,则某个增量被覆盖。尝试使用前增量和后增量并告诉我们 ;-)

                【讨论】:

                  【解决方案16】:

                  对于计数器,我建议使用比较和交换的习惯用法,它既非锁定又是线程安全的。

                  这里是Java:

                  public class IntCompareAndSwap {
                      private int value = 0;
                  
                      public synchronized int get(){return value;}
                  
                      public synchronized int compareAndSwap(int p_expectedValue, int p_newValue){
                          int oldValue = value;
                  
                          if (oldValue == p_expectedValue)
                              value = p_newValue;
                  
                          return oldValue;
                      }
                  }
                  
                  public class IntCASCounter {
                  
                      public IntCASCounter(){
                          m_value = new IntCompareAndSwap();
                      }
                  
                      private IntCompareAndSwap m_value;
                  
                      public int getValue(){return m_value.get();}
                  
                      public void increment(){
                          int temp;
                          do {
                              temp = m_value.get();
                          } while (temp != m_value.compareAndSwap(temp, temp + 1));
                  
                      }
                  
                      public void decrement(){
                          int temp;
                          do {
                              temp = m_value.get();
                          } while (temp > 0 && temp != m_value.compareAndSwap(temp, temp - 1));
                  
                      }
                  }
                  

                  【讨论】:

                  • 看起来类似于 test_and_set 函数。
                  • 你写了“非锁定”,但“同步”不就是锁定的意思吗?
                  猜你喜欢
                  • 2011-08-01
                  • 2014-02-07
                  • 2011-04-05
                  • 2021-10-12
                  • 2015-04-18
                  • 2011-10-07
                  • 2012-03-02
                  • 2011-10-28
                  • 2023-03-14
                  相关资源
                  最近更新 更多