☰
Linux系统篇48——线程(十三) 线程安全、重入与死锁,可重入为什么是线程安全的子集
2026/10/12 4:47:41 网站建设 项目流程


📚 本文收录于「流浪」的系列专栏

🐧Linux系统⚙️C++
📊数据结构与算法🐍Python
🔗LangChain & LangGraph🗄️MySQL 数据库
🌿Git 工具🌐计算机网络
🤖LLM💯大厂面试、八股
📚学习筑基专栏

🏠 博客主页:流浪 | 📝 原创首发于 CSDN


前言:线程(十二)用两个信号量把判空判满提前到访问之前,环形队列单生产单消费一把锁都不用。本篇收两笔账:一笔算给函数——线程安全与可重入到底什么关系;一笔算给锁——死锁怎么成型、怎么破。最后把 STL 和智能指针的线程安全边界标清楚。


一、线程安全与重入

1.1 两个词各描述谁

1. 线程安全是执行流自身的健康状态
  1. 线程安全说的是线程自身的健康状态:多个执行流并发跑,跑完结果照样正确、互相不破坏
  2. 判定标准线程(八)引过 man 手册口径:能被多个线程同时调用且结果照样正确的函数,才叫线程安全
  3. 反面就是全局资源没加保护——判断和减减的缝隙被别的线程钻了,ticket 抢票是原型
2. 重入描述的是函数的特征
  1. 重入说的是函数的特征:同一个函数,前一次调用还没返回,又一次进入了
  2. 再次进入的那次执行能不能照样正确,决定了这个函数可重入还是不可重入
  3. 两个词的宾语不同——线程安全的宾语是执行流跑起来的状态,重入的宾语是函数这件东西的属性

线程安全与重入不在同一个层面:前者说执行流安不安全,后者说函数能不能被重复进入。分清各描述谁,两个词就搅不到一起。

1.2 重入的两种情况

1. 多线程重入函数
  1. 第一种情况:多个线程并发调用同一个函数,第一个没跑完,第二个又进来了
  2. 两个执行流各拿一层调用,函数里的共享数据同时被两份执行踩——线程(八)的抢票现场就是这一种
2. 信号导致一个执行流重复进入函数
  1. 第二种情况:一个线程正跑在函数里,信号打进来,处理函数里又调了同一个函数
  2. 同一条执行流把函数夹了两层——内层跑完回外层,外层接着跑,两层同时活着
  3. 线程(八)提过 glibc 把两个维度分开标:MT-Safe 管多线程并发进入,AS-Safe 管中断后重入

两条路不同,对函数的要求是同一个——重入进来的时候,自己带的东西够不够用,碰不碰外面的共享状态。

1.3 可重入是线程安全的子集

1. 可重入函数一定是线程安全的
  1. 只碰自己局部变量和参数的函数,每一层调用都有独立的一份栈帧,变量各是各的
  2. 多线程进、信号打断后进,每一层拿到的都是自己那一份,结果互不污染
  3. 连重复进入都扛得住的函数,多线程同时调用没有出错的路径
2. 线程安全不一定可重入
  1. 加锁能把不可重入函数改造成线程安全:一次只放一个进来,并发的数据竞争被锁挡住——线程(八)的结论
  2. 但重入撞的是另一堵墙:第一次进入持着锁跑到一半,第二次进入在 lock 上等(递归死锁)
  3. 等的是第一次把锁解开,而第一次正等着第二次返回——自己等自己,谁都返回不了
3. 不考虑信号重入时两者可以不区分
  1. 两个方向合起来是子集关系:可重入函数一定是线程安全的,线程安全的函数不一定是可重入的
  2. 若不考虑信号导致的那一种重入,两个词在安全角度可以不区分——线程安全侧重并发访问公共资源的安全,可重入描述函数能否被重复进入
  3. 把信号场景算进来,两者就必须分开答:加锁是线程安全的,恰恰在重入场景卡死

可重入 ⊆ 线程安全。线程安全用锁就买得到,可重入得看函数碰了谁——只碰自己栈帧的可以,碰了共享状态的,加锁也救不了重入。

1.4 不可重入的典型函数

1. 四类典型
函数特征为什么不可重入
不保护共享变量全局、静态数据被多层执行同时改写,ticket 抢票是原型
返回静态变量指针static 缓冲只有一份,第二次进入直接覆盖第一次的结果
调用 malloc/free堆由全局链表管理,改链表改到一半被重入,链就断了
调用标准 I/O 库全局缓冲区同理——手册口径:stdio 全部函数不是异步信号安全的

四类的共性只有一条——函数依赖了自己栈帧之外的共享状态。名单可以背,机制记住这一条就推得出来。


二、死锁与四个必要条件

2.1 死锁是什么

1. 死锁的定义
  1. 一组执行流,各自占着一部分资源不放手,又互相申请对方手里的资源
  2. 你等我的、我等你的,谁也拿不到想要的、谁也不撒手里的,永久等待——这个僵局就是死锁
  3. 本文语境里资源主要是锁,但死锁不挑资源:两把锁、锁加条件变量、锁加信号量都能成型
2. 出现过的两个现场
  1. 线程(十)推演过:拿着锁去等条件变量、锁又攥在手里——唤醒的人进不了临界区,等的人和持锁的人互卡
  2. 线程(十二)的面试题里算过:生产者持锁睡在信号量上,消费者进不去、空位腾不出——双双卡死
  3. 两个现场角色不同,僵局的共同点一样:占着的资源不放手,等着的资源在别人手里

2.2 双锁交叉申请的时序

1. 僵局成型的四步
  1. 线程一锁住锁 A,线程二锁住锁 B——各持一把,互不相干
  2. 线程一申请锁 B:锁被线程二持有,睡下等待,锁 A 不放手
  3. 线程二申请锁 A:锁被线程一持有,睡下等待,锁 B 不放手
  4. 双方各持一把、各等一把,能解开对方的那个动作永远轮不到执行

2.3 死锁的四个必要条件

1. 互斥
  1. 资源一次只能被一个执行流占用——这是锁的本职,线程(九)拆互斥锁原理拆的就是它
  2. 破坏点:资源能同时使用就无需排队,但加锁就是为了独占资源,所以这个死锁条件最难打破。
2. 请求与保持
  1. 一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  2. 破坏点:要新的就别攥旧的——申请前先释放已有的,或者一次全拿
3. 不剥夺
  1. 一个执行流已获得的资源,在未使用完之前,不能强行剥夺
  2. 破坏点:等不到就上交——把已持有的资源还回去,稍后重来
4. 循环等待
  1. 若干执行流之间形成一种头尾相接的循环等待资源关系
  2. 破坏点:给资源排个全序,所有执行流按同一个顺序申请,等待关系排成一条直线,接不成环
5. 四条同时成立死锁才成立
  1. 四个条件都是必要条件:死锁发生,四条必然全部成立
  2. 反过来,任意一条被破坏,死锁就锁不成——「必要条件」的准确含义就是缺一不可,这也是避免死锁的全部抓手

防死锁不用四条全防,盯住最好破的那一条下手就够——互斥动不得,就从剩下三条里挑。

2.4 如何避免死锁

1. 破坏必要条件的四个抓手
  1. 资源一次性分配——破坏请求与保持:要拿一次全拿,不出现占着 A 等 B 的姿势
  2. 申请带超时——破坏不剥夺:等不到就超时返回,把手里已持有的还回去,稍后重试
  3. 加锁顺序一致——破坏循环等待:所有线程按同一顺序拿锁,等待关系成一条直线,头尾接不成环
  4. 避免锁未释放的场景:占着资源的窗口越短,僵局越难凑成——RAII 封装的 LockGuard 在任何路径都还锁,就是这一招的落地
2. 银行家算法
  1. 死锁避免的经典算法:把资源借出去之前先推演一圈——借给这批执行流之后,剩余资源还能不能让所有人按某个顺序全部跑完
  2. 能全部跑完的顺序叫安全序列,找得到就借、找不到就先不借——宁可不借,不进僵局
3. std::lock 一次性锁多把
  1. C++ 层给了现成工具:std::lock 用死锁避免策略一次锁两把,不存在锁住一把、卡在等另一把的窗口
  2. 配 std::unique_lock 的 defer_lock:构造时只包住锁不加锁,最后一起上
std::mutex mtx1,mtx2;// defer_lock:构造时只绑定锁、不立刻加锁std::unique_lock<std::mutex>lk1(mtx1,std::defer_lock);std::unique_lock<std::mutex>lk2(mtx2,std::defer_lock);// 一次性锁两把,顺序死锁从根上避开std::lock(lk1,lk2);

破坏条件是预防,银行家是避免,std::lock 是工具——三层防的是同一件事:别让四个条件凑齐。


三、STL、智能指针与其他锁

3.1 STL 容器不是线程安全的

1. 默认不加锁
  1. STL 容器默认不加任何锁——库追求极致性能,锁的开销宁可不付
  2. 多线程并发读写同一个容器,保护责任全在使用者:库不知道使用场景的并发模式,替用户加锁反而处处添堵

STL 的选择是性能优先、安全自理——容器跨线程用,锁自己配。

3.2 智能指针的线程安全性

1. unique_ptr 不进这个问题
  1. 只在当前代码块内生效,出了作用域自动析构,所有权独一份
  2. 不可拷贝、没有引用计数,不存在多方共改的问题
2. shared_ptr 的引用计数是原子的
  1. 引用计数被所有拷贝共享读写,标准库用原子操作(CAS)保证增减一步到位——多个线程各自拷贝、析构自己的副本,安全
  2. 边界要分清:计数原子只保计数这一层——同一个 shared_ptr 对象本身的并发读写、所指对象内部的并发访问,都不在保内

shared_ptr 是局部线程安全——计数这一层交给原子操作,所指对象那一层还得自己加锁。

3.3 悲观锁、乐观锁、CAS、自旋锁、读写锁

1. 五种锁的定位
  1. 悲观锁:取数据前先加锁,假设别人一定会改
  2. 乐观锁:不加锁,更新前用版本号或 CAS 校验没被改过
  3. CAS:比较当前值,相等才写入新值,不等就重试——一条原子指令的语义
  4. 自旋锁:抢不到就原地空转重试,不睡——与互斥锁的差别就在等锁的姿势:互斥锁睡回去,自旋锁站着等
  5. 读写锁:读共享、写独占,读多写少的场景放开并发度

自旋适合临界区极短的场景——等的时间比睡一觉的切换开销还短,站着等才划算;临界区一长,自旋就成纯烧 CPU。


四、文末面试题

4.1 推导题

1. 为什么线程安全不等于可重入?用一把锁说清。

答(推导):加锁保证多线程并发进入时一次只进去一个,数据竞争被挡在锁外,线程安全到手;但重入要求同一条执行流第二次进入时依然正确,而第二次进入撞上的是自己没解开的锁,卡死在 lock 上等一个永远等不到的解锁。前者说的是并发互斥,后者说的是重复进入——锁能解决前者,解决不了后者,所以线程安全买得到、可重入买不到,可重入只能是线程安全的子集。

2. 「四个必要条件」和「破坏任意一个即可避免」是什么逻辑关系?

答(推导):必要条件的含义是缺一不可——死锁要成立,互斥、请求与保持、不剥夺、循环等待必须同时成立。逆否过来就是:任意一条不成立,死锁必然锁不成。所以避免死锁不需要四条全破,破坏任意一条就够。工程上挑循环等待下手的原因也在这:互斥是锁的本职动不得,请求与保持和不剥夺要改分配协议,唯独加锁顺序一致只改次序不动语义,代价最小。

3. 两个线程两把锁交叉申请,推演死锁成型的全过程。

答(推导):线程一锁 A 成功,线程二锁 B 成功,两边各持一把;线程一申请 B,被线程二持有,睡下且不放手 A;线程二申请 A,被线程一持有,也睡下且不放手 B。此时互斥天然成立,请求与保持、不剥夺、循环等待三条同时凑齐,四条件全部满足——能解开对方的动作分别被对方的锁挡着,双方永久等待,死锁成型。

4. 加锁顺序一致为什么能破坏循环等待?

答(推导):循环等待要求等待关系头尾相接成环。规定所有线程都先 A 后 B 之后,等 B 的人必先持有 A,而等 A 的人按顺序规定手里一把锁都没有——他没有任何东西可以被别人等,成不了环上的一环,环从第一环就断。等待关系被排成一条单向直线,头尾永远接不上。

5. malloc/free 为什么不可重入?给它加一把锁之后可重入了吗?

答(推导):malloc 用全局链表管理堆——空闲块挂在全局结构上,申请和释放都要改链。第一次调用改链改到一半被信号打断,处理函数里再进 malloc,读到的是半新不旧的链表,插节点可能把链插丢甚至插成环。加锁之后多线程并发安全了,但信号重入时第二次调用撞上自己没解的锁——和上一题同一个结局:线程安全到手,可重入没到手。

6. shared_ptr 的引用计数,线程安全性从哪来?

答(推导):引用计数被多个线程同时增减,而 ++/-- 是读、改、写三步非原子,线程(八)的 ticket 已经证明过三步操作会被切走。加互斥锁能保安全但太重——标准库用原子操作(CAS)一步到位:比较当前值、相等才替换成新值,不等就重读重试。计数原子了,各线程拷贝析构各自的副本就安全;但它只保计数这一层,所指对象的并发访问还得自己加锁。

4.2 真题

1. 死锁产生的四个必要条件是:互斥、( )、环路等待和不剥夺。选项:释放和阻塞、请求和阻塞、请求和保持、请求和释放

答(推导 · 已对照解析,转述):正确答案是请求和保持——进程因请求资源被阻塞时,对已获得的资源保持不放,「请求」和「保持」两个动作缺一不可。四个选项全带「请求」,差别全在后半截,这题考的就是条件名里每个字的含义。

【真题·转述自 牛客网题库】

2. 请你说说互斥锁和自旋锁

答(推导 · 已对照解析,转述):加锁失败时互斥锁用线程切换应对——挂进等待队列睡回去;自旋锁用忙等待应对——原地循环重试不放弃 CPU。互斥锁适合持锁时间长、竞争激烈的场景,自旋锁适合临界区极短的场景。两者是锁的最基本处理方式,更高级的锁都基于二者之一实现——读写锁既可以基于互斥锁,也可以基于自旋锁。

【真题·转述自 牛客网面经题】


💬结语:线程安全看执行流、可重入看函数,死锁记牢四个必要条件——破坏任意一条就锁不成。Linux 系统篇到此收官,评论区聊聊你排查过的死锁现场,觉得有用点个赞。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询