Some Multi Thread
背景
早在单核时代,为了充分利用硬件的多发射和乱序执行能力,编译器就可能在编译期调整指令顺序。编译器重排与硬件乱序执行叠加后,硬件的实际执行顺序可能与源代码中的顺序存在偏差。不过,这些调整都遵循一个基本原则:从单线程的可观察行为来看,最终执行效果应当与顺序执行一致。
例如,对于下面的代码,如果两次写入之间不存在依赖关系,那么无论先写入 a 还是先写入 b,都不应影响后续指令的正确性。
a = 8;
b = 9;
进入并发环境后,上述原则可以描述为:从线程自身来看,线程内代码的执行效果与顺序执行一致。编译器和硬件的优化都需要遵循这一原则,但这也从另一个角度说明:不同线程观察到的执行效果可能并不一致。
下面是一个经典示例。按照通常的直觉,我们可能认为:如果 R0 == 0,那么 Thread 0 应当已经执行完毕,而 Thread 1 还没有开始执行,因此 R1 一定等于 1。然而,在特定环境中,R0 == 0 且 R1 == 0 的结果也可能出现。如果程序逻辑依赖“这两个值不可能同时为 0”的假设,就可能产生错误。
std::atomic<int> A{0}, B{0};
int R0, R1;
// Thread 0
A.store(1, std::memory_order_relaxed);
R0 = B.load(std::memory_order_relaxed);
// Thread 1
B.store(2, std::memory_order_relaxed);
R1 = A.load(std::memory_order_relaxed);
为了避免此类错误,我们需要了解 Memory Model(内存模型)。它是一组针对多线程环境下内存相关操作的定义与约束,例如如何描述内存操作的影响,以及这些操作的可观测性和原子性。其中比较重要的有:
- Atomicity(原子性):一次内存访问是否不可分割?多条指令能否构成一个原子操作?
- Coherence(一致性):不同线程如何观察同一内存位置上的修改?
- Ordering(内存排序):Load/Store 是否可能相对于彼此重排序?每个核心观察到的 Load/Store 需要符合哪些规律?
- 哪些硬件指令会影响上述三个方面的行为?
C++ 定义的 Memory Model 引入了 sequenced-before、happens-before、synchronizes-with、release、acquire、relaxed 等大量抽象概念,心智负担较重。
在一般开发中,建议优先使用 mutex 和默认内存序(seq_cst)的 atomic。在场景明确时,也可以使用 relaxed,但应尽量避免自行管理复杂的内存排序关系。
在对性能要求极高时,也可以参考对应硬件的手册。直接理解硬件指令的心智负担有时反而更小,因为硬件提供的指令通常更少,语义也更具体,理解起来可能更直接。
In Action
至此,我们已经介绍了足够多的基础概念。接下来看看它们可以解决哪些问题。
Lock-free(Lockless)
如果一个系统不会因为某个线程停止执行而永久卡死,其他线程仍然能够持续取得进展,那么这个系统就可以称为 lock-free。
Lock-free 是系统级的进展保证,只要求整个系统持续取得进展。更强的约束是 wait-free,它要求每个线程的操作都能在有限步骤内完成。一般而言,为了满足 wait-free 的要求,需要设计相当复杂的代码逻辑,因此采用这一约束的实现相对较少。
假设存在一个外部攻击者,通过某种手段让一个线程在获得 mutex 后永久阻塞,那么其他线程就再也无法获得这把锁,整个系统也可能永久卡住。这说明,使用 mutex 的算法并不具备 lock-free 属性。
另一方面,即使使用原子操作,如果代码逻辑不够严谨,也可能导致系统永久卡死,此时系统同样不能称为 lock-free。
此外,lock-free 实现经常需要使用 CAS loop。如果对CAS Loop的应用不够严谨,可能退化为类似 spin lock 的形式,相当于自己实现了一把 mutex,意义并不大。
你真的需要使用 lock-free 吗?可以先阅读 An Introduction to Lock-Free Programming 和 Locks Aren't Slow; Lock Contention Is。
Gossip of Mutex
“mutex 性能很差”的印象,在一定程度上是历史原因造成的。过去的部分 mutex 实现属于重量级的 kernel-space mutex,加锁可能需要进入内核,因此开销较大。
如今的 mutex 通常只有在 lock 竞争失败时,才可能需要系统调用。在锁竞争较弱时,系统调用的频率应当很低。例如,在理想情况下,一个线程可以直接在 user space 完成加锁,无须调用 wait;后续释放锁时,如果没有等待者,也无须调用 wake。因此,大部分操作可以完全不涉及系统调用。详情可参考 Futex 介绍。
参考相关 benchmark 后,一般可以认为:当 lock contention 不强烈时,mutex 实际上是一种性能良好且易于使用的工具。
Others
ABA
如果一个值从 A 变为 B,又从 B 变回 A,那么它究竟有没有发生过变化?在 lock-free 编程中,这类问题很容易出现,需要通过正确的算法设计加以处理。
Use CAS to Implement Any Atomic Operation
参考:You Can Do Any Kind of Atomic Read-Modify-Write Operation。
DCLP
Double-Checked Locking Pattern(DCLP)是一个经典问题,互联网上已有很多讨论。
不过,我们先讨论另一个重要特性:C++11 之后,局部 static 对象的初始化是线程安全的,并且能够保证正确的同步关系。下面的代码展示了这一特性。
MyType* Instance() {
static MyType obj;
return &obj;
}
上述代码是 C++11 之后实现单例模式的标准做法。巧合的是,大多数编译器正是通过类似 DCLP 的策略,来兼顾这段代码的线程安全性与性能。
下面以单例模式为例,介绍 DCLP 的逻辑。
std::atomic<Singleton*> Singleton::m_instance;
Singleton* Singleton::getInstance() {
Singleton* tmp = m_instance;
if (tmp == NULL) {
Lock lock;
tmp = m_instance;
if (tmp == NULL) {
tmp = new Singleton;
m_instance = tmp;
}
}
return tmp;
}
上面的代码使用默认的 seq_cst 内存序。在 C++ 内存模型下,这可能带来一些额外的性能开销。下面这个 DCLP 版本使用更精确的内存序来描述实际需要的约束:
std::atomic<Singleton*> Singleton::m_instance;
std::mutex Singleton::m_mutex;
Singleton* Singleton::getInstance() {
Singleton* tmp = m_instance.load(std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_acquire);
if (tmp == nullptr) {
std::lock_guard<std::mutex> lock(m_mutex);
tmp = m_instance.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton;
std::atomic_thread_fence(std::memory_order_release);
m_instance.store(tmp, std::memory_order_relaxed);
}
}
return tmp;
}
Barrier(Latch)
Barrier 用于确保 K 个线程都能到达某个同步点,这在并行计算中很常见(例如 CUDA 的 __syncthreads())。C++20 提供了标准的 std::barrier 来实现这一功能,std::latch 也能实现类似的一次性同步功能。
经验谈
- 使用的工具越高级,将来引入的 bug 就可能越复杂。
- 例如,应当优先使用标准库的 atomic,而不应当自行编写 lock-free 代码。
std::mutex的lock/unlock应尽量通过std::unique_lock、std::lock_guard等工具管理。- 为了避免不必要的麻烦,尽可能不要在多线程环境中使用
fork和signal。 - 为了避免麻烦,尽量通过正常的函数返回来结束线程。