5. 内存模型基础、标准原子类型、自旋锁
内存模型基础:对象和 内存位置
书上给出了 4 点:
- 每个变量都是对象,包括对象中的成员。
- 每个对象有至少一个内存位置。
- 每个基本类型(
int
/char
, …)刚好占用一个内存位置。 - 连续位域是同一个内存位置的一部分。
一个标量类型,或者非 0 宽的连续位域构成一个 memory location。
struct S
{
char a; // memory location #1
int b : 5; // memory location #2
int c : 11, // memory location #2 (continued)
: 0,
d : 8; // memory location #3
struct
{
int ee : 8; // memory location #4
} e;
} obj; // The object “obj” consists of 4 separate memory locations
可以参考 https://timsong-cpp.github.io/cppwp/n4868/basic.memobj ,这里有讲内存模型、对象模型、生命周期。
Warning
操作同一个内存位置时,就要小心并发安全问题。
标准原子类型
头文件 <atomic>
中定义了一些标准的原子类型,包括 std::atomic_flag
和 std::atomic<T>
。原子类型都是无法拷贝和移动的。
静态常量成员属性 std::atomic<T>::is_always_lock_free
标志着该原子类型是否始终是无锁的。而成员方法 std::atomic<T>::is_lock_free
返回当前对象是否是无锁的。标准允许一些原子类型在某些条件满足时(sometimes)无锁。GCC7 在 std::atomic<T>::is_always_lock_free
的行为上发生了变化,参见
5.0.1 GCC7 std atomic is_lock_free 的变化。
除了 std::atomic<T>
的静态成员属性之外,还有 ATOMIC_BOOL_LOCK_FREE
、ATOMIC_INT_LOCK_FREE
等宏可以用来检查是否 lock-free。因为宏比较多,这里不一一列举了。它们的值具有以下含义:0 表示不可能无锁,1 表示有时无锁,需要在运行时才能确认,2 表示总是无锁。
Tip
Linux 的 /proc/cpuinfo 中记录了每个 CPU 的标志位,其中 cx16 表示 16 字节的比较并交换操作。在 x86-64 上,这和指令 cmpxchg16b 对应,它是 16 字节的原子操作,要求目标地址必须按照 16 字节对齐。
即便是硬件支持 16 字节无锁原子操作,其性能相比 8 字节无锁原子操作可能也会低一点(load
/ store
可能是用 CAS 替代实现的)。主流硬件大多能保证 8 字节的无锁原子操作。(网上看到说 armv8-a 不支持 16 字节无锁原子操作,忘记了出处。)
std::atomic_flag
std::atomic_flag
没有 is_lock_free()
成员函数,因为它被要求总是无锁。它的其他成员函数的设计也很不一样,没有 load
和 store
操作。因此,std::atomic_flag
和 std::atomic<bool>
不是等价的。C++20 之前,std::atomic_flag
支持的操作很少:
std::atomic_flag::clear() // 受限的 store,相当于只能存储 false
std::atomic_flag::test_and_set() // 受限的 exchange,相当于新值固定为 true
std::atomic_flag
在 C++20 多了只读的 test()
操作,相当于 load()
。参考 https://stackoverflow.com/a/52072348/ 和 https://en.cppreference.com/w/cpp/atomic/atomic_flag 。
这个类型总是以 ATOMIC_FLAG_INIT
来初始化,因此刚开始的时候只能是 clear 状态。std::atomic_flag
的 clear()
和 test_and_set()
方法也接受内存序作为参数。std::atomic_flag
常常被用来实现自旋锁。
std::atomic<bool>
std::atomic<bool>
支持的操作
原子类型的赋值操作符不返回引用,而是返回非原子的值类型。因此 std::atomic<bool>
的的赋值操作符的返回值是 bool(参数也是 bool),参考 https://en.cppreference.com/w/cpp/atomic/atomic/operator%3D :
T operator=( T desired ) noexcept;
T operator=( T desired ) volatile noexcept;
atomic& operator=( const atomic& ) = delete;
atomic& operator=( const atomic& ) volatile = delete;
和 C++20 之前的 std::atomic_flag
不同,std::atomic<bool>
有 load
(非破坏性的查询方法)和 store
。还有 std::exchange
(read-modify-write 方法)。
除此之外,std::atomic<T>
还有 compare_exchange_weak()
和 compare_exchange_strong()
两个非常重要的 compare-and-swap (CAS) 方法(因而 std::atomic<bool>
也有)。函数名 compare_exchange
中的 exchange 和 swap 含义是等价的,只是 CAS 的说法更加常见。
std::atomic<T>
的 CAS 操作
两类重载
bool compare_exchange_weak( T& expected, T desired,
std::memory_order success,
std::memory_order failure ) noexcept;
bool compare_exchange_weak( T& expected, T desired,
std::memory_order success,
std::memory_order failure ) volatile noexcept;
这个重载函数的效果是:如果当前值 *this
和 (T&) expected
不相等,那么将当前值加载到 expected
中;否则将 (T) desired
写入原子变量中,返回值表示是否成功完成交换。成功时(这时是 Read-Modify-Write 操作,而不是单纯的 store 操作)使用的原子序由 success
指定,失败时读取旧值的内存序(这时是 load 操作)由 failure
指定。C++17 之前,如果 failure
指定的内存比 success
指定的内存序更强,或者是 std::memory_order_release
或 std::memory_order_acq_rel
,那么行为未定义;C++17 移除了 failure
不能比 success
内存序强的要求。
bool compare_exchange_weak( T& expected, T desired,
std::memory_order order =
std::memory_order_seq_cst ) noexcept;
bool compare_exchange_weak( T& expected, T desired,
std::memory_order order =
std::memory_order_seq_cst ) volatile noexcept;
还有一类只指定一个内存序的重载,这类重载的内存序参数会同时作用于 success 和 failure 两种情况,但是对 failure 情况会去除掉内存序中的 release 属性。
If you don’t specify an ordering for failure, it’s assumed to be the same as that for success, except that the release part of the ordering is stripped:
memory_order_release
becomesmemory_order_relaxed
, andmemory_order_acq_rel
becomesmemory_order_acquire
.
和其他原子操作一样,默认的内存序是最严格的 memory_order_seq_cst
。
理解比较并交换
“比较并交换”中的交换体现在哪里?假设 *this
和 (T&) expected
相等,那么即便多一个将 *this
的旧值加载到 (T&) expected
的操作,也不会改变 (T&) expected
的值,所以 expected
总能获得 *this
的旧值,且只有在相等条件满足时才将新值 desired
放进去。这句话比函数描述本身更容易理解,因为它注重结果而不是实现。我认为比较并交换是在交换操作上附加了一个条件,所以在参数相等时是交换,在参数不相等时不是完整的交换(只读取不写入)。
compare_and_exchange
的 weak 和 strong 版本区别在于:weak 版本可能会出现假性失败(比如机器没有直接提供可用的 CAS 指令),而 strong 版本不会。也就是虽然要比较的两个值是相等的,但 weak 版本仍然有可能认为它们不相等,并产生失败。因此,weak 版本必须要在循环中使用。例子:
bool expected = false;
extern atomic<bool> b; // set somewhere else
while(!b.compare_exchange_weak(expected, true) && !expected);
上述代码的设计是如果失败就继续循环。 https://stackoverflow.com/a/76285947/ 讲的很清楚,这段代码是对 compare_exchange_strong
的模拟,相当于对自旋锁的 try-lock。
可以用这样的代码来实现自旋锁(简化了代码,没有指定内存序):
bool expected = false;
extern atomic<bool> b; // set somewhere else
while(!b.compare_exchange_weak(expected, true)) {
expected = false;
}
上面的代码每次都将 expected 设置为 false,只有在 b 的值为 false 时才会退出循环。相关笔记:
CppCon 2023 C++ Memory Model - from C++11 to C++23 - Alex Dathskovsky。用 compare_exchange_strong
的话也可以,但是在一些平台上会更慢。
Tip
如果要用 CAS 实现自旋锁,考虑用 weak 版本来实现 lock()
,用 strong 版本来实现 try_lock()
。
接下来的还有一节会比较自旋锁的几种实现。
指针原子变量
多了两个操作 fetch_add()
和 fetch_sub()
。这两个函数和 operator+=
/-=
等价,但能指定内存序,而操作符版本只能是 memory_order_seq_cst
。
整型原子变量
基本操作都有,但是没有乘除法,没有移位运算符。如果有需要也可以放到循环中用 compare_exchange_weak()
来做。
特化 std::atomic<>
要特化这样的模板类,需要类型本身是可以平凡赋值的、要能用 memcpy
来复制、能用 memcmp
来比较。浮点数就不满足这一点。浮点数本身在标准库里面没有原子类,如果我们自己去创建特化可能会导致意想不到的 compare_exchange_strong
结果——因为浮点数涉及到值的表示问题。
原子操作的自由函数(free functions)
std::atomic_*
除了 std::atomic
的成员函数外,还有很多用来做原子操作的自由函数。他们的接口更加贴近 libatomic(见
5.0.1 GCC7 std atomic is_lock_free 的变化)。
std::atomic_store(&atomic_var,new_value);
std::atomic_store_explicit(&atomic_var,new_value,std::memory_order_release);
应该是为了和 C 语言兼容才没有使用重载,在要指定内存序时需要 _explicit
后缀,而且 CAS 的 _explicit
版本要完整指定两个内存序(成功时和失败时)。
尽管如此,std::atomic_store
等函数的第一个参数是 atomic<T> *
类型(可能由 volatile 修饰),而不是 T *
类型。
原子操作自由函数支持 std::shared_ptr<T>
对共享指针的支持是一个特例。这时这些函数的第一个参数是 std::shared_ptr<>*
。这些头文件都放在 <memory>
中,详见 https://en.cppreference.com/w/cpp/memory/shared_ptr/atomic 。在 C++20 之前只能通过自由函数来实现对共享指针的原子操作,在 C++20 之后 std::atomic
多了共享指针 / 弱指针的偏特化后这些自由函数被标记为弃用,在 C++26 时这些自由函数被移除。
给 std::shared_ptr<T>
提供的方法很少,只能 store
/ load
/ exchange
/ compare_exchange_weak
/ compare_exchange_strong
。不能进行指针算术运算(fetch_add
和 fetch_sub
)。
std::atomic_flag_*
这些是对 std::atomic_flag
操作的函数。
(C++20) 原子变量的 wait
和 notify
操作
见 5.0.2 阅读 libstdc++ 中原子变量 wait 和 notify 接口。
(C++20) std::atomic_ref<>
即便是 std::atomic<T*>
也只能保证对这个指针值的操作是原子的(注意,原子 ≠ 无锁)。要想保证一个已经分配的、其他位置的对象操作原子化,可以使用 std::atomic_ref<>
。std::atomic_ref<>
使用的前提是所引用对象的地址对齐到要求的位置。可以参考 https://stackoverflow.com/a/61994385/ 。
// for atomic_ref<T>
alignas(std::atomic_ref<T>::required_alignment) T sometimes_atomic_var;
// often equivalent, and doesn't require checking that atomic_ref<T> is supported
alignas(std::atomic<T>) T sometimes_atomic_var;
// use the same alignment as atomic<T>
(C++20) 智能指针对 std::atomic
的偏特化
std::atomic<shared_ptr<T>>
和 std::atomic<weak_ptr<T>>
在 C++20 中有了偏特化。由于要同时看到 std::shared_ptr<T>
和 std::atomic<T>
的定义,这些偏特化被放在头文件 <memory>
中。
在 C++20 之前,虽然有自由函数可以对共享指针进行原子操作,但是没有对 weak_ptr
进行原子操作的方法。C++20 的偏特化补足了这个空白。
Tip
虽然共享指针和弱指针现在有了 std::atomic
的偏特化,但是这也只是代表一些操作是原子的,不代表操作是无锁的。
自旋锁
Test-and-set 和 compare-and-swap 辨析
Test-and-set 和 compare-and-swap 都是无锁编程中非常重要的操作。在很多硬件上会直接提供相关指令。
- 前者针对 bool 类型,后者可以是其他类型。前者的写入是受限的,只能写 true,后者的写入可以指定值。
- 前者总会写入,后者是条件满足时才写入。
- 前者和后者都总能得到旧值。
在表现上,test-and-set 更像受限的 exchange,没有 compare 的过程。但从功能角度 TAS 既是 exchange 的子集,又是 CAS 的子集。也就是说,自旋锁可以用 TAS 实现,可以用 CAS 实现,还可以用原子的 exchange 实现。
Tip
在 libatomic 的 gexch.c (同样,可以参考
5.0.1 GCC7 std atomic is_lock_free 的变化)中对 exchange 的实现是:如果硬件有 exchange 支持且自然对齐,则用 exchange;否则尝试放到更大的一个范围中用 CAS;否则用 libat_lock_n
和 libat_unlock_n
等复杂的操作)。
如何用 CAS 实现 TAS?将 expect
设置为 0,desired
设置为 1,操作后:
expect
一定能得到旧值(前面说过)。- 条件满足时(旧值为 0)则写入 1,不满足则不写入(这时旧值必为 1,因为 bool 只有 0/1 两种状态),这样在结果上和无条件写入 1 等价。
- 除了
expect
之外,还能用返回值获取旧值。返回值用来表示是否交换成功(旧值是否为 0),对其取反就是旧值。
自旋锁的几种实现
std::atomic_flag
的 test_and_set
std::atomic_flag
是 C++ 唯一保证无锁的原子类型,但是在 C++20 之前没有 test()
方法,不能非破坏性地读取原来的值。我在网上看用 std::atomic_flag
来实现自旋锁的教程反而不如 std::atomic<bool>
多 ,还看到过 不提倡使用 std::atomic_flag
的观点,理由之一是该类型被标准规定无论如何都要无锁,在无锁难以实现时其操作代价极高,还不如直接加锁。
std::atomic<T>
的 exchange
用 std::atomic<bool>
的 exchange
操作实现自旋锁,这个 网页 讲解了更多技巧,包含:
- 正确选择内存序。
- test and test-and-set (TTAS) 技巧:如果 exchange 失败,那么不断 load 直到发现自旋锁未被锁定。这有助于减少写入,从而减少 CPU 维护多核缓存一致性的开销。
- 添加 pause 指令来减少资源消耗,这对支持 SMT 的逻辑处理器也有利。
感觉这一个实现方法是我看过的教程里面比较多的。
std::atomic<T>
的 compare_exchange_weak
实现无锁队列很可能会用 std::atomic<T*>
的 compare_exchange_weak
。但是用 std::atomic<bool>
来实现自旋锁,网上看到的例子就比较少,或许是因为绝大多数硬件都直接提供 exchange
指令,而在自旋锁上锁的场景中 compare 过程可能是不必要的。
但是在 libstdc++ 的 shared_ptr_atomic
实现 里有个比较有意思的点,就是引用计数值的 LSB 用来充当锁标志,这个时候的操作不是一次性读写,而是包含几个步骤,就必须 CAS 而不是 exchange
。可能因为循环中的操作代价高,需要规避假性失败,用的是 compare_exchange_strong
而不是 compare_exchange_weak
。
要不要使用自旋锁?
https://www.reddit.com/r/cpp/comments/g84bzv/correctly_implementing_a_spinlock_in_c/
Reddit 上有人说一般方法在用户空间实现自旋锁的效果基本上 不如 std::mutex
好。还有观点认为 自旋锁适合用在没有抢占性上下文切换的环境 下(比如中断处理器)。很容易想到的一个情况是某个线程持有锁但因为时间片用完进入了休眠状态,导致等待锁的线程即便获得了时间片也不能继续运行,浪费了大量 CPU 时间。
数据库等应用中通常会使用杂交的自旋锁,即自旋一定次数之后如果没能获得锁,则使用操作系统的锁。