目录标题
- 什么是线程安全
- 为什么会出现负数
- 几个概念的介绍
- 锁的理解
- 锁有关函数的介绍
- 锁的问题
- 如何看待加锁和解锁
- 锁的实现原理
- 锁的封装
- 线程安全和可重入函数
- 死锁的概念
什么是线程安全
我们通过下面的例子来了解一下线程安全问题,首先我们实现一个模拟抢票的功能创建一个全局变量ticket将其定义为1000,然后创建3个线程每个线程都是一个循环来不停的对变量ticket的值减一以表示票数的减少,每抢到一张票就打印一段话然后休眠一段时间,当票的数量减少为0的话就直接退出循环并结束线程的函数,那么这里的代码就如下:
int ticket_num=1000;
void * func(void* args)
{
string name=static_cast<const char*>(args);
while(true)
{
if(ticket_num>0)
{
usleep(12345);
cout<<name<<" 正在进行抢票 "<< ticket_num<<endl;
--ticket_num;
}
else
{
break;
}
}
return nullptr;
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1,nullptr,func,(void*)"user1");
pthread_create(&tid2,nullptr,func,(void*)"user2");
pthread_create(&tid3,nullptr,func,(void*)"user3");
pthread_join(tid1,nullptr);
pthread_join(tid2,nullptr);
pthread_join(tid3,nullptr);
return 0;
}
代码的结果如下:
可以看到程序运行到最后出现了0 和-1这样的数这是不符合我们的要求的,因为根据逻辑来看的话抢票应该抢到1的时候就截止了,那么这里出现0和-1的现象就称之为线程安全问题。当然有些小伙伴可能下去自己尝试并没有出现负数的情况要想出现这种情况就得尽可能让多个线程交叉执行,这样在不停的交叉运行的过程中就会出现数据交叉的情况也就会出现上面的现象,而多个线程交叉执行的本质就是让调度器尽可能的频繁发生线程调度和切换,线程发生切换的场景就是进程的时间片到了得换一个进程来接着执行,或者来了更搞优先级的线程得将当前的进程换下来让优先级高的进程来执行,或者是线程等待当前进程要等待其他的外设运行好才能接着执行下面的代码那么这个时候cpu不会等他就将其换下来,前两个我们都很难模拟出来所以采用第三种方法让线程进行等待也就是通过usleep函数让其休眠这样就等待了,当程序从内核太返回用户态的时候,线程就要对调度的状态进行检测,如果情况满足就可以发生线程切换,那么接下来我们就讨论一下为什么会出现这样的情况。
为什么会出现负数
我们将当前的情况推向极致,假设当前的ticket的值已经是1了,那么我们创建的三个线程中的循环结束了吗?没有结束,他们依然可以从头到尾的执行循环中的代码,而执行的第一个就是if语句他是用来判断的,而判断的本质是读取内存中的数据放到cpu的寄存器中然后进行判断,当变量的值为1时多个线程可以同时执行判断语句吗?答案是不可以的因为当前机器只有一个cpu所以每个时刻只能执行一个线程。但是当线程一判断完之后就会接着执行usleep函数他会让线程进行等待,所以这个时候就会发生线程切换线程一就会将自己的上下文数据(ticket_num的值是1)被切走了,线程一走了线程二就会接着执行if语句的判断,而判断的过程又是从内存中读取数据然后放到寄存器中进行判断,可是线程一在if语句的时候是没有对内存中的值进行修改的,所以当ticket为1时线程二也能进入if语句进入之后就休眠然后拿着上下文数据被切走了,同样的道理线程三也能进入if语句,也就是说当ticket的值为1时有三个线程在if语句中执行,并且这三个线程都认为自己拿到了ticket的值为1,当线程等待结束时就会接着执行下面的减减代码,而对数据–的操作分为三步:从内存中读取数据,更改数据,写回数据,所以最先被唤醒的线程就会最先对ticket的值进行减减变成了0然后写回内存,然后第二个被唤醒的线程就又会从内存中读取数据也就是0然后对其减减变成-1然后写回内存,第三个线程也是同样的道理,那么这就是为什么会出现负数的原因,那么这个时候就有小伙伴会想这里出现错误的原因是多个线程同时进入了if语句,那我们将if语句去掉能不能解决线程安全问题呢?答案是不行的,即使不加if就让多个线程对全局变量进行更改也不是安全的,对变量进行++,或者- -,在c,c++上看起来只有一条语句,但是汇编之后至少是三条语句:1.从内存中读取数据到寄存器中,2.在寄存器中让cpu执行对应的逻辑运算,3.得到新的结果然后写回内存,该语句的执行需要三个步骤也就是三条汇编语句才能完成,所以这里就会出现干扰问题,比如说当前的存在两个线程同时对一个数据进行减减,变量的值一开始为10000
线程一会先将num的值读取放到内存上:
然后对num的值减一:
然后正当线程一要完成第三步将寄存器中的数据写回内存时可能就会发生线程切换,因为寄存器的值是属于当前线程的,所以会将9999一起打包切走,然后就轮到线程二来执行,线程二也是完成同样的步骤先读取数据然后对数据进行修改,最后将数据写回内存,但是该操作是在一个循环里面执行的,也就会出现多次对数据进行减减的情况,假设线程将原来的10000减到了5000并将其写回到内存:
线程二将数据修改到一半时他的时间片可能就到了那么这个时候就会发生线程切换,线程一就会接着执行而线程在切换的时候会记录之前的上下文数据,所以线程一就会接着执行之前没有完成的第三步也就是将数据9999写入到内存:
所以这就会导致刚刚的线程二白做了很多的事情间接的造成了系统资源的浪费,那么这就是线程安全问题我们定义的全局变量在没有保护的时候往往是不安全的,像上面多个线程在交替执行照成的数据安全问题,发生了数据不一致的问题,那么接下来我们就来学习如何解决这个问题。
几个概念的介绍
多个执行流进行安全访问的共享资源称为临界资源,我们把多个执行流中访问临界资源的代码称为临界区,往往是线程代码点的很小的一部分,比如说上述代码中只有if语句的代码才访问到了临界资源,那么为了保证临界资源的安全我们就让多个线程串行的访问共享资源就,我们把这种串行访问的行为就称为互斥也就是说当一个线程访问临界资源时其他的线程不能访问,只有当这个线程访问结束时才能有一个线程接着进行访问,然后我们把对一个资源进行访问的时候要么不做要么就做完的特性称为原子性比如说上面的++,他就有三个步骤他可以做完前两个步骤然后被切换不做第三步所以他就不是原子性他有中间状态,准确来说上面的三个步骤在外来对应着三条汇编语句,而我们说的原子性就是一个对资源的操作只需要一条汇编语句就能完成,如果要多条汇编语句则不是原子。这里大家注意一下我们这里说的原子性的概念只是一个子集也就是完整概念的一部分我们后面会对原子性有根深的理解。那么接下来我们就来看看什么是锁。
锁的理解
上面写的程序出现问题的原因是多个执行流都进入到了if语句里面的然后导致了数据的异常,并且多个执行流对同一个数据++或者- -的话也会出现问题因为++和- -看上去只有一步但是在底层看来他是有多个步骤的,所以当进程切换的时候就可能会出现数据重复的问题导致了计算机资源的浪费,那么我们把上面的if语句和++或者- -看成一个房间,出问题的本质就是多个人同时进入了一个房间,或者一个人在房间里没有把一件事做完就被其他人赶了出来其他人又进入了房间,所以要想解决问题就得给房间的门上一把锁,当没有人进入这个房间的时候门是打卡的,当第一个人进入房间的时候不管第二个人离第一个人有多近这个门都会自动关上并锁上,那么这就确保了只有一个人在房间里面,并且房间的门一旦关上锁上之后外面的人是无法打卡的,只有房间里面的人干完了事主动打开才行,那么这就确保了房间里面的人不会其他人强行的赶出来,那么这个门上的锁不仅在生活中存在在程序里面也是存在的,接下来我们来看看锁的定义。
锁有关函数的介绍
首先来看看函数pthread_mutex_init的声明:
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const_pthread_mutexattr_t *restrict attr)
该函数的第一个参数就是一个指向pthread_mutex_t类型的指针,pthread_mutex_t就是一个锁当我们定义一个锁类型的变量时就得使用pthread_mutex_init函数对其进行初始化,第二个参数表示锁的属性这个我们不用管一般传递nullptr就行,当我们用完锁之后就要对锁进行销毁,那么这里使用函数就是
pthread_mutex_destroy(pthread_mutex_t *mutex)
该函数的参数就是一个指向锁的指针,想要销毁哪个指针就传递哪个锁的地址,将锁锁起来的函数就是
pthread_mutex_lock(pthread_mutex_t *mutex)
将锁上的锁打开函数就是
pthread_mutex_unlock(pthread_mutex_t *mutex)
这里有个特例就是如果锁是全局的话就不需要使用pthread_mutex_init函数进行初始化,而是使用pthread_mutex_t *mutex
进行初始化比如说下面的代码:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
这就是初始化全局锁的方式,有了上面几个函数我们就可以对上面写的代码就行修改:
void * func(void* args)
{
string name=static_cast<const char*>(args);
while(true)
{
if(ticket_num>0)
{
usleep(1245);
cout<<name<<" 正在进行抢票 "<< ticket_num<<endl;
--ticket_num;
}
else
{
break;
}
}
return nullptr;
}
首先我们得创建一个锁,然后while循环里面的if语句属于临界区,那么在访问这个区域的代码之前就得使用pthread_mutex_lock函数将锁锁上,当一个线程执行完if语句里面的内容时就得使用pthread_mutex_unlock函数将锁打开,这里大家要注意一下因为线程可能会执行else语句中的内容所以else里也得添加pthread_mutex_unlock函数不然就会导致锁没有被释放其他线程都无法申请的情况,那么修改后的代码就如下:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void * func(void* args)
{
string name=static_cast<const char*>(args);
while(true)
{
pthread_mutex_lock(&mutex);
if(ticket_num>0)
{
usleep(1245);
cout<<name<<" 正在进行抢票 "<< ticket_num<<endl;
--ticket_num;
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
return nullptr;
}
我们再运行一下程序让其多执行几次就可以看到,没有出现数据异常的问题:
那么这就是锁的应用,大家可以通过下面的图片再了解一下锁的使用:
锁的问题
第一个问题:如果大家亲自去写了执行了上述的代码就会明显的感觉到程序变慢了,而变慢的原因就是枷锁和解锁的过程是多个线程串行执行的。第二个问题:我们写的这个程序是用来模拟抢票的但是通过运行结果来大致比较的话会发现这里好像存在不公平的现象,我们创建了三个线程一共有1000张票,但是线程三一个人就抢走了快800张票并且还是连着抢的,线程二抢走了150张票并且也是连着抢的而线程一只抢到了区区的50张票,那这是为什么呢?原因很简单因为锁只规定了互斥访问也就是让执行流串行的访问并没有规定让谁先优先获取锁,所以锁就是真正的多个执行流竞争的结果,所以很可能会出现当一个线程释放锁之后该线程可能又会申请到锁资源的情况,那么这种现象的产生就必定会导致资源的分配不公平,有些线程过去的繁忙而有些线程又过于的悠闲的现象,并且一个线程执行完任务之后一般会对任务进行汇总并返回给用户,所以当释放掉锁的资源之后一般会干点其他的事情比如说将刚刚锁中计算的结果进行汇总等等(那么这里可以用休眠一下),这么做之后其他的线程才会有更大的几率申请到锁:
可以看到这会票的分配明显就分散一些并且没有出现连着抢的情况,那么这就是锁的一个问题:锁资源的竞争问题。
如何看待加锁和解锁
在上面的程序中我们创建了一个全局变量的锁,这个锁可以被多个线程使用每个线程都可以让其枷锁或者解锁,那为什么要有锁呢?因为我们要保证共享资源在被多线程访问时的数据安全,可是锁也是一个共享资源啊,锁的安全谁来保护呢?所以锁的枷锁过程必须得是线程安全的也就是必须得是原子的,如果锁申请成功就会继续往后自行,如果申请暂时没有成功执行流会如何?这里可以连续申请两次锁来沿着,然后会发现线程卡住了,所以申请锁没有成功时执行流就会阻塞也就相当于将自己挂起的状态,当然这里也可以使用pthread_mutex_trylock
来申请锁,这个申请的方式就是如果可以申请到就正常的申请,如果没有申请到就报错直接返回不会阻塞式等待:
谁持有锁谁就能进入临界区,如果线程1申请锁成功进入了临界资源并正在访问临界资源期间,其他的线程在做什么呢?答案是阻塞等待,那如果线程一申请锁成功进入临界资源并正在访问临界资源期间,我们能不能进行线程切换呢?答案是绝对可以的,因为当持有锁的线程被切走的时候他是和锁一起被切走的,所以即便获线程被切走了,其他的线程也依旧无法成功申请也就无法向后执行,直到该线程最终释放了锁所以对于其他线程而言锁的状态无非就两种1.申请锁之前,2.释放锁之后,所以站在其他线程的角度看待当前线程持有锁的过程就是原子也就是要么我没有持有锁,要么我持有锁就把该执行的操作都执行完然后再释放锁,所以未来我们在使用锁的时候一定要尽量保证临界区的粒度(锁中间保护的代码的多少)非常的小!这里要注意的一点就是枷锁是程序员的行为,必须做到要加锁就对所有访问该资源的全部线程都加锁,不能说有些线程一线程二访问ticket的时候枷锁,线程三就不枷锁。通过上面的讲解大家肯定能够理解枷锁的过程一定是原子的,那解锁的过程一定得是原子的吗?答案是没有必要的因为解锁的前提是已经枷锁了,而枷锁之后也就只有一个执行流在执行临界区的代码,所以这个时候就不太需要担心躲执行流的问题。
锁的实现原理
++i和i++都不是原子的需要多条汇编来实现,所以可能会出现数据的一致性问题,为了实现互斥锁的操作大多数体系结构都提供了swap或者exchange汇编指令,该指令的作用就是把内存单元的数据和寄存器中的数据进行交换,因为只有一条指令所以在执行这个语句的时候是能够保证原子性,锁就是一个数据类型为了理解我们可以将其看成整形类型并且大小为1,cpu中有很多的寄存器但是这些寄存器只有一套被所有的执行流共享,cpu寄存器的内容是每个执行流都私有的,我们将其称之为执行流运行的上下文,枷锁的伪代码就是下面这样:
cpu中有一个名为%al的寄存器,内存上有一个名为mutex的变量当前环境下有两个线程,一个线程A一个线程B,线程A先放到cpu上先被执行,那么他首先干的事情就是movb $0 ,$al
也就是将0放到%al寄存器里面:
那么在完成第一步分过程中会发生线程切换吗?答案是肯定会的!执行上面的任意一条汇编指令都可能会发生线程切换,但是寄存器中的数据是属于当前进程的,所以即使切换也不会有任何的影响等线程被切换回来时又会进行上下文回复,然后执行的第二步就是xchgb %al, mutex
也就是将寄存器中的值与内存上的mutex变量中的值进行交换,mutex是内存上的变量能够被多个线程同时访问而exchange交换只用了一条汇编指令就实现了,而交换的本质就是将共享的数据交换到我的上下文当中所以线程中的寄存器变成了1,内存中的寄存器变量变成了0,而寄存器又是线程A的上下文
执行完这个汇编指令之后也可能会发生线程交换,切换的时候就会将当前线程的上下文带走也就是将1给带走,当其他线程被切换上来执行枷锁代码的时候也会经历上述的过程,可是这个时候内存mutex变量中的值为0,新来的线程中的值也为0,执行第二个指令交换之后两者依然都为0,然后就会执行后面的if语句进行判断如果当前线程中的值为1的话就能申请锁成果,如果为其他的值就会被挂起等待,所以线程B切换了上来也只能挂起等待了所以申请锁就没有成功,而这个时候将线程A又切换了回来线程A中保存了之前的上下文的内容所以他的值为1,那么这个时候就能执行if语句中的内容申请锁成功直接返回0,所以线程A在申请锁的过程中是不担心被切换走的上述的过程就保证了申请锁的原子性,只要一个线程申请锁成功这个1就一直在这个线程的上下文当中,那么这就是枷锁的过程,而解锁的过程就十分的简单,move指令就是拷贝的意思,那么这里就是将1拷贝到mutex变量里面就可以了,那么这就是枷锁和解锁的原理。
锁的封装
那么有了上面的函数我们可以自行的封装一个锁,我们对这个锁的要求就是创建的时候就枷锁,销毁的时候就自动解锁,那么要想实现这样的功能我们就创建一类,类中有个指向锁类型的指针:
class Mutex
{
public:
private:
pthread_mutex_t *lock_p;
}
这个类的构造函数有一个参数用来接收锁的地址,然后还提供枷锁函数和解锁函数,这两个函数的内部都是调用库中对应的函数,那么这里的代码如下:
class Mutex
{
public:
Mutex(pthread_mutex_t*lock_p=nullptr)
{
_lock_p=lock_p;
}
void lock()
{
if(_lock_p!=nullptr)
{
pthread_mutex_lock(_lock_p);
}
}
void unlock()
{
if(_lock_p!=nullptr)
{
pthread_mutex_unlock(_lock_p);
}
}
private:
pthread_mutex_t *_lock_p;
}
有了这个类之后我们就可以再创建一个名为LockGuard的类,这个类里面就有一个Mutex变量:
class LockGuard
{
public:
private:
Mutex _mutex;
};
那么这个类的构造函数也是需要一个锁类型的指针将他用来初始化_mutex对象,然后在结构体类调用_mutex的lock函数,然后析构函数就直接调用_mutex的unlock函数即可,那么这里的代码就如下:
class LockGuard
{
public:
LockGuard(pthread_mutex_t* mutex)
:_mutex(mutex)
{
_mutex.lock();
}
~LockGuard()
{
_mutex.unlock();
}
private:
Mutex _mutex;
};
那么这就是锁的封装,有了自行封装的锁就可以对上述抢票代码进行修改,原代码如下:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void * func(void* args)
{
string name=static_cast<const char*>(args);
while(true)
{
pthread_mutex_lock(&mutex);
if(ticket_num>0)
{
usleep(1245);
cout<<name<<" 正在进行抢票 "<< ticket_num<<endl;
--ticket_num;
pthread_mutex_unlock(&mutex);
usleep(1243);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
return nullptr;
}
因为我们创建的是创建时枷锁,销毁时解锁,所以这里就可以这么修改:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void * func(void* args)
{
string name=static_cast<const char*>(args);
while(true)
{
//pthread_mutex_lock(&mutex);
LockGuard lockguard(&mutex);
if(ticket_num>0)
{
usleep(1245);
cout<<name<<" 正在进行抢票 "<< ticket_num<<endl;
--ticket_num;
//pthread_mutex_unlock(&mutex);
usleep(1243);
}
else
{
//pthread_mutex_unlock(&mutex);
break;
}
}
return nullptr;
}
代码的运行结果如下:
可以看到依然可以正常的执行,那么这就是锁的封装。
线程安全和可重入函数
线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
线程安全和可重入函数看上去是两个概念,但是线程不安全可能是不可重入函数导致的,如果一个函数不会因为多线程而出现问题,那么我们将其称为线程安全函数,可重入函数只是线程安全函数的一种,函数是否可重入描述的是函数在被多个执行流执行时会不会出现问题,如果出了问题这个函数就是不可重入的,如果一个代码片段被多个执行流执行会不会出现数据安全问题,出现数据安全问题那么这就是数据不安全的。可重入函数和线程安全的关系就是:1.函数是可重入的,那就是线程安全的,2.函数是不可重入的,那就不能由多个线程使用有可能引发线程安全问题 3.如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
死锁的概念
在多把锁的场景下,我们持有自己的锁不释放还要对方的锁,对方也是如此要我们的锁,那么这时就容易造成死锁双方都得不到对方的锁,这就好比两个小朋友各自都有5毛钱但是棒棒糖价格是1块钱,将两个5毛钱合并起来确实能买一个棒棒糖但是两个人都不想把钱给对方而是想对方把钱给自己,所以这导致了两个人谁也得不到另外一个5毛钱两个人都吃不到棒棒糖,并且一把锁也能照成死锁比如说连续对一把锁执行两次枷锁就会被阻塞,那为什么会有死锁,因为多线程的特性:多线程大部分资源都是共享的,所以多线程中的全局资源就可能在多线程的访问中出现数据不一致的问题,所以得保证临界资源的安全,所以得使用锁,使用锁了之后就会导致死锁的问题而造成死锁就得有四个必要条件:
第一个:互斥
访问某些资源的时候是互斥的
第二个:请求和保持
请求你的资源的时候还要保持我的资源,也就是我自己有5毛钱不给你我还想要你的5毛钱。
第三个:不剥夺
剥夺就是你不给我5毛钱我就打你揍你把你的5毛钱抢过来,那么不剥夺就是你不给我5毛钱我也不打你不揍你不抢你的我就等着。
第四个:环路等待
a有自己的锁然后她想要b的锁,b有自己的锁然后他想要c的锁,c有自己的锁然后他想要a的锁这就导致这三个人谁也要不了谁的锁。
所以要想破坏死锁本质上就得破坏上面的四个条件的任意一个,第一个互斥就不用考虑了因为互斥才有了锁,第二个就是:不请求与保持当我申请某个锁失败的时候就将自己的锁释放掉,第三个要剥夺:所以我们可以设置一个竞争策略当优先级更高的线程申请一个锁时优先级较低的线程应该主动释放自己的锁,第四个就是不形成环转也就是让申请锁的顺序保持一致不形成环比如说有abcd四把锁,那么每次申请锁的顺序都是a b c d这样a能申请成功那么b就一定能够申请成功,另外大家要注意的一点就是一个线程申请锁到了锁另外一个线程是可以对其进行解锁的,通过之前的伪代码便可以得知:
解锁的时候并不是去申请到锁的那个线程的上下文找1而是直接将1放到锁变量里面,因为这个特性所以就有了死锁检测算法和银行家算法这两个算法来避免死锁,那么这就是死锁的概念。