【Linux】多线程3——线程互斥/互斥量

1.Linux线程互斥

1.1.抢票问题

先来复习一下些概念!!!临界资源,临界区,互斥,原子性,这些我们可是讲过的啊!!!

  • 临界资源 多线程执行流共享的资源叫做临界资源。
  • 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区。
  • 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
  • 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。

如果不记得了请去这里看看:http://t.csdnimg.cn/JwVPt

临界资源和临界区

        进程之间如果要进行通信我们需要先创建第三方资源,让不同的进程看到同一份资源,由于这份第三方资源可以由操作系统中的不同模块提供,于是进程间通信的方式有很多种。进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码就叫做临界区。

        而多线程的大部分资源都是共享的,线程之间进行通信不需要费那么大的劲去创建第三方资源。

        例如,我们只需要在全局区定义一个count变量,让新线程每隔一秒对该变量加一操作,让主线程每隔一秒获取count变量的值进行打印。

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

int count = 0;
void* Routine(void* arg)
{
	while (1){
		count++;
		sleep(1);
	}
	pthread_exit((void*)0);
}
int main()
{
	pthread_t tid;
	pthread_create(&tid, NULL, Routine, NULL);
	while (1){
		printf("count: %d\n", count);
		sleep(1);
	}
	pthread_join(tid, NULL);
	return 0;
}

运行结果如下:

        此时我们相当于实现了主线程和新线程之间的通信,其中全局变量count就叫做临界资源,因为它被多个执行流共享,而主线程中的printf和新线程中count++就叫做临界区,因为这些代码对临界资源进行了访问。

抢票问题

        下面我们模拟实现一个抢票系统,我们将记录票的剩余张数的变量定义为全局变量,主线程创建四个新线程,让这四个新线程进行抢票,当票被抢完后这四个线程自动退出。

这个是c语言版本的

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

int tickets = 1000;
void* TicketGrabbing(void* arg)
{
	const char* name = (char*)arg;
	while (1){
		if (tickets > 0){
			usleep(10000);
			printf("[%s] get a ticket, left: %d\n", name, --tickets);
		}
		else{
			break;
		}
	}
	printf("%s quit!\n", name);
	pthread_exit((void*)0);
}
int main()
{
	pthread_t t1, t2, t3, t4;
	pthread_create(&t1, NULL, TicketGrabbing, "thread 1");
	pthread_create(&t2, NULL, TicketGrabbing, "thread 2");
	pthread_create(&t3, NULL, TicketGrabbing, "thread 3");
	pthread_create(&t4, NULL, TicketGrabbing, "thread 4");
	
	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);
	return 0;
}

这个是C++版本的

#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include<vector>
using namespace std;

#define NUM 4
int tickets=1000;

class threadData
{
	public:
	threadData(int number)
	{
		threadname="thrade-"+to_string(number);
	}
	public:
	string threadname;
};

void* getTicket(void* arg)
{
	threadData*td=static_cast<threadData*>(arg);
	while (1){
		if (tickets > 0){
			usleep(10000);
			printf("[%s] get a ticket, left: %d\n", td->threadname.c_str(), --tickets);
		}
		else{
			break;
		}
	}

	pthread_exit((void*)0);
}


int main()
{
	vector<pthread_t> tids;
	vector<threadData*> thread_datas;
	for(int i=0;i<NUM;i++)
	{
		pthread_t tid;
		threadData*td=new threadData(i);
		thread_datas.push_back(td);
		pthread_create(&tid,nullptr,getTicket,thread_datas[i]);
		tids.push_back(tid);
	}

	for(auto thread : tids)
	{
		pthread_join(thread,nullptr);
	}
	for(auto td : thread_datas)
	{
		delete td;
	}	
}

 运行结果显然不符合我们的预期,因为其中出现了剩余票数为负数的情况。

我们多运行几次,发现每次的这个负数还不一样

        该代码中记录剩余票数的全局变量tickets就是临界资源,因为它被多个执行流同时访问,而判断tickets是否大于0、打印剩余票数以及--tickets这些代码就是临界区,因为这些代码对临界资源进行了访问。

对一个全局变量进行多线程并发--/++操作是否安全?

肯定不安全

这个全局变量我们之前说过,每个进程都可以修改,那这里怎么出现了负数?

很有可能是某个线程票数为1的时候,在--的时候,另外一个进程检测到票数为1,然后也去--,这样子导致了票数为负数。也就是说,--ticket操作本身就不是一个原子操作。

怎么理解这个--ticket操作本身就不是一个原子操作。?

我们定义的全局变量tickets是在内存的时候,我们每个线程抢票的实质就是对tickets--,但是计算是要在cpu的寄存器上面的,所以抢票的过程会发生

  • 1.线程把tickets读取到cpu寄存器上面(本质就是把tickes的拷贝过来成为这个线程的上下文)
  • 2.在cpu寄存器上面实现tickets--
  • 3.将计算结果写回内存

这上面每一条都会对应一条汇编动作

当线程的任意两条语句间隔期间,随时都会被其他线程切换,一个线程在任何语句间都有可能被线程切换!!!!我们线程切换的时候要保存好它的上下文数据。

        我们先句个例子,我们有2个线程,线程2从1000张票开始抢,一直循环执行上面那3个步骤,直到剩10张票了,线程2还想抢,刚完成把tickets读取到cpu寄存器上面(也就是10)(上面的第1步),线程2的时间片到了,换线程1上来,线程1第一步就是把tickets读取到cpu寄存器上面,但是线程1的tickets可不是10,是1000,所以线程1运行一下,内存上面的全局变量变999了。这个就是数据不一致问题。

        我们这里还不止--呢,我们还做判断呢!!!

综上剩余票数推断一下出现负数的原因:

  • if语句判断条件为真以后,代码可以并发的切换到其他线程。
  • usleep用于模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
  • --ticket操作本身就不是一个原子操作。
  • 怎么理解--ticket不是原子操作?

我们对一个变量进行--,我们实际需要进行以下三个步骤:

  • load:将共享变量tickets从内存加载到寄存器中。
  • update:更新寄存器里面的值,执行-1操作。
  • store:将新值从寄存器写回共享变量tickets的内存地址。

- -操作对应的汇编代码如下:

        既然- -操作需要三个步骤才能完成,那么就有可能当thread1刚把tickets的值读进CPU就被切走了,也就是从CPU上剥离下来,假设此时thread1读取到的值就是1000,而当thread1被切走时,寄存器中的1000叫做thread1的上下文信息,因此需要被保存起来,之后thread1就被挂起了。

 假设此时thread2被调度了,由于thread1只进行了- -操作的第一步,因此thread2此时看到tickets的值还是1000,而系统给thread2的时间片可能较多,导致thread2一次性执行了100次 - -才被切走,最终tickets由1000减到了900。

        此时系统再把thread1恢复上来,恢复的本质就是继续执行thread1的代码,并且要将thread1曾经的硬件上下文信息恢复出来,此时寄存器当中的值是恢复出来的1000,然后thread1继续执行--操作的第二步和第三步,最终将999写回内存。

在上述过程中,thread1抢了1张票,thread2抢了100张票,而此时剩余的票数却是999,也就相当于多出了100张票。

        因此对一个变量进行--操作并不是原子的,虽然--tickets看起来就是一行代码,但这行代码被编译器编译后本质上是三行汇编,相反,对一个变量进行++也需要对应的三个步骤,即++操作也不是原子操作。

        在多线程情况下,如果这多个执行流都自顾自的对临界资源进行操作,那么此时就可能导致数据不一致的问题。解决该问题的方案就叫做互斥,互斥的作用就是,保证在任何时候有且只有一个执行流进入临界区对临界资源进行访问。        

我们现在要怎么解决上面那个问题呢?

        我们要做到,我在读的时候,别人不能进来读,保证每个线程整个--操作都是没有别的线程打扰,所以具体实现方案如下:

要解决上述抢票系统的问题,需要做到三点:

  1. 代码必须有互斥行为当代码进入临界区执行时,不允许其他线程进入该临界区。
  2. 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  3. 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

要做到这三点,本质上就是需要一把锁,Linux上提供的这把锁叫互斥锁。

1.2.互斥量mutex

互斥量就是一把锁

没有加锁的情况:不管是临界区还是非临界区,所有线程都可以并发运行

 加锁的情况:在非临界区的时候,所有线程是可以并发执行,当线程进入临界区前,线程之间就需要竞争申请锁(注意:每次只能有一个线程竞争到锁),申请到锁的线程才可以进入进入临界区,申请不到锁的线程就只能在锁外面一直等待当cpu执行到该线程的时候,因为这个线程没有锁只能阻塞在该锁的队列中,不能向下执行代码。当有锁的线程将释放出来后,其他线程才可以去竞争锁,谁竞争到锁,谁就可以进入临界区。这样就可以避免了多个线程在临界区中同时并发执行(如果锁没有释放掉,则外面的线程将会一直等待)。

因为这把锁要让所有线程看到,所以一般设置为全局变量 

  • 锁本身是定义全局变量,也属于临界资源,锁的存在是为了保护临界资源,那么锁需要被保护吗?

答案是不需要,因为申请锁的过程是原子性的,申请的过程不会被调度机制给打断的,锁的实现原理探究:为了实现互斥锁的操作,大多数体系结构都提供了swap或者exchange指令,该指令是能够直接将寄存器上的值与内存上单元上的数据直接交换

  • 申请锁的线程在临界区是否可以进行线程切换?

申请到锁有可能被切换走后,但是其他线程去申请锁申请不到会被挂起等待,因为申请到锁的线程还没有释放锁,其它线程申请不到该锁,所以其它线程就不能进入该临界区。这样保证了只有一个线程进入了临界区。

【互斥锁的特点】:

  • 1. 原子性:把一个互斥量锁定为一个原子操作,这意味着操作系统(或pthread函数库)保证了如果一个线程锁定了一个互斥量,没有其他线程在同一时间可以成功锁定这个互斥量;
  • 2. 唯一性:如果一个线程锁定了一个互斥量,在它解除锁定之前,没有其他线程可以锁定这个互斥量;
  • 3. 非繁忙等待:如果一个线程已经锁定了一个互斥量,第二个线程又试图去锁定这个互斥量,则第二个线程将被挂起(不占用任何cpu资源),直到第一个线程解除对这个互斥量的锁定为止,第二个线程则被唤醒并继续执行,同时锁定这个互斥量。
  • 互斥量的优点

保护临界资源,解决线程与线程之间出现数据不一致的问题,保证一次只能有一个线程进入互斥量保护的区域。

  • 互斥量缺点

加锁是会损耗线程的性能,因为加锁需要申请锁,和释放锁的过程,并且加锁之后,临界资源一次只能有一个线程进行运行,因此,加锁的区域破坏了多线程并发的过程,所以建议在编码的时候,非必要的情况下最后不要加锁。

1.3.互斥量的接口

1.3.1.初始化互斥量

初始化互斥量的函数叫做pthread_mutex_init,该函数的函数原型如下:

参数说明:

  • mutex:需要初始化的互斥量。
  • attr:初始化互斥量的属性,一般设置为NULL即可。

返回值说明:

  • 互斥量初始化成功返回0,失败返回错误码。

注意:pthread_mutex_t是一种数据类型

        调用pthread_mutex_init函数初始化互斥量叫做动态分配,除此之外,我们还可以用下面这种方式初始化互斥量,该方式叫做静态分配:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

一般我们是将他定义成全局的,这样子就不用销毁了,如果是局部的,就要手动把它删除。 

1.3.2.销毁互斥量

销毁互斥量的函数叫做pthread_mutex_destroy,该函数的函数原型如下:

int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数说明:

  • mutex:需要销毁的互斥量。

返回值说明:

  • 互斥量销毁成功返回0,失败返回错误码。

销毁互斥量需要注意:

  • 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁。
  • 不要销毁一个已经加锁的互斥量。
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。

1.3.3.互斥量加锁

互斥量加锁的函数叫做pthread_mutex_lock,该函数的函数原型如下:

int pthread_mutex_lock(pthread_mutex_t *mutex);

参数说明:

  • mutex:需要加锁的互斥量。

返回值说明:

  • 互斥量加锁成功返回0,失败返回错误码。

调用pthread_mutex_lock时,可能会遇到以下情况:

  1. 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
  2. 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

1.3.4.互斥量解锁

互斥量解锁的函数叫做pthread_mutex_unlock,该函数的函数原型如下:

int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数说明:

  • mutex:需要解锁的互斥量。

返回值说明:

  • 互斥量解锁成功返回0,失败返回错误码。

我们看看互斥量上锁和解锁的伪代码

 

在(用户)线程中,情形有所不同,因为没有时钟停止运行时间过长的线程。结果是通过忙等待的方式来试图获得锁的线程将永远循环下去,绝对不会得到锁,因为这个运行的线程不会让其他线程运行从而释放锁

        取锁失败时,它调用thread_yield将CPU放弃给另一个线程。这样,就没有忙等待。在该线程下次运行时,它再一次对锁进行测试

        由于thread_yield只是在用户空间中对线程调度程序的一个调用,所以它的运行非常快捷。这样, mutex_lock和mutex_unlock都不需要任何内核调用。通过使用这些过程,用户线程完全可以实现在用户空间 中的同步,这些过程仅仅需要少量的指令

使用示例:

        我们先讲1个例子,我们学校有1间vip自习室,为什么叫vip自习室呢?因为在里面每次只有1个人能进去上自习,这个自习室里面有1把锁,我们可以用锁把自习室锁起来,这样子就自习室只有1个人啦!别人也进不来,当我们走的时候,必须把锁解开才能出去是吧!

        我们在上述的抢票系统中引入互斥量,把线程比作上面的人,临界资源比作上面的vip自习室,每一个线程要进入临界区之前都必须先申请锁,只有申请到锁的线程才可以进入临界区对临界资源进行访问,并且当线程出临界区的时候需要释放锁,这样才能让其余要进入临界区的线程继续竞争锁。锁本身也是共享资源

加锁的本质就是用时间换安全,加锁的原则:尽量保证加锁区域小点!!

【互斥锁的操作流程如下】:

  • 1. 在访问共享资源后临界区域前,对互斥锁进行加锁;
  • 2. 在访问完成后释放互斥锁导上的锁。在访问完成后释放互斥锁导上的锁;
  • 3. 对互斥锁进行加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放。对互斥锁进行加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放。

c语言版本

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

int tickets = 1000;
pthread_mutex_t mutex;//定义锁,把锁定义成全局的

void* TicketGrabbing(void* arg)
{
	const char* name = (char*)arg;
	while (1){
		pthread_mutex_lock(&mutex);//加锁
		if (tickets > 0){
			usleep(100);
			printf("[%s] get a ticket, left: %d\n", name, --tickets);
			pthread_mutex_unlock(&mutex);//解锁
		}
		else{
			pthread_mutex_unlock(&mutex);//解锁
			break;
		}
	}
	printf("%s quit!\n", name);
	pthread_exit((void*)0);
}
int main()
{
	pthread_mutex_init(&mutex, NULL);//初始化锁
	pthread_t t1, t2, t3, t4;
	pthread_create(&t1, NULL, TicketGrabbing, "thread 1");
	pthread_create(&t2, NULL, TicketGrabbing, "thread 2");
	pthread_create(&t3, NULL, TicketGrabbing, "thread 3");
	pthread_create(&t4, NULL, TicketGrabbing, "thread 4");

	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);
	pthread_mutex_destroy(&mutex);//毁掉锁
	return 0;
}

运行代码,此时在抢票过程中就不会出现票数剩余为负数的情况了,但是有问题!!!先不着急,我们去先来看看C++版本的

#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include<vector>
#include<string>
using namespace std;

#define NUM 4
int tickets=1000;

class threadData
{
public:
	threadData(int number,pthread_mutex_t *mutex)
	{
		threadname="thrade-"+to_string(number);
		lock=mutex;
	}
public:
	string threadname;
	pthread_mutex_t *lock;
};

void* getTicket(void* arg)
{
	threadData*td=static_cast<threadData*>(arg);
	while (1){
		pthread_mutex_lock(td->lock);//加锁,申请锁成功,才能往后执行,不成功,阻塞等待
		//加锁和解锁之间的代码叫临界区
		if (tickets > 0){
			usleep(10000);
			printf("[%s] get a ticket, left: %d\n", td->threadname.c_str(), --tickets);
			pthread_mutex_unlock(td->lock);//解锁
		}
		else{
			pthread_mutex_unlock(td->lock);//解锁
			break;
		}
	}
	printf("%s.....quit\n",td->threadname.c_str());
	pthread_exit((void*)0);
}


int main()
{
	pthread_mutex_t lock;
	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,thread_datas[i]);
		tids.push_back(tid);
	}

	for(auto thread : tids)
	{
		pthread_join(thread,nullptr);
	}
	for(auto td : thread_datas)
	{
		delete td;
	}	

	pthread_mutex_destroy(&lock);//销毁锁
}

我们发现虽然解决了票的数量为0的状态,但是怎么全是1个进程在抢票,其他进程在干什么?

        这种情况是正常的,因为有可能这个线程一释放锁就去申请锁了,导致一直都是它在抢,其他进程都在阻塞等待。也就是说线程对锁的竞争能力有所不同。

        其次,这个代码有逻辑问题,我们把票抢到了,会再抢下一张吗?答案是不会,所以我们要模拟抢到票的后续动作!这里就用usleep来让它休眠一下子

我们优化一下这个线程,让它休眠一下子

void* getTicket(void* arg)
{
	threadData*td=static_cast<threadData*>(arg);
	while (1){
		pthread_mutex_lock(td->lock);//加锁,申请锁成功,才能往后执行,不成功,阻塞等待
		//加锁和解锁之间的代码叫临界区
		if (tickets > 0){
			usleep(10000);
			printf("[%s] get a ticket, left: %d\n", td->threadname.c_str(), --tickets);
			pthread_mutex_unlock(td->lock);//解锁
		}
		else{
			pthread_mutex_unlock(td->lock);//解锁
			break;
		}
		usleep(13);//注意
	}
	printf("%s.....quit\n",td->threadname.c_str());
	pthread_exit((void*)0);
}

现在就多个线程在抢了,很好啊!!!

我们还可以把锁定义成全局的或者静态的,这样子就不用初始化锁和销毁锁

#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include<vector>
#include<string>
using namespace std;

#define NUM 4
int tickets=1000;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//注意这里

class threadData
{
public:
	threadData(int number)
	{
		threadname="thrade-"+to_string(number);
	}
public:
	string threadname;
};

void* getTicket(void* arg)
{
	threadData*td=static_cast<threadData*>(arg);
	while (1){
		pthread_mutex_lock(&mutex);//加锁,申请锁成功,才能往后执行,不成功,阻塞等待
		//加锁和解锁之间的代码叫临界区
		if (tickets > 0){
			usleep(10000);
			printf("[%s] get a ticket, left: %d\n", td->threadname.c_str(), --tickets);
			pthread_mutex_unlock(&mutex);//解锁
		}
		else{
			pthread_mutex_unlock(&mutex);//解锁
			break;
		}
		usleep(13);//注意
	}
	printf("%s.....quit\n",td->threadname.c_str());
	pthread_exit((void*)0);
}


int main()
{


	vector<pthread_t> tids;
	vector<threadData*> thread_datas;
	for(int i=0;i<NUM;i++)
	{
		pthread_t tid;
		
		threadData*td=new threadData(i);
		thread_datas.push_back(td);
		
		pthread_create(&tid,nullptr,getTicket,thread_datas[i]);
		tids.push_back(tid);
	}

	for(auto thread : tids)
	{
		pthread_join(thread,nullptr);
	}
	for(auto td : thread_datas)
	{
		delete td;
	}	

}

有没有感觉更好一点呢 

接下来有几个问题哦!! 

  • 加锁后的原子性体现在哪里?

        引入互斥量后,当一个线程申请到锁进入临界区时,在其他线程看来该线程只有两种状态,要么没有申请锁,要么锁已经释放了,因为只有这两种状态对其他线程才是有意义的。

        例如,图中线程1进入临界区后,在线程2、3、4看来,线程1要么没有申请锁,要么线程1已经将锁释放了,因为只有这两种状态对线程2、3、4才是有意义的,当线程2、3、4检测到其他状态时也就被阻塞了。

此时对于线程2、3、4而言,它们就认为线程1的整个操作过程是原子的。

  • 临界区内的线程可能进行线程切换吗?

        临界区内的线程完全可能进行线程切换,但即便该线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了。

        其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。

  • 我们说了这个锁是共享资源,那谁来保护锁的安全啊?

        我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。

        既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?

        锁实际上是自己保护自己的,我们只需要保证申请锁和释放的过程是原子的,那么锁就是安全的。 

1.4.互斥锁原则性实现原理

  • 为什么我们之前说--操作不是原子操作?

因为一条--语句,就会变成3条汇编语句。我们认为一条汇编就是原子的,所以--就不是原子的。

cpu是很笨的,你让它干什么,它就干什么,cpu怎么识别自己在干什么呢?cpu就是靠存在cpu上的指令集来识别自己在干什么的! 

   为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换由于只有一条指令,保证了原子性即使是多处理器平台,连接内存的总线只有1套,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期  。

1.4.1.申请锁原理 

下面我们来看看lock伪代码:

我们别把锁想的太复杂,我们就可以把他当成1个变量mutex!

我们可以认为mutex的初始值为1,al是计算机中的一个寄存器(eax的一部分)

当线程申请锁时,需要执行以下步骤:

  1. 先将al寄存器中的值清0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(上下文信息),执行该动作本质上是将自己的al寄存器清0。
  2. 然后交换al寄存器和mutex中的值。xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换。
  3. 最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。

例如,此时内存中mutex的值为1,线程申请锁时先将al寄存器中的值清0,然后将al寄存器中的值与内存中mutex的值进行交换。

        交换完成后检测该线程的al寄存器中的值为1,则该线程申请锁成功,可以进入临界区对临界资源进行访问。

       而此后的线程若是再申请锁,与内存中的mutex交换得到的值就是0了,此时该线程申请锁失败,需要被挂起等待,直到锁被释放后再次竞争申请锁。

1.4.2.释放锁原理 

当线程释放锁时,需要执行以下步骤:

  • 将内存中的mutex置回1。使得下一个申请锁的线程在执行交换指令后能够得到1,形象地说就是“将锁的钥匙放回去”。
  • 唤醒等待Mutex的线程。唤醒这些因为申请锁失败而被挂起的线程,让它们继续竞争申请锁。

注意:

  1. 在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的。
  2. 在线程释放锁时没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令。
  3. CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的。申请锁实际就是,把内存中的mutex通过交换指令,原子性的交换到自己的al寄存器中。

2.锁的接口的封装

我们上面都是使用原生接口来使用锁的,我们能不能把它进行封装呢?当然可以!

我们把这个锁封装到1个头文件"mutex.h"里

#pragma once

#include<pthread.h>

class Mutex
{
    public:
    Mutex(pthread_mutex_t*lock):lock_(lock)
    {
    }
    void Lock()
    {
        pthread_mutex_lock(lock_);
    }
    void Unlock()
    {
        pthread_mutex_unlock(lock_);
    }
    ~Mutex()
    {
    }

pthread_mutex_t *lock_;
};

class LockGuard
{
    public:
    LockGuard(pthread_mutex_t*lock):mutex_(lock)
    {
        mutex_.Lock();
    }
    ~LockGuard()
    {
        mutex_.Unlock();
    }
    private:
    Mutex mutex_;
};
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include<vector>
#include<string>
#include"mutex.h"
using namespace std;

#define NUM 4
int tickets=1000;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//注意这里

class threadData
{
public:
	threadData(int number)
	{
		threadname="thrade-"+to_string(number);
	}
public:
	string threadname;
};

void* getTicket(void* arg)
{
	threadData*td=static_cast<threadData*>(arg);
	while (1){
		{
		LockGuard lockguard(&mutex);
		//加锁,申请锁成功,才能往后执行,不成功,阻塞等待
		//加锁和解锁之间的代码叫临界区
		if (tickets > 0){
			usleep(10000);
			printf("[%s] get a ticket, left: %d\n", td->threadname.c_str(), --tickets);
		}
		else{
			break;
		}
		}
		usleep(13);//注意
	}
	printf("%s.....quit\n",td->threadname.c_str());
	pthread_exit((void*)0);
}


int main()
{
	vector<pthread_t> tids;
	vector<threadData*> thread_datas;
	for(int i=0;i<NUM;i++)
	{
		pthread_t tid;
		
		threadData*td=new threadData(i);
		thread_datas.push_back(td);
		
		pthread_create(&tid,nullptr,getTicket,thread_datas[i]);
		tids.push_back(tid);
	}

	for(auto thread : tids)
	{
		pthread_join(thread,nullptr);
	}
	for(auto td : thread_datas)
	{
		delete td;
	}	

}

3.可重入VS线程安全

  • 线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题。
  • 重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。

注意: 线程安全讨论的是线程执行代码时是否安全,重入讨论的是函数被重入进入。

  • 线程安全和重入是同一种东西吗? 

不是

  • 常见的线程安全的情况
  1. 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
  2. 类或者接口对于线程来说都是原子操作。
  3. 多个线程之间的切换不会导致该接口的执行结果存在二义性。
  • 常见的不可重入的情况
  1. 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
  2. 调用了标准I/O库函数,标准I/O可以的很多实现都是以不可重入的方式使用全局数据结构。
  3. 可重入函数体内使用了静态的数据结构。
  • 常见的可重入的情况
  1. 不使用全局变量或静态变量。
  2. 不使用malloc或者new开辟出的空间。
  3. 不调用不可重入函数。
  4. 不返回静态或全局数据,所有数据都由函数的调用者提供。
  5. 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
  • 可重入与线程安全联系

  1. 函数是可重入的,那就是线程安全的。
  2. 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
  3. 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
  • 可重入与线程安全区别

  1. 可重入函数是线程安全函数的一种。
  2. 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  3. 如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。

4.死锁

  • 死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。

单执行流可能产生死锁吗?

        单执行流也有可能产生死锁,如果某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。

        因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。

例如,在下面的代码中我们让主线程创建的新线程连续申请了两次锁。

#include <stdio.h>
#include <pthread.h>

pthread_mutex_t mutex;
void* Routine(void* arg)
{
	pthread_mutex_lock(&mutex);
	pthread_mutex_lock(&mutex);
	
	pthread_exit((void*)0);
}
int main()
{
	pthread_t tid;
	pthread_mutex_init(&mutex, NULL);
	pthread_create(&tid, NULL, Routine, NULL);
	
	pthread_join(tid, NULL);
	pthread_mutex_destroy(&mutex);
	return 0;
}

运行代码,此时该程序实际就处于一种被挂起的状态。

ps命令查看该进程时可以看到,该进程当前的状态是Sl+,其中的l实际上就是lock的意思,表示该进程当前处于一种死锁的状态。

我们怎么理解上面的阻塞??

进程运行时是被CPU调度的,换句话说进程在调度时是需要用到CPU资源的,每个CPU都有一个运行等待队列(runqueue),CPU在运行时就是从该队列中获取进程进行调度的。

在运行等待队列中的进程本质上就是在等待CPU资源,实际上不止是等待CPU资源如此,等待其他资源也是如此,比如锁的资源、磁盘的资源、网卡的资源等等,它们都有各自对应的资源等待队列。

例如,当某一个进程在被CPU调度时,该进程需要用到锁的资源,但是此时锁的资源正在被其他进程使用:

  • 那么此时该进程的状态就会由R状态变为某种阻塞状态,比如S状态。并且该进程会被移出运行等待队列,被链接到等待锁的资源的资源等待队列,而CPU则继续调度运行等待队列中的下一个进程。
  • 此后若还有进程需要用到这一个锁的资源,那么这些进程也都会被移出运行等待队列,依次链接到这个锁的资源等待队列当中。
  • 直到使用锁的进程已经使用完毕,也就是锁的资源已经就绪,此时就会从锁的资源等待队列中唤醒一个进程,将该进程的状态由S状态改为R状态,并将其重新链接到运行等待队列,等到CPU再次调度该进程时,该进程就可以使用到锁的资源了。

总结一下:

  1. 站在操作系统的角度,进程等待某种资源,就是将当前进程的task_struct放入对应的等待队列,这种情况可以称之为当前进程被挂起等待了。
  2. 站在用户角度,当进程等待某种资源时,用户看到的就是自己的进程卡住不动了,我们一般称之为应用阻塞了。
  3. 这里所说的资源可以是硬件资源也可以是软件资源,锁本质就是一种软件资源,当我们申请锁时,锁当前可能并没有就绪,可能正在被其他线程所占用,此时当其他线程再来申请锁时,就会被放到这个锁的资源等待队列当中。

4.1.死锁的四个必要条件

  1. 互斥条件: 一个资源每次只能被一个执行流使用。
  2. 请求与保持条件: 一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件: 一个执行流已获得的资源,在未使用完之前,不能强行剥夺。
  4. 循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系。

注意: 这是死锁的四个必要条件,也就是说只有同时满足了这四个条件才可能产生死锁。这4个条件满足了,不一定会产生死锁

  • 避免死锁的方法
  1. 破坏死锁的四个必要条件。
  2. 加锁顺序一致。
  3. 避免锁未释放的场景。
  4. 资源一次性分配。

除此之外,还有一些避免死锁的算法,比如死锁检测算法和银行家算法。

  • 22
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值