Linux线程(五):线程安全、可重入与死锁
目录
一、线程安全
欢迎来到并发编程的深水区。在之前的文章中,我们讨论了如何创建线程以及如何通过互斥锁保护数据,但 "为什么一定要加锁?" 以及 "加了锁就一定安全吗?" 这两个问题,指向了并发编程最核心的挑战:线程安全
本篇博客深入底层逻辑,揭示程序崩溃的根本原因
1. 什么是线程安全
线程安全是指在多线程环境下,多个执行流同时执行一段代码或访问同一组数据时,程序的运行结果始终符合预期,且不会出现数据损坏或逻辑混乱的情况
通俗地讲,如果一个函数、类或程序库在多线程并发执行时,不需要调用者进行额外的同步处理(如手动加锁),依然能表现出正确的行为,我们称之为线程安全
核心特征:结果的可预测性。无论线程如何调度,无论 CPU 核心如何分配,结果始终如一
2. 为什么不安全
一个经典问题:我只是给一个全局变量加了 1,代码只有一行 count++,为什么会不安全
关键在于:看似简单的一行 C/C++ 代码,在CPU底层执行时实际上经历了复杂的多步操作
(1) 操作的非原子性
以 count++ 为例,在汇编层面它通常由三条指令组成:
-
Load:将内存中的 count 值读取到 CPU 寄存器中
-
Add:在寄存器中执行加 1 操作
-
Store:将寄存器中计算好的新值写回内存
假设 count 为 10。线程 A 执行完 Load 和 Add 后(寄存器里是 11),时间片耗尽被切走。线程 B 此时进入,完整地执行了三步,将内存中的 count 变为了 11。当线程 A 恢复执行时,它直接执行第三步 Store,将自己寄存器里的 11 写回内存
结果:两个线程各加了一次,本应是 12,结果却是 11。这就是典型的数据丢失
(2) 共享资源的暴露
线程之间共享同一个进程的地址空间。这意味着:
-
全局变量
-
静态变量
-
堆区数据
这些资源对所有线程都是可见的。如果没有同步机制,多个线程就像多个厨师在没有沟通的情况下共用一个调料罐,谁先放盐、放多少盐完全处于失控状态
(3) 系统的随机调度
现代操作系统是抢占式的。线程在任何时刻、任何一行指令执行完后,都可能被系统强行剥夺 CPU 资源。这种不确定性是导致不安全的外部诱因
线程不安全的本质是:原本需要原子性执行的业务逻辑被操作系统的随机调度在非原子的硬件指令层面给打断了,且操作的对象是公共资源
二、可重入
可重入性是比线程安全更为严格的概念。如果说线程安全是代码在并发环境下正确运行的保障,那么可重入性则代表代码自身具备无状态、不依赖外部全局变量的特性,能够在被多个调用者同时执行时仍保持行为一致
1. 什么是可重入
可重入是指一个函数可以被重复进入
具体来说,当一个函数正在执行时,由于某种原因(如硬件中断、信号触发、或者在多线程中被调度),执行流离开了当前函数去执行另一段代码,而那段代码再次调用了这个函数。如果该函数在第二次被调用运行结束后,回到第一次被中断的地方继续执行,依然能产生正确的结果,那么这个函数就是可重入函数
核心逻辑: 可重入函数通常不使用任何全局变量或静态变量。它所有的操作都依赖于:
函数内部开辟的栈区空间(局部变量)
外部传入的参数
2. 多线程与信号重入
多线程重入
在多线程环境下,重入是常态。多个线程几乎同时进入同一个函数
-
栈区的独立性:每个线程都有自己独立的局部栈空间。如果一个函数只操作局部变量,那么每个执行流在进入该函数时,实际上是在操作自己那份独立的数据副本
-
天然的安全性:由于不涉及共享资源的争夺,可重入函数在多线程并发执行时,天生就是线程安全的
信号导致重入
这是最经典的重入场景。它的并发本质是:同一个执行流在逻辑上的自竞争
-
危险动作:假设你的函数正在执行一个链表的 insert 操作。刚走到一半(指针指向已改,但未完全链接),系统发来一个信号
-
二次进入:信号处理函数(Handler)中恰好也调用了这个 insert
-
逻辑崩坏:Handler 执行完并返回后,主执行流继续从断点工作。由于两次操作共用了同一个全局链表,指针的指向已经面目全非,最终会导致链表节点丢失或程序崩溃
在这种场景下,即便给 insert 加了互斥锁也是没用的,因为同一个执行流在 Handler 里尝试申请锁时,会导致自死锁。这就是为什么在信号处理函数中,我们只能调用异步信号安全的函数
三、线程安全与可重入
弄懂线程安全与可重入的基础定义后不难发现,二者概念相近但本质特性差异明显。想要在实际开发中准确区分、规避使用误区,就需要明确具体的辨别要点与实践规范
1. 常见线程安全情况
确保线程安全,关键在于 "严格管控"。
-
只读不写:多个线程对全局或静态变量只有读取权限,没有写入权限
-
原子操作:类或接口的操作设计为不可分割的原子任务
-
结果无二义性:无论操作系统如何进行线程切换,接口的执行结果都是确定的、唯一的

2. 常见线程不安全情况
代码写得过于随意,线程安全问题就会接踵而至。以下是几种典型的线程安全隐患场景:
-
不保护共享变量:函数操作了全局变量、静态变量或堆区数据,却没有使用互斥锁、信号量等同步机制进行保护
-
状态随调用变化的函数:某些函数内部维护了状态(类似于 "计数器"),随着被调用的次数增加,输出结果会发生变化,但在多线程下这种变化往往是不可控的
-
返回指向静态变量指针的函数:如果函数返回一个指向 static 变量的指针,多个线程拿到的是同一个地址,彼此的修改会互相覆盖
-
嵌套调用:函数本身看起来没问题,但它调用了其他线程不安全的函数

3. 常见可重入情况
可重入代码往往资源独立,不依赖外部环境:
-
外部解耦:不使用任何全局变量或静态变量
-
栈区作业:不使用 malloc 或 new 开辟空间,所有数据都存在栈帧中
-
避免嵌套:不调用任何不可重入的函数
-
数据隔离:不返回静态或全局数据,所有输出数据由调用者提供,或者使用全局数据的本地拷贝进行操作

4. 常见不可重入情况
如果函数在执行过程中被中断后再次调用会出问题,通常由以下原因导致:
-
调用了 malloc/free:因为 malloc 内部通常使用全局链表来管理堆空间,重入可能导致链表结构被破坏
-
调用标准 I/O 库函数:标准 I/O 库(如 printf, fread)的很多实现都使用了全局数据结构(如缓冲区)来提高效率
-
使用静态数据结构:在函数体内使用了 static 修饰的局部变量

5. 联系与区别
关于线程安全与可重入,我们可以将其总结为以下逻辑链条:
(1) 联系
-
如果一个函数是可重入的,那么它一定是线程安全的
-
如果函数是不可重入的,那么在多线程使用时极易引发线程安全问题
-
如果函数中有未加锁的全局变量,那么它既不是线程安全的,也不是可重入的
(2) 区别
-
可重入函数是线程安全函数的一个子集
-
线程安全不一定是可重入的(如加了锁的函数);可重入函数则必定是线程安全的
-
死锁:如果一个函数通过加锁实现了线程安全,但它在锁未释放时被重入,就会产生自死锁
(3) 侧重点
-
线程安全:侧重于说明多线程并发访问公共资源时的安全性,它表现的是并发执行的特点
-
可重入:侧重于描述函数是否能被重复进入(无论是因为多线程还是信号中断),它表现的是函数本身的特性
在日常线程安全编程中,我们通常无需严格区分这些情况。然而,对于系统级代码或底层库开发而言,这种区分却是至关重要
四、死锁
在并发编程的资源管理中,锁是确保数据一致性的核心工具。然而,不当的加锁策略会导致程序陷入停滞状态。本章将系统性地探讨死锁的产生、性质以及预防手段
1. 常见锁
在 Linux 环境下,根据资源竞争的激烈程度和业务场景的不同,存在多种锁的实现方案:
-
互斥锁 (Mutex):最基础的同步原语。当线程尝试获取已被占用的互斥锁时,该线程会被操作系统挂起(放入等待队列),进入休眠状态。这种方式在等待时间较长时能有效释放 CPU 资源,但存在线程上下文切换的开销
-
读写锁 (rwlock):针对读多写少场景优化的锁。它区分读模式和写模式。在读模式下,允许多个线程同时持有锁;而写模式下则是排他的。这极大地提高了多线程读取共享数据的并发性能
-
自旋锁 (Spinlock):一种处于忙等状态的锁。当线程获取锁失败时,它不会进入休眠,而是在 CPU 上循环检查锁的状态。自旋锁适用于临界区极短、预期等待时间远小于线程切换开销的场景

2. 什么是死锁
死锁是指两个或多个执行流在运行过程中,因争夺共享资源而造成的一种互相等待的僵持状态。若无外力干涉,这些执行流将永远无法继续向下执行
典型的死锁场景如下:
-
线程 A 已经持有了互斥锁 Mutex_1,此时它尝试申请 Mutex_2
-
线程 B 已经持有了互斥锁 Mutex_2,此时它尝试申请 Mutex_1
-
由于双方都在等待对方释放资源,且在获取新资源前都不会放弃已持有的资源,导致两个线程永久阻塞

3. 死锁的四个必要条件
死锁的发生并非偶然,必须同时满足以下四个核心条件。只要破坏其中任意一个条件,死锁便无法形成
-
互斥条件:
资源是排他性的。在一段时间内,某资源仅能由一个执行流占用。如果此时有其他执行流请求该资源,则请求者必须等待

-
请求与保持条件:
执行流已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他执行流占用。此时请求执行流被阻塞,但对自己已获得的资源保持不放

-
不剥夺条件:
执行流已获得的资源在未使用完之前,不能被剥夺,只能在使用完时由自己释放

-
循环等待条件:
存在一个执行流的资源循环链,即执行流集合 {P0, P1, P2, ..., Pn}。其中 P0 正在等待 P1 占用的资源,P1 正在等待 P2 占用的资源,……,Pn 正在等待 P0 占用的资源

4. 如何避免死锁
避免死锁的核心思想在于破坏上述四个必要条件中的一个或多个,或者在资源分配时进行动态检测
(1) 破坏 "请求与保持" 条件
要求执行流在开始运行前,必须一次性申请其在整个运行过程中所需的全部资源。如果资源不满足,则不运行
(2) 破坏 "不剥夺" 条件
当一个执行流申请新资源受阻时,它必须主动释放已经占有的所有资源。待以后需要时再重新申请
(3) 破坏 "循环等待" 条件
对系统中的所有资源进行线性编号。规定每个执行流必须按编号递增的顺序申请资源。例如,必须先申请 Mutex_1 才能申请 Mutex_2。这样可以从逻辑上消除闭环出现的可能性
(4) 算法预防
在资源分配过程中,使用如避免死锁算法等手段进行安全性检查,确保系统始终处于安全状态
5. 银行家算法
银行家算法是一种事前检查策略。它的核心逻辑是:当线程请求资源时,系统不立刻分配,而是先进行一次预先模拟
-
核心思想:系统始终保持在安全状态。所谓安全状态,是指系统能找到一种分配顺序(安全序列),使得即便所有线程都突然要求最大资源,系统也能让它们一个接一个地顺利运行完
-
分配逻辑:
-
线程提出请求
-
系统模拟:假设分给它,计算剩余资源还够不够凑出下一个安全序列
-
如果够:通过模拟,正式分配资源
-
如果不够:判定为不安全状态,拒绝分配,线程继续等待
-
-
总结:它是一种 "宁可效率降低,也绝不让系统陷入危险" 的保守策略
6. 死锁检测算法
死锁检测算法是一种事后清算策略。系统平时不管你如何申请资源,但会定期进行全局状态检查
-
核心思想:通过资源分配图(RAG)检查系统中是否存在无法打破的环路
-
检测逻辑:
-
构建拓扑图:画出谁占着谁的资源、谁又在等谁
-
化简过程:找出拿了资源就能跑完、跑完就能还回资源的线程,把它们的边删掉
-
最终判定:如果最后图里还剩下孤立的、互相指着的 "环路",那么这些剩下的线程就是陷入了死锁
-
-
处理手段:一旦检测到死锁,系统会通过强制剥夺资源、杀死进程或直接重启来解决
五、STL 与智能指针
在深入探讨了各种复杂的同步机制与死锁算法后,我们需要回到 C++ 开发的基础工具:STL 容器与智能指针。我们经常误认为这些标准库工具是 "自动安全" 的,但事实并非如此
1. STL 线程安全
STL 容器本身不是线程安全的
(1) 什么时候不安全?
当多个执行流同时并发访问同一个 STL 容器(如 std::vector, std::map, std::list 等),且至少有一个线程在执行写操作(增加、删除、修改元素)时,是不安全的
(2) 为什么不安全?
-
内存重新分配:以 vector 为例,当执行 push_back 触发扩容时,它会开辟新空间、拷贝旧元素、释放原空间。如果此时另一个线程在读取旧空间的地址,将导致野指针访问或段错误
-
结构完整性:容器的内部实现(如红黑树、链表)涉及复杂的指针操作。多线程并发修改会导致指针指向混乱,破坏数据结构的完整性
-
性能考量:STL 的设计哲学是 "不为不使用的功能买单"。如果在内部加锁,会给单线程环境下的使用带来巨大的性能损耗
(3) 如何保证安全?
-
只读操作:如果多个线程仅仅是读取容器内容,而没有任何修改,则是安全的
-
外部加锁:通过在容器外部手动封装互斥锁(Mutex),由程序员保证同一时间只有一个线程操作容器。
2. 智能指针线程安全
对于 std::shared_ptr,我们需要从引用计数和指向的对象两个层面拆解其安全性
(1) 引用计数的安全性:安全
-
原因:shared_ptr 的内部引用计数是基于原子操作实现的
-
表现:多个线程同时对同一个 shared_ptr 对象进行拷贝构造(增加计数)或析构(减少计数)时,引用计数的加减是线程安全的,不会因为竞争导致内存泄漏或重复释放
(2) 指向对象的访问:不安全
-
原因:智能指针只是一个管家
-
表现:如果两个线程通过不同的智能指针副本访问同一个底层的原始对象,且其中一个在修改该对象,这依然属于并发修改共享资源,必须手动加锁
(3) 智能指针对象本身的读写:不安全
-
原因:shared_ptr 对象本身包含两个指针(指向资源和指向控制块)
-
场景:如果线程 A 正在修改 shared_ptr 本身的指向(调用 reset),而线程 B 正在读取这个 shared_ptr 本身,这也不是原子的
-
结论:对智能指针对象本身的跨线程并发读写(修改指向),需要加锁
| 组件 | 场景 | 安全性 | 原因 |
| STL 容器 | 任意写操作 | 不安全 | 内部无同步机制,追求极致性能 |
| shared_ptr | 引用计数加减 | 安全 | 底层原子操作保证 |
| shared_ptr | 访问底层资源 | 不安全 | 资源访问与管理是分离的 |
| shared_ptr | 修改指针指向 | 不安全 | 涉及两个内部指针的非原子更新 |
六、其他锁
在多线程并发场景中,除了常用的互斥锁之外,还存在一类依托运行策略与硬件特性实现的同步机制。这类机制在处理资源竞争时,分别采用严格阻塞等待与乐观尝试竞争两种不同的设计思路。
1. 悲观锁
设计思想: 悲观锁的核心假设是:冲突一定会发生。 它认为每当有线程去访问数据时,大概率会有其他线程同时也在修改。因此,为了保证绝对安全,它要求在进行任何操作(读或写)之前,必须先获取锁。只有拿到了锁,才允许操作数据;操作完成后再释放锁
特征:
-
强烈的独占性
-
依赖于操作系统提供的锁机制(如 pthread_mutex)
应用场景:
-
高竞争场景:当多个线程频繁争夺同一资源时,悲观锁能有效避免冲突带来的数据混乱
-
写操作密集型:如果数据的修改频率极高,提前加锁是最稳妥的选择,能防止大量的无效重试
-
数据库事务:如 SELECT ... FOR UPDATE 语句
2. 乐观锁
设计思想: 乐观锁的核心假设是:冲突不常发生。 它在访问数据时不会加锁,而是直接进行读取和计算。直到准备提交更新时,才会去检查在此期间有没有其他线程修改了这份数据。如果数据没变,则更新成功;如果数据变了,则更新失败(通常会选择报错、回滚或重试)
特征:
-
不依赖内核锁机制,减少了上下文切换的开销
-
通常通过版本号(Version)或时间戳来实现
应用场景:
-
低竞争场景:数据被修改的概率较低,大部分时间是并发读取
-
读操作密集型:由于不需要频繁加锁,极大地提升了系统的吞吐量
-
分布式系统:在跨服务器的资源管理中,维护一个全局互斥锁成本太高,通常采用版本号校验的乐观锁机制
3. CAS
CAS 是实现乐观锁的一种底层硬件机制。目前主流的 CPU 都提供了原子的 CAS 支持
逻辑流程: 一个 CAS 操作涉及三个操作数:
-
内存地址 V:要读取或修改的变量位置
-
预期旧值 A:认为该变量目前应该是什么值
-
新值 B:想要将其修改成的值
执行过程:
比较 V 处的值是否等于 A。如果等于,则将 V 处的值更新为 B 并返回 true;如果不等于,说明有其他线程改了它,放弃操作并返回 false

核心价值:
-
无锁化(Lock-Free):它不需要让线程进入内核态休眠,而是通过硬件指令直接保证了操作的原子性
-
性能极致:在极短的临界区操作(如加个计数器)时,CAS 的性能远超互斥锁
应用场景:
-
原子变量:C++11 中的 std::atomic<int> 系列
-
自旋锁内部实现:自旋锁的抢锁动作本质上就是一个循环执行的 CAS
-
无锁数据结构:如高性能的无锁队列(Lock-free Queue)
4. 自适应锁
设计思想: 自适应锁并不是一种全新的物理锁,而是一种动态优化的加锁策略。它的核心逻辑是:根据上一次加锁的成功经验,决定这次是自旋一会儿还是直接睡眠
在传统的同步机制中,程序员必须手动决定使用 mutex 还是 spinlock。但由于线程执行环境是动态变化的,这种硬编码往往不是最优解。自适应锁通过引入统计学和反馈机制,解决了这个痛点
逻辑流程:
-
初始尝试:当线程尝试获取锁发现已被占用时,它不会立刻进入阻塞(休眠)状态
-
动态自旋:它会先进行一段周期的自旋。这个自旋的时间长度是不固定的:
-
如果在该锁上,上一个自旋等待的线程刚刚成功获取了锁,且持有锁的线程正在另一个 CPU 核心上运行,那么系统会认为这次自旋也极大概率能成功,从而延长自旋时间
-
如果在该锁上,很少有自旋成功的记录,或者持有锁的线程已经进入休眠状态,那么系统会认为自旋是浪费 CPU,从而让当前线程立刻进入阻塞状态
-
-
自动降级:一旦自旋超过了设定的阈值仍未成功,线程将彻底放弃自旋,转入互斥等待
应用场景:
-
现代内核优化:Linux 内核和 Java 虚拟机都广泛采用了自适应自旋技术
-
混合负载环境:在任务时长不固定的多线程环境中,自适应锁能自动平衡自旋与休眠的矛盾
总结
综上所述,从线程安全、可重入,到死锁、CAS、自旋锁、悲观锁与自适应锁,我们逐步理解了并发编程中最核心的矛盾:
多个执行流共享资源时,程序执行顺序将彻底失去确定性
而所谓的锁、原子操作、条件变量,本质上都是为了在性能与正确性之间寻找平衡
其中:
- 临界区非常小,且并发极高:优先考虑 CAS(原子操作)实现无锁化
- 临界区执行极快(如加个计数),且不希望线程切换:使用自旋锁
- 临界区可能涉及 I/O 或耗时操作:必须使用互斥锁(悲观锁)
- 读多写少:使用读写锁提高并发度
- 不确定具体场景,追求系统性能均衡:信任现代内核或虚拟机的自适应锁机制
与此同时,我们也进一步认识到:线程安全与可重入虽然密切相关,但两者关注的问题并不完全相同——线程安全更强调 "多个执行流并发访问",而可重入则更强调 "执行流被异步打断后再次进入"
至此,我们已经基本完成了 Linux 并发与线程基础部分的整体学习。从线程创建、同步互斥,到线程池、锁机制与并发安全问题,我们已经逐渐接近现代高并发程序设计的核心区域

更多推荐

所有评论(0)