先上代码:
cpp
#include <exception>
#include <memory>
#include <mutex>
#include <stack>
struct empty_stack : std::exception
{
const char* what() const throw() {
return "empty stack!";
}
};
template<typename T>
class threadsafe_stack
{
private:
std::stack<T> data; // 实际存数据的栈
mutable std::mutex m; // 互斥量,用来保护 data
public:
threadsafe_stack()
: data(std::stack<T>()) {}
threadsafe_stack(const threadsafe_stack& other)
{
std::lock_guard<std::mutex> lock(other.m);
data = other.data; // 在构造函数体中执行拷贝
}
threadsafe_stack& operator=(const threadsafe_stack&) = delete;
void push(T new_value)
{
std::lock_guard<std::mutex> lock(m);
data.push(new_value);
}
std::shared_ptr<T> pop()
{
std::lock_guard<std::mutex> lock(m);
if (data.empty())
throw empty_stack(); // 空栈则抛异常
// 在修改栈之前,先构造好要返回的 shared_ptr
std::shared_ptr<T> const res(std::make_shared<T>(data.top()));
data.pop();
return res;
}
void pop(T& value)
{
std::lock_guard<std::mutex> lock(m);
if (data.empty())
throw empty_stack();
value = data.top();
data.pop();
}
bool empty() const
{
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
};
1. 自定义异常 empty_stack
cpp
struct empty_stack : std::exception
{
const char* what() const throw() {
return "empty stack!";
}
};
-
继承自
std::exception,表示一种异常类型:栈为空时的异常。 -
重写了
what()函数,返回错误信息"empty stack!"。 -
what() const throw():-
const:保证这个函数不会修改对象状态。 -
throw():老式的异常说明,表示函数不会抛出异常(C++11 之后用noexcept,这里是书上经典写法)。
-
以后当栈为空却调用 pop 时,就会 throw empty_stack(); 抛出这个异常。
2. 类模板 threadsafe_stack<T>
2.1 成员变量
cpp
std::stack<T> data;
mutable std::mutex m;
-
data:真正存放元素的标准库栈。 -
m:互斥量,用来保护data的并发访问。 -
mutable:-
允许在
const成员函数中也能修改m(加锁本身会修改互斥量的内部状态)。 -
因为
empty()被声明为bool empty() const,但里面还要加锁,所以必须把m声明为mutable。
-
2.2 构造函数
cpp
threadsafe_stack()
: data(std::stack<T>()) {}
-
默认构造一个空的
std::stack<T>。 -
写成初始化列表形式效率更好。
2.3 拷贝构造函数
cpp
threadsafe_stack(const threadsafe_stack& other)
{
std::lock_guard<std::mutex> lock(other.m);
data = other.data;
}
-
接受另一个
threadsafe_stack的引用,做拷贝构造。 -
首先
std::lock_guard<std::mutex> lock(other.m);-
构造时自动对
other.m加锁; -
离开作用域时自动解锁(RAII机制)。
-
-
在已经加锁的情况下安全地访问
other.data:data = other.data;把对方的栈内容完整拷贝过来。
-
这样就保证了:拷贝构造时不会与其他线程对
other的并发访问产生数据竞争。
2.4 删除拷贝赋值运算符
cpp
threadsafe_stack& operator=(const threadsafe_stack&) = delete;
-
禁止赋值操作:
a = b这种用法不允许。 -
原因:安全地实现一个"线程安全的赋值运算符"比较麻烦:
-
要同时锁住
this->m和other.m; -
还要注意避免死锁;
-
书中简单做法就是干脆不支持赋值,只支持拷贝构造。
-
push:线程安全入栈
cpp
void push(T new_value)
{
std::lock_guard<std::mutex> lock(m);
data.push(new_value);
}
-
调用
push时:-
lock_guard构造 → 对m加锁; -
data.push(new_value);安全地把元素压入栈; -
函数结束 →
lock_guard析构 → 自动解锁。
-
-
这样多个线程同时
push也不会互相冲突。
- 第一种
pop:返回shared_ptr<T>
cpp
std::shared_ptr<T> pop()
{
std::lock_guard<std::mutex> lock(m);
if (data.empty())
throw empty_stack();
std::shared_ptr<T> const res(std::make_shared<T>(data.top()));
data.pop();
return res;
}
流程:
-
加锁:
std::lock_guard<std::mutex> lock(m); -
检查栈是否为空:
- 如果为空,抛出
empty_stack异常。
- 如果为空,抛出
-
如果非空:
-
先用
data.top()的值构造一个shared_ptr<T>:cppstd::make_shared<T>(data.top()) -
然后再
data.pop();弹出栈顶。 -
最后返回这个
shared_ptr<T>。
-
这里有两个细节:
-
在修改栈之前先创建返回值
如果你先
pop()再去用那个值构造shared_ptr,一旦中间抛异常(例如构造 T 时抛了),栈顶元素已经被移除,数据丢失。现在的写法是:
-
先从
data.top()复制一份内容出来构造shared_ptr; -
构造成功以后,再
data.pop()修改栈; -
这样即使构造时抛异常,也还没修改栈内容,不会数据丢失。
-
-
返回
shared_ptr<T>的好处-
避免返回引用或指针悬空的问题;
-
即使栈内部已经把元素弹走,
shared_ptr还持有那份拷贝,外面用起来更安全。
-
-
第二种
pop:通过引用返回值
cpp
void pop(T& value)
{
std::lock_guard<std::mutex> lock(m);
if (data.empty())
throw empty_stack();
value = data.top();
data.pop();
}
-
同样先加锁,再检查是否为空。
-
value = data.top();把栈顶元素复制到调用者提供的变量value中。 -
然后
data.pop();弹出栈顶。 -
这种写法比返回
shared_ptr<T>少了一个堆分配(不用new),适合拷贝成本不太高的类型。
注意:如果 T 的拷贝操作抛异常,那么在 pop() 中,data.pop() 不会执行,栈保持不变,这是异常安全的行为。
empty():线程安全地检查是否为空
cpp
bool empty() const
{
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
-
函数被声明为
const,说明逻辑上不修改对象状态。 -
但内部依然需要加锁(会修改
m的状态),所以互斥量必须是mutable。 -
防止这样一种情况:一个线程刚检查完
empty()==false,另一个线程马上把最后一个元素pop掉,导致调用方以为还能pop,但其实已经空了。所以正确模式一般是:
不要先用empty()再pop(),而是直接在pop()内部检查并抛异常 。这里提供
empty()主要是给一些非严格场景用。
关于锁的粒度的讨论:
① "之前对 top() 和 pop() 的讨论中,恶性条件竞争已经出现,因为锁的粒度太小,需要保护的操作并未全覆盖到。"
意思是:
-
top()和pop()是两个分开的操作 -
如果每个操作分别加锁,很有可能一个线程执行完
top()返回数据后,另一个线程在pop()之前就把数据弹走了 -
这就产生了 条件竞争(race condition)
➡️ 原因:锁的粒度太小(只锁住某个操作,而不是整个逻辑动作)
② "不过,锁住的颗粒过大同样会有问题。"
这里说另一种极端情况:
-
如果把所有操作都用一个大锁锁住
-
虽然解决了 race condition
-
但导致并发度极低,大家都在排队
➡️ 锁太大 → 性能下降
③ "一个全局互斥量要去保护全部共享数据...抵消了并发带来的性能提升。"
意思是:
-
假设系统中所有共享数据都用一个互斥量来保护
-
哪怕线程访问的不是同一块数据,也必须等待同一个锁
-
最终导致并发几乎没效果
➡️ 全局大锁 = 只有一个线程能真正运行
④ "第一版为多处理器系统设计的 Linux 内核,用了一个全局内核锁。"
历史事实:
-
Linux 2.0 时代采用 Big Kernel Lock (BKL)
-
内核中的大部分代码都必须先获取这个大锁才能运行
-
导致多核 CPU 基本不能并行执行内核任务
⑤ "在双核系统上的性能比两个单核系统还差,四核更不能提了。"
原因:
-
多个 CPU 同时想进入内核,却卡在那一个大锁上
-
造成大量锁竞争、上下文切换(context switch)
-
CPU 态的并行度无法发挥出来
➡️ 多核系统本来应该更快
➡️ 但因为大锁的存在,反而更慢
⑥ "随后修正的 Linux 内核加入了细粒度锁方案......性能接近线性增长"
后来的 Linux 改进:
-
将大锁拆解为多个小锁:
-
多个子系统锁
-
自旋锁
-
读写锁
-
每 CPU 锁
-
-
让不同 CPU 访问不同资源时不会互相阻塞
-
多核效率大幅提升
最终结果:
四核系统的性能 ≈ 单核性能 × 4
(接近线性扩展)