C++多线程2 如何优雅地锁门 (lock_guard) 多线程里的锁的种类

多线程里锁的种类:

C++提供了多种锁类型,用于简化互斥量的使用和管理。

常见的锁类型包括:

复制代码
std::lock_guard:作用域锁,当构造时自动锁定互斥量,当析构时自动解锁。
std::unique_lock:与std::lock_guard类似,但提供了更多的灵活性,例如可以转移所有权和手动解锁。

1. 关于"谦让"和"锁的模式"

你记得的那个"谦让",在操作系统里通常指 Peterson 算法( flag[i]=true; turn=j; )。这就像马云和张凌赫在厕所门口互掐:

  • 马云说:"我想进。"
  • 但马云紧接着说:"张凌赫你先请(turn = 张)。"
  • 如果这时候张凌赫也想进,马云就会在门口等着。
  • 这种"谦让"机制是为了避免死锁保证公平

但在 C++ 的 std::mutex 里,它默认是"不公平锁 "。也就是说,马云刚出来把锁挂上,哪怕张凌赫在门口等了半小时,只要马云手速够快(CPU 调度),他可能反手又把锁抢走了。张凌赫只能继续憋着。这种设计是为了性能,因为让等了很久的线程"醒过来"是很费劲的。

2. 关于函数指针

没错! 在 C++ 里,函数名会自动退化(decay)为函数指针

所以 std::thread t1(save_ma_yun); 是完全合法的。底层其实就是把这个函数的首地址传给了新线程,告诉它:"哥们,你去这个地址开始执行代码。"


🟢 第二步:进阶科普------如何优雅地锁门 (lock_guard)

既然你提到了锁,那咱们得讲个实用的。在 C++ 里,没人直接手写 lock()unlock(),因为万一你中间代码报错了,或者你忘了 unlock(),那这门就永远锁死了(死锁)。

问题:如何防止"占着茅坑不拉屎"(死锁/忘记解锁)?

  • 正式答案 :使用 RAII (Resource Acquisition Is Initialization) 机制,即 std::lock_guardstd::unique_lock。它们在构造时自动加锁,在生命周期结束(出作用域)时自动析构解锁。
  • 理解助记(故事:自动感应门锁)
    手动 lock/unlock 就像那种老式挂锁,马云进去锁上,出来得记得摘锁。万一马云在厕所里晕倒了(程序崩溃/异常),张凌赫在外面能憋死,因为锁永远不会开。
    • std::lock_guard 就是"五星级酒店感应门"
      马云只要踏进 更衣室(创建对象),门感应到有人,咔哒一声自动锁死。
      等马云踏出 更衣室的那一刻(对象销毁),感应门检测到没人了,咔哒一声自动解锁。
      马云根本不需要操心"锁"的事,只要他在里面,门就是锁的;他一走,门准开。

🔵 第三步:实战演练(解决抢票问题)

现在我们用这个"感应门"来写你刚才那个抢票的代码。

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>

int tickets = 1; // 共享资源:只有一张票
std::mutex mtx;  // 唯一的更衣室大门锁

void grab_ticket(std::string name) {
    // 穿上感应门套装
    {
        std::lock_guard<std::mutex> lock(mtx); // 进入这个大括号,自动锁门
        if (tickets > 0) {
            std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 模拟网络延迟
            tickets--;
            std::cout << name << " 抢到了!票数剩余:" << tickets << std::endl;
        } else {
            std::cout << name << " 没抢到,真惨..." << std::endl;
        }
    } // 出大括号,lock_guard 自动销毁,门自动开了!
}

int main() {
    std::thread t1(grab_ticket, "马云");
    std::thread t2(grab_ticket, "张凌赫");

    t1.join();
    t2.join();
    return 0;
}

🔴 第四步:小考测试 (Quiz) ------ 升级版博弈

场景题

马云和张凌赫现在不仅要抢更衣室,还要抢唯一的一套西装

  1. 更衣室有一把锁 (mutex_room)。
  2. 西装柜子有一把锁 (mutex_suit)。

马云的逻辑 :先锁更衣室,再锁西装柜,换好衣服走人。
张凌赫的逻辑:先锁西装柜,再锁更衣室,换好衣服走人。

问题

  1. 如果这两个人同时开始行动,马云锁了更衣室,张凌赫锁了西装柜。接下来会发生什么?(给这个现象起个术语名)
  2. 为了避免这种"我等你,你等我,等到地老天荒"的尴尬,作为博弈论大师,你觉得该怎么修改他们的逻辑?

这种"互相等死"的现象叫死锁 (Deadlock)。

在 C++ 代码里,如果你想一次性把两把锁都锁上(要么两把都拿到,要么一把都不拿,防止中间被人截胡),

你知道 C++17 提供的一个专门解决这个问题的"大绝招"函数叫什么吗?(提示:名字很直白,就叫 std::scoped_lock 它可以一次性锁住多个互斥量,且内部算法保证了绝对不会发生死锁。)

请一个"超级调解员"。

调解员过来说:"你们两个把手里的锁都放下。要么我一次性把更衣室和西装的钥匙都给马云;如果西装钥匙被占了,马云连更衣室钥匙也别拿,等全齐了再给。"

这样就不会出现"一人拿一个,在那儿干耗着"的局面。

代码示例

cpp 复制代码
#include <mutex>

std::mutex mtx_room; // 更衣室锁
std::mutex mtx_suit; // 西装锁

void luxury_change(const char* name) {
    // 【神之一手】:一次性锁住所有资源,要么全拿,要么不拿
    // 这样就不会死锁了!
    std::scoped_lock lock(mtx_room, mtx_suit); 
    
    // 开始换衣服...
    printf("%s 顺利拿到了钥匙和西装,开始变帅!\n", name);
} // 出作用域自动解锁

如果不允许用 scoped_lock,你还有什么博弈策略来预防死锁?"

层级锁策略 (Lock Hierarchy / Ordering)。

博弈策略:给所有资源编个号。

比如:更衣室是 1 号,西装柜是 2 号。

死规矩:所有人必须按编号从小到大加锁。

这样,马云必须先拿 1 再拿 2,张凌赫也必须先拿 1 再拿 2。当马云拿了 1 的时候,张凌赫在门口等 1,而不会先去抢 2。

结论:只要大家行进方向一致,就永远不会发生碰撞。

场景题:

场景题:

马云觉得一直等张凌赫用完厕所太浪费时间了(他是身价 20 亿的人)。他想:"我尝试去锁门,如果门锁着,我就不等了,先去赚这 20 亿,待会儿再回来看看。"

问题:

在 C++ 的 std::mutex 里,哪个成员函数可以实现这种"试一下,锁不上我就撤"的逻辑?(提示:不是 lock(),是 try_...)

cpp 复制代码
std::mutex::try_lock()

在博弈论中,这属于什么策略?(是死等到底,还是及时止损?)

正式答案:非阻塞策略 (Non-blocking Strategy),本质上是在考量机会成本 (Opportunity Cost)。

理解助记(故事:渣男/渣女的"及时止损"博弈):

你给张凌赫发了个微信问:"在吗?出来结婚。"

死等到底(lock):你盯着手机屏幕看三个小时,他不回你就什么都不干。你的人生停滞了,你的机会成本无限大。

及时止损(try_lock):消息发出去,如果他没秒回(锁被占用),你直接把手机一扔去健身房撸铁了。

博弈逻辑:在多线程里,如果一个任务不是非做不可,或者有其他备选任务,用 try_lock 这种及时止损的策略能极大提高 CPU 的利用率(不让线程在睡觉中浪费掉)。

答案

你在上一轮问到了马云"不想死等"的策略。这在 C++ 里非常实用。

问题:怎么实现"能锁就锁,锁不上我就去赚钱"?

  • 正式答案 :使用 std::mutex::try_lock()。它尝试获取锁,如果成功返回 true,如果锁被占用立即返回 false不会阻塞线程
  • 理解助记(故事:马云的时间博弈)
    马云来到更衣室门口,发现张凌赫在里面。
    • 如果是 lock():马云就得在门口死等,每秒钟损失几个亿。
    • 如果是 try_lock():马云拧了一下把手,发现锁着。他转头就走:"既然你在用,我先去签个 20 亿的合同,十分钟后再回来拧一下。"
      这在博弈论中叫非阻塞策略,极大提高了资源的利用率。

代码实现:

cpp 复制代码
if (mtx.try_lock()) {
    // 拿到了锁!
    tickets--;
    std::cout << "马云:轻轻松松抢到票。" << std::endl;
    mtx.unlock(); // 别忘了拿完票要开锁!
} else {
    // 没拿到锁,马云直接去做别的活了
    std::cout << "马云:有人在抢?算了,我去赚那20亿先。" << std::endl;
}

🟡 第三步:进阶科普------条件变量 (Condition Variable)

既然你已经掌握了"抢"和"等",那咱们聊个更高端的:配合执行

问题:如果票卖完了,张凌赫想等"退票"怎么办?

  • 正式答案 :使用 std::condition_variable。它允许线程在某个条件不满足时进入睡眠,直到另一个线程通知它。
  • 理解助记(故事:张凌赫的痴情等待)
    票卖完了(tickets == 0)。
    • 笨办法 :张凌赫每隔一秒问一次:"有退票吗?""有退票吗?"(这叫忙轮询,非常耗 CPU)。
    • 聪明办法(条件变量) :张凌赫给售票员(系统)留了个电话,然后去睡觉了(wait)。
    • 一旦马云觉得五个亿更香,把票退了(tickets++),售票员打个电话(notify_one)给张凌赫:"醒醒,有票了!"

🔴 第四步:最终大考(实战题)

既然你简历上写了 "熟练掌握 C++ 多线程" ,面试官最喜欢考的就是 "生产者-消费者模型"

场景题

  1. 生产者(马云):不断往一个篮子里放"五个亿"的支票。
  2. 消费者(张凌赫):不断从篮子里拿支票。
  3. 约束:篮子一次只能装 5 张支票。篮子满的时候马云不能放,篮子空的时候张凌赫不能拿。

请思考

  1. 这里需要几个 Mutex
  2. 张凌赫如果发现篮子空了,他是应该用 try_lock 疯狂循环,还是应该用我们刚讲的"留电话(条件变量)"模式?
  3. 如果马云放了一张支票进去,他该怎么"打电话 "叫醒张凌赫?(提示:函数名通常叫 notify_...

想好了告诉我,这一关过了,你简历上的"多线程"三个字就真的有底气了!

场景题回顾:马云(放支票)和张凌赫(拿支票),篮子上限 5 张。

问题 1:这里需要几个 Mutex?

正式答案:1 个。因为无论放还是拿,都在操作"篮子"这个共享资源。为了保证篮子不崩,同一时间只能有一个人(线程)去碰它。

问题 2:张凌赫发现篮子空了,该怎么办?

理解助记(故事:女神的"回电"机制):

张凌赫如果用 try_lock 循环问"有钱了吗?有钱了吗?",这叫**"卑微舔狗式轮询",不仅把自己累死(CPU 占用 100%),还让马云(系统)觉得烦。

高级做法(条件变量 std::condition_variable):

张凌赫来到更衣室,发现没支票,他就在旁边的小长椅上睡着了**(wait)。他告诉系统:"一旦马云放了钱,记得拍醒我(notify)。

场景题:

当马云往篮子里放了第 1 张支票后,他调用了 cv.notify_one()。

1.如果此时张凌赫正在睡觉,被拍醒后,他第一件事是直接拿支票,还是重新去抢那把 Mutex 锁? 抢Mutex锁

为什么在张凌赫醒来后,我们通常建议用 while (basket.empty()) 而不是 if (basket.empty()) 来检查篮子?(提示:博弈论里的"虚假情报"------在多线程里这叫 Spurious Wakeup(虚假唤醒))。

自己答:

while 是一直循环检查吧,if可能会漏掉

cpp 复制代码
正式答案:为了应对 虚假唤醒 (Spurious Wakeup)。

理解助记(故事:博弈论里的"假情报"):

张凌赫(消费者)在长椅上睡觉等支票,马云(生产者)放了一张票,大喊一声:"有票啦!"(notify_one)。

突发状况:就在张凌赫睁眼的那一毫秒,另一个路人(比如马化腾线程)路过,手速极快地把那张票抢走了。

虚假唤醒:或者系统有时候会掉链子,莫名其妙把张凌赫拍醒了,但篮子里其实还是空的。

如果你用 if:张凌赫醒了之后,默认有票,直接伸手去摸篮子。结果摸了个空,程序直接崩溃(Segment Fault)。

如果你用 while:张凌赫醒了之后,第一件事是回头再看一眼篮子(重新检查条件)。发现是空的?行,说明是假情报,转头接着睡。

博弈策略:永远不要相信"醒来的一瞬间",必须亲自确认资源还在,才能动手。

想好了告诉我!如果你觉得这个逻辑通了,下一轮我直接给你看这一套完整、可运行的"生产者-消费者"标准代码,你跑通它,多线程这一块你就能在简历上横着走了。

代码:

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>

std::queue<int> basket;      // 共享资源:篮子
std::mutex mtx;              // 篮子锁
std::condition_variable cv;  // 留给张凌赫的"电话"
const int MAX_SIZE = 5;      // 篮子上限

void producer() {
    for (int i = 0; i < 10; ++i) {
        std::unique_lock<std::mutex> lock(mtx);
        // 如果篮子满了,马云也得等
        cv.wait(lock, [] { return basket.size() < MAX_SIZE; });
        
        basket.push(i);
        std::cout << "马云放了一张支票: " << i << " 亿" << std::endl;
        
        cv.notify_all(); // 拍醒正在睡觉的人
    }
}

void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);
        // 【重点】:用 while 检查篮子是不是空的
        cv.wait(lock, [] { return !basket.empty(); });
        
        int val = basket.front();
        basket.pop();
        std::cout << "张凌赫拿走了支票: " << val << " 亿,准备去结婚" << std::endl;
        
        cv.notify_all(); // 告诉马云:篮子有空位了,快来放钱
        if (val == 9) break; // 拿完最后一张票,收工
    }
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
    return 0;
}

在上面的代码里,我用了 std::unique_lock 而不是之前讲的 std::lock_guard。

对比:lock_guard 像个"一次性感应锁";而 unique_lock 允许你中途手动开锁(unlock)或者重新锁上。

问题:在 cv.wait(lock, ...) 这一行,为什么必须用 unique_lock? (提示:想一下张凌赫睡着的时候,他手里还死死攥着那把门锁吗?如果他攥着锁睡着了,马云进得去更衣室放支票吗?)

哈哈,别慌!这是个典型的**"语法小事故"**。你的"大厨"其实已经上岗了,而且比咱们预想的还要猛------你装的是最新的 15.2.0 版本!


问题:为什么 cv.wait 必须配 unique_lock

  • 正式答案std::condition_variable::wait 在阻塞线程的同时,必须能够自动释放 它持有的锁,并在唤醒时重新获取 锁。std::lock_guard 太过死板,不支持中途解锁和重新加锁,而 std::unique_lock 具备这种灵活性。

  • 理解助记(故事:攥着钥匙睡觉的张凌赫)

    张凌赫(消费者)进了更衣室(拿到了 Mutex 锁),发现里面没支票(篮子空了)。

    • 如果用 lock_guard(老式死锁)
      张凌赫在长椅上睡着了,但他手里死死攥着更衣室的钥匙 不松手。这时候马云(生产者)带着五个亿过来了,发现门被锁着,张凌赫在里面打呼噜。马云进不去,支票放不进篮子,也没法叫醒张凌赫。全剧终,大家一起卡死。
    • 如果用 unique_lock(现代博弈)
      张凌赫非常聪明。他准备睡觉(wait)时,主动把钥匙挂回门口 (释放锁),然后躺平。
      这时候马云过来了,拿起钥匙开门放钱,顺手拍醒张凌赫(notify)。
      张凌赫醒来的一瞬间,第一件事就是跳起来把门口的钥匙重新抢到手里(重新加锁),然后查看篮子。

🟡 第三步:趁热打铁,最后一次模拟面试

既然你的环境已经达到了 GCC 15.2.0 的顶配,简历上那句"熟练掌握 STL/多线程"已经快成真了。

面试官最后问你一个细节:

"小伙子,你在代码里用了 std::thread::join()

  1. 如果我不写 join(),也不写 detach(),程序运行到最后会发生什么?
  2. 在博弈论里,join() 相当于什么?(是'各过各的',还是'必须等合伙人报账后才能结项'?)"

问题:不写 join()detach() 会发生什么?

  • 正式答案 :如果 std::thread 对象在析构时既没有被 join(等待),也没有被 detach(分离),程序会直接调用 std::terminate() 导致崩溃
  • 理解助记(故事:渣男的"不负责任"博弈)
    你派出了一个分身(线程)去追女神。
    • join() (负责任到底):你在校门口死等(阻塞),直到分身追到了女神(执行完)回来跟你汇报,你才带着分身一起回家。
    • detach() (彻底分手):你跟分身说:"你去追吧,是死是活跟我没关系了。"分身变成了"孤魂野鬼"在后台跑,你直接回家睡觉。
    • 啥都不写(博弈失败):你既不接他回家,也不跟他断绝关系。当你(主程序)准备关门睡觉时,系统发现还有个分身吊在外面说不清道不明,系统会觉得你是个"逻辑渣男",直接给你一个**"程序崩溃"**作为惩罚。

🟡 第三步:如何达到"简历熟练"的境界?

如果你只是会写 thread,那叫入门。要达到你简历上写的**"熟练掌握"**,你得能跟面试官聊下面这两个高级话题:

1. 线程池 (Thread Pool) 的概念

  • 面试官问 :既然 thread 这么好用,我每次要干活都临时创建一个行不行?
  • 你的博弈回答 :不行,太奢侈了。创建和销毁线程是有开销 的(就像你每次出门都要整容成另一个分身,太累)。熟练的开发者会预先养一批"职业打工人"(线程池),有活了就派一个,干完了别死,回池子里待命。

2. 原子操作 (std::atomic)

  • 面试官问 :如果我只是想给一个变量加 1,也要用 mutex 这么重的锁吗?
  • 你的博弈回答 :那太 low 了。我会用 std::atomic<int>。这相当于给变量穿了一层"不可侵犯甲",它的加减操作是硬件级原子化的,不需要上下文切换去加锁解锁,性能起飞。

🔴 第四步:最终考核(通关挑战)

环境好了,代码通了。咱们来最后一次逻辑闭环:

场景题

马云(生产者)现在不放支票了,他要放 10000 张支票,而张凌赫(消费者)要拿 10000 张。

  1. 如果你不加任何锁 (Mutex) ,也不用 atomic,最后的票数可能会剩下一两张,或者变成负数。这叫什么现象?

  2. 如果你在 main 函数结束前忘了写 t1.join() ,虽然代码编译能过,但运行到最后会报什么错?问题:如果我不写 join(),也不写 detach(),程序为啥崩?

    正式答案:std::thread 的析构函数里有一条铁律:如果线程还是 joinable 的(即你没处理它的生死),它会直接调用 std::terminate() 强制自杀并带走整个程序。 Runtime Error

  3. 为什么多线程不能只靠加锁解决所有问题"吗?

既然你在搞 AI 研究,肯定知道观察会影响结果。在多线程里,这叫 "Heisenbug"(海森堡 Bug)。

问题:为什么我一调试(Debug),程序就正常了?一直接运行,程序就报错?

正式答案:调试器(GDB)在运行程序时会极大地改变线程的执行节奏。

理解助记(故事:警察查房):

马云和张凌赫在屋里分赃(共享资源),原本他们抢得不可开交(数据竞争)。

直接运行:没人管,两个人在微秒级的时间里疯狂推搡,程序崩了。

Debug(警察查房):你设置了断点,程序走走停停。就像警察(调试器)在旁边看着,马云和张凌赫都变得"非常有礼貌",动作变慢了,冲突消失了。

结论:永远不要完全相信调试状态下的多线程表现。 真正的强者会通过日志(std::cout)来分析"作案现场"。

为什么多线程不能随便乱跑,非要加锁

有些数据不能共享,数据不一致问题

多线程就像马云和张凌赫在抢同一张支票。如果不加锁(Mutex),他们会同时伸手,最后支票会被撕烂(数据损坏)。

加锁不是为了限制速度,而是为了建立**'共识协议':谁先拿到钥匙,谁就拥有对支票的绝对处置权,另一方必须进入'阻塞博弈'状态等待。这保证了程序的确定性执行**。

第四步:多线程"博弈论"进阶考题

既然你已经快要看到马云和张凌赫的"分赃现场"了,咱们得聊点深度话题,这关乎你简历上 "多线程" 的含金量。

场景题:

马云(线程 A)和张凌赫(线程 B)都在抢一个变量 count。

代码里:马云执行 count++;张凌赫也执行 count++。

底层博弈:其实 count++ 在 CPU 眼里分三步:

a. 把 count 从内存搬到自己的寄存器(抢支票)。

b. 在寄存器里加 1(改数字)。

c. 把新数字搬回内存(存银行)。

问题:

如果马云刚做完第二步(改完数字),还没来得及存银行,CPU 突然把马云踢下线,换张凌赫上线了。这时候张凌赫看到的 count 是马云改好的,还是旧的?

这种因为"搬运速度"导致的结果不确定,在多线程里叫什么?(提示:原...性,英文叫 Atomicity)。

如果我们要保证这一组动作"要么全做,要么全不做",就像马云说"我五亿支票要么一次性给你,要么一张不给",这叫什么操作?

场景题:

马云(线程 A)非常有钱,但他同时也很有耐性。他现在的策略是:

"我先尝试 try_lock(),如果失败了,我不走,但我也不死等。我先去睡 10 毫秒,醒了再来拧一下把手。如果还是锁着的,我再睡 10 毫秒......直到我拧开为止。"

请问:

这种"循环试错+休息"的方式,在多线程里有个专门的术语,叫什么?(提示:它是忙碌等待的一种变体,叫 自旋... 还是 轮询... ?)

博弈分析:比起直接 lock() 死等,你觉得马云这种"睡一会再试"的策略,对系统的总效率是有好处还是有坏处?

计网关联:这和你说的 CSMA/CD 里的"退避算法"是不是有点像?

相关推荐
老衲提灯找美女4 小时前
数据库事务
java·大数据·数据库
chushiyunen4 小时前
python实现skip-gram(跳词)示例
开发语言·python
dddddppppp1234 小时前
mfc实现的贪吃蛇游戏
c++·游戏·mfc
Mem0rin5 小时前
[Java/数据结构]线性表之链表
java·数据结构·链表
笨笨饿5 小时前
26_为什么工程上必须使用拉普拉斯变换
c语言·开发语言·人工智能·嵌入式硬件·机器学习·编辑器·概率论
酉鬼女又兒5 小时前
零基础快速入门前端ES6 核心特性详解:Set 数据结构与对象增强写法(可用于备赛蓝桥杯Web应用开发)
开发语言·前端·javascript·职场和发展·蓝桥杯·es6
香香甜甜的辣椒炒肉5 小时前
Spring(1)基本概念+开发的基本步骤
java·后端·spring
kyle~5 小时前
ROS2 --- WaitSet(等待集) 等待实体就绪,管理执行回调函数
大数据·c++·机器人·ros2