10. 线程安全的数据结构:从栈到队列,再到无锁编程
线程安全的数据结构,说白了就是能让多个线程同时读写,还不出乱子的容器。我刚开始接触并发编程时,觉得这玩意儿不就是给每个操作加个锁嘛,有什么难的?后来在项目中踩过几次坑,才明白事情没那么简单。
10.1 线程安全的栈:最基础的并发容器
先聊聊栈。栈的特点是后进先出,操作就那么几个:push、pop、top、empty。但一旦涉及多线程,每个操作都得小心。
我见过最典型的错误写法是这样的:
// 错误示范:这样写线程不安全
std::stack<int> s;
void push(int v) {
std::lock_guard<std::mutex> lk(mtx);
s.push(v);
}
int pop() {
std::lock_guard<std::mutex> lk(mtx);
auto v = s.top();
s.pop();
return v;
}
问题出在哪?你想想看,如果两个线程同时调用pop,第一个线程刚拿到top值,还没来得及pop,第二个线程也调了top——这时候两个线程拿到的值是一样的。更糟的是,如果栈里只有一个元素,两个线程都调了top,然后一个pop了,另一个再pop就崩溃了。
正确的线程安全栈应该这样设计:
template<typename T>
class threadsafe_stack {
std::stack<T> data;
mutable std::mutex mtx;
public:
void push(T v) {
std::lock_guard<std::mutex> lk(mtx);
data.push(std::move(v));
}
std::shared_ptr<T> pop() {
std::lock_guard<std::mutex> lk(mtx);
if (data.empty()) return nullptr;
auto sp = std::make_shared<T>(std::move(data.top()));
data.pop();
return sp;
}
bool empty() const {
std::lock_guard<std::mutex> lk(mtx);
return data.empty();
}
};
这里有个小技巧:pop返回的是shared_ptr,而不是直接返回值。为什么?因为拷贝返回值本身也可能抛异常。用智能指针,就算拷贝失败,栈里的数据也不会丢。
10.2 线程安全的队列:生产者-消费者的核心
队列比栈稍微复杂一点。队列是先进先出,常用于生产者-消费者模型。我参与过一个日志系统,就是用线程安全队列来解耦日志写入和业务逻辑的。
先看一个基础版本:
template<typename T>
class threadsafe_queue {
std::queue<T> data;
mutable std::mutex mtx;
std::condition_variable cv;
public:
void push(T v) {
std::lock_guard<std::mutex> lk(mtx);
data.push(std::move(v));
cv.notify_one();
}
T pop() {
std::unique_lock<std::mutex> lk(mtx);
cv.wait(lk, [this]{ return !data.empty(); });
T v = std::move(data.front());
data.pop();
return v;
}
};
这里用到了条件变量。当队列为空时,消费者线程会阻塞等待,直到生产者push了数据并通知。嗯,这里要注意:wait的第二个参数是个谓词,用来防止虚假唤醒。我在早期写代码时没加这个谓词,结果程序偶尔会从空队列里取数据,排查了好久才发现是虚假唤醒的问题。
10.3 无锁编程的初步探索
锁虽然好用,但性能开销不小。锁竞争会导致线程阻塞,严重时甚至引发优先级反转。我做过一个高频交易系统,锁的竞争直接拖慢了整个系统的吞吐量。那时候我开始研究无锁编程。
无锁编程的核心是CAS(Compare-And-Swap)操作。C++11提供了std::atomic,底层就是CAS。
举个最简单的例子:无锁计数器
std::atomic<int> counter{0};
void increment() {
int old = counter.load();
while (!counter.compare_exchange_weak(old, old + 1)) {
// 如果CAS失败,old会被更新为当前值,继续重试
}
}
compare_exchange_weak这个函数,说白了就是:如果当前值等于old,就把它改成old+1,返回true;否则把old更新为当前值,返回false。这个循环就是自旋锁的思想。
但无锁数据结构远不止这么简单。以无锁栈为例,它的核心思想是用原子指针来管理链表:
template<typename T>
class lockfree_stack {
struct node {
T data;
node* next;
};
std::atomic<node*> head{nullptr};
public:
void push(T v) {
node* new_node = new node{std::move(v), nullptr};
new_node->next = head.load();
while (!head.compare_exchange_weak(new_node->next, new_node)) {
// 重试直到成功
}
}
};
这里有个关键点:CAS操作的是head指针。如果多个线程同时push,只有一个能成功更新head,其他的会重试。这比用锁要轻量得多。
std::atomic<std::uintptr_t>),或者用 hazard pointer。
10.4 知识体系总览
下面这张图总结了本章的核心脉络:
10.5 避坑指南与最佳实践
最后,分享几个我在实战中总结的经验:
- 能用锁就别用无锁。无锁编程的调试难度是指数级的。我见过一个团队花了两周排查一个ABA问题,最后发现加个锁就能解决。
- 锁的粒度要细。别一把锁锁住整个数据结构。可以试试读写锁(shared_mutex),读多写少时性能提升明显。
- 注意死锁。如果两个线程各自持有一个锁,又都在等对方的锁,就死锁了。我习惯用
std::lock一次性锁住多个互斥量,或者用std::scoped_lock。 - 测试要覆盖并发场景。单线程跑得欢,多线程就崩,这是并发编程的常态。用ThreadSanitizer这类工具跑一跑,能发现很多隐藏问题。
📌 一句话总结: 线程安全的数据结构,核心就两件事——要么用锁保护好临界区,要么用CAS实现无锁操作。但无论哪种方式,都要把数据一致性放在第一位。
公众号:蓝海资料掘金营,微信deep3321