文章目录
一.线程安全问题
- 当多个线程并发地对同一个共享资源进行修改操作时,可能会引发数据读写错误(比如读取无效(脏)数据,丢失更新等等)
读取无效(脏)数据
- 多个线程修改同一个全局变量的示例(模拟抢票):
cpp
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>
#include "LockGuard.cpp"
using namespace std;
//四个线程模拟抢票
#define NUM 4
//用于记录线程信息的类
class threadData
{
public:
threadData(int number){
threadname = "thread-" + to_string(number);
}
public:
string threadname;
};
//10张票作为临界资源
int Tickets = 10;
//线程执行流
void * GetTickets(void * args){
//获取线程名
threadData * TName = static_cast<threadData *>(args);
//执行抢票逻辑
while(true){
if(Tickets > 0){
usleep(10000);
Tickets--;//修改临界资源
cout << TName->threadname << "Get one ticket, tickets left:" << Tickets << endl;
}
else{
break;
}
usleep(10000);
}
cout << TName->threadname << "exit" << endl;
return nullptr;
}
int main(){
//线程名数组
vector<threadData*> threadName(NUM);
//线程标识符数组
vector<pthread_t> threads(NUM);
//创建4个线程
for(int i = 0; i < NUM; ++i){
threadName[i] = new threadData(i+1);
pthread_create(&threads[i],nullptr,GetTickets,threadName[i]);
}
//轮询阻塞线程等待
for(int i =0 ; i < NUM ; ++i){
pthread_join(threads[i],nullptr);
}
//线程名结构体释放
for (auto td : threadName)
{
delete td;
}
return 0;
}
- 代码逻辑限制共享变量
Tickets
不能小于零,但实际执行结果显示共享变量Tickets
在多线程环境中被减到了-1
,引发该错误的原因如下图所示:
- 线程在
if(Tickets > 0)
处读取到了无效的数据
丢失更新
-
C/C++
中对共享变量的++
,--
操作也是非线程安全的,Var++
的汇编代码:
-
两个线程并发对共享变量
int Var = 10
进行++
操作引发的丢失更新问题
时间 | 线程1 | 线程2 | Var的值 |
---|---|---|---|
1 | Mov [Var] ,%eax (CPU调度切换至线程2) | 10 | |
2 | Mov [Var] ,%eax (CPU调度切换至线程1) | 10 | |
3 | Inc %eax | 10 | |
4 | Mov %eax,[Var] | 11 | |
5 | Inc %eax | 11 | |
6 | Mov %eax,[Var] | 11 |
- 两次
++
并发操作只有一次有效
线程安全的保证--操作的原子性
- 在多线程环境中,要确保线程安全,各个线程对于同一共享资源的修改操作必须是串行执行的(或者执行过程是可串行化的),即同一时刻只能有一个线程对同一共享资源进行修改操作.
- 满足这样性质的操作称为原子性操作,原子性操作:不可拆分的最小执行单位(一次操作在某个线程中执行完毕之前不可被其他线程重入)
- 在计算机系统中,最基本的原子性操作就是一条汇编语句,一条汇编语句的执行是不会因为CPU执行流调度切换而中断的,因而是线程安全的,其他操作的原子性只能通过互斥锁来保证
二.互斥锁及其实现原理
互斥锁的实现原理
-
锁的本质是进程中的共享资源,可以理解为内存中的一个
0/1
标记位,对于进程而言锁是一个全局变量 -
线程加锁的本质是将内存中的的锁变量(值为
1
)交换到CPU中的某个特定的寄存器中(寄存器的初始值为0
),当线程被切换时,会将它在CPU中的执行流上下文信息(包括锁标记1
)保存到PCB
中,相当于线程"带着锁一起被切换掉了" -
因此线程持有锁的本质是:线程在CPU中的执行流上下文(各寄存器和缓存中的内容)中带有锁标记,锁资源和线程的绑定关系体现在操作系统的内核层面
-
线程解锁的本质是将特定的寄存器中的锁标记
1
交换回内存中的的锁变量(值为0
)中 -
上述的
0/1
标记位的交换过程是在一条汇编语句中完成的,保证了加锁和解锁过程的原子性,因而是线程安全的
-
当内存中的锁变量为
0
时,其他线程申请锁时就会进入等待队列中休眠直到申请到锁后才能继续执行后续代码,从而在多线程环境中保证了加锁代码段的串行执行.
pthread线程库提供的锁操作
- 定义全局的锁变量并初始化:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
- 代码段的加锁和解锁:
cpp
//代码段加锁,防止线程重入
pthread_mutex_lock(&lock);
//临界区代码段,同一时刻只能有一个线程在执行
//代码段解锁,防止线程重入
pthread_mutex_unlock(&lock);
- 加锁后的模拟抢票代码:
- 加锁后,线程等待休眠的可能性增大了,为了保证效率和系统并发量,保证线程安全的前提下,加锁的临界区中的代码量应尽可能少
三.死锁问题
- 一种常见的死锁情况是:当各线程等待锁资源的逻辑链出现回路时,发生死锁
时间 | 线程1 | 线程2 |
---|---|---|
1 | 申请锁1(申请成功) | |
2 | 申请锁2(申请成功) | |
3 | 申请锁2(等待锁资源) | |
4 | 申请锁1(等待锁资源)(死锁) | |
5 | ||
6 |