std::atomic如何保证原子性
How does std::atomic ensure atomicity
如果我有一个代码 a = a + 1
,现在我知道执行此代码需要多个 CPU 级别的操作,但是如何将 a
定义为 std::atomic<int>
使这些多个事务原子化?
它是否改变了 CPU 指令的执行方式。我假设它必须以某种方式将指令数减少到 1,这样任何上下文切换都不会导致不可靠的结果,但它是如何做到的呢?
如果编译器总能创建那样的代码,为什么不总是这样做呢?
如果有可以下发的原子指令(对于已知可能的原子操作),则下发这条原子指令,否则要带锁机制。
有一个函数 (C++17) 可以告诉您原子类型是否始终是无锁的:is_always_lock_free
。
注意如果这个函数returnsfalse
,至少有一些操作不是无锁的(不一定是全部)。这些非无锁操作通常会比原子操作更昂贵(它们本身比传统操作更昂贵)。
并不是所有的硬件都支持原子操作的所有组合,所以不同的编译器后端会产生不同的解决方案,有时是单个原子操作,有时是加锁机制。
所以它不能总是创建这样的 1 指令代码。
[B]ut how does definining a as std::atomic make these multiple
transactions atomic?
它不会使任意表达式中的 "multiple transactions" 成为原子(例如,它对您的 a = a + 1
示例没有帮助)。相反,您需要使用像 a++
这样的操作,它保证是原子的。在那种情况下,它如何实现取决于编译器和硬件,但最常见的策略是:
- 使用单条指令原子操作,可以自动递增内存中的值。在 x86 上,这将是 something like
lock add
指令。
- MIPS64 上的某种 compare-and-exchange (CAS) or load-linked store-conditional (LL-SC) loop is used to repeatedly attempt to do the increment in an atomic fashion. You can see LL-SC in action。
- 最后,在不支持此类操作或数据类型与这些指令不兼容的平台上,在使用常规非原子指令执行操作时可以获得锁以排除任何并发访问。对于原子类型,大多数主流平台不需要退回到此,但您仍然可以在旧的 ARM 编译器上看到 an example here。
您可以通过检查生成的程序集来检查编译器和硬件组合的行为。有时这很棘手,因为编译器可能会调用在运行时库中实现的函数,在这种情况下,您将不得不检查该函数的源代码或反汇编。这意味着如果运行时库实现不同,相同的二进制文件可以在不同的主机上有不同的原子操作实现!
If the compiler can always create code like that, why not always do
that?
编译器并不总是生成这些,因为它们在硬件级别上很昂贵。例如,在大多数现代 CPU1 上,正常(非原子)加法通常需要 1 个周期或更少 2,而原子加法可能需要15 到 100 个循环。使用 CAS 或 LL-SC 的方法通常更慢并且需要重试循环,使二进制大小膨胀。
1 在某些微控制器上最多可能有几个周期 class CPU - 但原子操作通常不太相关,因为可能没有多个内核.
2 这取决于你如何衡量它 - 加法通常需要一个周期才能 complete (延迟),但你通常可以执行在同一循环中不止一次独立加法。例如,现代 Intel CPU 可以在一个周期内执行四个。
如果我有一个代码 a = a + 1
,现在我知道执行此代码需要多个 CPU 级别的操作,但是如何将 a
定义为 std::atomic<int>
使这些多个事务原子化?
它是否改变了 CPU 指令的执行方式。我假设它必须以某种方式将指令数减少到 1,这样任何上下文切换都不会导致不可靠的结果,但它是如何做到的呢?
如果编译器总能创建那样的代码,为什么不总是这样做呢?
如果有可以下发的原子指令(对于已知可能的原子操作),则下发这条原子指令,否则要带锁机制。
有一个函数 (C++17) 可以告诉您原子类型是否始终是无锁的:is_always_lock_free
。
注意如果这个函数returnsfalse
,至少有一些操作不是无锁的(不一定是全部)。这些非无锁操作通常会比原子操作更昂贵(它们本身比传统操作更昂贵)。
并不是所有的硬件都支持原子操作的所有组合,所以不同的编译器后端会产生不同的解决方案,有时是单个原子操作,有时是加锁机制。
所以它不能总是创建这样的 1 指令代码。
[B]ut how does definining a as std::atomic make these multiple transactions atomic?
它不会使任意表达式中的 "multiple transactions" 成为原子(例如,它对您的 a = a + 1
示例没有帮助)。相反,您需要使用像 a++
这样的操作,它保证是原子的。在那种情况下,它如何实现取决于编译器和硬件,但最常见的策略是:
- 使用单条指令原子操作,可以自动递增内存中的值。在 x86 上,这将是 something like
lock add
指令。 - MIPS64 上的某种 compare-and-exchange (CAS) or load-linked store-conditional (LL-SC) loop is used to repeatedly attempt to do the increment in an atomic fashion. You can see LL-SC in action。
- 最后,在不支持此类操作或数据类型与这些指令不兼容的平台上,在使用常规非原子指令执行操作时可以获得锁以排除任何并发访问。对于原子类型,大多数主流平台不需要退回到此,但您仍然可以在旧的 ARM 编译器上看到 an example here。
您可以通过检查生成的程序集来检查编译器和硬件组合的行为。有时这很棘手,因为编译器可能会调用在运行时库中实现的函数,在这种情况下,您将不得不检查该函数的源代码或反汇编。这意味着如果运行时库实现不同,相同的二进制文件可以在不同的主机上有不同的原子操作实现!
If the compiler can always create code like that, why not always do that?
编译器并不总是生成这些,因为它们在硬件级别上很昂贵。例如,在大多数现代 CPU1 上,正常(非原子)加法通常需要 1 个周期或更少 2,而原子加法可能需要15 到 100 个循环。使用 CAS 或 LL-SC 的方法通常更慢并且需要重试循环,使二进制大小膨胀。
1 在某些微控制器上最多可能有几个周期 class CPU - 但原子操作通常不太相关,因为可能没有多个内核.
2 这取决于你如何衡量它 - 加法通常需要一个周期才能 complete (延迟),但你通常可以执行在同一循环中不止一次独立加法。例如,现代 Intel CPU 可以在一个周期内执行四个。