随着多核处理器的普及,高效的并发编程变得至关重要。C++11及更高版本引入了原子操作、内存序和线程支持库,为开发者提供了构建高性能无锁并发程序的工具。
本指南将深入探讨原子操作的原理、不同内存序模型的特性以及如何避免伪共享等常见性能陷阱,帮助您掌握现代C++并发编程的核心概念。
原子操作是在多线程环境中不会被线程调度机制中断的操作单元。它们具有三个关键特性:
与传统锁机制相比,原子操作通常能提供更好的性能,特别是在争用较低的情况下,因为它们避免了线程阻塞和上下文切换的开销。
原子操作保证操作的完整性,而非原子操作在多线程环境中可能导致数据竞争
C++11引入了std::atomic<T>模板类,提供了一系列针对并发编程的原子操作。这个模板适用于任何平凡可复制类型,包括内置类型和某些用户定义类型。
// 原子类型基本用法
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> counter(0); // 初始化为0的原子整型
void increment_counter() {
for (int i = 0; i < 1000; ++i) {
counter++; // 原子自增操作
}
}
int main() {
std::thread t1(increment_counter);
std::thread t2(increment_counter);
t1.join();
t2.join();
std::cout << "Counter value: " << counter.load() << std::endl; // 总是输出2000
return 0;
}
load():原子地获取存储的值store():原子地替换存储的值exchange():原子地替换并返回之前的值compare_exchange_weak/strong():比较并交换操作fetch_add()/fetch_sub():原子加/减并返回之前的值++/--操作符:原子自增/自减is_lock_free():检查原子操作是否无锁实现在多处理器系统中,不同CPU核心可能会以不同于程序中指定的顺序执行指令(乱序执行)。内存序定义了原子操作之间以及原子操作与非原子操作之间的可见性和顺序约束。
C++提供了六种内存序选项,允许开发者在性能和安全保证之间做出权衡。选择正确的内存序对于确保程序正确性和优化性能至关重要。
memory_order_relaxed
仅保证原子性,不提供同步或顺序保证。只能保证该原子变量自身的修改顺序一致性。
memory_order_acquire, memory_order_release, memory_order_acq_rel
提供单向同步,确保release操作之前的所有内存写入对执行acquire操作的线程可见。
memory_order_seq_cst
最严格的内存序,提供全局一致的顺序。所有操作看起来都按照程序指定的顺序执行。是原子操作的默认内存序。
内存序强度从左到右增加,同时性能开销也随之增加
最高性能,最少保证
平衡的性能与同步
强一致性,较低性能
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<bool> x, y;
std::atomic<int> z;
// 松散序示例 - 不保证操作顺序
void relaxed_example() {
x.store(true, std::memory_order_relaxed);
y.store(true, std::memory_order_relaxed);
}
// 获取-释放序示例 - 建立同步点
void release_example() {
x.store(true, std::memory_order_relaxed); // 1. 先设置x
z.store(42, std::memory_order_release); // 2. 释放z,确保x的写入可见
}
void acquire_example() {
if (z.load(std::memory_order_acquire) == 42) { // 3. 获取z
// 如果z是42,那么x的写入在这里一定可见
if (x.load(std::memory_order_relaxed)) { // 4. 一定能看到x的变化
// 这里会执行
}
}
}
// 顺序一致性示例 - 全局一致顺序
void sequential_example() {
x.store(true, std::memory_order_seq_cst); // 全局可见的写入
y.store(true, std::memory_order_seq_cst); // 全局可见的写入
}
除了针对单个原子操作的内存序外,C++还提供了atomic_thread_fence函数,它在没有实际读写操作的情况下创建内存屏障:
// 在当前线程中建立acquire内存栅栏
std::atomic_thread_fence(std::memory_order_acquire);
// 在当前线程中建立release内存栅栏
std::atomic_thread_fence(std::memory_order_release);
无锁队列是一种不使用互斥锁,而是依赖原子操作和精心设计的算法来实现线程安全的数据结构。它们主要有以下优势:
无锁队列常见于高性能计算、实时系统、游戏引擎和金融交易系统等对延迟敏感的应用领域。
// 无锁队列实现片段
#include <atomic>
#include <memory>
template<typename T>
class LockFreeQueue {
private:
struct Node {
std::shared_ptr<T> data;
std::atomic<Node*> next;
Node() : next(nullptr) {}
};
std::atomic<Node*> head;
std::atomic<Node*> tail;
public:
LockFreeQueue() {
// 初始化一个哨兵节点
Node* dummy = new Node;
head.store(dummy);
tail.store(dummy);
}
// 入队操作
void enqueue(T value) {
// 创建一个装有数据的新节点
std::shared_ptr<T> new_data(std::make_shared<T>(std::move(value)));
Node* new_node = new Node;
new_node->data = new_data;
// 获取当前尾指针
Node* old_tail = tail.load();
Node* nullptr_node = nullptr;
// 循环直到成功将新节点链接到队列
while (!old_tail->next.compare_exchange_weak(
nullptr_node, new_node,
std::memory_order_release,
std::memory_order_relaxed)) {
// 如果CAS失败,更新tail和重置nullptr_node
old_tail = tail.load();
nullptr_node = nullptr;
}
// 尝试更新尾指针
tail.compare_exchange_strong(old_tail, new_node);
}
// 出队操作
bool dequeue(T& result) {
Node* old_head = head.load();
// 循环直到成功弹出节点或队列为空
while (old_head != tail.load()) {
Node* next = old_head->next.load();
if (next == nullptr) {
// 队列为空
return false;
}
// 尝试更新头指针
if (head.compare_exchange_strong(old_head, next)) {
// 成功更新头指针,取出值
result = *(next->data);
delete old_head; // 释放旧的头节点
return true;
}
}
// 队列为空
return false;
}
// 析构函数和其他必要方法省略...
};
CAS是无锁编程的核心原语,通过compare_exchange_weak/strong方法实现。它在更新值之前先检查该值是否与预期相同,避免竞争条件。
当一个值从A变为B再变回A,CAS操作会认为值没变,但实际上可能有重要的中间状态。解决方案包括使用带版本号的指针或风险指针。
无锁队列通过原子更新头尾指针,并使用CAS操作确保线程安全
根据实际需求选择适当的内存序,避免过度同步带来的性能损失。
使用适当的内存回收技术,如风险指针或询问计数,防止内存泄漏和UAF问题。
使用压力测试和竞争检测工具验证实现的正确性和性能。
考虑不同硬件架构和编译器对内存模型的支持差异。
伪共享是现代多核处理器中一种常见但隐蔽的性能杀手,源于CPU缓存系统的工作方式。当多个线程修改同一缓存行(Cache Line)中的不同变量时,会导致严重的缓存一致性流量,进而严重降低并发性能。
缓存行是CPU缓存中的最小操作单位,通常为64字节。处理器不会直接读写单个字节,而是一次性加载或写回整个缓存行。
这种机制在单线程环境下可提高数据访问性能,但在多线程环境中可能导致问题。
当多个线程频繁修改位于同一缓存行的不同变量时,尽管它们操作的是不同数据,但因为共享缓存行,每次修改都会导致整个缓存行在所有CPU核心间同步。
当两个线程频繁修改同一缓存行中的不同变量时,会导致大量的缓存同步通信
// 避免伪共享的C++实现
#include <atomic>
// 使用填充技术避免伪共享
struct alignas(64) PaddedCounter {
std::atomic<int> value;
char padding[64 - sizeof(std::atomic<int>)];
PaddedCounter() : value(0) {}
void increment() {
value.fetch_add(1, std::memory_order_relaxed);
}
int get() const {
return value.load(std::memory_order_relaxed);
}
};
// C++17 以后可以使用硬件破坏缓存行
struct alignas(64) ModernPaddedCounter {
std::atomic<int> value;
ModernPaddedCounter() : value(0) {}
void increment() {
value.fetch_add(1, std::memory_order_relaxed);
}
int get() const {
return value.load(std::memory_order_relaxed);
}
};
// 使用数组时避免伪共享的示例
constexpr size_t CACHE_LINE_SIZE = 64;
constexpr size_t COUNTER_COUNT = 8;
// 不好的实现 - 可能导致伪共享
std::atomic<int> bad_counters[COUNTER_COUNT];
// 好的实现 - 避免伪共享
alignas(CACHE_LINE_SIZE) std::atomic<int> good_counters[COUNTER_COUNT][CACHE_LINE_SIZE/sizeof(std::atomic<int>)];
性能较低,伪共享严重
性能明显提升
性能最佳
使用Intel VTune、AMD CodeAnalyst等工具监测缓存一致性事件
比较使用填充技术前后的性能差异
监视缓存不命中率和缓存一致性协议事件
按访问模式组织数据,将经常同时修改的数据放在一起
使用alignas或特定的编译器指令确保缓存行对齐
为每个线程分配独立的数据块,避免同时访问相邻内存区域
下面的演示展示了伪共享如何影响并发性能。点击按钮开始模拟并观察不同情况下的性能差异。
-
-
注意:此演示只是模拟效果,实际性能差异在真实硬件上会更为显著。
我们已经深入探讨了C++并发编程中的关键概念:原子操作、内存序、无锁队列以及伪共享。掌握这些概念对开发高性能并发系统至关重要。
现代C++并发编程是一个深入且复杂的领域,需要同时理解底层硬件架构和高级语言抽象。通过持续学习和实践,你将能够开发出既正确又高效的并发系统。