前言:本篇文章主要讲述linux线程的互斥的知识。 讲解流程为先讲解锁的工作原理, 再自己封装一下锁并且使用一下。 做完这些就要输出一堆理论性的东西, 但博主会总结两条结论!!最后就是讲一下死锁。 那么, 废话不多说, 我们开始学习吧!
ps:本节内容建议先了解一下数据不一致问题以及锁的使用的友友们进行观看哦。
目录
锁的原理
锁的封装
接口
测试
可重入VS线程安全
概念
结论
死锁的概念
要如何解决死锁问题呢?
我们回忆一下上一节, 上一节我们知道了:为了提高并发度, 所以有了多线程。 为了使用多线程, 我们就有了线程之间的资源共享。 而资源共享又引入了多线程的访问数据不一致的问题。 因为数据不一致问题, 我们又引入了互斥锁, 而互斥锁需要考虑临界资源和临界区以及原子性和互斥性的概念。——这些就是上一节的大体内容。 现在看本节的内容吧!
锁的原理
我们现在来重新思考一下一个问题——tickets--为什么不是原子的? 因为汇编会变成三条汇编语句。 我们认为一条汇编语句已经是计算机中最基本的指令了。什么是原子的? 这里可以下一个定义: 我们认为, 一条汇编, 就是原子的。
其实, cpu这个硬件资源是很笨的, 我们让他去move, sub, add。 他就按照我们的指令去做。 这里面就有一个问题, 就是cpu很笨, 我们让他干什么, 他就干什么。 但是cpu为什么知道我们让他去干什么呢? 是因为我们对应的芯片当年在制作的时候, 他得在自己的芯片的硬件电路离, 一定要以硬件的方式设计出一系列能够让硬件识别的基本指令。 这个基本指令叫做芯片的指令集(区别于代码, 就是我们的汇编move, sub, add这些)
为了实现互斥操作, 大多数体系结构提供了swap和exchange指令。 这些指令比如swap,那么他就是一条利用汇编把寄存器里面的值和内存里面的值做交换。 由于只有一条指令, 所以就保证了交换的动作是原子的。 即便是多处理器平台(多个cpu), 我们需要知道的是, 即便我们的cpu有很多块, 但是我们的cpu和内存之间的总线只有一套。所以这么多cpu通过总线访问内存的时候就会通过总线里面的硬件——仲裁器, 来决定内存由哪一个cpu来访问。 也就是说所有的cpu在访问内存的时候还是串行的, 只是在计算的时候大家可以在双cpu或者多cpu下进行计算。
现在看下面一串伪代码, 这串伪代码是mutex_lock的伪汇编:
move $0, %al
xchgb %al, mutex
if (al寄存器的内容 > 0){
return 0;
} else
挂起等待;
goto lock;
这串伪代码怎么理解, 下面博主带友友们理解一下:
- 图中是一块cpu和一块物理内存。 物理内存中有一块锁, 锁里面的数据就代表锁!我们设置为1.
- 然后第一条语句是move 0 al。意思就是将0赋值给al变量(al此时已经被加载到寄存器)。
- 第二条语句是xchgb al mutex, 意思就是将al 和 mutex中的数据调换。这也是传统意义上申请锁的动作。然后调换后就是下图:
- 第三条语句就是对al寄存器里面的数据进行判断。 如果寄存器数据大于0。 那么就申请成功, 返回零。 如果小于零, 就申请失败, 挂起等待。
但是!这是只有一个线程的情况, 我们可以一步一步地向后执行指令。 当有多个线程的时候就要考虑线程切换的问题了。 下面是多线程的情况(注意:寄存器 != 寄存器的内容):
- 假设一开始线程1执行第一条语句, 将al赋值为了零。 然后线程1就要被切换走了。
- 那么此时线程1就要将寄存器中的al数据记录下来,同时将下一步执行哪一步记录下来。这叫带走上下文!
- 然后呢线程2来了, 线程2开始将数据加载到寄存器, 然后al 置为0.但是线程2运气好, 他没有被切换走, 然后他就继续执行xchgb操作。 成功的将al的数据0和mutex的数据1完成了交换! 此时现成2终于要被切换走了!那么现成2就要将自己的上下文带走!!!
- 线程2切换走了之后,线程1回来执行xchgb, 但是此时mutex的值已经是0了,线程1交换al 和 mutex相当于什么都没有交换到! 然后执行判断, 对不起, 判断失败, 线程1需要挂起等待。
- 那么线程2回来之后继续执行判断, 注意, 此时原本的mutex的那个1就在线程2的上下文中!那么线程2判断结果一定会正确, 那么线程2就申请成功了锁!
所以, 综上我们其实就能感觉出来, 上面的mutex_lock函数那哪一步最重要? 是不是就是xchgb这一步最重要。 谁先交换成功, 谁就相当于拿到了锁!!
锁的封装
接口
接下来我们对锁进行一下封装。 让锁的接口不再暴露在外面, 用户直接使用我们的接口:
首先我们创建一个类,这个类对锁进行了一下封装:
class Mutex //创建锁的类
{
public:
private:
pthread_mutex_t* _lock;
};
然后定义这个类的构造函数, 析构函数;以及加锁解锁函数:
class Mutex
{
public:
//构造函数
Mutex(pthread_mutex_t* lock)
:_lock(lock)
{}
//加锁
void Lock()
{
pthread_mutex_lock(_lock);
}
//解锁
void Unlock()
{
pthread_mutex_unlock(_lock);
}
//析构函数
~Mutex()
{}
private:
pthread_mutex_t* _lock;
};
然后我们创建一个类似于“开关”的对象。 这个对象只要创建, 就代表我们的加锁;这个对象一小会就代表我们的解锁!如下这里面封装了我们上面刚刚定义的Mutex类的对象。
class LockGuard
{
public:
private:
Mutex _mutex;
};
那么如何实现这种“资源创建即初始化”的效果呢?我们可以在它的构造函数初始化_mutex成员变量并且使用_mutex的方法lock。然后析构函数就是调用_mutex的unlock
class LockGuard
{
public:
LockGuard(pthread_mutex_t* lock)
:_mutex(lock)
{
_mutex.Lock();
}
//
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex _mutex;
};
测试
有了上面的代码, 我们可以拿出我们之前写的买票的代码(不知道的友友请看之前一篇文章:linux线程 | 把握线程的知识要点 | 同步与互斥-CSDN博客, 也可以看下面代码):
#include<iostream>
using namespace std;
#include<pthread.h>
#include<vector>
#include<unistd.h>
#include<string.h>
#include"LockGuard.h"
#define NUM 5 //创建多个执行流, NUM为执行流个数
using namespace std;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int* p = nullptr;
//线程的数据信息。
struct threadData
{
public:
threadData(int number, pthread_mutex_t* mutex)
{
threadname = "thread-" + to_string(number);
lock = mutex;
}
public:
string threadname;
pthread_mutex_t* lock;
};
int tickets = 1000;
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;
}
usleep(13);
}
return nullptr;
}
int main()
{
pthread_mutex_init(&lock, nullptr);
vector<pthread_t> tids;
vector<threadData*> thread_datas;
//我们创建多个执行流, 为了能够验证每个线程都有一个独立的栈结构
for (int i = 0; i < NUM; i++)
{
pthread_t tid;
threadData* td = new threadData(i, &lock);
thread_datas.push_back(td);
pthread_create(&tid, nullptr, getTicket, td);
tids.push_back(tid);
}
for (auto e : tids)
{
pthread_join(e, nullptr);
}
// pthread_mutex_destroy(&lock);
return 0;
}
然后我们如何改动上面的代码呢? 就是换一下GetTickets函数里面的锁的创建方法, 改成直接一个LockGurard类型的对象:
void* getTicket(void* args)
{
threadData* td = static_cast<threadData*>(args);
const char* name = td->threadname.c_str();
while (true)
{
{ //这个花括号是为了规定LockGuard对象的作用域
LockGuard lockguard(&lock); //定义临时的lockguard对象。 RAII风格的锁
if (tickets > 0)
{
usleep(1000);
printf("who=%s, get a ticket: %d\n", name, tickets);
tickets--;
}
else
{
break;
}
}
usleep(13);
}
return nullptr;
}
然后运行:
可以看到, 和原本创建锁的结果一样。
可重入VS线程安全
概念
如果多个线程同时访问一段代码的时候, 不管如何运作, 那么我们的线程都不会出现问题, 那么就称为线程安全。如果我们的代码访问某些全局变量, 然后导致其他线程出现问题了, 这个就叫做线程不安全。
重入就是同一个函数被多个执行流调用, 正在被一个执行流调用的时候, 其他的执行流又进来了。 然后在这种情况下仍然没有出现问题的函数称为可重入函数, 否则称为不可重入函数。
线程安全和重入这两个概念是一样的概念吗?不是的, 因为重入不可重入描述的是函数的特点, 而线程安全描述的是多线程并发的特点。目前, 我们遇到的大部分函数都是不可重入的。一个函数可重入或者不可重入, 其实并没有褒贬之分,他只是在描述这个函数的特征。但是!对于不可重入的函数我们的多执行流要用, 就必须要加锁了!!!
因为这里有一个结论性的话:一般情况下, 一个函数是不可能重入的,那么在多线程执行下, 就有可能出现问题。 但是, 如果一个函数是可重入的, 那么在多线程的调用下, 它一定也是线程安全的。
结论
常见的线程不安全的情况:
- 不保护共享变量的函数。
- 函数状态随着被调用, 状态发生变化的函数。
- 返回指向静态变量指针的函数。
- 调用线程不安全函数的函数。
常见的线程安全的情况:
- 每个线程对于全局变量或者静态变量只读!
- 类或者接口原子的!
- 多个线程之间的切换不会导致接口结果产生二义性!
常见不可重入的情况:
- IO
- malloc/free函数等等函数
- 可重入函数体内使用了静态数据结构
可重入与线程安全的联系(重要)
- 函数如果是可重入的, 那么多线程调用这个函数的过程, 就是线程安全的!
- 函数是不可重入的, 那么一般不可多线程使用, 有可能引发线程安全问题。
- 如果一个函数中使用了全局变量。那么这个函数既不是线程安全的, 也不是可重入的。
可重入与线程安全的区别:
- 可重入是线程安全函数的一种
- 可重入一定是线程安全的,但是线程安全, 不一定是可重入的。
上面最最重要的就是两个结论:
- 线程安全是描述线程并发的问题, 可重入是描述函数特点的问题。
- 不可重入函数在多线程访问时可能会出现线程安全问题, 但是一个函数如果可重入, 他不会有线程安全问题。
死锁的概念
首先什么是死锁呢? ——一般在多线程访问的时候, 我们一方面持有自己的锁, 还申请其他人的锁。我们双方既不释放自己的锁, 而且要求释放对方的锁, 并且还不强占对方的锁, 只是申请对方的锁。 这就会导致死锁的问题。 以现象定义就是在多线程的情况下, 因为锁的使用导致多线程的代码都不往后执行了。
那么问题是, 一把锁可不可能产生死锁呢?——是可以的。当我们的同一把锁被申请两次的时候,就会产生死锁,就如同下面的代码:
运行的时候就会阻塞住了:
我们称一个线程持有一把锁, 另一个线程持有另一把锁。 他们两个却又申请对方的锁进而导致进入一种永久等待状态的情况我们称之为死锁。 就比如张三和李四买棒棒糖, 但是张三和李四兜里只有五毛钱, 棒棒糖要一块钱, 所以张三就让李四把五毛钱给他, 他给两个人买一根棒棒糖。 那李四不愿意, 李四就和张三说把五毛钱给他, 他给两个人买棒棒糖。 这两个小朋友一不释放自己手中的五毛钱, 而还一直要对方的五毛钱。这种情况叫做张三和李四陷入了死锁问题!
那么产生死锁的必要条件有什么呢? ——》只要产生死锁, 这四个条件一定产生!!!
- 1:互斥条件——》一个资源一次只能被一个执行流使用。——前提
- 2:请求与保持条件——》所谓死锁就是在互相保持自己的资源, 同时还在申请着对方的资源。——原则
- 3:不剥夺条件——就类似于张三想要对方的五毛钱,但是他不去抢。 一个执行流以获得的资源在未使用完之前,不能强行掠夺。——原则
- 4:循环等待条件——》若干执行流之间形成的一种头尾相连的循环等待资源的关系。 就类似于张三等待李四, 李四等待张三。——重要条件
要如何解决死锁问题呢?
我们知道, 对于死锁来说有四个必要的条件。 也就是说死锁必须有着这四个条件。 那么我们如果想要破坏死锁, 那么是不是只需要破坏掉其中某一个条件就可以了?
所以方法就是:
- 破坏请求与保持——》我们可以使用trylock, 它是lock的非阻塞版本, 很容易就能破坏请求与保持原则。
- 破坏不剥夺——》只需要将对方的锁释放掉, 就能破坏掉不剥夺条件。
- 破坏循环与等待条件——》破坏掉这个后, 申请锁的时候按照顺序申请锁!一般加锁的顺序保持一致!
——————以上就是本节全部内容哦, 如果对友友们有帮助的话可以关注博主, 方便学习更多知识哦!!!