目录
[三、STL, 智能指针和线程安全](#三、STL, 智能指针和线程安全)
一、线程安全与重入问题
概念
线程安全
线程安全就是当多个线程同时访问同一块资源(如全局变量、任务队列、打印终端)时,最终结果能符合预期,不会出现数据错乱、逻辑错误,这就是线程安全。
我们可以结合上一篇线程池的代码来理解,任务队列 _tasks 是线程池最核心的共享资源。
不安全场景:如果没有 _mutex 锁保护的话,线程 1 刚把任务 push 进去,任务队列结构还没稳定,线程 2 就进来 pop,会导致队列链表断裂、数据覆盖,程序直接崩溃。
**安全场景:**我们代码里所有对 _tasks 的操作(push、pop、empty)都进行了加锁和解锁。同一时刻只有一个线程能操作队列,所以线程是安全的。
重入
重入这个概念稍微抽象一点,它描述的是函数被调用的状态。同一个函数,被不同的执行流打断,并且在还没执行完的时候就被再次进入。
重入分为两种情景 : 1. 多线程重入函数,2. 信号导致一个执行流重复进入函数
多线程重入函数
我们先来看看第一种情况
在我们的线程池代码中,HandlerTask 函数被线程 1、线程 2、线程 3、线程 4 同时执行。
假设线程 1 正在执行 HandlerTask 里的取任务逻辑,此时时间片到了,操作系统切走线程 1,换线程 2 进入 HandlerTask。这就是多线程的重入。
这里我们强调一下可重入函数和不可重入函数:
- 可重入函数:函数内部只使用局部变量(栈上的变量),不管多少个执行流同时进来,结果都正确。
- 不可重入函数:函数内部使用了全局变量或静态变量,且没有锁保护。如果一个执行流还没改完变量,另一个进来改了,第一个流再继续执行时,变量值就错了。
我们线程池代码是安全的:虽然发生了多线程重入,但因为我们对共享资源(队列)加了锁,所以它是 "安全的重入"。如果不加锁,就是 "危险的重入"。
信号导致的重入
信号导致的重入是 Linux 系统编程里更底层的重入。
举个例子一个线程正在运行,突然收到一个信号(比如 SIGINT 中断),操作系统暂停当前线程,去执行信号处理函数。如果信号处理函数里恰好调用了某个普通函数,而这个普通函数刚好正在被主逻辑执行,这就叫信号重入。
危险的地方就是信号处理函数和主逻辑可能会同时修改同一个全局变量,导致数据竞争。
可重入和不可重入,哪个更好?
可重入更好,因为可重入就代表着同一个函数,被多个线程多次调用后,也不会出错、不会乱、不会崩。不可重入代表当一个线程在执行某个函数时,其余的线程一律不能执行这个函数,如果其他线程也执行的话,就会出现问题、容易出 bug、多线程时绝对不能乱用。
所以可重入函数的特点可以总结为以下几点 :
- 不用全局变量
- 不用静态变量
- 不操作共享资源
- 或者操作共享资源时加锁
- 只使用自己的局部变量
我们的线程池代码中的 HandlerTask 函数就是标准的:可重入 + 线程安全 函数。因为多线程环境下不会崩溃,我们的线程池中的 4 个线程同时调用 HandlerTask 时就是可重入的,也就代表着怎么调用 HandlerTask 都不会出错。
可重入与线程安全的联系

可重入与线程安全的区别

注意:

二、死锁
概念
死锁是多线程/多进程并发场景下的致命 bug,指的是两个或多个线程,各自拿着对方需要的锁,同时又在等待对方手里的锁,谁都不肯先释放自己的锁,最终所有线程都陷入永久阻塞、程序卡死、再也跑不下去的状态。
如下图所示,为了方便表述,假设现在线程 A,线程 B 必须同时持有锁 1 和锁 2,才能进行后续资源的访问:

我们知道申请一把锁是原子的,但是申请两把锁就不是原子的了。

此时线程A自己持有锁1,但是它还想要线程B持有的锁2,线程B也是同理。

所以最终造成的结果就是死锁,谁都不肯先释放自己的锁,最终所有线程都陷入永久阻塞、程序卡死、再也跑不下去的状态。
造成死锁的4个必要条件
- 互斥条件:一个资源只能被一个线程持有,不能共享。
- 请求与保持条件:线程已经持有了一把锁,还不释放,并且同时还要去去申请另一把锁。
- 不可剥夺条件:线程持有的锁,不能被其他线程强行抢走,只能自己主动释放。
- 循环等待条件:多个线程形成一个循环等待链,每个线程都在等下一个线程手里的资源。(A 等 B,B 等 A)
因此避免死锁的方法就是只要破坏其中任意一个条件,死锁就不会发生。
避免死锁的做法:
怎么避免死锁?
方案 1:统一锁的获取顺序
让所有线程,都按照完全相同的顺序抢锁:比如永远先抢锁 1,再抢锁 2。这样就不会出现 A 拿 1 等 2、B 拿 2 等 1 的情况,从根源避免循环等待。
方案 2:一次性申请所有锁(原子性获取)
用 std::lock 一次性同时抢多把锁,要么全抢到,要么一把都不抢,不会出现拿一半等一半的情况。
方案 3:避免持有锁的同时申请其他锁
尽量让线程拿一把锁,用完释放后,再拿下一把,不要在持有锁的代码里去抢其他锁。
方案 4:使用带超时的锁
比如std::timed_mutex,抢锁超时就主动释放自己手里的锁,重试,避免永久阻塞。
三、STL, 智能指针和线程安全
STL 中的容器是否是线程安全的?
不是。原因是 STL 的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。而且对于不同的容器,加锁方式的不同,性能可能也不同 (例如 hash 表的锁表和锁桶)。因此 STL 默认不是线程安全。如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。
智能指针是否是线程安全的?
对于 unique_ptr, 由于只是在当前代码块范围内生效,因此不涉及线程安全问题。
对于 shared_ptr, 多个对象需要共用一个引用计数变量,所以会存在线程安全问题。但是标准库实现的时候考虑到了这个问题,基于原子操作 (CAS) 的方式保证 shared_ptr 能够高效,原子的操作引用计数。
四、总结
本文探讨了多线程编程中的核心问题:线程安全与重入、死锁机制以及STL和智能指针的线程安全性。线程安全指多线程访问共享资源时结果的正确性,通过加锁保护实现;重入分为多线程和信号重入,可重入函数通过避免全局变量和加锁确保安全。死锁由四个必要条件引发,可通过统一锁顺序、原子获取锁等方式避免。STL容器默认非线程安全,需自行加锁;智能指针中shared_ptr通过原子操作保证线程安全。文章为多线程开发提供了关键概念和解决方案。
谢谢大家的观看!



