"无线程" (no thread)指的是一个 std::thread 对象没有关联任何实际的执行线程。这发生在以下情况:
1. 默认构造的 std::thread 对象
cpp
std::thread t; // 默认构造,不关联任何线程
std::cout << t.get_id() == std::thread::id() << std::endl; // 输出: 1 (true)
2. 已移动的 std::thread 对象
cpp
std::thread t1([](){ std::cout << "Hello" << std::endl; });
std::thread t2 = std::move(t1); // t1 的所有权转移给 t2
// 现在 t1 不再关联任何线程
if (t1.get_id() == std::thread::id()) {
std::cout << "t1 is no thread" << std::endl; // 会输出
}
3. 已分离(detach)的线程
cpp
std::thread t([](){
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Detached thread" << std::endl;
});
t.detach(); // 线程分离,t 不再关联该线程
// 立即检查
if (t.get_id() == std::thread::id()) {
std::cout << "t is no thread after detach" << std::endl; // 会输出
}
二、与 join() 的关系
join() 之后线程状态:
cpp
std::thread t([](){
std::cout << "Thread running" << std::endl;
});
t.join(); // 等待线程结束
// join() 之后,线程已经结束,thread对象也不再关联任何线程
if (t.get_id() == std::thread::id()) {
std::cout << "After join: no thread" << std::endl; // 会输出
}
关键区别:
-
join()之前 :std::thread对象关联着一个正在运行或可运行的线程 -
join()之后 :线程已结束,std::thread对象不再关联任何线程(变为"无线程"状态)
三.栈举例演示
1. 单线程里为什么是安全的?
经典写法:
cpp
std::stack<int> s; if (!s.empty()) { // ① int const value = s.top(); // ② s.pop(); // ③ do_something(value); }
在单线程里:
-
empty()为真 ⇒top()一定有元素、不会 UB -
top()和pop()连续执行,中间没有别的线程来插刀 -
所以这是安全的代码
2. 一旦共享到多线程,就出现两个"比赛"
现在多个线程一起用同一个 stack<int> s;。
条件竞争 1:empty() 和 top() 之间
线程 A:
cpp
if (!s.empty()) // A: 看一眼,还不空 int v = s.top();
线程 B 可能在 A 的 empty() 和 top() 之间 调了:
cpp
s.pop(); // B: 把最后一个元素弹走了
于是 A 的 top() 对空栈调用 → 未定义行为(典型 data race)。
重点:
-
就算你在
stack的每个成员函数里面都加互斥量,也没用。 -
因为 空检查和取值分成了两个独立的接口,两次加锁之间会被别的线程插入调用。
条件竞争 2:top() 和 pop() 之间(更隐蔽)
两个线程同时跑这段代码、共享同一个 s,栈里一开始只有 2 个元素:
cpp
if (!s.empty()) { int const value = s.top(); s.pop(); do_something(value); }
可能的执行顺序类似表里说的那样:
-
A:
!empty()(非空) -
B:
!empty()(非空) -
A:
value = s.top();// 读到的是栈顶元素 X -
B:
value = s.top();// 还没被 pop,所以也读到 X -
A:
s.pop();// 弹出 X -
A:
do_something(X); -
B:
s.pop();// 弹出第二个元素 Y -
B:
do_something(X);// 再次处理 X(而 Y 被弹出但没处理)
结果可能是:
-
某个元素被处理两次
-
另一个元素被弹掉但没处理
更坏的是:程序看起来不会崩溃,也不会报错,只是逻辑错了,非常难查。
3. 为啥简单"加锁"也没用?------接口的问题
你可能会想着:
我在
empty()、top()、pop()里面都加std::mutex不就行了?
问题在于:锁粒度太小 ------ 每个函数各自锁,但没有一个"原子操作"表示"取出栈顶并删除"。
调用顺序:
-
加锁 → 调
empty()→ 解锁 -
加锁 → 调
top()→ 解锁 -
加锁 → 调
pop()→ 解锁
三个操作之间都可以被插队,因此逻辑上的原子动作("从栈中取出一个元素")被拆碎了,就会有条件竞争。
4. 那怎么改?------改"接口",而不是只改"实现"
书里想表达的核心:
这个问题是接口设计导致的,只能靠改接口解决。
4.1 直觉上的"一个函数干完"版本
比如设计成:
cpp
template<typename T> class threadsafe_stack { public: T pop(); // 直接返回一个值,内部加锁并从栈中删除 // ... };
接口含义:"取出并删除栈顶元素"是一个原子操作。
内部可以:
cpp
T pop() { std::lock_guard<std::mutex> lk(m); if (data.empty()) throw empty_stack(); // 自定义异常 T value = data.top(); data.pop(); return value; }
-
对调用者来说,就不需要
empty() + top() + pop()这种串联了; -
也就没有上面那两个条件竞争了。
4.2 但又来了新问题:异常安全
书里提到 stack<vector<int>> 的例子:
-
pop()里要返回一个大vector<int>; -
为了构造返回值,要拷贝 / 分配堆内存;
-
如果内存不足,
vector的拷贝构造可能抛std::bad_alloc。
如果你已经从栈里 pop() 了元素,然后在"拷贝到返回值"的过程中抛异常:
-
栈顶元素已经被移除
-
但调用者没拿到那个元素(返回值构造失败)
-
⇒ 数据被"吞掉"了
这就是书里说的:
"当 pop() 函数返回'弹出值'时......如果拷贝数据的时候抛异常,要弹出的数据将会丢失"。
std::stack 为啥拆成 top() + pop()?
就是为了异常安全:如果拷贝失败,栈里的元素还在,不会丢失。
5. 书里的典型改法(你以后写线程安全栈可以照抄)
通常会这样设计一个线程安全栈(这本书里的典型实现):
cpp
template<typename T> class threadsafe_stack {
private: std::stack<T> data;
mutable std::mutex m;
public: threadsafe_stack() = default;
threadsafe_stack(const threadsafe_stack& other) { std::lock_guard<std::mutex> lk(other.m); data = other.data; }
void push(T value) { std::lock_guard<std::mutex> lk(m); data.push(std::move(value)); } std::shared_ptr<T> pop() { std::lock_guard<std::mutex> lk(m); if (data.empty()) throw empty_stack(); // 自定义异常类型
std::shared_ptr<T> res(std::make_shared<T>(std::move(data.top()))); data.pop(); return res; }
void pop(T& value) { std::lock_guard<std::mutex> lk(m); if (data.empty()) throw empty_stack(); value = std::move(data.top()); data.pop(); } bool empty() const { std::lock_guard<std::mutex> lk(m); return data.empty(); } };
设计上的优点:
-
对外接口是原子操作 :
pop()自己完成"检查是否空 + 取值 + 删除"一整套 -
内部加锁,对同一个栈来说不会发生并发读写
-
使用
std::shared_ptr<T>或移动语义来减轻异常问题(书上会讨论各种异常安全写法) -
你不会再在用户代码里写:
if (!s.empty()) { auto v = s.top(); s.pop(); ... }而是:
auto v = s.pop(); // 要么成功拿一个,要么抛异常,逻辑清晰
6. 小结(把这几个点记住就行)
-
单线程安全 ≠ 多线程安全 ,
empty()+top()+pop()这种分步接口在并发下必出事。 -
即便给每个成员函数都加互斥量,接口层面的条件竞争依然存在。
-
想真正线程安全,必须改接口,让"取出并删除栈顶元素"成为原子操作(一个成员函数干完)。
-
std::stack 之所以分成
top()和pop(),是为了异常安全,但这恰好导致它不适合作为共享栈的直接接口。 -
典型做法:写一个
threadsafe_stack,内部用std::stack + std::mutex,对外提供pop()、try_pop()、push()这些 原子操作接口。
四,针对栈问题的几个解决办法
一、四个"pop 接口设计选项"到底差在哪
✅ 选项 1:传入一个引用参数
std::vector<int> result; some_stack.pop(result); // 把弹出的值写进 result
优点:
-
不用返回值,直接往外写,接口简单。
-
可以避免"内部先 pop,再构造返回值失败导致元素丢失"的问题(你可以先把值拷贝/移动给调用者,再 pop)。
缺点:
-
调用者必须先构造一个"接收用"的对象:
-
有的类型构造很贵(比如巨大的容器)
-
有的类型构造需要复杂参数,这里不一定有
-
-
要求类型 可赋值(有赋值运算符),这对某些只支持 move、不支持 copy/assign 的类型是个限制。
✅ 选项 2:只允许"不抛异常"的拷贝/移动构造
思路是:
pop() 返回一个值,但只允许 存储那些
拷贝/移动构造函数不抛异常的类型。
可以用类型特征限制:
static_assert(std::is_nothrow_move_constructible<T>::value || std::is_nothrow_copy_constructible<T>::value, "T must be nothrow move/copy constructible");
优点:
-
pop() 返回值,调用者代码好写:
T value = s.pop(); -
不用担心"拷贝途中抛异常导致元素丢失"。
缺点:
-
很多自定义类型的构造函数会抛异常,或者没标 nothrow,直接被排除掉。
-
限制太严,对通用库不友好。
✅ 选项 3:返回指向弹出值的指针(书里主推 std::shared_ptr<T>)
std::shared_ptr<T> p = some_stack.pop(); // p 指向弹出的元素
关键点:
-
指针本身拷贝不会抛异常。
-
可以用智能指针管理内存,避免手动
new/delete泄露。 -
栈内部可以:
auto res = std::make_shared<T>(std::move(data.top())); // 如果这里抛异常,还没 pop data.pop(); return res; // 之后如果拷贝 shared_ptr 抛异常,也只是指针复制失败,栈已经安全弹出
优点:
-
避免 Cargill 提到的"值已经从栈中 pop 出,但拷贝返回值失败导致数据丢失"的问题。
-
shared_ptr比裸指针安全得多。
缺点:
-
每个元素都要单独堆分配一次(内存分配开销比直接放在 stack 里大很多),尤其对
int这种小类型非常不划算。 -
额外的引用计数开销。
✅ 选项 4:组合拳:"选项1 + 选项2" 或 "选项1 + 选项3"
也就是:
-
提供
void pop(T& value);(选项1) -
再提供一个:
-
要么
T pop();(限制 nothrow 构造,选项2) -
要么
std::shared_ptr<T> pop();(选项3)
-
好处:
-
用户可以根据自己类型的特点和性能需求选择合适的接口:
-
简单、便宜的类型:可以用 by-value 版本
-
大对象 / 拷贝代价高:用引用或
shared_ptr
-
-
对库作者来说,接口更通用、更灵活。
书里的清单 3.4 就是 "选项1 + 选项3" 的组合。
cpp
#include <stack>
#include <mutex>
#include <memory>
#include <exception>
struct empty_stack : std::exception {
const char* what() const noexcept override {
return "threadsafe_stack: empty stack";
}
};
template <typename T>
class threadsafe_stack {
private:
std::stack<T> data;
mutable std::mutex m;
public:
threadsafe_stack() = default;
// 拷贝构造:整体加锁,复制底层栈
threadsafe_stack(const threadsafe_stack& other) {
std::lock_guard<std::mutex> lk(other.m);
data = other.data;
}
threadsafe_stack& operator=(const threadsafe_stack&) = delete;
// push:传值或移动都可以
void push(T value) {
std::lock_guard<std::mutex> lk(m);
data.push(std::move(value));
}
// 选项3:返回 std::shared_ptr<T>
std::shared_ptr<T> pop() {
std::lock_guard<std::mutex> lk(m);
if (data.empty())
throw empty_stack();
// 先用栈顶元素构造 shared_ptr,如果这里抛异常,栈保持不变
std::shared_ptr<T> res(
std::make_shared<T>(std::move(data.top()))
);
data.pop(); // 这一步之后,元素才真正从栈中移除
return res;
}
// 选项1:通过引用返回弹出值
void pop(T& value) {
std::lock_guard<std::mutex> lk(m);
if (data.empty())
throw empty_stack();
value = std::move(data.top()); // 可能抛异常,但这时还没 pop
data.pop(); // 到这里栈才被修改
}
bool empty() const {
std::lock_guard<std::mutex> lk(m);
return data.empty();
}
};
cpp
threadsafe_stack<std::vector<int>> s;
// 线程 A:生产数据
s.push(std::vector<int>{1,2,3});
// 线程 B:用 shared_ptr 方式取数据
auto p = s.pop(); // std::shared_ptr<std::vector<int>>
do_something(*p);
// 线程 C:用引用方式取数据
std::vector<int> v;
s.pop(v);
do_something(v);