1. 多线程同步的核心挑战
当我们在C++中编写多线程程序时,最头疼的问题莫过于数据竞争和竞态条件。想象一下,多个线程同时访问同一个银行账户进行取款操作,如果没有适当的同步机制,账户余额可能会被错误地更新。这就是为什么我们需要深入理解同步原理。
在单核CPU时代,线程切换是显式的,程序员可以相对容易地控制执行顺序。但现代多核处理器让事情变得复杂 - 线程真正并行执行,内存访问顺序变得不可预测。我曾在一个金融项目中遇到过这样的bug:两个线程同时更新交易记录,导致总金额计算错误,直到对账时才发现问题。
2. 内存模型基础
2.1 顺序一致性模型
这是最直观的内存模型,所有线程看到的操作顺序都一致。但实际上,现代CPU为了性能优化,会进行指令重排。比如:
cpp复制// 线程1
x = 1;
ready = true;
// 线程2
while(!ready);
assert(x == 1); // 可能失败!
即使代码顺序如此,编译器或CPU可能会重排这两条指令。这就是为什么我们需要内存屏障。
2.2 happens-before关系
C++11引入了严格的内存模型,定义了操作间的happens-before关系。如果A happens-before B,那么A对内存的修改对B可见。同步原语就是用来建立这种关系的工具。
3. 同步原语详解
3.1 mutex的底层实现
一个典型的mutex实现包含:
- 原子标志位(表示锁状态)
- 等待队列
- 内存屏障指令
cpp复制class SimpleMutex {
std::atomic<bool> locked{false};
public:
void lock() {
while(locked.exchange(true)) // 原子交换
; // 自旋等待
}
void unlock() {
locked.store(false);
}
};
注意:实际实现更复杂,需要考虑线程调度和系统调用
3.2 condition_variable的陷阱
条件变量必须和mutex配合使用,经典用法:
cpp复制std::mutex m;
std::condition_variable cv;
bool ready = false;
// 等待线程
{
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, []{return ready;});
}
// 通知线程
{
std::lock_guard<std::mutex> lk(m);
ready = true;
cv.notify_one();
}
常见错误:
- 不加锁修改条件变量
- 虚假唤醒(总是用谓词检查)
- 通知时未持有锁(可能丢失通知)
3.3 原子操作的代价
原子操作比普通操作慢得多,典型对比:
- 普通加载:~1 cycle
- 原子加载:~10-100 cycles
- 带内存序的原子操作:可能触发缓存一致性协议
4. 内存序深度解析
4.1 六种内存序
C++提供了六种内存序,从强到弱:
- memory_order_seq_cst
- memory_order_acq_rel
- memory_order_release
- memory_order_acquire
- memory_order_consume
- memory_order_relaxed
4.2 典型使用模式
cpp复制// 发布-订阅模式
std::atomic<int*> data;
std::atomic<bool> ready{false};
// 生产者
int* p = new int(42);
data.store(p, std::memory_order_release);
ready.store(true, std::memory_order_release);
// 消费者
while(!ready.load(std::memory_order_acquire));
int* p = data.load(std::memory_order_acquire);
5. 无锁编程实践
5.1 CAS操作模式
比较并交换(Compare-And-Swap)是无锁编程的基础:
cpp复制template<typename T>
class LockFreeStack {
struct Node {
T data;
Node* next;
};
std::atomic<Node*> head;
public:
void push(const T& data) {
Node* new_node = new Node{data, nullptr};
new_node->next = head.load();
while(!head.compare_exchange_weak(new_node->next, new_node));
}
};
5.2 ABA问题解决方案
ABA问题是指值从A变B又变回A,CAS会误判没变化。解决方案:
- 使用带标记的指针(低几位做版本号)
- 垃圾回收(如RCU)
- Hazard Pointer
6. 性能优化技巧
6.1 伪共享(false sharing)
当不同CPU核心频繁修改同一缓存行的不同变量时,会导致性能急剧下降。解决方案:
cpp复制struct alignas(64) Counter { // 缓存行对齐
std::atomic<int> value;
};
Counter counters[4]; // 每个核一个
6.2 锁粒度优化
- 细粒度锁:每个数据结构单独加锁
- 读写锁:读多写少场景
- 锁分段:如ConcurrentHashMap的实现
7. 调试与测试
7.1 数据竞争检测工具
- ThreadSanitizer(TSan):
bash复制
clang++ -fsanitize=thread -g test.cpp - Helgrind(valgrind插件)
- Intel Inspector
7.2 压力测试模式
cpp复制void test_concurrent_access() {
SharedData data;
std::vector<std::thread> threads;
for(int i=0; i<10; ++i) {
threads.emplace_back([&]{
for(int j=0; j<10000; ++j) {
data.modify();
}
});
}
for(auto& t : threads) t.join();
assert(data.check_invariant());
}
8. 现代C++同步工具
8.1 std::latch和std::barrier
C++20引入的新同步原语:
cpp复制std::latch completion_latch(3); // 需要3次count_down
void worker() {
do_work();
completion_latch.count_down();
}
// 主线程
std::thread t1(worker), t2(worker), t3(worker);
completion_latch.wait(); // 等待所有worker完成
8.2 std::atomic_ref
C++20允许对非原子变量创建原子引用:
cpp复制int normal_var = 0;
void thread_func(std::atomic_ref<int> ref) {
ref.fetch_add(1);
}
std::thread t(thread_func, std::atomic_ref(normal_var));
9. 跨平台注意事项
9.1 内存屏障差异
- x86:强内存模型,普通load/store已有一定屏障效果
- ARM:弱内存模型,必须显式使用屏障指令
- PowerPC:更复杂的屏障类型
9.2 锁实现差异
Windows的CRITICAL_SECTION和Linux的pthread_mutex_t性能特征不同,特别是在争用情况下。
10. 实战经验分享
- 避免在锁内执行耗时操作(如IO)
- 锁的获取顺序要全局一致,防止死锁
- 优先使用RAII管理锁(std::lock_guard)
- 无锁编程不是银弹,复杂度高且难以调试
- 线程数不要超过CPU核心数太多(通常2x是合理上限)
我曾经遇到一个死锁案例:线程A先锁mutex1再锁mutex2,线程B相反。当两者同时执行时就会死锁。解决方案是统一锁的获取顺序。
在多线程日志系统中,我们最终采用了双缓冲+无锁队列的方案:前端线程写入内存缓冲区,后台线程定期交换缓冲区并写入磁盘。这避免了日志写入成为性能瓶颈。
