第1章:库的并发与同步——线程安全设计、锁策略、原子操作、无锁数据结构
各位同学,咱们今天聊点硬核的。库的并发与同步,说白了就是解决一个问题:多个线程同时访问同一份数据时,怎么保证不出乱子。
我刚开始写多线程程序时,觉得加锁就完事了。后来线上服务莫名其妙地卡死,查了三天才发现是死锁。嗯,从那以后我再也不敢轻视并发设计了。
1.1 线程安全设计:你的库扛得住并发吗?
一个库是线程安全的,意味着多个线程可以同时调用它的函数,而不会产生数据竞争或未定义行为。我个人习惯把线程安全分为三个层次:
- 不可重入:函数内部有全局或静态变量,不加任何保护。多线程调用必崩。
- 线程安全:通过锁或原子操作保护了共享数据。可以放心并发调用。
- 可重入:函数不依赖任何外部状态,所有数据都来自参数或栈。这是最高境界。
核心原则:设计库时,默认所有函数都可能被多线程调用。不要假设调用者会帮你加锁。
我在项目中遇到过这样一个坑:一个日志库,内部用了静态缓冲区来格式化字符串。单线程跑得好好的,一上多线程就出现日志内容错乱。原因就是缓冲区被多个线程同时写。解决方案很简单——要么每个线程用独立的缓冲区,要么加锁。
1.2 锁策略:选对锁,少踩坑
锁是并发编程中最基础的同步工具。但锁不是万能的,用不好反而会拖垮性能。
1.2.1 互斥锁(std::mutex)
最常用的锁。谁拿到锁,谁就拥有临界区的独占权。我建议用 std::lock_guard 或 std::unique_lock 来管理锁的生命周期,避免忘记解锁。
#include <mutex>
std::mutex mtx;
int shared_counter = 0;
void safe_increment() {
std::lock_guard<std::mutex> lock(mtx);
++shared_counter; // 临界区
// 锁在 lock_guard 析构时自动释放
}
1.2.2 读写锁(std::shared_mutex)
读多写少的场景,读写锁比互斥锁高效得多。多个线程可以同时读,但写操作是独占的。
#include <shared_mutex>
std::shared_mutex rw_mtx;
std::map<int, std::string> cache;
std::string read_cache(int key) {
std::shared_lock<std::shared_mutex> lock(rw_mtx);
return cache[key]; // 多个线程可以同时读
}
void write_cache(int key, const std::string& val) {
std::unique_lock<std::shared_mutex> lock(rw_mtx);
cache[key] = val; // 写操作独占
}
我的经验:读写锁不是万能的。如果写操作非常频繁,读写锁的开销可能比普通互斥锁还大。你想想看,每次写都要等所有读完成,读又要等写完成,反而增加了上下文切换。
1.2.3 自旋锁(std::atomic_flag)
自旋锁不会让线程休眠,而是忙等待。适合临界区极短(几条指令)的场景。
class SpinLock {
std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag.test_and_set(std::memory_order_acquire)) {
// 忙等待
}
}
void unlock() {
flag.clear(std::memory_order_release);
}
};
警告:我曾经在一个高并发服务里用了自旋锁,临界区稍微长了一点(几十微秒),结果 CPU 直接飙到 100%。自旋锁只适合纳秒级的临界区,否则老老实实用互斥锁。
1.3 原子操作:轻量级的同步利器
原子操作是 CPU 直接支持的不可分割的操作。比锁轻量得多,没有上下文切换的开销。
C++11 提供了 std::atomic 模板,支持整数、指针等类型的原子操作。常用的操作有:
load()/store():原子读/写fetch_add()/fetch_sub():原子加减compare_exchange_weak()/compare_exchange_strong():CAS(比较并交换)
#include <atomic>
std::atomic<int> counter{0};
void increment() {
counter.fetch_add(1, std::memory_order_relaxed);
}
// 用 CAS 实现无锁的 push(单生产者单消费者场景)
std::atomic<int> head{0};
int buffer[1024];
bool push(int val) {
int old_head = head.load(std::memory_order_relaxed);
int new_head = (old_head + 1) % 1024;
// 如果 head 没被其他线程修改,就更新
return head.compare_exchange_weak(old_head, new_head,
std::memory_order_release,
std::memory_order_relaxed);
}
这里要注意内存序(memory order)的选择。我个人习惯:
- 计数器累加用
memory_order_relaxed,不关心顺序 - 生产者-消费者场景用
memory_order_release/memory_order_acquire - 搞不清楚的时候,用
memory_order_seq_cst(最严格,但性能最差)
1.4 无锁数据结构:高性能的终极方案
无锁数据结构,说白了就是不用锁,只靠原子操作来保证线程安全。好处是:
- 不会死锁
- 没有锁竞争导致的上下文切换
- 性能通常比加锁版本高
但代价是:实现极其复杂,容易出 bug。我建议你:除非性能瓶颈明确在锁上,否则别碰无锁。
1.4.1 无锁栈(Treiber Stack)
一个经典的无锁栈实现,基于 CAS 操作:
#include <atomic>
template<typename T>
class LockFreeStack {
struct Node {
T data;
Node* next;
};
std::atomic<Node*> head{nullptr};
public:
void push(T val) {
Node* new_node = new Node{val, nullptr};
new_node->next = head.load(std::memory_order_relaxed);
// CAS:如果 head 没变,就把新节点设为 head
while (!head.compare_exchange_weak(new_node->next, new_node,
std::memory_order_release,
std::memory_order_relaxed)) {
// 失败则重试,new_node->next 会被更新为最新的 head
}
}
bool pop(T& val) {
Node* old_head = head.load(std::memory_order_relaxed);
while (old_head != nullptr) {
if (head.compare_exchange_weak(old_head, old_head->next,
std::memory_order_acquire,
std::memory_order_relaxed)) {
val = old_head->data;
delete old_head; // 注意:这里可能有 ABA 问题
return true;
}
}
return false;
}
};
避坑指南:上面的代码有 ABA 问题——一个节点被弹出后又被重新压入,导致 CAS 误判。我曾经在这个问题上折腾了两天。解决方案是用带标记的指针(tagged pointer)或 hazard pointer。嗯,这些内容咱们后面章节再细讲。
1.4.2 无锁队列(Michael-Scott Queue)
这是最常用的无锁队列,支持多生产者多消费者(MPMC)。核心思路是用两个原子指针:head 和 tail。
// 简化版:只展示核心逻辑
template<typename T>
class LockFreeQueue {
struct Node {
T data;
std::atomic<Node*> next{nullptr};
};
std::atomic<Node*> head;
std::atomic<Node*> tail;
public:
LockFreeQueue() {
Node* dummy = new Node;
head.store(dummy, std::memory_order_relaxed);
tail.store(dummy, std::memory_order_relaxed);
}
void enqueue(T val) {
Node* new_node = new Node{val, nullptr};
while (true) {
Node* last = tail.load(std::memory_order_acquire);
Node* next = last->next.load(std::memory_order_acquire);
if (last == tail.load(std::memory_order_acquire)) {
if (next == nullptr) {
if (last->next.compare_exchange_weak(next, new_node,
std::memory_order_release,
std::memory_order_relaxed)) {
tail.compare_exchange_weak(last, new_node,
std::memory_order_release,
std::memory_order_relaxed);
return;
}
} else {
tail.compare_exchange_weak(last, next,
std::memory_order_release,
std::memory_order_relaxed);
}
}
}
}
};
1.5 知识体系总览
下面这张图总结了本章的核心知识结构,方便你对照复习:
1.6 总结与避坑
好了,咱们把这一章的核心点捋一捋:
- 线程安全设计:从设计之初就考虑并发,不要事后补救
- 锁策略:根据场景选锁,互斥锁最通用,读写锁适合读多写少,自旋锁只适合极短临界区
- 原子操作:轻量级同步,适合计数器、标志位等简单场景
- 无锁数据结构:高性能但高复杂度,非必要不使用
我的建议:初学者先从互斥锁入手,把 std::lock_guard 用熟练。等遇到性能瓶颈了,再考虑原子操作和无锁结构。别一上来就追求「无锁」,那玩意儿调试起来能让你怀疑人生。
我曾经接手过一个项目,前任工程师用无锁队列实现了一个消息分发模块。代码看起来很高大上,但线上偶尔会出现消息丢失。我花了整整一周,用 ThreadSanitizer 和 GDB 反复排查,最后发现是内存序用错了。改成 memory_order_seq_cst 后问题解决,但性能下降了 30%。嗯,这就是无锁的代价——你很难在正确性和性能之间找到完美的平衡点。
下一章咱们会深入讲 死锁的检测与预防,包括死锁的四个必要条件、银行家算法、以及实际工程中如何避免死锁。到时候我会分享一个我亲手踩过的死锁大坑,保证让你印象深刻。