Linux线程同步与互斥(六):线程安全、可重入与死锁
为什么有些函数不能在多线程环境下使用?为什么加了锁的程序还是可能崩溃?什么是死锁?STL容器线程安全吗?
一、线程安全和重入话题
1.1 线程安全
定义:多个线程同时访问一个函数或对象,不会出现不确定的结果,即使没有额外的同步机制(或者有正确的同步),也能正确执行。
通俗理解:你的代码不会因为多线程调度而产生“数据损坏”或“逻辑错误”。
例子:
全局变量
int counter两个线程同时counter++→ 不安全。如果对
counter加锁保护 → 安全。翻译成大白话:
多个线程一起跑
- 一起读
- 一起写
- 一起改
结果永远正确,不会出现负数、错乱、覆盖、脏数据 → 就是线程安全!
1.2 可重入
定义:函数可以被多个执行流同时进入,而不会产生数据错乱。这里的“执行流”可以是多个线程,也可以是同一个线程在执行过程中被信号处理函数打断后再次进入该函数。
通俗理解:函数即使被“重入”(比如刚执行到一半,又再次被调用),也能正确运行。
典型场景:信号处理函数中调用的函数必须是可重入的,因为信号可能在主程序的任意位置触发,导致同一个函数被重复进入。
小白话
函数跑到一半,被 “中途插队” 再进一次!
两种重入场景:
- 多线程重入(两个线程同时进同一个函数)
- 信号打断重入(一个线程被信号中断,又进一次)
可重入函数的特点
- 不使用全局变量、静态变量
- 不调用 malloc/free
- 不调用不可重入函数
- 所有变量都是局部变量(栈上)
1.3 什么时候线程不安全?
只要出现下面任意一种,一定不安全:
- 多个线程访问共享资源(全局变量 / 静态变量)
- 又读又写
- 没有加锁保护
满足这 3 条 = 线程不安全!
1.4 总结(重点)
不要被上面的绕口令式的话语吓唬住,你只需要指定,其实对应概念说的都是一回事
最简单的 3 句话:
- 可重入函数一定是线程安全的
- 线程安全函数不一定是可重入的
- 使用了全局变量但加了锁 → 线程安全,但不可重入
为什么加锁的函数是线程安全,但不可重入?
void func() { lock(); // 加锁 ... // 中途被信号打断 unlock(); // 没执行到 }信号来了 → 又进一次 func ()→ 再次 lock ()→ 锁已经被持有 →死锁!
所以:
✅ 线程安全(多线程用没问题)
❌ 不可重入(信号 / 递归重入会死锁)
二、常见锁概念
2.1 死锁
两个或多个线程互相持有对方需要的资源,并且都不释放,导致所有线程永久阻塞。
你拿着我要的,我拿着你要的,互相不释放 →永远卡住
假设现在的线程A、线程B必须同时拥有锁1和锁2,才能进行后续资源的访问
// 线程 A // 线程 B pthread_mutex_lock(&mutex1); pthread_mutex_lock(&mutex2); pthread_mutex_lock(&mutex2); pthread_mutex_lock(&mutex1);申请一把锁式原子的,但是申请两把锁就不一定了。
造成的结果是:
2.2 死锁的四个必要条件
互斥条件 ,一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已经获得的资源保持不放
不剥夺条件:一个执行流已经获得的资源,在未使用完之前,不得强行剥夺
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
| 条件 | 说明 |
|---|---|
| 互斥 | 资源一次只能被一个线程占用 |
| 请求与保持 | 线程持有资源的同时请求其他资源 |
| 不剥夺 | 线程不释放已占有的资源 |
| 循环等待 | 存在等待环路:A等B,B等A |
2.3 避免死锁
破坏“请求与保持”:一次性申请所有资源(
std::lock或同时加多把锁)。破坏“循环等待”:所有线程按固定顺序加锁(例如总是先锁 mutex1 再锁 mutex2)。
破坏“不剥夺”:使用
pthread_mutex_trylock,失败时释放已有的锁。使用超时机制:
pthread_mutex_timedlock。
示例:固定顺序加锁
// 线程 A 和 B 都先锁 mutex1,再锁 mutex2 pthread_mutex_lock(&mutex1); pthread_mutex_lock(&mutex2);示例:使用std::lock一次锁多把锁(C++11)
std::lock(mutex1, mutex2); // 不会死锁 std::lock_guard<std::mutex> lock1(mutex1, std::adopt_lock); std::lock_guard<std::mutex> lock2(mutex2, std::adopt_lock);2.4 常见锁概念(简介)
| 锁类型 | 特点 | 适用场景 |
|---|---|---|
| 互斥锁 | 独占锁,阻塞等待 | 保护普通临界区 |
| 自旋锁 | 忙等待,不释放CPU | 临界区极短,避免上下文切换 |
| 读写锁 | 多读单写 | 读多写少 |
| 乐观锁 | 不加锁,更新前检查版本 | 数据库、并发控制 |
| 悲观锁 | 每次访问都加锁 | 冲突概率高 |
| CAS(Compare And Swap) | 原子比较并交换 | 无锁编程基础 |
悲观锁 vs 乐观锁:悲观锁假设冲突会发生,提前加锁;乐观锁假设冲突很少,更新时检测,失败则重试。
避免死锁算法:死锁检测算法、银行家算法
三、STL、智能指针和线程安全
3.1 STL容器是否线程安全?
不是。STL容器的设计目标是极致性能,没有内部同步机制。多线程环境下:
多个线程同时读取同一个容器是安全的。
只要有一个线程写,就必须由用户加锁保护。
std::vector<int> vec; // 多线程 push_back 需加锁3.2 智能指针是否线程安全?
std::unique_ptr:不涉及共享,完全在线程栈上,安全。
std::shared_ptr:引用计数是原子操作,所以多个线程同时拷贝、析构shared_ptr是安全的(计数不会出错)。但是,指向的对象本身不是线程安全的,如果多个线程通过shared_ptr修改同一个对象,仍需要加锁
std::shared_ptr<int> sp = std::make_shared<int>(10); // 多个线程拷贝 sp 或让 sp 析构,安全 // 但 *sp = 20; 不安全,需保护