前言
对于初学线程 的伙伴来讲,多线程并发访问导致的数据不一致问题,总是让人陷入怀疑,很多人只是给你说加锁!但没有人告诉你为什么?本篇博客将详解!
目录
[• 为什么票会出现负数的情况?](#• 为什么票会出现负数的情况?)
[• 互斥锁 mutex](#• 互斥锁 mutex)
[• 从原理的角度理解锁](#• 从原理的角度理解锁)
[• 从实现的角度理解锁](#• 从实现的角度理解锁)
[• 死锁问题](#• 死锁问题)
[• 死锁 产生的四个必要条件](#• 死锁 产生的四个必要条件)
[• 避免死锁](#• 避免死锁)
[• 避免死锁的算法](#• 避免死锁的算法)
一、线程互斥
OK,我们先来看一个多线程并发访问的例子(模拟抢票):
cpp
class customer
{
public:
customer(const std::string &name)
: _name(name)
{}
std::string getName()
{
return _name;
}
private:
std::string _name; // 线程的名字
};
这里先来实现一个顾客的类 ,其中**_name** 是线程的名字
cpp
int tickets = 10000;// 1W张票
void* route(void* args)
{
customer* cum = static_cast<customer*>(args);
while (true)
{
if(tickets > 0)
{
usleep(1000);// 模拟抢票
std::cout << cum->getName() << ", 抢到了第" << tickets << "票" << std::endl;
tickets--;
}
else
{
break;
}
}
return nullptr;
}
在实现一个模拟抢票的函数,其中这个函数当每个线程后面进入后判断当前票数大于0 ,说明有票,就抢票 ,然后打印 出当前抢到的票的编号,然后让票的总数--,否则就结束掉
cpp
int main()
{
std::vector<customer*> cusm; // 管理customer对象
std::vector<pthread_t> tids; // 管理线程
for (int i = 0; i < num; i++)
{
std::string name = "thread_" + std::to_string(i + 1);
pthread_t tid;
customer *cum = new customer(name);
pthread_create(&tid, nullptr, route, (void *)cum);// 创建线程
cusm.emplace_back(cum);
tids.emplace_back(tid);
}
for (auto &tid : tids)
{
pthread_join(tid, nullptr);// 等待线程结束
}
return 0;
}
然后我们在这里创建num = 5个线程并启动,让你他们都执行抢票,其中创建两个数组来分被管理对象和tid,最后等待他们结束!OK,先来看结果:
这里在出现了负数票的编号?不应该是到判断是0的时候就不在抢了吗?
上面的全局变量tickets是所有的线程共享的,像这种资源叫做共享资源!上面出现的负数票编号的情况就是典型的多线程并发访问同一个共享资源导致的数据不一致问题!下面我们不急着解决,我们先来探索一下,为什么会出现这种情况!
在正式的介绍前,我们先来铺垫一些前置的知识:
1、计算机中的运算不仅仅值得是算数运算,还有逻辑运算!
上面的if判断票数是否大于0就是一次逻辑运算,tickets--就是算数运算
2、CPU中的寄存器只有一套但是里面的数据可以有多套!
这里主要说的就是我们进程切换那里说的上下文数据,其实不止进程,线程也是有上下文的!
3、语言上的算数/逻辑运算底层不是一条语句!
这句话的意思就是上述的运算虽然在语言上看起来是一条语句,但是在汇编层面上可能就是很多条 !上述的tickets ,他是存放在内存 的(直接透过虚拟地址空间),而所有的运算是CPU运算的 ,所以要被运算前,先得要加载到CPU的寄存器,然后在运算,完了将结果写回内存!
其中执行tickets--这里有一个大坑,即他分为三步执行:
1、重新从内存读取数据
2、--数据
3、写回内存!
我们可以看看他的汇编
cpp
// 取出 tickets--部分的汇编代码
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax
# 600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip)
# 600b34 <tickets>
这里有的朋友可能对汇编不咋熟悉,我来解释一下:
OK,有了这些认识我们就可以介绍为什么会出现负数了!
• 为什么票会出现负数的情况?
OK,我们先把上面的模型简化一下,多线程简化为两线程,这里多线程访问共享资源的地方共有两个:
我们直接来分析一个极端的情况,当只有最后一张票时 ,假设此时 thread_1 先被 CPU 调度,此时 thread_1先判断,发现票数是1,然后进入抢的时候,时间片到了,此时要从 CPU 上切换下来,并将 CPU 寄存器中的数值保存到当前线程的上下文中 !然后,thread_2被调度,一看票数是1大于0, 就进入了,他把票买了消息输出了,刚要做tickets--的时候,时间片到了,他也将 CPU中的数据保存到上下文中被切换走了!然后再切换回 thread_1继续执行,这次 thread_1继续执行到tickets-- 的操作,然后将此时重读内存数据1,加载到寄存器1,然后运算完后写入结果寄存器0,最后写道内存0!调度结束,thread_2又开始调度,他开始往后执行**tickets--**的操作,即重新读取内存的数据0,在做--操作,将结果-1放到结果寄存器,最后写入内存-1,此时不就是出现了负数了嘛!
当这里的线程不是两个而是好多个,这不就是出现了更多的负数了吗!OK,现在分析清楚了问题,那如何解决呢?答案是:加锁!
• 互斥锁 mutex
Linux中的互斥锁,又称互斥量!我们先来见一见他的接口!
互斥量的定义
定义互斥量/锁的方式有两种,分别是静态和动态:
1、静态定义
cpp
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
2、动态定义
cpp
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const
pthread_mutexattr_t *restrict attr);
参数:
mutex:要初始化的互斥锁
attr:内部属性直接不关心,设为nullptr即可
返回值:
成功:返回0
失败:返回一个非0 的错误码
注意:互斥锁接口的返回值都是一样的,后续不再介绍!
互斥量的销毁
cpp
int pthread_mutex_destroy(pthread_mutex_t *mutex);
销毁互斥量需要注意:
1、使用全局/静态定义的互斥量mutex,不需要销毁!
2、不要销毁一个已经加锁的互斥量
3、已经销毁的互斥量,确保后期不会再有线程使用
互斥量的加锁和解锁
cpp
int pthread_mutex_lock(pthread_mutex_t *mutex);/*没抢到锁,阻塞时的等待*/
int pthread_mutex_trylock(pthread_mutex_t *mutex);/*没抢到锁,不发生阻塞时的等待*/
int pthread_mutex_unlock(pthread_mutex_t *mutex);/*解锁*/
调用pthread_mutex_lock时,可能会遇到以下的情况:
1、互斥量处于解锁状态,该函数会将该互斥量锁定,同时成功返回,执行后续代码
2、当他调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但当前线程没有抢到,此时pthread_mutex_lock会导致调用该函数的线程陷入阻塞(当前执行流被挂起到相关的阻塞队列),等待互斥量解锁,再去竞争!
3、上面的phread_mutex_lock和phread_mutex_trylock的区别就是,前者当发现互斥量已经被其他的执行流给锁定时,会陷入阻塞等待,而后者不会阻塞等待,会直接返回一个错误码EBUSY,然后去执行其他的内容!
OK,介绍完互斥锁,按我们对上述的抢票改一下:
上面的代码是由于,多个执行流并发访问共享资源导致的数据不一致,为了保障数据安全,我们让他们在访问个共享资源时,串行的访问,即一个访问完了另一个在访问,所以,我们可以把锁加在访问全局的ticktes的前后
我们先来使用以下全局的锁:
cpp
pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER;// 定义一把全局的互斥锁
void *route(void *args)
{
customer *cum = static_cast<customer *>(args);
while (true)
{
// 对访问共享资源的前后加锁
pthread_mutex_lock(&g_mutex);
// 临界区
if (tickets > 0)
{
usleep(1000); // 模拟抢票
std::cout << cum->getName() << ", 抢到了第" << tickets << "票" << std::endl;
tickets--;
// 临界区
pthread_mutex_unlock(&g_mutex);// 访问结束了解锁
}
else
{
pthread_mutex_unlock(&g_mutex);// 条件不满足解锁
break;
}
}
return nullptr;
}
再来看看运行结果:
OK,这就没有问题了!介绍到这里我们在引入一批概念:
1、临界资源:多个执行流共享的资源就叫做临界资源
2、临界区:访问临界资源的代码就叫做临界区
3、互斥:任何时刻,只允许一个执行流进入临界区访问临界资源(可以保护临界资源)
4、原子性:不会被任何调度机制打断操作,该操作只有两态:要么完成、要么未完成
5、对所有临界资源的访问,本质都是通过代码访问;所以对临界资源的保护本质上是对临界区代码的保护!
其中,上述代码中tickets就是被多执行流关系的资源,即临界资源!进行对tickets进行访问的代码就叫做临界区!互斥就是加锁之后只有一个执行流进入临界区访问临界资源!原子性表现在,当一个执行流加锁访问临界资源执行tickets--时要么做完,要么一次性执行完他的那三步,要么不执行!
OK,下面我们再来使用以下,局部定义的互斥锁:
在这里直接将上述的互斥锁,放到线程的属性中:
cpp
const int num = 5; // 创建5个线程
int tickets = 10000; // 1W张票
class customer
{
public:
customer(const std::string &name, pthread_mutex_t& mutex)
: _name(name), _mutex(mutex)
{}
std::string getName()
{
return _name;
}
pthread_mutex_t &getMutex()
{
return _mutex;
}
private:
std::string _name; // 线程的名字
pthread_mutex_t& _mutex; // 互斥锁
};
注意:这里的锁属性必须得是引用,也就是那把局部所的别名,这样做的目的是让不同的线程看到同一把锁!当然你这里使用指针也是可以的!
cpp
void *route(void *args)
{
customer *cum = static_cast<customer *>(args);
while (true)
{
// 对访问共享资源的前后加锁
pthread_mutex_lock(&cum->getMutex());
// 临界区
if (tickets > 0)
{
usleep(1000); // 模拟抢票
std::cout << cum->getName() << ", 抢到了第" << tickets << "票" << std::endl;
tickets--;
// 临界区
pthread_mutex_unlock(&cum->getMutex()); // 当访问结束了就解锁
}
else
{
pthread_mutex_unlock(&cum->getMutex()); // 当访不符合条件就解锁
break;
}
}
return nullptr;
}
然后买票这里就可以直接使用了!
cpp
int main()
{
pthread_mutex_t mutex; // 定义一把局部的互斥锁
pthread_mutex_init(&mutex, nullptr);//初始化
std::vector<customer *> cusm; // 管理线程
std::vector<pthread_t> tids; // 管理线程
for (int i = 0; i < num; i++)
{
std::string name = "thread_" + std::to_string(i + 1);
pthread_t tid;
customer *cum = new customer(name, mutex);
pthread_create(&tid, nullptr, route, (void *)cum);// 创建线程
cusm.emplace_back(cum);
tids.emplace_back(tid);
}
for (auto &tid : tids)
{
pthread_join(tid, nullptr);// 等待线程结束
}
for(auto& e : cusm)
{
delete e;// 释放资源
}
pthread_mutex_destroy(&mutex);// 释放锁
return 0;
}
然后使用完之后,记得将锁释放!OK,看一下效果:
初识RAII风格
OK,互斥锁的相关接口比较简单,代码也比较好写!但是让人麻烦的一点是:每次都得手动的加锁和解锁,如果忘记解锁,会导致其他线程一直阻塞 !为了解决这个问题,我们实现一个小的组件!
这里也就是实现一个专门管理锁的类(LockGuard类):
cpp
#ifndef __LOCKGUARD
#define __LOCKGUARD
#include <pthread.h>
class LockGuard
{
public:
LockGuard(pthread_mutex_t &mutex)
:_mutex(mutex)
{
pthread_mutex_lock(&_mutex);
}
~LockGuard()
{
pthread_mutex_unlock(&_mutex);
}
private:
pthread_mutex_t &_mutex; // 注意这里得是引用!
};
#endif
然后有了这个类之后,我们就不再担心忘记解锁了:
这里就完美的结合了类和对象的特性!构造时加锁 ,用完之后,不管你是时间片完了结束还是没有满足条件的结束,此时出了作用域对象就会调用析构函数,自动解锁了~!很优雅!!
像这种 获取资源即初始化 的风格称为RAII风格,由 C++
之父 本贾尼·斯特劳斯特卢普 提出,非常巧妙的运用了 类和对象 的特性,实现半自动化操作!
这里我们是第一次见RAII ,后面我们在介绍C++11智能指针的时候我们再来谈~!
总结
1、加锁是让临界资源保证"安全"的,在加锁时当尽量使得加锁的粒度小(加锁的代码行数少)
2、线程申请锁成功了,在执行临界区期间,可以被切换走吗?
答案是肯定的,如果当前线程持有锁被切走了,其他线程依然在阻塞,因为被切走的线程没有释放锁!这样其实也保证了,我在访问临界区时对于其他线程是原子的~!
• 从原理的角度理解锁
原理角度其实上面我们都介绍过了,本质就是让多执行流在执行临界区的代码访问临界资源时,一个一个的来,即串行的访问,当一个线程持有锁时,对于其他线程是原子的!OK,这都没有问题,我在的问题是:
1、如何理解申请锁成功,允许你进入临界区?
加锁本质上是去调用,pthread_mutex_lock函数,当他的内部判断当前线程是持有锁资源时 ,成功返回 !当pthread_mutex_lock函数返回后,不就是可以继续执行后面的代码了吗!后面不就是临界区嘛!
2、如何理解申请锁失败,不允许你进入临界区?
当pthread_mutex_lock函数内部判断,当前线程不具有锁时,直接阻塞住!其实内部可能也就是一个判断,将当前的线程挂接到特定的阻塞队列!等到上一个线程把锁释放了再去把他们唤醒,开始竞争锁资源~!其实,这个和我们C语言的scanf一样,当CPU执行到它时,会检测键盘是否输入了数据,没有输入不就是一直的卡着嘛!
• 从实现的角度理解锁
所有线程在竞争锁的前提是得让他们都能够看到同一把锁,即锁本身也是共享资源,那加锁的过程也必须也得是原子的!
现在的问题是如何保证加锁的过程也是原子的呢?
在如今,大多数CPU的体系结构(比如 ARM、X86、AMD 等)都提供了 swap 或者exchange 指令,这种指令可以把 寄存器 和 内存单元 的数据 直接交换 ,由于这种指令只有一条语句,可以保证指令执行时的 原子性
即便是在多处理器环境下(总线只有一套),访问内存的周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期,即 swap 和 exchange 指令在多处理器环境下也是原子的
我们来看看一段pthread_mutex_lock的汇编伪代码:
cpp
lock:
movb $0, %al
xchgb %al, mutex
if(al寄存器里的内容 > 0){
return 0;
} else
挂起等待;
goto lock;
这里的意思是先将0放到al寄存器,然后将内存中的值和al寄存器中的值交换(使用的时swap/exchange指令,原子的)再下来就是判断al中的值是否大于0,如果是返回!否则挂起等待!等到有锁资源了再去继续执行上述的操作,继续判断!
OK,我们现在回头看前面的加锁过程,假设有两个线程加锁,需要申请锁资源:
同理多线程也是一样的!OK,再来看看,解锁的汇编伪代码:
cpp
unlock:
movb $1, mutex
唤醒等待 [锁资源] 的线程;
return
相比较加锁,解锁简单的多!这里只是一个lock中的值进行swap/exchange并没有新增值,多个线程都在竞争这一个值~!这就是加锁和解锁的实现原理!至于各种被线程执行某条汇编指令时被切出的情况,都可以不会影响整体 加锁 情况~!
总结
- 加锁是一个让不让你通过的策略
- 交换指令swap或exchange是原子的,确保 锁 这个临界资源不会出现问题
- 未获取到 [锁资源] 的线程会被阻塞至pthread_mutex_lock处
- 数据在内存中是被多执行流共享的,但是加载到CPU后就将共享变成了私有!
二、可重入VS线程安全
概念
1、线程安全:多个线程并发访问同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题!
2、重入:同一个函数被不同的执行流调用,当前一个执行流还没有执行完,就有其他的执行流再次进入,这被称为重入!一个函数在重入情况下,运行结果不会出现任何不同或者任何问题,则称该函数为可重入函数,否则,是不可重入函数;
常见的线程不安全的情况
• 不保护共享变量的函数
• 函数状态随着被调用,状态发生变化的函数、
• 返回指向静态变量指针的函数
• 调用线程不安全函数 的函数
常见线程安全的情况
• 每个线程对全局变量或静态变量只有读取权限,而没有写入权限,一般来说都是线程安全的
• 类或者接口对于线程来说都是原子操作
• 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
• 调用了 malloc/free 函数,因为 malloc 函数使用全局链表来管理堆的
• 调用标准I/O库函数,标准I/O库函数的很多实现都以不可重入的方式,使用全局数据结构
• 可重入函数内使用了静态的数据结构
常见的可重入的情况
• 不适用全局或者静态的变量
• 不适用malloc 或者 new 开辟出的内存空间
• 不调用不可重入函数
• 不返回静态或者全局的数据,所有数据都由函数的调用者提供
• 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局的数据
可重入与线程安全的联系
• 函数是可重入的,那就是线程安全的
• 函数是不可重入的,那就不能由多个线程使用,否则有可能引起线程安全的问题
• 如果一个函数中有全局变量,那么这个函数既不是线程安全,也不是可重入的
可重入与线程安全的区别
• 可重入函数是线程安全函数的一种
• 线程安全不一定是可重入的,二可重入函数一定是线程安全的
• 如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放择会产生死锁,因此是不可重入的!
三、常见锁的概念
• 死锁问题
死锁 :指在一组进程中的各个线程均占有不会释放的资源,但因相互申请被其他线程所占用不会释放的资源处于一种永久等待状态
概念比较绕,简单举个例子
两个小朋各持 五毛钱 去商店买东西,俩人同时看中了一包 辣条 ,但这包 辣条 售价 一块钱,两个小朋友都想买了自己吃,但彼此的钱都不够,双方互不谦让,此时局面就会僵持不下
两个小朋友:两个不同的线程
辣条:临界资源
售价:访问临界资源需要的锁资源数量,这里需要两把锁
两个小朋友各自手里的钱:一把锁资源
僵持不下的场面:形成死锁,导致程序无法继续运行
所以死锁就是 多个线程都因锁资源的等待而被同时挂起,导致程序陷入 死循环
只有一把锁会造成死锁吗?
答案是 会的 ,如果线程 thread_A 申请锁资源,访问完临界资源后没有释放,会导致 线程 thread_B 无法申请到锁资源,同时线程 thread_A 自己也申请不到锁资源了,不就是 死锁 吗!
• 死锁 产生的四个必要条件
1、互斥:一个资源每次只能被一个执行流使用
2、请求与保持:一个执行流因请求资源而阻塞时,对已获得的资源保持不释放
3、环路等待:若干执行流之间形成一种首尾相接的循环等待资源关系
4、不剥夺条件:不能强行剥夺其他线程的资源
注意:只有四个条件都满足了,才会引发 死锁 问题!!!
**•**避免死锁
1、破坏死锁的四个必要条件
2、加锁的顺序一致
3、避免锁未释放的场景
4、资源一次性分配
**•**避免死锁的算法
1、死锁检测算法(了解)
2、银行家算法(了解)
OK,好兄弟本期分享就到这里,我是cp我们下期再见~!