👦个人主页:Weraphael
✍🏻作者简介:目前正在学习c++和算法
✈️专栏:Linux
🐋 希望大家多多支持,咱一起进步!😁
如果文章有啥瑕疵,希望大佬指点一二
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍
目录
- 一、相关背景概念
- 二、多线程的并发访问
- 2.1 前言
- 2.2 例子引入:买票
- 2.3 解释多线程并发访问的问题
- 三、Linux互斥锁相关接口
- 3.1 前言
- 3.2 初始化互斥锁
- 3.3 销毁互斥锁
- 3.4 加锁操作
- 3.5 解锁操作
- 四、改进售票系统
- 4.1 代码改进
- 4.3 总结一波
- 五、互斥锁的实现原理
- 六、封装加锁和解锁操作
- 七、线程安全 VS 重入
- 7.1 概念
- 7.2 常见线程不安全的情况
- 7.3 常见线程安全的情况
- 7.4 常见不可重入的情况
- 7.5 常见可重入的情况
- 7.6 重入与线程安全的联系
- 7.7 重入与线程安全的区别
- 八、死锁Deadlock
- 8.1 概念
- 8.2 模拟死锁
- 8.2 形成死锁的四个必要条件
- 8.3 如何避免死锁
- 九、相关代码
一、相关背景概念
- 临界资源:多执行流共享的资源就叫做临界资源。
- 临界区:执行流访问临界资源的代码。
- 互斥:本质就是任何时刻,只允许一个执行流访问共享资源(保护共享资源免受并发访问的影响)
- 原子性:指的是一个操作是不可中断的,只有两种状态:要么执行完毕,要么没有执行。没有正在执行这一说法。
以上概念均在往期博客说过:点击跳转
二、多线程的并发访问
2.1 前言
线程使用的数据都是局部变量,变量存储在线程的独立栈空间内。这种情况,变量归属单个线程,其他线程无法获得这种变量(其实也可以获取,只是不这么做而已)。
但有时候,很多变量都需要在线程间共享,这样的变量称为共享资源。那现在就会有一个问题:这个共享资源在被多线程并发访问的时候(并发访问:指的是系统能够同时处理多个任务),有没有可能出现一个线程正在访问,另一个线程正在写呢?
答案是当然有可能!比方说有一个电影院,里面有多个售票窗口。每个窗口(线程)可以同时处理多个观众(任务),这些观众都想购买电影票。如果所有售票窗口都能够同时处理售票请求(并发访问),可能会出现多个观众同时选择同一张座位。
即多线程的可能会导致线程读取到不一致或者不正确的值,或者写入线程的修改被读取线程所干扰,造成数据污染或者逻辑错误。
接下来引入一个日常生活例子:买票。来帮助大家更好理解这一现象。
2.2 例子引入:买票
以下是用多线程来模拟用户买票。有1000
张票和4
个线程,4
个线程同时抢票。
其中规定:
- 票的编号代表座位号。即一个影院厅做多有
1000
个位置。不考虑特殊情况,如婴儿家长手抱等。 - 票数为
0
表示票已经售完了。
#include <iostream>
#include <pthread.h>
#include <vector>
#include <string>
#include <unistd.h>using namespace std;int tickets = 1000; // 1000张票
#define THREAD_NUM 4 // 4个售票窗口class threadData
{
public:threadData(int number){threadname = "thread-" + to_string(number);}public:string threadname;
};void *getTicket(void *args)
{threadData *td = static_cast<threadData *>(args);const char *name = td->threadname.c_str();while (true){// 有票就抢if (tickets > 0){printf("who = %s, get a ticket: %d\n", name, tickets);--tickets;}else{break;}}printf("%s ... quit\n", name);return nullptr;
}int main()
{vector<pthread_t> tids;vector<threadData *> thread_datas;for (int i = 1; i <= THREAD_NUM; i++){pthread_t tid;threadData *td = new threadData(i);thread_datas.push_back(td);pthread_create(&tid, nullptr, getTicket, thread_datas[i - 1]);tids.push_back(tid);}for (auto thread : tids){pthread_join(thread, nullptr);}for (auto td : thread_datas){delete td;}return 0;
}
【程序结果】
如果,一个卖票程序出现了4
种不同情况,这不是搞嘛。显然多线程并发访问是绝对存在问题的!
2.3 解释多线程并发访问的问题
全局变量tickets
是所有线程的共享数据,这个共享数据在被多线程并发读写时,并没有我们期望的那样,而这种情况我们称数据在无保护的情况下,造成数据不一致问题。这个数据不一致的原因肯定和多线程并发访问是有关系的。
其中,每个线程无非就是判断线程是否有票,有票就做--
操作。然而对一个临界资源(共享资源)进行多线程并发访问,--
操作是不安全的(++
操作同理)。
注意:从直观上看,--
是由一条语句完成的,但其实并不是!通过翻译成反汇编是三条
步骤如下:
- 先将
tickets
从内存读入到CPU
的寄存器中。 - 再在
CPU
内部对其进行--
操作 。 - 最后将计算完成的结果写回内存中。
而CPU
再执行这三条语句的过程中,线程都有可能被切换走,即一个线程执行--
操作时,可能会在中间被暂停,而另一个线程开始执行。
-
线程被切换的时候,需要保存寄存器上下文。
-
线程被换回的时候,需要恢复寄存器上下文。
-
寄存器上下文:当操作系统切换执行不同的执行时,它会保存当前执行流的寄存器状态,包括程序计数器
PC
和其他寄存器的内容。这些信息会被保存在用户级线程控制块中。当线程再次被调度执行时,之前保存的寄存器状态会被恢复,使得线程可以从上次中断的地方继续执行。 -
注意:虽然线程共享进程地址空间,但它也有自己栈空间、寄存器等(最重要的就是这两个)。
举个例子:
- 线程
1
正在在执行--tickets
的任务,且初始时tickets
为1000
。当tickets
在寄存器已经计算一次完毕,即tickets = 9999
,准备将计算的结果写回内存的时候,此时发生了线程切换(由线程1
切至线程1
)。线程1
要保存寄存器的上下文,此时寄存器里的值9999
。 - 接下来,线程
2
也要执行--tckets
的任务,且线程2
运气非常好,它运行过程中没有发生线程切换,因此可以不断循环此--
操作(读到寄存器,计算,返回结果)。但是,当tickets
自减到11
的时候,再次--
,读取寄存器,自减到10
,准备将结果写回内存的时候,线程2
被切走了。同样的,线程2
将数据值10
保存自己的上下文数据。注意此时内存中tickets
的数据是11
,而不是10
。 - 线程
1
被切回来了,需要恢复上下文(999
重新读回CPU
的寄存器里),然后从上次切换的地方继续执行,即将计算结果999
写回内存。此时内存中tickets
由11
变成了999
。 - 明明票还剩下
11
张(编号[1,11]
的位置),这下可好,999
又可以被别人买了。
因此,上述就是典型的数据不一致问题!所以,--
操作不是原子的,或者可以说--
操作对多线程访问临界资源是不安全的。
另外,不只--
会出现数据不一致的问题,判断tickets > 0
时也同样会出现数据不一致。
常识告诉我们:购票需要时间,买票成功后也需要时间,这里通过usleep
函数模拟耗费时间。
【关键代码段】
void *getTicket(void *args)
{threadData *td = static_cast<threadData *>(args);const char *name = td->threadname.c_str();while (true){// 有票就抢if (tickets > 0){usleep(1000); // 抢票花的时间printf("who = %s, get a ticket: %d\n", name, tickets);--tickets;}else{break;}}printf("%s ... quit\n", name);return nullptr;
}
【程序结果】
解释如下:
-
判断的过程也是一种运算(逻辑运算)。
CPU
内部进行的运算分为两类:逻辑运算、算术运算。因此在判断的时候还是要将数值写入到寄存器中。 -
现假设
tickets
的值为1
,此时线程1
执行if
判断,此步骤同样需要在CPU
内的寄存器执行,tickets == 1 > 0
,判断后准备把返回结果正好发生线程切换(由线程1
切至线程2
)。此时ticket
在内存中的值是1
。 -
线程
2
也要执行if
判断,把1
从内存读到CPU
寄存器里判断,发现tickets == 1 > 0
,判断后返回结果到内存,随后执行--tickets
语句,计算后把结果0
返回至内存。 -
此时线程切换回至线程
1
,线程1
继续执行未完成的--tickets
语句,这次是将tickets == 0
去自减,计算后把结果-1
返回至内存。
这就是为什么买票结果为负数的原因了。
能够出现数据不一致的问题本质还是线程切换过于频繁。而线程切换的场景如下:
-
时间片:大多数操作系统使用时间片轮转调度算法来分配处理器时间。当一个线程的时间片用完(通常是几毫秒到几十毫秒),操作系统会暂停该线程的执行,并将处理器分配给另一个准备好的线程。
-
线程阻塞:当线程执行过程中发生阻塞(以上就是线程阻塞现象),操作系统会将该线程标记为不可执行状态,并在条件满足后唤醒它,这时可能会进行线程切换。
-
系统调用:当线程需要执行系统调用,操作系统可能会暂停当前线程,处理器会从用户态切换到内核态来执行操作系统的代码,这通常需要进行线程切换。
如何解决多线程访问临界资源导致数据不一致问题呢?
对于共享数据(临界资源)的访问,只要确保:任何时候只有一个执行流可以访问临界资源,即保证--
的行为是原子的。在技术层面上,可以通过加锁进行保护,Linux
上提供的这把锁叫互斥锁。
三、Linux互斥锁相关接口
3.1 前言
- 在
Linux
系统中,互斥锁相关的接口函数同样位于原生线程库pthread
中,函数名通常以pthread_mutex_
开头。 - 互斥锁相关的接口函数的参数均有用到
pthread_mutex_t
类型,这是原生线程库pthread
封装的,开发者通常不需要知道其内部结构的细节,只需使用提供的函数接口进行操作即可。 - 这些函数用于创建、销毁、锁定(加锁)、解锁互斥锁等操作,确保多线程程序中的共享资源可以安全访问,避免数据不一致的发生。
3.2 初始化互斥锁
初始化互斥锁有两种方法:
- 方法一:静态分配。
使用静态分配时,互斥锁必须定义为全局锁,并且可以直接通过初始化变量来完成。它是通过使用 PTHREAD_MUTEX_INITIALIZER
宏来静态初始化一个互斥锁变量。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
注意:对于静态分配的互斥锁对象,是不需要显式调用pthread_mutex_destroy
函数来销毁它。静态分配的互斥锁会在程序结束时自动释放其资源,因为它们的生命周期与程序的生命周期相同。
- 方法二:动态分配。需要通过
pthread_mutex_init
函数来进行初始化。
#include <pthread.h>int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
// 定义一把互斥锁
pthread_mutex_t lock;
// 初始化互斥锁
pthread_mutex_init(&lock, nullptr)
说明:
mutex
:是一个指向pthread_mutex_t
结构的指针,用来表示要初始化的互斥锁对象。attr
:是一个指向pthread_mutexattr_t
结构的指针,用来指定互斥锁的属性。一般直接设置为nullptr
,表示使用默认属性。- 返回值:成功返回
0
,失败返回错误码。
3.3 销毁互斥锁
互斥锁是一种向系统申请的资源,在使用完毕后需要销毁。
#include <pthread.h>int pthread_mutex_destroy(pthread_mutex_t *mutex);
说明:
mutex
:指向要销毁的互斥锁对象的指针,类型为pthread_mutex_t*
。- 返回值:销毁成功返回
0
,失败返回非0
的错误码。
销毁互斥锁需要注意:
- 使用
PTHREAD_ MUTEX_ INITIALIZER
初始化的互斥量不需要销毁。 - 不再需要互斥锁时,需要显式地调用
pthread_mutex_destroy
函数来释放其占用的资源。这是为了避免内存泄漏和资源浪费。 - 调用
pthread_mutex_destroy
时,确保没有其他线程正在使用该互斥锁,否则会导致未定义的行为。可以在线程等待后调用即可。 - 不能重复销毁互斥锁。
【修改买票代码(核心代码)】
int main()
{// ======== 定义互斥锁 ======pthread_mutex_t lock;// ======== 初始化互斥锁 ======pthread_mutex_init(&lock, nullptr);vector<pthread_t> tids;vector<threadData *> thread_datas;for (int i = 1; i <= THREAD_NUM; i++){pthread_t tid;// 使不同线程看到同一把锁threadData *td = new threadData(i, &lock);thread_datas.push_back(td);pthread_create(&tid, nullptr, getTicket, thread_datas[i - 1]);tids.push_back(tid);}for (auto thread : tids){pthread_join(thread, nullptr);}for (auto td : thread_datas){delete td;}// ===== 销毁互斥锁 =====pthread_mutex_destroy(&lock);return 0;
}
注意:对于多线程来说,应该让线程看到同一把锁。即要将在main
函数定义的锁传给线程所执行的函数getTicket
class threadData
{
public:threadData(int number, pthread_mutex_t *mutex){threadname = "thread-" + to_string(number);lock = mutex;}public:string threadname;pthread_mutex_t *lock;
};
3.4 加锁操作
加锁操作保证了多个线程不会同时进入被保护的临界区,从而避免了数据不一致问题。即如果某个线程成功获取了互斥锁,它将可以独自并安全地访问共享资源或者临界区。
#include <pthread.h>int pthread_mutex_lock(pthread_mutex_t *mutex);
说明:
mutex
:指向要锁定的互斥锁对象的指针,类型为pthread_mutex_t*
。- 返回值:成功返回
0
,失败返回一个非零的错误码,可以通过errno
全局变量获取具体的错误信息。
注意:虽然加锁解决了数据一致性问题,但天下没有免费的午餐,当调用pthread_mutex_lock
时,如果互斥锁已经被线程A
持有,表示当前只有线程A
可以访问临界区。若并发访问的线程B
也要访问临界区,那么线程B
将会被阻塞,直到该互斥锁被解锁为止,即等待锁资源就绪。
【总结】
- 加锁的表现:线程对于临界区代码串行执行。即只有一个线程访问完共享资源,另一个线程才能访问。相当于食堂排队打饭。
- 加锁的原则:尽量保证临界区代码越少越好。因为线程都是并发访问的,如果阻塞的时间变长,则会降低多线程的并发度,进而降低效率。
- 加锁的本质:用时间换安全。
3.5 解锁操作
#include <pthread.h>int pthread_mutex_unlock(pthread_mutex_t *mutex);
说明:
mutex
:指向要解锁的互斥锁对象的指针,类型为pthread_mutex_t*
。- 返回值:如果成功返回值
0
,如果失败返回一个非零的错误码,可以通过errno
全局变量获取具体的错误信息。
说明:当前线程获取锁资源并完成对临界资源的访问后,就应该进行解锁,将锁资源让出,供其他线程进行加锁。 如果不进行解锁操作,会导致后续线程无法申请到锁资源而永久阻塞,会引发死锁问题。
四、改进售票系统
使用以上接口改进售票系统。
4.1 代码改进
【改进前】
【改进后】
#include <iostream>
#include <pthread.h>
#include <vector>
#include <string>
#include <unistd.h>using namespace std;int tickets = 1000; // 1000张票
#define THREAD_NUM 4 // 4个售票窗口class threadData
{
public:threadData(int number, pthread_mutex_t *mutex){threadname = "thread-" + to_string(number);lock = mutex;}public:string threadname;pthread_mutex_t *lock;
};void *getTicket(void *args)
{threadData *td = static_cast<threadData *>(args);const char *name = td->threadname.c_str();while (true){// 加锁pthread_mutex_lock(td->lock); // 线程申请锁资源,申请成功执行,失败则阻塞// ==================== 临界区 =====================if (tickets > 0) // 有票就抢{usleep(1000); // 抢票花的时间printf("who = %s, get a ticket: %d\n", name, tickets);--tickets;// 解锁pthread_mutex_unlock(td->lock);}else{// 解锁pthread_mutex_unlock(td->lock);break;}// ==================== 临界区 =====================}printf("%s ... quit\n", name);return nullptr;
}int main()
{// ======== 定义互斥锁 ======pthread_mutex_t lock;// ======== 初始化互斥锁 ======pthread_mutex_init(&lock, nullptr);vector<pthread_t> tids;vector<threadData *> thread_datas;for (int i = 1; i <= THREAD_NUM; i++){pthread_t tid;threadData *td = new threadData(i, &lock);thread_datas.push_back(td);pthread_create(&tid, nullptr, getTicket, thread_datas[i - 1]);tids.push_back(tid);}for (auto thread : tids){pthread_join(thread, nullptr);}for (auto td : thread_datas){delete td;}// ===== 销毁互斥锁 =====pthread_mutex_destroy(&lock);return 0;
}
【程序结果】
测试了三次,我们发现已经没有数据不一致的问题了。但是为什么都是一个执行流(线程)在买票?
在多线程环境下,如果一个线程执行完临界区的代码后立即解锁,并且其他线程正在被阻塞,等待获取这个锁,那么操作系统需要进行调度,以决定哪个线程将会获得这个锁。这个过程可能涉及线程的唤醒和重新调度,而这些操作通常是有成本的,包括上下文切换和调度延迟。因此,每次申请到锁资源的线程都是第一次获取锁资源的线程。
【故事
1
】
- 就好比方说,有一个无人监管
VVIP
自习室,只能有一个人在里面学习。所以,为了保证任何时刻只能有一个人进来,大家都要遵守一个规则:门口有一把锁,谁获得这个锁资源,谁就有资格在里面学习。- 有一天你起的很早,当你来到自习室门口发现门旁有锁,于是你就把锁带进去了。当下一个同学来的时候,发现门旁没有锁,就只能在外面等待。突然某一个时刻,你饿了,想去食堂干饭。你刚刚出门准备把钥匙放回门旁,发现门外
50m
有一堆同学正在等待,为了能独享这个自习室,你就赶快拿回钥匙回到自习室了,然后自习从早到晚。 那么自习室外的这批人因为长时间得不到锁资源,导致了饥饿问题。- 因此,在多线程环境中,如果某个线程长时间占用了关键资源,其他线程可能因为无法获得资源而长时间等待,甚至导致饥饿问题。
这种情况下,为了公平和效率,需要考虑资源分配的策略。学校再次规定:自习室外面的人(等待线程)必须排队,并且出来的人不能立马重新申请锁,必须排到队列的尾部。这种让所有同学(线程)获取锁按照一定的顺序获取资源,我们称为同步。
所以,我们可以在每次抢完票以后等待一会(排队),让其他线程有机会申请锁资源。
【程序结果】
每一个线程在访问临界资源前,都必须得干一件事:申请锁来保证当前临界资源只有一个执行流在访问。但锁本身也是临界资源(共享资源),保护别人的前提是先保护自己,那么谁来保证锁的安全呢?
因此,库的设计者也考虑到了这个问题,于是将锁这种临界资源进行了特殊化处理:加锁和解锁操作都是原子的,即不会被中断或被其他线程干扰,有且只有一个线程能得到锁资源。
至于是如何做到的,在【互斥锁的原理】中讲解。点击跳转
现在又有一个问题:加了锁之后,在临界区中,已经保证了一个线程具有执行临界资源的能力,那么在执行的过程中,该线程有没有被切换的可能?
答案是有可能被切换!那可能就有就有疑问:切换了不就不能保证--
操作是原子的。再次引入上面的故事
【故事
2
】
第二天,又是你第一个到达自习室的门口,你拿到锁就进去自习了,而后面陆陆续续来的同学只能在门口等待。但天不测风云,人有三急,此时此刻你的肚子非常疼,而你又不想失去锁。于是你就突发奇想:我直接把锁放在升上一起去上厕所不就完事了。即便自习室空无一人,但其他同学也无法进入自习室!因为他们没有锁。
你上厕所带着锁的行为可以看作:线程在持有锁资源的情况下被调度了。显然对于整体程序是没有影响的,因为锁自此自终都还在某一个线程上,持有锁的线程在操作系统调度器的管理下可能会被暂时挂起,即使发生线程切换,因为该线程没有锁,也就没有执行临界资源的权利。
4.3 总结一波
- 在多线程环境下,通过使用互斥锁来控制临界区的访问,保证任何时刻只有一个执行流访问临界资源,而且必须让所有线程看到的是同一把锁。
- 每一个线程访问临界区资源之前都要加锁,本质上是给临界区加锁。
- 加锁和解锁必须配套出现,并且它们的操作都是原子的。
- 线程在持有锁的情况下被切换是没有影响的。
- 当某个线程持有锁资源时,其他线程并不关心持有锁的线程的执行状态,而是关心该锁是否被释放。这种机制确保了临界区内的操作对其他线程来说是原子的(要么执行完毕,要么没有执行)。
【题外话】
只要解决方案的出现,必然会产生新的问题:解决并发度(描述了系统或程序同时处理多少个独立任务或操作的能力)问题,引入多线程,但同时产生了并发访问的问题(数据不一致),随后又引入了锁。
五、互斥锁的实现原理
为了实现互斥锁操作,如今大多数
CPU
的体系结构(如ARM
、X86
、AMD
等)都提供了一些特定的硬件操作指令,如swap
或exchange
指令,这种指令可以把寄存器和内存单元的数据直接交换,这些语句在执行时只需要一条CPU
指令来完成(在汇编或者底层机器语言)。该语句要么执行,要么不执行,只有两态,因此可以保证指令执行时的原子性。
下面来看加锁函数pthread_mutex_lock
和解锁函数pthread_mutex_unlock
的伪汇编代码:
其中movb
表示转移,al
是一个寄存器,xchgb
就是支持原子操作的exchange
交换语句。另外,大家可以将锁mutex
理解成内存中的一个整型变量,1
代表当前有锁资源,反之没有。
每个线程申请锁时都要执行上述语句,执行步骤如下:
movb $0,%al
:将0
加载到寄存器al
中。xchgb %al,mutex
:交换al
寄存器和内存中mutex
的值。- 最后判断
al
寄存器中的值是否大于0
。若大于0
则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。
吸取了--
操作的经验,我们发现多个线程在执行加锁函数pthread_mutex_lock
也有可能会发生线程切换。那么申请锁资源不是也会导致数据不一致的问题吗?答案是不会的,因为我们一开始在上面说过,加锁和解锁的操作一定是原子。
大家只要在纸上模拟一遍就可以证明出来了
因此,加锁操作和解锁之所以是原子的,主要依赖于CPU
的指令集是原子的(如xchgb
指令),确保了在执行期间不会被中断或者干扰,从而保证了操作的完整性,使得多线程程序能够有效地管理共享资源的访问。
六、封装加锁和解锁操作
- 版本一
#pragma once#include <pthread.h>class Mutex
{
public:Mutex(pthread_mutex_t *lock) : _lock(lock){}~Mutex(){}void Lock() // 加锁{pthread_mutex_lock(_lock);}void Unlock() // 解锁{pthread_mutex_unlock(_lock);}private:pthread_mutex_t *_lock;
};class LockGuard
{
public:LockGuard(pthread_mutex_t *lock): _mutex(lock){_mutex.Lock();}~LockGuard(){_mutex.Unlock();}private:Mutex _mutex;
};
- 版本二
#pragma once#include <pthread.h>class LockGuard
{
public:LockGuard(pthread_mutex_t *lock): _mutex(lock){pthread_mutex_lock(_mutex);}~LockGuard(){pthread_mutex_unlock(_mutex);}private:pthread_mutex_t *_mutex;
};
代码非常的简单,主要的用途:用对象的生命周期来管理加锁和解锁操作。
比方说:
像这种获取资源即初始化的风格称为 RAII
风格,由C++
之父本贾尼·斯特劳斯特卢普提出,非常巧妙的运用了 类和对象 的特性,实现半自动化操作。这里是首次遇到,后面学习`C++```智能指针时还会遇到。
七、线程安全 VS 重入
7.1 概念
- 线程安全:多线程并发访问同一段代码时,不会出现不同的结果,此时就是线程安全的;但如果在没有加锁保护的情况下访问全局变量或静态变量,导致出现不同的结果,此时线程就是不安全。
- 重入:同一个函数被多个线程(执行流)调用,当前一个执行流还没有执行完函数时,其他执行流可以进入该函数,这种行为称之为 重入;
- 在发生重入时,函数运行结果不会出现问题,称该函数为可重入函数,
- 否则就是不可重入函数。
- 说明:是否可重入只是函数的一种特征,没有好坏之分。
7.2 常见线程不安全的情况
- 不保护共享变量。
- 函数的状态随着被调用,而导致状态发生变化。比如要统计一个函数被调用了多少次,在函数内部定义一个
static int cnt = 0
,那么我们说函数的状态随着被调用,而导致状态发生变化 - 返回指向静态变量指针的函数。
- 调用线程不安全函数的函数。
7.3 常见线程安全的情况
- 每个线程对全局变量或静态变量只有读取权限,而没有写入权限,一般来说都是线程安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致执行结果存在二义性。
7.4 常见不可重入的情况
- 调用了
malloc / free
函数,因为这些都是C
语言提供的接口,通过全局链表进行管理。 - 调用了标准
I/O
库函数,其中很多实现都是以不可重入的方式来使用全局数据结构。 - 可重入函数体内使用了静态的数据结构。
我们目前写的99.99999999999999%
的代码都是不可重入函数!!!
7.5 常见可重入的情况
- 不使用全局变量或静态变量。
- 不使用
malloc
或new
开辟空间。 - 不调用不可重入函数。
- 不返回全局或静态数据,所有的数据都由函数调用者提供。
- 使用本地数据或者通过制作全局数据的本地拷贝来保护全局数据。
7.6 重入与线程安全的联系
- 如果函数是可重入的,那么函数就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。注意是有可能。
- 如果一个函数中使用了全局变量,那么这个函数既不是线程安全的,也不是可重入的 。
7.7 重入与线程安全的区别
- 可重入函数是线程安全函数的一种。
- 只要函数不可重入,在多线程调用时可能会出问题;但可重入函数一定是线程安全的。
- 如果对于临界资源的访问加上锁,则这个函数是线程安全的;但如果这个重入函数中没有解锁而引发死锁,因此是不可被重入的。
总结
- 线程安全描述的是线程并发的问题,可重入描述的是函数特点的问题。
- 不可重入函数在多线程并发访问时,可能会出现线程安全问题;而一个函数时可重入的,则不会有线程安全问题。
八、死锁Deadlock
8.1 概念
死锁指的是:同一个进程中的多个线程实例中,这些线程彼此持有对方所需要的资源,导致所有参与的进程或线程都无法继续执行。
举一个简单的例子
假设有两个小朋友,Alice
和Bob
,每人都带了五毛钱,他们同时想买一块钱的冰棍,这里冰棍的购买可以被视为一个临界资源,因为它需要两个单独的锁资源才能完成购买过程(例如,一把锁用于支付,另一把锁用于取冰棍)。
现在的情况是这样的:
Alice
想要买冰棍,但她只有五毛钱。她需要获取第一把锁来支付,然后第二把锁来取冰棍。- 同时,
Bob
也想要买冰棍,同样只有五毛钱。他也需要获取相同的两把锁来完成购买过程。
可能出现的问题是:
Alice
先获取了第一把锁来支付,但在尝试获取第二把锁时,可能由于竞争或者执行顺序问题,这第二把锁正在被Bob
持有。- 同时,
Bob
也已经获取了第一把锁,但在尝试获取第二把锁时,可能这第二把锁正在被Alice
持有。
这种情况下,Alice
和Bob
都无法继续完成购买冰棍的过程,因为他们彼此占用了彼此所需的资源,而又不愿意释放已占用的资源。他们互相等待对方释放资源,从而导致了死锁。
8.2 模拟死锁
如下我创建了两个线程,两把锁。线程1
先申请A
锁,线程2
先申请B
锁,申请完后,线程1
又开始申请B
锁,而线程2
又开始申请A
锁,此时就出现了线程1
拿着A
锁,线程2
拿着B锁,他俩还互相想要对方的锁,但是他们要的锁已经被对方所拿走,此时就出现,线程1
在申请B
锁的时候申请不到,线程1
抱着A
锁挂起等待,线程2
也不可能申请到A
锁,线程2
抱着B
锁挂起等待。
#include <iostream>
#include <cstring>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/syscall.h>using namespace std;pthread_mutex_t mutexA = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutexB = PTHREAD_MUTEX_INITIALIZER;void *startRoutine1(void *args)
{while (true){pthread_mutex_lock(&mutexA);sleep(1);pthread_mutex_lock(&mutexB);cout << "我是线程1, 我的tid: " << pthread_self() << endl;pthread_mutex_lock(&mutexA);pthread_mutex_lock(&mutexB);}
}
void *startRoutine2(void *args)
{while (true){pthread_mutex_lock(&mutexB);sleep(1);pthread_mutex_lock(&mutexA);cout << "我是线程2, 我的tid: " << pthread_self() << endl;pthread_mutex_lock(&mutexB);pthread_mutex_lock(&mutexA);}
}
int main()
{pthread_t t1, t2;pthread_create(&t1, nullptr, startRoutine1, nullptr);pthread_create(&t2, nullptr, startRoutine2, nullptr);pthread_join(t1, nullptr);pthread_join(t2, nullptr);return 0;
}
【程序结果】
经典问题:只有一把锁会造成死锁吗?
答案是:会!在只有一把锁的情况下,如果一个线程(比如线程A
)获取了锁并且在使用临界资源后没有释放锁,而其他线程也需要这把锁来继续执行,那么这些线程会被阻塞,无法继续执行。同时,线程A
由于没有释放锁,那么它会一直处于等待状态,直到获取到锁为止。这不就是死锁的表现吗?
【程序结果】
8.2 形成死锁的四个必要条件
- 互斥:一个资源每次只能被一个执行流使用(使用锁)。【前提】
- 请求与保持:一个执行流因请求资源而阻塞时,对已获得的资源保持不释放。【原则】
- 不剥夺条件:不能强行剥夺其他线程的资源。【原则】
- 循环等待条件:若干执行流之间形成一种首尾相接的循环等待资源的关系。【重要条件】
只有四个条件都同时满足了,才会引发死锁问题!
8.3 如何避免死锁
核心思路:破坏四个必要条件的其中一个或多个。
-
方法一:不加锁。本质是不保证互斥。(破坏条件
1
) -
方法二:尝试主动释放锁资源给对方。本质就是一个牺牲自己,成就对方。(破坏条件
2
)
可以借助pthread_mutex_trylock
函数实现这种方案
#include <pthread.h>int pthread_mutex_trylock(pthread_mutex_t *mutex);
该函数主要用于避免线程阻塞等待锁资源的情况。
其功能是:用于尝试获取一个互斥锁,如果该锁当前没有被其他线程占用,则立即获取并返回成功。如果锁已经被其他线程占用,则立即返回失败,而不是阻塞等待。
- 方法三:不剥夺对方的资源,那就释放对方的资源。(破坏条件
3
)
调用pthread_mutex_unlock
接口即可。
- 方法四:按照顺序申请锁。(破坏条件
4
)
环路问题的根本在于:双方都有对方需要的资源。所以可以按顺序申请锁资源呢?然后再按照顺序释放锁。
总结
- 破坏死锁的四个必要条件的其中之一即可
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
- 死锁检测算法
- 银行家算法
九、相关代码
Gitee
代码仓库:点击跳转