Linux:多线程的同步与互斥(互斥锁、条件变量、死锁、银行家算法)


线程安全概念

在多个执行流中对一个临界资源进行操作访问,而不会造成数据二义性

如何实现线程安全: 同步与互斥

  • 互斥:通过保证同一时间只有一个执行流可以对临界资源进行访问(一个执行流访问期间,其他执行流不能访问),来保证数据访问的安全性
  • 同步:通过一些条件判断来实现多个执行流对临界资源访问的合理性(有资源则访问,没有资源则等着,等有资源了再被唤醒)

线程间的互斥

概念

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

互斥量mutex

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
  • 多个线程并发的操作共享变量,会带来一些问题。

代码示例:

// 操作共享变量会有问题的售票系统代码 
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h> 
#include <pthread.h>

int ticket = 100;

void *route(void *arg)
{
	while(1){
		if (ticket > 0) {
			// 如果有票就一直枪
			usleep(1000);
			printf("sells ticket:%d\n", ticket);
			ticket--; 
		} 
		else {
			pthread_exit(NULL);
		}
	}
}

int main(){
	pthread_t tid[4];
	int i, ret;
	
	for(i = 0; i < 4; i++){
		ret = pthread_create(&tid[i], NULL, route, NULL);
		if(ret != 0){
			printf("thread create error");
			return -1;
		}
	}

	for(i = 0; i < 4; i++){
		pthread_join(tid[i], NULL);
	}

	return 0;
}

一次执行结果:
sell ticket:100
sell ticket:99
sell ticket:98
sell ticket:97
...
sell ticket:1
sell ticket:0
sell ticket:-1
sell ticket:-2

为什么可能无法获得争取结果?

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  • ticket-- 操作本身就不是一个原子操作
取出ticket--部分的汇编代码
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
<ticket>

- - 操作并不是原子操作,而是对应三条汇编指令:

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

要解决以上问题,需要做到三点:

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

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

在这里插入图片描述

互斥的实现:互斥锁

原理:

互斥锁本质是一个0/1计数器,对临界资源当前的访问状态进行标记( 0-不可访问;1-可以访问)

所有执行流在访问临界资源之前先尝试加锁(通过计数器判断当前状态是否能够访问临界资源)

  • 如果可以访问,则将状态修改为不可访问状态,然后再让执行流访问临界资源
  • 如果不允许访问,则让执行流等待,直到持有锁的线程解锁

对临界资源访问完毕之后进行解锁(将临界资源的访问状态置为可访问,唤醒等待的线程,大家重新开始竞争这个资源)

所有的执行流都需要通过同一个互斥锁实现互斥,意味着:
互斥锁本身就是一个临界资源,但是互斥锁自身计数器的操作是原子操作。

互斥锁的操作流程、接口介绍:

  1. 定义互斥锁变量 pthread_mutex_t
  2. 初始化互斥锁变量
pthread_mutex_init(pthread_mutex_t *mutex, pthread_mutexattr_t *attr)// 动态分配
参数:
	mutex: 要初始化的互斥量
	attr: NULL
	
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER	// 静态分配
  1. 在临界资源访问之前加锁(不能加锁则等待,可以加锁则修改资源状态,然后调用返回,访问临界资源)
pthread_mutex_lock(pthread_mutex_t *mutex);	// 阻塞加锁 - 如果当前不能加锁(锁已经被别人加了),则一直等待直到加锁成功调用返回
返回值: 成功返回0,失败返回非0- 错误编码
pthread_mutex_trylock(pthread_mutex_t *mutex);	// 非阻塞加锁 - 如果当前不能加锁,则立即报错返回 -EBUSY

  1. 在临界资源访问完毕后解锁(将资源状态置为可访问,将其他执行流唤醒) pthread_mutex_unlock(pthread_mutex_t *mutex);
  2. 销毁互斥锁 pthread_mutex_destroy(pthread_mutex_t *mutex);

销毁互斥量需要注意:

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

使用注意事项

  1. 锁尽量只保护对临界资源的访问操作
  2. 在任意有可能退出的地方退出前都要解锁

简单案例

// 操作售票系统代码 
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h> 
#include <pthread.h>

int ticket = 100;
pthread_mutex_t mutex;

void *route(void *arg)
{
	while(1){
		// 加锁一定是只保护临界资源的访问
		pthread_mutex_lock(&mutex);
		if (ticket > 0) {
			// 如果有票就一直枪
			usleep(1000);
			printf("sell ticket:%d\n", ticket);
			ticket--; 
			pthread_mutex_unlock(&mutex);
		} 
		else {
			// 加锁后在任意有可能退出线程的地方都要解锁
			pthread_mutex_unlock(&mutex);
			pthread_exit(NULL);
		}
	}
}

int main(){
	pthread_t tid[4];

	int i, ret;
	// 互斥锁的初始化一定要放在线程创建之前
	pthread_mutex_init(&mutex, NULL);
	for(i = 0; i < 4; i++){
		ret = pthread_create(&tid[i], NULL, route, NULL);
		if(ret != 0){
			printf("thread create error");
			return -1;
		}
	}

	for(i = 0; i < 4; i++){
		pthread_join(tid[i], NULL);
	}
	// 互斥锁的销毁一定是不再使用这个互斥锁
    pthread_mutex_destroy(&mutex);

	return 0;
}

运行结果:

sell ticket:100
sell ticket:99
sell ticket:98
sell ticket:97
sell ticket:96
...
sell ticket:5
sell ticket:4
sell ticket:3
sell ticket:2
sell ticket:1

互斥量(mutex)实现原理探究

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

在这里插入图片描述

不管当前mutex的状态是什么,反正一步交换之后,其他的线程都是不可访问的;这时候当前线程就可以慢慢判断了

  1. 先将寄存器中的值置为0
  2. 直接将寄存器的值域内存空间中的数据进行交换 - 这个交换指令是一步可以完成的(这时候内存中mutex的值就是0了,别人访问肯定发现无法加锁)
  3. if(%eax == 1) // eax 是一个累加寄存器(如果是1,则pthread_mutex_lock直接返回,访问临界资源;如果是0,则让线程等待)

线程间的同步

同步的实现

通过条件判断保证资源访问的合理性 – 条件变量

  • 线程满足获取资源的访问条件才能去访问资源
  • 没有资源的时候则需要让线程等待,等待被唤醒(其他线程产生一个资源的时候)
    • 等待:将pcb状态置为可中断休眠状态 (表示当前休眠)
    • 唤醒:将pcb状态置为运行态(则可以开始调度)

条件变量

向外提供了使线程等待的接口和唤醒线程的接口+pcb的等待队列

注意:
条件变量只提供了使线程等待和唤醒的接口,因此什么时候让线程该等待/唤醒就需要程序员在进程中判断。

操作接口介绍

  1. 定义条件变量 pthread_cond_t;
  2. 初始化条件变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

int pthread_cond_init(pthread_cont_t* cond,pthread_condattr_t *attr);
  1. (访问条件不满足时)使线程挂起休眠的接口:条件变量是搭配互斥锁一起使用(判断条件是否满足的条件本身就是一个临界资源,需要被保护)
pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t mutex);	// 一直等待被唤醒
pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t mutex, struct timespec);	// 等待指定时间内都没有被唤醒则自动醒来

  1. (访问条件满足时)唤醒线程的接口:
pthread_cond_signal(pthread_cond_t * cond);	// 唤醒至少一个等待的线程(并不是唤醒单个)
pthread_cond_broadcast(pthread_cond_t * cond);	// 唤醒所有等待的线程
  1. 销毁条件变量:
pthread_cond_destory(pthread_cond_t * cond);

注意事项:

  1. 条件变量需要搭配互斥锁一起使用,pthread_cond_wait 集合了解锁/休眠/被唤醒后加锁的三步操作
  2. 在程序中对访问条件是否满足的判断需要使用while循环进行判断
  3. 在同步实现中,多种角色线程应该使用多个条件变量,不要让所有的线程等待在同一个条件变量上

简单案例

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

int bowl = 0;	// 默认0表示碗中没有饭

pthread_cond_t cook_cond;	// 实现线程间对bowl变量访问的同步操作
pthread_cond_t customer_cond;	// 实现线程间对bowl变量访问的同步操作
pthread_mutex_t mutex;	// 保护bowl变量的访问操作

void *thr_cook(void *arg){
	while(1){
		// 加锁
		pthread_mutex_lock(&mutex);
		while(bowl != 0){	// 表示有饭,不满足做饭条件
			// 让厨师线程等待,等待之前先解锁,被唤醒之后再加锁
			// pthread_cond_wait 接口中就完成了解锁,休眠,被加锁后加锁三步操作
			// 并且解锁和休眠操作是一步完成的,保证原子操作
			pthread_cond_wait(&cook_cond, &mutex);
		}
		bowl = 1;	// 能够走下来表示没饭,则做一碗饭,将bowl改为1
		printf("I made a bowl of rice!\n");
		// 唤醒顾客吃饭
		pthread_cond_signal(&customer_cond);
		// 解锁
		pthread_mutex_unlock(&mutex);
	}
	return NULL;
}

void *thr_customer(void *arg){
	// 顾客现场被唤醒加锁成功后重新判断有没有饭,没有就休眠,有则吃饭
	while(1){
		// 加锁
		pthread_mutex_lock(&mutex);
		while(bowl != 1){	// 没有饭,不满足吃饭条件,则等待
			// 没有饭则等待,等待前先解锁,被唤醒后加锁
			pthread_cond_wait(&customer_cond, &mutex);
		}
		bowl = 0;	// 能够走下来表示有饭,吃完饭,将bowl修改为0
		printf("I had a bowl of rice.It was delicious~\n");
		// 唤醒厨师做饭
		pthread_cond_signal(&cook_cond);
		// 解锁
		pthread_mutex_unlock(&mutex);
	}
	return NULL;
}

int main(){
	pthread_t cook_tid[4], customer_tid[4];
	int i, ret;

	pthread_mutex_init(&mutex, NULL);
	pthread_cond_init(&cook_cond, NULL);
	pthread_cond_init(&customer_cond, NULL);

	for(i = 0; i < 4; i++){
		ret = pthread_create(&cook_tid[i], NULL, thr_cook, NULL);
		if(ret != 0){
			printf("pthread_create error!\n");
			return -1;
		}
		ret = pthread_create(&customer_tid[i], NULL, thr_customer, NULL);
		if(ret != 0){
			printf("pthread_create error!\n");
			return -1;
		}
	}

	pthread_join(cook_tid[0], NULL);
	pthread_join(customer_tid[0], NULL);

	pthread_mutex_destroy(&mutex);
	pthread_cond_destroy(&cook_cond);
	pthread_cond_destroy(&customer_cond);

	return 0;
}

运行结果:

I made a bowl of rice!
I had a bowl of rice.It was delicious~
I made a bowl of rice!
I had a bowl of rice.It was delicious~
...
I made a bowl of rice!
I had a bowl of rice.It was delicious~
I made a bowl of rice!
I had a bowl of rice.It was delicious~
^CI made a bowl of rice!
I had a bowl of rice.It was delicious~

死锁概念:

多个执行流对锁资源争抢访问,但是因为访问推进顺序不当,造成互相等待最终导致程序流程无法继续推进,这时候就造成了死锁
死锁实际是一种程序流程无法继续推进,卡在某个位置的一种概念

死锁的产生通常是在访问多个锁的时候需要注意的事项

死锁产生的必要条件:

  1. 互斥条件:我加了锁,别人就不能再继续加锁
  2. 不可剥夺条件:我家的锁,别人不能解,只有我能解锁
  3. 请求与保持条件:我加了A锁,然后去请求B锁;如果不能对B锁加锁,则也不释放A锁
  4. 环路等待条件:我加了A锁,然后去请求B锁;另外一个人加了B锁,然后去请求A锁

死锁的预防

破坏死锁产生的必要条件(主要避免3和4两个条件的产生)

死锁的避免

  • 死锁检测算法
  • 银行家算法

银行家算法的思路:系统的安全状态/非安全状态

每一个线程进入系统时,它必须声明在运行过程中,所需的每种资源类型最大数目,其数目不应超过系统所拥有每种资源总量,当线程请求一组资源系统必须确定有足够资源分配给该进程。
若有再进一步计算这些资源分配给进程后,是否会使系统处于不安全状态?

  • 如果有可能就不能分配(等待)
  • 如果不会(即若能在分配资源时找到一个安全序列),系统是安全的,则分配这个锁

后续若不能分配锁,可以资源回溯,把当前执行流中已经加的锁释放掉 - 破坏请求与保持
非阻塞加锁操作,若不能加锁,则把手上的其他的锁也释放掉 - 破坏请求与保持

加锁对临界资源进行保护,实际上对程序的性能是一个极大的挑战!
在高性能程序中通常会讲究一种无锁编程 - CAS锁(乐观锁) / 一对一的阻塞队列 / atomic 原子操作

具体介绍:

假定系统中有五个线程{P0,P1,P2,P3,P4}和三类资源{A,B,C},各类资源数量分别为10,5,7,在T0时刻分配资源情况如图:

  • Max: 表示线程对每类资源的最大需求量;
  • Allocation: 表示系统给线程已分配每类资源的数目;
  • Need:表示线程还需各类资源数目;
  • Available:表示系统当前剩下的资源。

在这里插入图片描述

从初始找出安全序列:

  1. 首先系统剩下资源{3,3,2},查表可满足5个进程Need的进程有:P1(1,2,2)、P3(0,1,1),先给P1分配;
  2. P1分配以后执行完释放其所占资源后系统此时剩下资源有:Allocation+{3,3,2}={5,3,2};
  3. 根据系统剩下资源查表可满足剩下4个进程Need的进程有P3{0,1,1}、P4{4,3,1},再给P3分配;
  4. P3分配以后执行完释放其所占资源后系统此时剩下资源有:Allocation+{5,3,2}={7,4,3};
  5. 根据系统剩下资源查表可满足剩下3个进程Need的进程有P0{7,4,3}、P2{6,0,0}、P4{4,3,1},再给P4分配;
  6. P4分配以后执行完释放其所占资源后系统此时剩下资源有:Allocation+{7,4,3}={7,4,5};
  7. 根据系统剩下资源查表可满足剩下2个进程Need的进程有P0{7,4,3}、P2{6,0,0},再给P2分配;
  8. P2分配以后执行完释放其所占资源后系统此时剩下资源有:Allocation+{7,4,5}={10,4,7};
  9. 根据系统剩下资源查表可满足剩下1个进程Need的进程有P0{7,4,3},最后给P0分配;
  10. P0分配以后执行完释放其所占资源后系统此时剩下资源有:Allocation+{10,4,7}={10,5,7};
  11. 所有进程按此序列{P1,P3,P4,P2,P0}可安全执行完毕,最后系统资源全部释放。(由以上也可知安全序列不唯一,但只要找出一个安全序列,则说明此系统是安全的(找到安全序列可按此序列真正执行进程推进顺序,若没找到,则恢复初始状态,其并没有真正给进程分配资源,只是提前避免))

由表表示:

  • work:表示系统当前剩下的资源数;

在这里插入图片描述

个人觉得:死锁检测算法和银行家算法大同小异。。。

银行家算法强调的是运行的安全状态和非安全状态,只是安全状态的检测和死锁检测算法比较类似(安全状态前提要求就是不能出现死锁)


如果本篇博文有帮助到您,请留赞激励博主~~

猜你喜欢

转载自blog.csdn.net/AngelDg/article/details/106864897