C++ 多线程进阶:Lambda、条件变量与死锁

一、进程与线程的核心区别

1. 不同系统下的调度模型

系统 进程角色 线程角色
Windows 资源分配单位(拥有代码/数据/堆/栈) 调度执行单位(由 main 函数创建主线程)
Linux 既是资源分配单位,也是可调度实体 轻量级进程,同样可调度,共享进程资源

2. 关键差异对比

对比项 进程 线程
资源共享 进程间完全隔离,需通过 IPC(管道/消息队列)通信 共享同一进程的代码区、数据区、堆区,仅栈和寄存器独立
切换开销 大(需保存完整上下文) 小(仅切换栈和寄存器)
崩溃影响 单个进程崩溃不影响其他进程 任意线程崩溃会导致整个进程终止

补充知识点

  • std::thread::hardware_concurrency() 可获取 CPU 逻辑核心数

  • 线程的内核栈在进程的堆区分配,代码仍在代码区(地址不变)

  • 一个 main 函数就是一个进程

二、Lambda 表达式深度解析

1. 完整语法与组成

cs 复制代码
[capture] (params) opt -> ret { body; }
部分 说明
[capture] 捕获列表(决定外部变量访问方式)
(params) 参数列表
opt 函数选项(如 mutable
-> ret 返回值类型(可自动推导)
{ body } 函数体

2. 捕获列表详解

捕获方式 行为说明
[] 不捕获任何变量
[&] 按引用捕获所有外部变量(可修改原变量)
[=] 按值捕获所有外部变量(默认是 const 副本,不可修改)
[=, &foo] 按值捕获所有变量,按引用捕获 foo
[bar] 仅按值捕获 bar
[this] 捕获当前类的 this 指针,可访问成员变量/函数

关键点:

  • 引用捕获的生命周期风险:捕获的引用必须在 Lambda 执行时仍有效,否则会出现悬空引用

  • 嵌套捕获规则:内层 Lambda 默认只能捕获外层 Lambda 的变量,而非直接捕获最外层的变量

  • 类成员捕获[=]/[&] 会自动捕获 this,可直接访问类成员

3.值捕获的可修改性

默认情况下,值捕获的变量在 Lambda 内部是 const 副本 ,不能修改。如果要修改,必须加 mutable 关键字,且修改的是副本,不影响原变量。

cpp 复制代码
int x = 10;

// 不加 mutable:编译错误
auto f1 = [=]() { x += 10; };  // ❌ 错误:不能修改 const 副本

// 加 mutable:可以修改副本
auto f2 = [=]() mutable { x += 10; };  // ✅ 修改的是副本,原 x 不变

f2();
cout << x << endl;  // 输出 10,原变量未改变

关键点

  • mutable 去掉的是 const 属性,让副本可以被修改

  • 修改的是 Lambda 内部存储的副本,不是外部的原变量

  • 即使加了 mutable,外部变量也不会改变

4. 返回值自动推导规则

规则1:单条 return 语句可自动推导

cpp 复制代码
auto f1 = [](int i) { return i; };        // ✅ 推导为 int
auto f2 = [](double d) { return d; };     // ✅ 推导为 double
auto f3 = [](int a, int b) { return a + b; };  // ✅ 推导为 int

规则2:多条 return 语句且类型一致,可自动推导

cpp 复制代码
auto f4 = [](int x) {
    if (x > 0) return 1;
    else return -1;      // 都是 int,✅ 推导为 int
};

规则3:多条 return 语句类型不一致,编译错误

cpp 复制代码
auto f5 = [](int x) {
    if (x > 0) return 1;
    else return 1.0;     // ❌ 错误:int 和 double 冲突,无法推导
};

规则4:初始化列表 {} 无法推导,必须显式指定

cpp 复制代码
auto f6 = []() { return {1, 2, 3}; };      // ❌ 错误:无法推导
auto f7 = []() -> vector<int> { return {1, 2, 3}; };  // ✅ 正确

原因{1, 2, 3} 可以是 vector<int>list<int>array<int,3> 等多种类型,编译器不知道你要哪一种,所以必须显式指定。

5. Lambda 本质

编译器会为每个 Lambda 生成一个匿名类 ,重载 operator(),捕获的变量会作为类的成员存储:

  • 值捕获 → 成员变量是拷贝副本

  • 引用捕获 → 成员变量是引用

Lambda 本质是一个带状态的可调用对象。

三、条件变量 std::condition_variable 高级用法

1. 带谓词的 wait

cv.wait(lock, pred) 等价于:

cpp 复制代码
while (!pred()) {
    cv.wait(lock);
}

pred 是返回 bool 的可调用对象(常为 Lambda),作用:防止虚假唤醒,只有 pred()true 时才会继续执行。

示例:

cpp 复制代码
// 写法1:手动 while 循环
while (i % 2 != 0) { cv.wait(lock); }

// 写法2:带谓词 wait(等价)
cv.wait(lock, []() { return i % 2 == 0; });

2. 生产者-消费者模型(简单版)

场景:一个生产者生产数据,一个消费者消费数据,两者交替执行。

核心思路 :用 bool tag 标志当前应该生产还是消费,配合条件变量实现交替。

cpp 复制代码
int num = -1;
const int n = 10;
bool tag = true;  // true:应该生产,false:应该消费
mutex mtx;
condition_variable cv;

void Producer() {
    unique_lock<mutex> lock(mtx);
    for (int i = 0; i < n; ++i) {
        // 等待 tag == true(轮到生产)
        cv.wait(lock, [] { return tag; });
        num = i;
        cout << "生产数据:" << num << endl;
        tag = false;           // 生产完,轮到消费
        cv.notify_one();       // 唤醒消费者
    }
}

void Consumer() {
    unique_lock<mutex> lock(mtx);
    for (int i = 0; i < n; ++i) {
        // 等待 tag == false(轮到消费)
        cv.wait(lock, [] { return !tag; });
        cout << "消费数据:" << num << endl;
        num = -1;
        tag = true;            // 消费完,轮到生产
        cv.notify_one();       // 唤醒生产者
    }
}

执行流程

  1. 生产者先执行,生产数据,tag 变为 false

  2. 消费者被唤醒,消费数据,tag 变为 true

  3. 生产者被唤醒,继续生产... 循环 10 次

3. 封装同步队列(通用版)

场景:多个生产者、多个消费者共享一个固定大小的队列。队列满时生产者等待,队列空时消费者等待。

核心设计

  • put():生产者放数据,队列满则等待

  • get():消费者取数据,队列空则等待

cpp 复制代码
#include <list>
#include <mutex>
#include <condition_variable>
using namespace std;

template<class _Ty>
class SyncQueue {
private:
    list<_Ty> qu;
    static const int maxsize = 8;      // 队列最大容量
    mutex m_mutex;
    condition_variable m_cv;
    
    bool IsFull() const { return qu.size() >= maxsize; }
    bool IsEmpty() const { return qu.empty(); }
    
public:
    // 生产者放数据
    void put(const _Ty& val) {
        unique_lock<mutex> lock(m_mutex);
        // 队列满则等待
        m_cv.wait(lock, [this] { return !IsFull(); });
        qu.push_back(val);
        m_cv.notify_all();   // 唤醒消费者
    }
    
    // 消费者取数据
    void get(_Ty& val) {
        unique_lock<mutex> lock(m_mutex);
        // 队列空则等待
        m_cv.wait(lock, [this] { return !IsEmpty(); });
        val = qu.front();
        qu.pop_front();
        m_cv.notify_all();   // 唤醒生产者
    }
};

const int n = 100;

void Producer(SyncQueue<int>& qu) {
    for (int i = 0; i < n; ++i) {
        cout << "生产:" << i << endl;
        qu.put(i);
    }
}

void Consumer(SyncQueue<int>& qu) {
    for (int i = 0; i < n; ++i) {
        int val;
        qu.get(val);
        cout << "消费:" << val << endl;
    }
}

int main() {
    SyncQueue<int> myqu;
    thread prod(Producer, ref(myqu));
    thread cons(Consumer, ref(myqu));
    prod.join();
    cons.join();
    return 0;
}

关键点

  • wait(lock, predicate) 中的谓词 [this] { return !IsFull(); } 确保只有队列不满时才继续

  • notify_all() 唤醒所有等待线程(生产者和消费者都可能被唤醒)

  • 使用 std::ref 传递同步队列的引用,避免拷贝

四、死锁问题与解决方案

1.死锁的核心成因

死锁是多线程编程中典型的并发问题。本文以银行双向转账为例:

  • 线程1:从账户A转给账户B,先锁A,再锁B

  • 线程2:从账户B转给账户A,先锁B,再锁A

当两个线程同时执行时,线程1持有A锁等待B锁,线程2持有B锁等待A锁,双方互相等待,形成循环等待,程序永久阻塞。

关键点:单个互斥锁不会死锁,死锁一定发生在多锁竞争、加锁顺序不一致的场景。

2.错误代码(会死锁)

cpp 复制代码
void transferBad(Account& from, Account& to, int money) {
    unique_lock<mutex> lockFrom(from.getMutex());
    unique_lock<mutex> lockTo(to.getMutex());  // 加锁顺序不固定,容易死锁
    
    if (from.getMoney() >= money) {
        from.subMoney(money);
        to.addMoney(money);
    }
}

3.解决方案一:std::lock 批量加锁

核心思想:要么同时拿到所有锁,要么一把都不拿,彻底打破循环等待。

cpp 复制代码
void transfer(Account& from, Account& to, int money) {
    // 批量同时加锁,要么全拿,要么不拿
    lock(from.getMutex(), to.getMutex());
    
    // adopt_lock:表示锁已持有,lock_guard 只负责自动解锁
    lock_guard<mutex> lock1(from.getMutex(), adopt_lock);
    lock_guard<mutex> lock2(to.getMutex(), adopt_lock);
    
    if (from.getMoney() >= money) {
        from.subMoney(money);
        to.addMoney(money);
        cout << from.getName() << "给" << to.getName() << "转账:" << money << "元" << endl;
    } else {
        cout << "余额不足" << endl;
    }
}

关键点

  • std::lock:批量加锁,保证原子性

  • adopt_lock:告诉 lock_guard 锁已持有,只需负责解锁

  • 必须配合使用,缺一不可

4.解决方案二:单例模式 + 集合管理账户

核心思路:全局只有一个账户管理实例,转账前必须先申请账户使用权,申请成功才能转账,否则阻塞等待。

set 区分账户的关键问题

set 默认通过 operator< 判断元素是否相同。如果只比较余额,两个不同账户(余额相同)会被视为同一个元素,无法同时插入。

解决方法

方法 做法 说明
按地址区分 set<Account*> 存储指针 每个对象地址唯一,天然区分
按账户名区分 重载 < 时比较 m_name 按业务逻辑区分,更语义化
cpp 复制代码
// 按账户名区分(推荐)
bool operator<(const Account& src) const {
    return this->m_name < src.m_name;
}

完整代码

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <set>
#include <string>
using namespace std;

class Account {
private:
    string m_name;
    int m_money;
    mutex m_mtx;
public:
    Account(string name, int money) : m_name(name), m_money(money) {}
    string getName() const { return m_name; }
    int getMoney() const { return m_money; }
    void addMoney(int money) { m_money += money; }
    void subMoney(int money) { m_money -= money; }
    mutex& getMutex() { return m_mtx; }
    
    // 按账户名区分,解决余额相同无法插入 set 的问题
    bool operator<(const Account& src) const {
        return this->m_name < src.m_name;
    }
};

class AcCountManager {
private:
    AcCountManager() = default;
    AcCountManager(const AcCountManager&) = delete;
    AcCountManager& operator=(const AcCountManager&) = delete;
    
    set<Account> accountSet;
    mutex m_mtx;
    condition_variable m_cv;
    
public:
    static AcCountManager* getInstance() {
        static AcCountManager instance;
        return &instance;
    }
    
    void applyAccount(Account& from, Account& to) {
        unique_lock<mutex> lock(m_mtx);
        while (accountSet.count(from) > 0 || accountSet.count(to) > 0) {
            m_cv.wait(lock);
        }
        accountSet.insert(from);
        accountSet.insert(to);
    }
    
    void freeAccount(Account& from, Account& to) {
        unique_lock<mutex> lock(m_mtx);
        accountSet.erase(from);
        accountSet.erase(to);
        m_cv.notify_all();
    }
};

void transfer(Account& from, Account& to, int money) {
    AcCountManager* manager = AcCountManager::getInstance();
    manager->applyAccount(from, to);
    
    lock(from.getMutex(), to.getMutex());
    lock_guard<mutex> lock1(from.getMutex(), adopt_lock);
    lock_guard<mutex> lock2(to.getMutex(), adopt_lock);
    
    if (from.getMoney() >= money) {
        from.subMoney(money);
        to.addMoney(money);
        cout << from.getName() << "给" << to.getName() << "转账:" << money << "元" << endl;
    } else {
        cout << from.getName() << "余额不足" << endl;
    }
    
    manager->freeAccount(from, to);
}

int main() {
    Account user1("火龙果", 5000);
    Account user2("菠萝", 10000);
    
    thread t1(transfer, ref(user1), ref(user2), 1000);
    thread t2(transfer, ref(user2), ref(user1), 500);
    
    t1.join();
    t2.join();
    
    cout << user1.getName() << "余额:" << user1.getMoney() << endl;
    cout << user2.getName() << "余额:" << user2.getMoney() << endl;
    
    return 0;
}

5.常见疑问解答

疑问1:std::lock 方案中,lock_guard 没有加锁,为什么还要写?

std::lock 已经完成了加锁操作。lock_guard 配合 adopt_lock 的作用是自动解锁 ,确保离开作用域时锁一定会被释放,避免忘记 unlock() 导致死锁,同时保证异常安全。

cpp 复制代码
lock(from.getMutex(), to.getMutex());      // 加锁
lock_guard<mutex> lock1(from.getMutex(), adopt_lock);  // 只负责解锁
lock_guard<mutex> lock2(to.getMutex(), adopt_lock);    // 只负责解锁

疑问2:方案一是两个线程一个管A、一个管B吗?

不是。 方案一是每个线程自己同时锁住两个账户,不是分别管理。

  • 错误理解:线程1管A,线程2管B

  • 正确理解:每个想转账的线程,必须自己一次性拿到A和B两把锁

cpp 复制代码
// 线程A执行转账:必须同时锁住A和B
thread t1(transfer, A, B, 100);
thread t2(transfer, B, A, 200);  // 同样需要同时锁住B和A

疑问3:方案二是只有一个线程在管理吗?

不是。 方案二是通过**管理员(单例对象)**来分配账户的使用权,不是只有一个线程。

  • 可以有多个线程同时向管理员申请

  • 管理员保证同一时刻只有一个线程能拿到A和B

  • 其他线程阻塞等待,直到释放后才能申请成功

cpp 复制代码
// 多个线程可以同时调用转账
thread t1(transfer, A, B, 100);  // 申请A和B
thread t2(transfer, A, B, 200);  // 阻塞等待,直到t1释放
thread t3(transfer, C, D, 300);  // 不同账户,可以并行

疑问4:两种方案都是同一时刻只有一个线程能操作A和B吗?

是的。 这是两种方案的共同目标,也是避免死锁的关键。

方案 如何保证 同一时刻操作A和B的线程数
方案一 线程自己一次性拿两把锁 1个
方案二 管理员分配账户使用权 1个

疑问5:不同账户的转账能同时进行吗?

可以。 两种方案锁的都是具体账户,不是锁整个函数。

cpp 复制代码
thread t1(transfer, A, B, 100);  // 操作A和B
thread t2(transfer, C, D, 200);  // 操作C和D,与t1并行执行,互不影响

五、总结

知识点 核心要点
进程 vs 线程 进程资源隔离,线程共享资源;线程崩溃导致进程终止
Lambda 捕获 [=] 值捕获是 const 副本,需 mutable 修改;[&] 引用捕获注意生命周期
Lambda 本质 编译器生成匿名类,捕获变量作为成员存储
条件变量 wait(lock, pred) 带谓词可防止虚假唤醒
生产者-消费者 用条件变量实现生产/消费同步,队列满/空时阻塞
死锁原因 多锁竞争 + 加锁顺序不一致 + 循环等待
死锁解决方案一 std::lock 批量加锁 + adopt_lock,一次性获取所有锁
死锁解决方案二 单例 + set 集合管理账户,管理员分配使用权
set 容器 元素唯一、自动排序、查找快,用于记录正在使用的账户
adopt_lock 告诉 lock_guard 锁已持有,只负责解锁
不同账户转账 可以并行执行,互不影响
相关推荐
unicrom_深圳市由你创科技2 小时前
上位机开发常用的语言 / 框架有哪些?
c++·python·c#
|_⊙3 小时前
C++ 智能指针
开发语言·c++
Jasmine_llq3 小时前
《B4356 [GESP202506 二级] 数三角形》
开发语言·c++·双重循环枚举算法·顺序输入输出算法·去重枚举算法·整除判断算法·计数统计算法
山栀shanzhi3 小时前
在做直播时,I帧的间隔(GOP)一般是多少?
网络·c++·面试·ffmpeg
王老师青少年编程4 小时前
csp信奥赛C++高频考点专项训练之贪心算法 --【排序贪心】:魔法
c++·算法·贪心·csp·信奥赛·排序贪心·魔法
晓觉儿4 小时前
【GPLT】2026年第十一届团队程序设计天梯赛赛后题解(已写2h,存档中)
数据结构·c++·算法·深度优先·图论
6Hzlia5 小时前
【Hot 100 刷题计划】 LeetCode 394. 字符串解码 | C++ 单栈回压法
c++·算法·leetcode
流年如夢5 小时前
自定义类型进阶:联合与枚举
java·c语言·开发语言·数据结构·数据库·c++·算法
Little At Air5 小时前
C++stack模拟实现
linux·开发语言·c++·算法