C++并发编程

"无线程" (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 不就行了?

问题在于:锁粒度太小 ------ 每个函数各自锁,但没有一个"原子操作"表示"取出栈顶并删除"

调用顺序:

  1. 加锁 → 调 empty() → 解锁

  2. 加锁 → 调 top() → 解锁

  3. 加锁 → 调 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(); } };
点击并拖拽以移动
复制代码
 设计上的优点:
  1. 对外接口是原子操作pop() 自己完成"检查是否空 + 取值 + 删除"一整套

  2. 内部加锁,对同一个栈来说不会发生并发读写

  3. 使用 std::shared_ptr<T> 或移动语义来减轻异常问题(书上会讨论各种异常安全写法)

  4. 你不会再在用户代码里写:

    if (!s.empty()) { auto v = s.top(); s.pop(); ... }

    而是:

    auto v = s.pop(); // 要么成功拿一个,要么抛异常,逻辑清晰


6. 小结(把这几个点记住就行)

  1. 单线程安全 ≠ 多线程安全empty() + top() + pop() 这种分步接口在并发下必出事。

  2. 即便给每个成员函数都加互斥量,接口层面的条件竞争依然存在

  3. 想真正线程安全,必须改接口,让"取出并删除栈顶元素"成为原子操作(一个成员函数干完)。

  4. std::stack 之所以分成 top()pop(),是为了异常安全,但这恰好导致它不适合作为共享栈的直接接口。

  5. 典型做法:写一个 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);
相关推荐
曹牧2 小时前
C#:foreach
开发语言·c#
计算机学姐2 小时前
基于Python的商场停车管理系统【2026最新】
开发语言·vue.js·后端·python·mysql·django·flask
小猪快跑爱摄影2 小时前
【AutoCad 2025】【Python】零基础教程(一)——简单示例
开发语言·python
AI云原生2 小时前
在 openEuler 上使用 x86_64 环境编译 ARM64 应用的完整实践
java·运维·开发语言·jvm·开源·开源软件·开源协议
曹牧2 小时前
C#中解析JSON数组
开发语言·c#·json
while(1){yan}2 小时前
多线程CAS八股文
java·开发语言·面试
飞Link2 小时前
【轻量拓展区】网络 QoS 与带宽、延迟、抖动:AI 推理的性能瓶颈
开发语言·网络·人工智能
Haoea!2 小时前
jkd8特性
开发语言
南莺莺3 小时前
二叉排序树的创建和基本操作---C++实现
数据结构·c++·算法··二叉排序树