多线程里锁的种类:
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_guard或std::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) ------ 升级版博弈
场景题 :
马云和张凌赫现在不仅要抢更衣室,还要抢唯一的一套西装。
- 更衣室有一把锁 (
mutex_room)。 - 西装柜子有一把锁 (
mutex_suit)。
马云的逻辑 :先锁更衣室,再锁西装柜,换好衣服走人。
张凌赫的逻辑:先锁西装柜,再锁更衣室,换好衣服走人。
问题:
- 如果这两个人同时开始行动,马云锁了更衣室,张凌赫锁了西装柜。接下来会发生什么?(给这个现象起个术语名)
- 为了避免这种"我等你,你等我,等到地老天荒"的尴尬,作为博弈论大师,你觉得该怎么修改他们的逻辑?
这种"互相等死"的现象叫死锁 (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++ 多线程" ,面试官最喜欢考的就是 "生产者-消费者模型"。
场景题:
- 生产者(马云):不断往一个篮子里放"五个亿"的支票。
- 消费者(张凌赫):不断从篮子里拿支票。
- 约束:篮子一次只能装 5 张支票。篮子满的时候马云不能放,篮子空的时候张凌赫不能拿。
请思考:
- 这里需要几个 Mutex?
- 张凌赫如果发现篮子空了,他是应该用
try_lock疯狂循环,还是应该用我们刚讲的"留电话(条件变量)"模式? - 如果马云放了一张支票进去,他该怎么"打电话 "叫醒张凌赫?(提示:函数名通常叫
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()。
- 如果我不写
join(),也不写detach(),程序运行到最后会发生什么? - 在博弈论里,
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 张。
-
如果你不加任何锁 (Mutex) ,也不用
atomic,最后的票数可能会剩下一两张,或者变成负数。这叫什么现象? -
如果你在
main函数结束前忘了写t1.join(),虽然代码编译能过,但运行到最后会报什么错?问题:如果我不写 join(),也不写 detach(),程序为啥崩?正式答案:std::thread 的析构函数里有一条铁律:如果线程还是 joinable 的(即你没处理它的生死),它会直接调用 std::terminate() 强制自杀并带走整个程序。 Runtime Error
-
为什么多线程不能只靠加锁解决所有问题"吗?
既然你在搞 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 里的"退避算法"是不是有点像?