目录
前言:
1 线程互斥
1.1 多线程并发问题
1.2 线程锁
1.3 锁的接口
2 线程安全与可重入函数
3 死锁
前言:
本篇文章主要讲解了线程互斥的实现方式,还有原理,并附上代码讲解。并且讲解了锁的概念,问题等。
1 线程互斥
还记得我上一篇文章的结尾有提过的问题吗?如果多个线程同时访问同一个全局变量是否会导致什么问题呢?
答案很明确,那就是一定会导致某种错误,那么这种错误是什么呢?请看下面的代码:
#include<iostream>
#include<unistd.h>
#include<pthread.h>
#include<string>
using namespace std;
//1000张票
int Ticket = 1000;
#define NUM 5
//买票
void* buyTicket(void* args)
{
string name = static_cast<const char*>(args);
while(1)
{
if(Ticket > 0)
{
usleep(1000);
--Ticket;
cout << name << "购买了一张票,还剩下:" << Ticket<< endl;
}
else
{
//没票了就退出
break;
}
usleep(10);
}
return nullptr;
}
//创建线程
int main()
{
pthread_t tids[NUM];
for(int i = 0; i < NUM; ++i)
{
char* name = new char[64];
snprintf(name,64,"%s-%d","thread",i+1);
int n = pthread_create(tids+i,nullptr,buyTicket,name);
if(n != 0)
{
cout << "create thread fail" << endl;
}
}
for(int i = 0; i < NUM; ++i)
{
int n = pthread_join(tids[i],nullptr);
if(n != 0)
{
cout << "join thread fail" << endl;
}
}
return 0;
}
我先讲解一下这一段代码,首先,我们生成了5个线程,这5个线程都会去执行buyTicket这个函数,其中我们定义的Ticket是一个全局变量,所有的线程都能够访问到这个资源,并且它们都能对这个值进行修改,当这个值从1000变为了0的时候,所有的线程都会退出,那么最终的输出值一定是“某一个线程购买了一张票,还剩下0”,这个样式,但是请看我们的实际输出是什么?
输出很奇怪欸,为什么会出现票的数量被减到了负数呢?这很明显不对吧,这确实不对,那么产生这个问题的原因是什么呢?这就涉及到了我们多线程并发访问的问题咯。
1.1 多线程并发问题
我先问大家一个问题,那就是--Ticket这一句代码,在底层真的是一句指令吗?
我的回答是并不是,现在我就为大家讲解一下,--Ticket的真正运行逻辑。
请大家看到上图,我省略了虚拟地址空间这个步骤。在内存当中才会有真正的Ticket这个变量的位置,这个没问题,当我们进行--Ticket的时候,CPU会去将Ticket的内容加载进来,然后通过运算器运行了之后,再写回我们Ticket内存当中,那么也就是说,就算是我们简化的运算逻辑,都是分了三步才能对一个变量进行--操作,也就证明了我们的--Ticket这个过程并不是原子的。(原子表示一个动作只有完成或则没有开始两种动作,没有正在做这个过程)。
记住了上面的结论,我们再来下一个过程,我们的OS会定期的切换PCB,没有任何一个执行流能够有特权,那么这就会导致一个什么问题呢?那就是当其中一个线程加载Ticket进入CPU,并且运算完成之后,正打算将数据写回到内存当中,这时,时间片到了,没办法只能就此作罢。然后另一个线程又来改Ticket这个变量,能改吗?能改,因为它是有权限的,所以他就疯狂的更改ticket的值,假设Ticket的值变为了100。然后时间片又到了,换回最开始的那个线程,他发现,我上次的Ticket还没有写回内存呢,所以赶紧写入了,Ticket就变为了999。这个时候下一个线程再来访问,Ticket的值正确吗?不对了吧。
可能有小伙伴在这里会有一些疑问,那就是为什么CPU能够认识哪一个线程执行到了那个位置了呢?他又是如何辨别的?其实这一点可以归为一类,那就是我们的线程是由PCB的,还记得我上一篇文章当中讲了线程的私有属性当中有一个什么东西吗?一组寄存器量,没错,这组寄存器会记录下当前CPU里寄存器的值,也就是正在运行的代码的上下文,然后在下一次调用这个线程的时候,通过读取这些寄存器的变量,就能够知道上一次运行到了那个位置了。这就是线程切换不会出现问题的真正原因。
所以因为有了这个问题,所以才出现了线程互斥这个概念。以及锁这个产物。
1.2 线程锁
通过上面的讲解,我们已经明白了为什么会出现多线程访问并发的问题,所以咱们就需要想方法改进咯,那么我们的改进策略就是添加锁。
那么什么是锁呢?其实就是字面意思,谁持有锁,谁就能够打开“某一扇门”,在我们的线程当中,这个门就是临界区的资源。什么又是临界区呢?所谓临界区其实就是含有临界资源的那部分代码,而临界资源又是什么?临界资源就是有可能会被多个线程同时访问的资源,上文的Ticket就是临界资源,所以我们上锁的位置就是线程访问临界区之前。
我们的线程互斥也是基于锁来实现的,也就是说,当一份资源不允许被多个线程同时访问的时候,就需要对这一份资源添加一把锁,这样就可以让所有的执行流在运行到这个地方只能够单执行流运行,而不是多执行流并发访问。
所以更改代码之后如下:
//锁的初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void* buyTicket(void* args)
{
string name = static_cast<const char*>(args);
while(1)
{
//上锁
pthread_mutex_lock(&mutex);
if(Ticket > 0)
{
usleep(1000);
--Ticket;
cout << name << "购买了一张票,还剩下:" << Ticket<< endl;
//解锁
pthread_mutex_unlock(&mutex);
}
else
{
//解锁
pthread_mutex_unlock(&mutex);
break;
}
//拿到票之后的其它动作
usleep(10);
}
return nullptr;
}
输出如下:
我知道大伙现在脑袋里面懵懵的,首先第一个疑问,我们的临界区是因为不是原子的,从而导致了有多线程并发访问的问题,但是你的锁又是如何保证自己的原子性的呢?
对于锁如何保证自己的原子性的其实很简单,并不是说它是由另一个锁保证的,而是说锁本身就是原子的。
好,说到这里大伙估计更加懵比了,什么玩意哇?锁是原子的,那为什么之前的变量就不是了?这不是搞特殊对待嘛,最关键的是,这是怎么实现的。
请看到锁实现的代码:
lock:(上锁)
movb $0, %al
xchgb %al, mutex
if(al > 0){
return 0;
}else
goto lock;
unlock:
movb $1, mutex
return 0;
上面的代码是锁的实现,al是寄存器,mutex是锁,上锁的逻辑是先将寄存器的值置为0,然后再与mutex交换,如果交换之后,al从零变为了一个非零的值,那么就代表上锁成功,mutex的值也变为了0,程序正确返回,就能够继续向后执行,从而可以使用我们临界区的资源。如果锁之前就已经被拿走了,那么锁与寄存器交换之后,寄存器的值还是0,也就是表明了之前是有人上锁了,所以只能返回回去继续的判断,直到条件申请成功,上锁过程才会结束。
而解锁过程就简单了,直接对锁置一,表示归还锁就行了。
但是这样我并不能感受锁是原子性的,比如交换过程,我就感觉不是原子的,你的疑问确实是有道理的,但是事实上,这个过程就是原子的,因为这属于硬件层面的内容了,它使用了总线锁或者是缓存锁,以次来保证执行这一条指令时是不能够被打断的。
第一个问题我解决了,第二个问题,为什么我在循环当中要添加一个usleep(10)这一句代码来模拟访问临界资源之后的动作呢?不加有什么影响嘛?
删除usleep(10)的影响,输出:
与之前的输出有什么不同?抢票的线程全部变为了1号线程了,这科学吗?这不科学,我明明是多个线程在访问的哇?
其实这个问题理解起来很简单,那就是当前线程持有锁,当它释放锁了之后,后续有没有代码执行,他又返回了循环,又把锁给拿到了,其它线程又只能等待,因为它是正在执行的线程,所以他拿到锁的概率就是最大的。
1.3 锁的接口
这部分博主就不细讲了,本身也没什么难度,只要记得锁的使用场景即可。
锁的初始化,如果用函数接口,那么用了init之后就必须destory,如果用下面的这种方式就不需要这么做了:PTHREAD_MUTEX_INITIALIZER
对于这个函数来说,mutex参数就是我们需要传入的锁,而attr表示锁的一些基本属性,我们不需要管,平时一般都是置为nullptr。
锁的使用:
lock表示上锁,unlock表示解锁,trylock是什么意思呢?其实很简单,trylock表示当我们多次的申请锁不行之后,他就不再申请锁了。
2 线程安全与可重入函数
概念:
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
对于一个函数是否可重入其实并不是评判这个函数是否写的优秀与否,甚至可以说毫无关系,因为可重入函数与不可重入函数只是表示这个函数的性质而已。比如我们常见的printf函数他就是不可重入的函数,但是你能说它是写的不好的吗?不可能的。
但是一个函数有线程安全问题,这确实是一个非常严重的事情,因为它会导致我们的程序在某种莫名其妙的情况下崩溃,根本无从下手更改Bug,所以线程安全问题必须规避。
常见的线程不安全的情况:
不保护共享变量的函数函数状态随着被调用,状态发生变化的函数返回指向静态变量指针的函数调用线程不安全函数的函数
常见的线程安全的情况:
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的类或者接口对于线程来说都是原子操作多个线程之间的切换不会导致该接口的执行结果存在二义性
常见的不可重入的情况:
调用了 malloc/free 函数,因为 malloc 函数是用全局链表来管理堆的调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构可重入函数体内使用了静态的数据结构
常见可重入的情况:
不使用全局变量或静态变量不使用用 malloc 或者 new 开辟出的空间不调用不可重入函数不返回静态或全局数据,所有数据都有函数的调用者提供使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
3 死锁
相信大家学习了前面关于锁的相关知识,大家对于锁还是有一定的了解了,那么我提一个问题,假设现在有了两个线程,也有两份临界资源,假设资源为A,B,此时两个线程分别拿到了A和B,然后各自都想要去拿到另一份资源,请问它们谁会让步,让另一个线程先运行完呢?
答案是,根本不可能,上面的情况就是死锁,因为上面的情况满了死锁的四个必要条件:
互斥条件:一个资源每次只能被一个执行流使用请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放不剥夺条件 : 一个执行流已获得的资源,在末使用完之前,不能强行剥夺循环等待条件 : 若干执行流之间形成一种头尾相接的循环等待资源的关系
因为死锁的产生条件必须是上面四个条件同时满足,那么破坏死锁的方式就是破坏其中一个条件即可。
第一点,那就是不加锁,可能大伙觉得我有病,你让我加锁,为了规避线程安全,你又让我不加锁。大伙别急,其实我想说,对于一个程序而言,锁的使用是越少越好,如果一个程序时不时出现死锁问题,博主认为不如丢掉或则重构了,留着村村恶心自己。
第二点,按照一定的顺序申请锁,也就是申请了A之后才能申请B,不能申请B之后再申请A,但是这种方式说实话只能避免一些常见的死锁情况,有些场景无法克服。
第三点,当一个线程多次申请一把锁未果之后,那么它会释放自己持有了所有资源,避免导致其它线程申请自己的资源导致了死锁。 第四点,当已经出现了死锁问题,那么所有线程都别玩了,全都给我去重新排队玩,也就是所有线程释放所有持有的临界资源。
文章来源:https://www.toymoban.com/news/detail-496293.html
以上就是博主对于线程互斥的所有理解了,希望能够帮助大家。还有一个问题留给大家,那就是我们的程序当中有一个Ticket变量,如果我们卖完了之后并不是break,而是我们会定期的放出一些票,但是买票线程并不知道,那么一直让他访问,上锁,解锁,合适吗?这也是关于我们线程同步的内容。文章来源地址https://www.toymoban.com/news/detail-496293.html
到了这里,关于Linux->线程互斥的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!