目录
本文主要介绍C++新标准中的内存模型和无锁编程的原理和实现
无锁队列在多线程编程中是一个重要的概念,它指的是在不依赖传统互斥锁(如mutex)的情况下,通过原子操作来保证线程安全的队列数据结构。这样的设计可以提高并发性能,尤其是在高竞争的场景下,因为它们减少了线程间的等待时间。
无锁队列通常利用了现代处理器提供的原子操作来实现线程安全,比如比较并交换(Compare and Swap, CAS)操作。
优点
- 高并发性能:由于不需要锁,所以在多线程环境下可以减少线程之间的阻塞,从而提高系统的整体吞吐量。
- 减少上下文切换:避免了频繁的线程挂起和恢复,这有助于减少CPU开销。
缺点
- 实现复杂:无锁算法通常比加锁版本更难理解和实现。
- CPU资源消耗:如果线程间的竞争过于激烈,可能会导致大量的CAS失败,进而引起忙等(spin),消耗不必要的CPU周期。
- ABA问题:即在两次检查之间,值A可能被修改成了B然后再改回A的情况。这可以通过使用版本号或者标记指针等方式解决。
总的来说,无锁队列通过牺牲一定的实现复杂度换取了更高的并发性能,在某些特定的应用场景下是非常有用的工具。然而,它的使用需要谨慎考虑具体的上下文环境以及对系统性能的影响。
基本原理
无锁队列的核心目标是允许多个线程并发地对队列进行入队(enqueue)和出队(dequeue)操作,而不依赖互斥锁。它的实现基于以下关键原理:
无锁并发模型
无锁(Lock-Free):保证系统中至少有一个线程能够继续执行,而不会被其他线程的操作永久阻塞。这是无锁编程的核心特性,区别于基于锁的阻塞机制。
无等待(Wait-Free):更强的保证,要求每个线程的操作都在有限时间内完成。无锁队列通常只满足“无锁”条件,而非“无等待”。
线程安全:通过硬件支持的原子操作,确保多个线程同时操作队列时不会破坏数据结构的一致性。
原子操作
无锁队列依赖C++11引入的std::atomic模板提供的原子操作,这些操作由硬件直接支持,具有不可分割性。常见原子操作包括:
Load/Store:原子地读取或写入变量(如std::atomic::load、store)。
Compare-And-Swap (CAS):比较并交换,检查变量是否为预期值,若是则更新为新值(如std::atomic::compare_exchange_strong)。
Fetch-And-Add (FAA):原子地获取当前值并增加(如std::atomic::fetch_add)。
Test-And-Set:设置标志并返回旧值(较少用于队列)。
这些操作确保多线程操作时,关键数据(如队列的头尾指针)不会出现中间状态。
内存序(Memory Ordering)
C++内存模型定义了多线程访问共享内存时的行为,std::memory_order枚举控制原子操作的同步方式:
std::memory_order_relaxed:无同步保证,仅保证原子性,适合本地操作。
std::memory_order_acquire:确保后续内存操作不会重排到该操作之前,适合消费者读取。
std::memory_order_release:确保之前内存操作不会重排到该操作之后,适合生产者写入。
std::memory_order_acq_rel:结合acquire和release,用于读改写操作。
std::memory_order_seq_cst:最强的顺序一致性,保证所有线程看到一致的操作顺序,但性能开销较大。
内存序的选择直接影响无锁队列的正确性和性能。无锁队列通常使用acquire和release来确保生产者和消费者之间的同步。
实现原理
无锁队列的实现依赖于原子操作和内存序,确保线程安全的同时避免锁竞争。以下以单生产者单消费者(SPSC)队列和多生产者多消费者(MPMC)队列为例,说明实现原理。
SPSC无锁队列
SPSC队列假设只有一个生产者线程执行入队操作,一个消费者线程执行出队操作。这种场景简化了并发控制,核心原理如下:
- 环形缓冲区:使用固定大小的数组,head(消费者读取位置)和tail(生产者写入位置)通过原子变量管理。
- 入队操作
- 生产者读取当前tail(std::atomic::load)。
- 检查队列是否满(tail + 1 == head)。
- 若不满,写入数据到buffer[tail],然后原子更新tail(std::atomic::store)。
- 出队操作:
- 消费者读取当前head(std::atomic::load)。
- 检查队列是否空(head == tail)。
- 若不空,读取buffer[head],然后原子更新head(std::atomic::store)。
- 内存序:
- 使用acquire内存序读取head或tail,确保看到其他线程的最新状态。
- 使用release内存序写入head或tail,确保数据写入对其他线程可见。
- 优势:SPSC队列简单高效,避免了CAS循环的竞争开销。
MPMC无锁队列
MPMC队列允许多个生产者和消费者并发操作,复杂度更高,核心原理如下:
- 链表结构:使用单向链表,每个节点包含数据和指向下一个节点的原子指针(std::atomic<Node*>)。
- 入队操作:
- 创建新节点,准备插入链表尾部。
- 使用CAS操作将新节点链接到当前tail的next指针。
- 使用CAS更新tail指向新节点。
- 如果CAS失败(其他线程已修改),重试操作。
- 出队操作:
- 读取当前head和其next节点。
- 如果next为空,队列为空,返回失败。
- 使用CAS将head更新为next节点,获取数据并释放旧节点。
- 如果CAS失败,重试。
- 内存序:通常使用acquire和release确保数据一致性,CAS操作可能需要acq_rel。
- 挑战:需要处理ABA问题(见下文)。
ABA问题
ABA问题是无锁编程中的经典问题,特别是在基于链表的MPMC队列中:
- 定义:线程A读取指针值为X,准备通过CAS更新为Y;在此期间,另一线程将指针从X改为Z再改回X;线程A的CAS成功,但未察觉中间变化,导致逻辑错误。
- 示例:
- 队列的head指向节点A。
- 线程1读取head == A,准备出队。
- 线程2出队A,添加新节点B,释放A,再添加新节点C并重用A的内存。
- 线程1的CAS认为head仍为A,成功更新,但逻辑错误(未处理B和C)。
- 解决方法:
- 版本计数器:为每个节点添加版本号(如std::atomic<uint64_t>),每次更新时递增,CAS同时比较指针和版本号。
- 双字CAS(Double-Word CAS):如std::atomic::compare_exchange_strong支持的64位或128位原子操作。
- 内存管理:避免重用已释放的节点(如使用内存池或延迟回收)。
- C++20的std::hazard_pointer:提供危险指针机制,防止ABA问题(需支持的编译器)。
数据结构
无锁队列通常基于以下两种数据结构:
环形缓冲区(Circular Buffer):固定大小的数组,适合单生产者单消费者(SPSC)队列,性能高但容量有限。
单向链表:动态分配节点,适合多生产者多消费者(MPMC)队列,支持动态扩展但复杂度较高。
概念理解
happens-before
在并发编程中,"happens-before"是一个非常重要的概念,它描述了内存操作之间的偏序关系。
"happens-before"关系定义了两个操作之间的可能的交错情况。如果操作A happens-before操作B,那么在所有的可观察行为中,操作A的结果都将对操作B可见,换句话说,操作B可以看到操作A的结果。
这个概念对于理解和设计多线程程序的正确性是非常关键的,因为它能够帮助我们理解在没有适当同步的情况下,哪些操作序列可能会导致不确定的行为。
例如,如果一个线程在没有同步的情况下写入一个变量,然后另一个线程读取这个变量,那么读操作可能看不到写操作的结果,因为写操作并没有happens-before读操作。
为了建立"happens-before"关系,通常可以使用一些同步机制,如锁、volatile变量或者原子操作等。
synchronizes-with
"synchronizes-with"是一个特殊的"happens-before关系,它描述了不同线程间的操作如何通过同步来建立偏序关系。
具体来说,当一个线程释放一个锁,然后另一个线程获得了这个锁,我们就说第一个线程的锁释放操作"synchronizes-with"第二个线程的锁获取操作。
这意味着第一个线程在释放锁之前的所有操作,在第二个线程看来,都发生在它获得锁之前。这就建立了一个跨线程的happens-before关系,确保了在没有适当同步的情况下,不会出现不确定的行为。
"synchronizes-with"关系是建立并发程序正确性的一个重要工具,它能够帮助我们理解和设计正确的多线程程序。
内存模型
通常是一个硬件上的概念,表示的是机器指令是以什么样的顺序被处理器执行的
共有六种内存顺序:memory_order_relaxed, memory_order_consume, memory_order_acquire,
memory_order_release, memory_order_acq_rel, memory_order_seq_cst.,默认为memory_order_seq_cst。分为三种模型
顺序一致排序
内存模型:memory_order_seq_cst
完全顺序一致性(Sequential Consistency)是一种最强的内存模型,要求所有操作都必须按照它们在程序中出现的顺序来执行,它符合我们日常的思维习惯,是最直观和易于理解的。然而,这种严格的顺序要求也带来了显著的性能开销。在多处理器系统中,为了维持顺序一致性,所有的处理器需要进行全局同步。也就是说,一个处理器上的操作必须在其他处理器上被“看到”之后,才能执行下一个操作。这可能需要处理器之间进行大量的、耗时的通信。因此,尽管顺序一致性易于理解,但在实际应用中,由于性能开销大,往往不会被首选。
自由序列
内存模型:memory_order_relaxed
在单一线程内仍然遵从happens-before关系。在一个线程对某一个原子变量的访问不能重排序。
void write_x_then_y() {
x.store(true, std::memory_order_relaxed);
y.store(true, std::memory_order_relaxed);
}
void read_y_then_x() {
while (!y.load(std::memory_order_relaxed));
if (x.load(std::memory_order_relaxed))
++z;
}
void memory_relexed_test() {
x = false;
y = false;
z = 0;
std::thread a(write_x_then_y);
std::thread b(read_y_then_x);
a.join();
b.join();
assert(z.load() != 0); // 很有可能z.load() == 0
}
在单个线程中,std::memory_order_relaxed不会改变数据依赖关系的顺序。也就是说,如果一个操作的结果依赖于另一个操作,那么这两个操作的顺序是不能改变的。然而,对于没有数据依赖关系的操作,编译器和处理器仍然可以对其进行重新排序。
对不同变量的 relaxed 操作可以被自由地重排,只要他们遵守任何他们被约束的“happens-before”(先行发生)关系。例如,同一线程内的操作必须遵守它们发生的先后顺序。然而,这些“松散”操作并不引入“synchronizes-with”(同步于)关系,这意味着它们不会强制要求其他线程看到的操作顺序与它们的执行顺序一致。所以,它的操作可能是以下这样的:

一个形象比喻:
原子变量就是一个小房间中的人,他会记录一系列值,线程就要求这个人修改值或者向他询问这个值是多少的电话。write_x_then_y就是打电话分别告诉x和y这两个人将值修改为true,然后另外一个电话(另一个线程)read_y_then_x首先询问y的值,一直到y告诉他为true,这时他再询问x的值,但x完全可以不告诉他前面的人更新后的值(这就是relaxed的特点),而是告诉他之前的值false。这样z就完全有可能是0。
所以,如果想要在不引入完全顺序一致性的开销的情况下实现额外的同步,可以使用获取-释放(acquire-release)排序。
获取-释放序列
内存模型:memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel
相比较完全顺序一致性(Sequential Consistency),获取-释放排序是一种更为轻量级的内存模型,只要求某些特定的操作(例如锁的获取和释放)必须按照顺序来执行,而其他操作则可以被自由地重排。这种模型可以在保证足够的同步性的同时,减少性能开销。相比较 relaxed 顺序,又是一种进步。
在这种顺序模型下,原子加载是获取操作(memory_order_acquire), 原子存储是释放操作 (memory_order_release),read-modify-write操作时acquire,release或者两者都有(memory_order_acq_rel)。同步是在线程之间release和acquire成对出现的。一个release的写操作会同步给acquire的读操作。这意味着不同的线程仍然可以看到不同的顺序,但这个顺序是被限制的。简单来说,就是在并发编程中,获取-释放操作是成对出现的,一个线程释放(写入)一个值,另一个线程获取(读取)这个值,这两个操作之间存在“同步-发生”关系。虽然不同线程可以看到操作的不同顺序,但这些顺序是受获取-释放规则限制的,不能随意改变。这样可以在一定程度上保证程序的正确性,同时避免了完全顺序一致性带来的性能开销。
示例代码1:
std::atomic<bool> x, y;
std::atomic<int> z;
void write_x() {
x.store(true, std::memory_order_release);
}
void write_y() {
y.store(true, std::memory_order_release);
}
void read_x_then_y() {
while (!x.load(std::memory_order_acquire));
if (y.load(std::memory_order_acquire)) {
++z;
}
}
void read_y_then_x() {
while (!y.load(std::memory_order_acquire));
if (x.load(std::memory_order_acquire)) {
++z;
}
}
void main() {
x = false;
y = false;
z = 0;
std::thread a(write_x);
std::thread b(write_y);
std::thread c(read_x_then_y);
std::thread d(read_y_then_x);
a.join();
b.join();
c.join();
d.join();
assert(z.load() != 0);
}
这段代码有可能z.load() != 0,因为x和y之间没有happens-before的关系。(x86 linux和win自验证上自验证ok,有可能arm可能会有问题)

需要在一个线程中设置x和y的值,如下:
void write_x_then_y() {
x.store(true,std::memory_order_relaxed);
y.store(true,std::memory_order_release);
}
void read_y_then_x() {
while(!y.load(std::memory_order_acquire));
if(x.load(std::memory_order_relaxed))
++z;
}
int main() {
x=false;
y=false;
z=0;
std::thread a(write_x_then_y);
std::thread b(read_y_then_x);
a.join();
b.join();
assert(z.load()!=0);
}
lock-free数据结构
volatile:用来提供过度优化,既不保证不可切割,也不保证次序
terminate函数在默认情况下是去调用abort函数的,不过用户可以通过set_terminate函数来改变默认行为
abort函数更加底层,abort不会调用任何析构
exit属于正常退出,会调用自动变量的析构函数,并且还会调用atexit注册的函数,这和main函数结束时的清理工作是一样的
C++11标准引入快速退出:quick_exit,at_quick_exit
无锁队列的实现思路
-
节点结构:队列中的每个元素通常被包装成一个节点,节点除了存储元素值外,还需要维护指向下一个节点的指针。
-
头尾指针:队列通常会维护两个指针,一个是头指针(指向队列的第一个元素),另一个是尾指针(指向队列的最后一个元素)。
-
入队操作:新节点加入队列时,首先创建一个新的节点,并将其链接到当前队尾之后,然后更新尾指针指向新节点。为了保证线程安全,这些更新操作需要使用CAS等原子指令来完成。
-
出队操作:移除队列前端的元素时,读取头指针的值,然后使用CAS尝试更新头指针到当前头节点的下一个节点。如果CAS成功,则说明当前线程获得了队首元素的所有权,并可以从队列中移除该元素。
-
循环检测:由于CAS可能失败(如果有其他线程也在尝试修改相同的内存位置),因此需要在失败时重试,直到成功为止。
本文详细介绍了C++新标准中的内存模型,包括happens-before和synchronizes-with的概念,以及顺序一致、自由序列和获取-释放等内存顺序模式。此外,还探讨了lock-free数据结构和volatile关键字在并发编程中的作用。
763

被折叠的 条评论
为什么被折叠?



