现代操作系统day6:线程的实现方法;进程间通信

实现线程包的方法:

线程放在用户空间:

运行时系统:
一种介乎编译(Compile)和解释(Interpret)的运行方式,由编译器(Compiler)首先将源代码编译为一种中间码,在执行时由运行时(Runtime)充当解释器(Interpreter)对其解释。

具体方法:
用户空间的线程库函数管理线程:线程在一个运行时系统的顶部运行,这个运行时系统是一个【管理线程的过程】的集合。在用户空间管理线程时,每个进程需要有专门的线程表(TCB),由运行时系统管理,用来追踪该进程中的所有线程。

if(线程引起本地阻塞){
	/*调用一个运行时系统的过程*/
	检查该线程是否必须进入阻塞状态
	if(必须进入阻塞状态){
		保存该线程的寄存器
		查看表中可运行的就绪线程
		将新线程的保存值重新装入机器的寄存器
		/*只要堆栈指针和程序计数器一被切换,就运行新的线程*/
		}
}

N个用户线程对应M个内核线程

优点:
1)这样的线程切换比陷入内核快一个数量级(不需要陷阱,不需要上下文切换,不需要对高速缓存进行刷新)
2)用户级线程包可以在不支持线程的操作系统上实现
3)允许每个进程有自己定制的调度线程算法(每个进程各自实现自己的线程在什么时候停止什么时候运行)
缺点:
1)如果进程里的某个线程进行了阻塞型系统调用(例如读文件),那么进程里的其他线程都会等待
2)一个线程如果不主动放弃CPU的使用权,其他线程将无法运行
3)由于时间片是分配给进程的,进程内的线程数目增多,执行的比较慢

线程放在内核

CPU的调度单位是线程而不是进程,由内核进行线程,进程主要完成资源的管理
缺点:
1)开销比起上一种方法会更大

轻量级进程

常见于linux,更简洁

进程间通信

进程间通信要解决的问题是多个相互协作的进程间,如何让他们正确地操作共享资源
进程调度要解决的问题是安排进程工作先后顺序,来使得系统开销和公平性之间取得平衡

进程间通信(IPC)不是中断

进程间通信要解决的问题:
1.如何传递信息
2.如何确保进程间在关键活动中不会出现交叉
3.正确的顺序
同样的问题和解决方法也适用于线程

竞争与互斥

竞争条件:两个或多个进程读写某些共享数据,而最后的结果取决于进程运行的精确时序
例如:两个进程都去修改原有数字,一个加,一个减,得到的结果却可能是只加或只减,而并非想象中的既加又减。
在这里插入图片描述
问题:如果需要,就有人去买面包;最多有一个人去买面包
解决方法:在冰箱上设置锁和钥匙,去买面包之前要锁上并带走钥匙
死锁:A拿到锁1,B拿到锁2,A想要拿锁2,B想要拿锁1,但双方都要求对方先放弃锁,自己才肯给锁,结果大家都拿不到锁

问题的症结在于,在进程A对共享变量的使用未结束之前,系统发生中断,进程B就使用该共享变量。而B结束恢复到A时,A使用的依然是中断前一刻保存的变量的值,没有更新变量的值

为解决该问题,就要实现互斥,要求
1)互斥:两个进程不能同时处于临界区
2)不应该对CPU的速度和数量做任何假设
3)无忙等待:等待着的进程可以去睡眠
4)有限等待:不得使进程无限期等待进入临界区

临界区:对共享内存进行访问的程序片段

在这里插入图片描述
当一个进程在临界区中更新共享内存时,其他进程将不会进去其临界区,也不会带来任何麻烦

实现互斥

基于硬件的解决方法(仅适用于单处理器)

1.屏蔽中断
在每个进程刚刚进入临界区后立刻屏蔽所有中断(包括时钟中断),并在将要离开之前再打开中断
缺点:
1)对于多处理器的系统,其它CPU仍可以访问该共享内存
2)整个系统都会因此为你而停下来,影响了系统其他本应执行而且当前进程无关的操作
3)只能用于临界区很小的情况

2.锁变量(不可行)
缺点:
同样可能出现恰好在进程A锁变量之前,进程B被调度并提前锁了进程,无法解决竞争
3.严格轮换法(不可行)
缺点:
违反了“临界区外运行的进程不得阻塞其他进程”的原则

基于软件的解决方法(复杂)

4.Peterson解法(两个线程)

#define FALSE 	0
#define TRUE 	1
#define N 		2   /*进程数量*/

int turn;			/*现在轮到谁?*/
int interested[N];	/*所有值初始化为0*/

void enter_region(int process);/*进程号是0或1*/
{
	int other;						/*其他进程号*/
	
	other = 1-process;				/*另一方进程*/
	interested[process] = TRUE;		/*表明所感兴趣的*/
	turn = process;					/*设置标志*/
	while (turn == process && interested[other] == TRUE);/*空语句*/
}
/*	后调用enter_region的进程1会因为先调用的进程0把interested[0]=TRUE而陷入死循环,
	直到进程0调用leave_region*/

void leave_region(int process)/*进程:谁离开?*/
{
	interested[process] = FALSE;/*表示离开临界区*/
}

5.Bakery算法:(n个线程)
Bakery算法就像是去银行,每个进来的人都要取号,按照号码大小来先后服务。如果恰好两个人的号码一致,则根据两个人各自的ID(例如身份证号)的大小来比较。

这两种方法的缺点是:
1)需要忙等待
2)如果没有硬件的保证,软件解决方案都不可行
3)相对复杂

原子指令(单处理器或多处理器均可)

原子指令指的是利用特殊的指令——TSL指令或exchange指令,来实现互斥

TSL指令的特别之处在于,执行TSL指令的CPU会在执行完成前锁住内存总线,不允许其他程序读写该寄存器

/*单条TSL指令包含三步:
1)从某一寄存器中读值
2)判断该值是否为1(并返回真或假)
3)将该寄存器赋值为1*/
boolean TestAndSet(boolean *value)
{
	boolean rv = *value;
	*value = TRUE;
	return rv;
}

TSL实现算法:

//忙等待
//缺点:线程在循环的等待过程会不停消耗CPU周期
class LOCK{
	int value = 0;
}

LOCK::Acquire(){
	while(TestAndSet(value))
	;//spin
}

LOCK::Realease(){
	value = 0;
}

为了解决忙等待问题,可以把等待着的进程放到队列中,就可以把CPU让出来给其他进程使用。退出的进程还要进行唤醒操作,来使得等待着的进程再次判断(TSL)

//无忙等待
class LOCK{
	int value = 0;
	WaitQueue q;
}

LOCK::Acquire(){
	while(TestAndSet(value)){
		add this TCB to wait queue q;
		schedule();
	;}//spin
}

LOCK::Realease(){
	value = 0;
	remove one thread t from q;
	wakeup(t);
}

但其实忙等待更好,因为不需要上下文切换,而上下文切换同样需要很大的开销

同理,可以将TSL指令更换成exchange指令,令LOCK初始值为0,每个进程都拥有KEY。
当Acquire时,进程自身的KEY赋值为1,交换LOCK和KEY的值,KEY=1时进入循环,KEY=0时跳过循环进入临界区
当Release时,LOCK=0

优点:
1)实现简单
2)容易扩展到N个进程
3)开销小
缺点:
1)忙等待可能会有较大开销;
2)while判断会去抢LOCK,而这是一个随机的过程,从而可能有饥饿现象(某个进程很不幸一直不能进入临界区);
3)会出现死锁(低优先级的进程一直不能进入临界区)

光有锁机制不够,还有同步互斥和原子操作P/V,同步区可以有多个进程/线程进行

信号量

使用信号量,来实现比LOCK更高级别的功能,也就是同步互斥。
信号量是一个整型(sem),有两个针对信号量的原子操作:
P():sem-1,如果sem<0,当前进程就等待(阻塞或挂起),否则继续(类似于获取LOCK)
V():sem+1,如果sem<=0,就唤醒一个等待的进程

信号量类似于铁路,有一个红绿灯,进入红绿灯后有两条铁轨可以并发的走,LOCK只允许一辆列车通过,而双铁轨允许若新来了列车,如果两条铁轨都有车,新列车必须在灯外等待;只要其中一条铁轨空了,就让等着的火车到空铁轨上运行

现在很少用,但在计算机科学研究中还是非常重要

使用信号量:

使用有符号整型来表示信号量,初始化设成大于0的数
一旦多次P后sem<0,说明当前执行了P操作的进程不能继续执行下去了,需要转为等待状态,必须其他进程进行V操作才能使等待进程唤醒
唤醒谁呢?这涉及到公平性,一般来说应该是先等待的先被唤醒(如果一次只能唤醒一个),也就是先进先出(FIFO),从等待队列的头来取进程唤醒

忙等待的LOCK不能进行先进先出的操作

锁机制的取值只能是0和1,使用二进制信号量来实现
而一般的信号量可以取任何非负值。
信号量可以用在条件同步中

如果利用信号量来实现LOCK的话,可以将信号量初始值设为1
当Acquire时,执行一次P操作
当Release时,执行一次V操作

从这个角度来说,锁机制也可以归类在信号量机制中

==利用信号量实现调度约束(同步)==时,初始值要设置为0
如果A必须等到B运行到某个域才能运行(同步)
要实现这样的功能,就要使A运行到某处时进行P操作进入等待状态,B运行完进行V操作唤醒A继续执行

条件同步:
例如,有一个有界缓冲区(意味着如果不停往里面装数据,缓冲区会被塞满),总共有多个producer不停地往缓冲区输入数据,一个consumer不停地往缓冲区拿走数据,任何一个时间只能有一个线程操作缓冲区,当缓冲区为空,consumer必须等待producer,使得缓冲区有数据;当缓冲区满,producer必须等到consumer取数据,使得缓冲区有空位(调度/同步约束)

解决方法:
三个信号量:二进制信号量互斥,一般信号量fullBuffers,一般信号量emptyBuffers

class BounderBuffer{
	mutex = new Semaphore(1);
	fullBuffers = new Semaphore(0);/*一开始buffer是空的*/
	emptyBuffers = new Semaphore(n);/*生产者总共可以往buffer塞n个数据*/
}

/*生产者的操作函数*/
BoundedBuffer::Deposit(c){
	emptyBuffers->P();
	/*直到emptyBuffers<0,说明已经塞满了,生产者的操作才会被阻塞,等待消费者的操作*/
	mutex->P();
	add c to the buffer;
	mutex->V();
	fullBuffers->V();
	/*当fullBuffers<0,说明已经空了,消费者的操作已经被阻塞,需要生产者塞数据才能重新运行*/
}

/*消费者的操作函数*/
BoundedBuffer::Remove(c){
	fullBuffers->P();
	/*直到fullBuffers<0,说明已经空了,消费者的操作才会被阻塞,等待生产者的操作*/
	mutex->P();
	remove c from the buffer;
	mutex->V();
	emptyBuffers->V();
	/*当emptyBuffers<0,说明已经塞满了,生产者的操作已经被阻塞,需要消费者拿数据才能重新运行*/
}

/*P操作和V操作的顺序没有影响*/
/*生产者第一步和第二步如果交换,会出现生产者进去了,发现缓冲区满了想叫消费者过来,却发现自己从里面上锁了,消费者进不来了(死锁现象)*/

信号量实现

使用硬件原语:禁用中断、原子操作(TSL)
1.要使用整型
2.使用P操作时,理应会出现进程“等”的现象。如何实现进程“等”呢?–等待队列

class Semaphore{
	int sem;
	WaitQueue q;
}

Semaphore::P(){
	sem--;
	if(sem<0){
		add this thread t to q;
		block(q);
	}
}

Semaphore::V(){
	sem++;
	if(sem<=0){
		remove a thread t from q;
		wakeup(q);
	}
}

需要注意:
信号量使用中除了用在互斥,还可以用在同步操作中,和LOCK有一定区别
使用时必须非常精通信号量
若P和V的位置设置不当,容易出错,可能会出现死锁等现象–改进方法:

管程

管程最初提出是在高级语言中,来简化高级语言完成同步互斥

管程monitor
目的:分离互斥和条件同步
核心技术:条件变量
概念:包含了【一系列共享变量以及针对这些变量的操作函数】的集合
具体设计中,管程
1.包含了一个LOCK,同一时间访问管程管理的函数只能有一个线程,需要LOCK来确保互斥性
2.很多条件变量,每个条件变量都有一条属于自己的等待队列,条件变量也与对应的操作函数关联。如果进程使用操作函数时触发了条件变量对应的特定条件,就要挂在条件变量的等待队列上,释放LOCK让新的进程进来临界区,直到其他操作函数的作用下原进程的特定条件被满足,原进程才能被唤醒,重新继续原来的操作函数在这里插入图片描述
进入管程是互斥的,需要一个队列和一个LOCK
进入管程后,线程可以执行管程所维护的函数,这些函数对共享资源进行操作,如果针对某个共享资源的操作得不到满足,则需要等待。因为此时是互斥,需要把自身挂到某个地方并且释放LOCK(挂到条件变量上,通过wait()函数和signal()函数实现条件变量中线程睡眠和唤醒的操作)

实现:

class Condition{
	int numWaiting = 0;
	WaitQueue q;
}

Condition::Wait(lock){
	num Waiting++;
	add this thread to q;
	Release(lock);//睡眠之前一定要释放锁,不然的话等待队列的会一直进不来
	schedule();//取另外一个线程来运行
	Acquire(lock);
}

Condition::Signal(){
	if(numWaiting>0){
		remove a thread t from q;
		wakeup(t);//need mutex
		numWaiting--;
	}
}

操作:

/*Hansen-style*/
class BounderBuffer{
	//...
	LOCK lock;
	int count = 0;
	Condition notFull,notEmpty;
}

/*生产者的操作函数*/
BoundedBuffer::Deposit(c){
	lock->Acquire();
	while(count=n)/*如果满了,生产者就要睡眠*/
		notFull Wait(&lock);
		/*释放锁,让另一个线程来执行,直到消费者操作使得缓冲区不再满了以后,生产者才能继续执行*/
	add c to the buffer;
	count++;	
	notEmpty Signal();
	lock->Release();
}

/*消费者的操作函数*/
BoundedBuffer::Remove(c){
	lock->Acquire();
	while(count=0)/*如果空了,消费者就要睡眠*/
		notEmpty Wait(&lock);
	remove c from the buffer;
	count--;
	notFull Signal();
	/*操作完了就唤醒消费者*/
	lock->Release();
}

/*使用管程实现互斥和使用信号量实现互斥不一样,管程时,上锁放在函数开头*/
/*因为这两个函数deposit和remove都是属于管程管理的两个函数,要确保一进入缓冲区就是互斥的*/

在这里插入图片描述
缺点:
同步互斥问题的存在,不确定性使得调试分析很困难

1、信号量可以并发,并发量是取决于s的初始值,而管程则是在任意时刻都是只能有一个。
2、信号量的P操作在操作之前不知道是否会被阻塞,而管程的wait操作则是一定会被阻塞。
3、管程的进程如果执行csignal后,却没有在这个条件变量上等待的任务的话,则丢弃这个信号。进程在发出信号后会将自己置于紧急队列之中,因为它已经执行了部分代码,所以应该优先于入口队列中的新进入的进程执行。
4、当前进程对一个信号量加1后,会唤醒另一个进程,被唤醒进程程与当前进程并发执行,因此V操作一般放在最后
————————————————
原文链接:https://blog.csdn.net/ljbdream00/article/details/83501948

wait的原理是:在进入管程获取锁后,如果不满足要求就使用wait函数。wait函数释放锁,让其他人获取锁进来操作,自己必然进入等待队列
P的原理是:每个进入的进程都要P操作,满足了才能获取锁进行各自的操作
使用了wait函数,本进程就必定进入等待队列,因此需要在wait函数之前添加条件判断什么时候用wait函数
而使用了P函数,不一定会进入等待队列,因为P的初始值可以设定,从而达到条件同步或锁的功能。因此需要在P函数内添加条件判断什么时候才进入等待队列
再说的俗一点,P操作像是临界区门口有个人安检,只有检查通过了才能放进程进去临界区,不通过的只能在门外另一条等待队列中排队,直到符合要求才能进去。而wait操作像是临界区门内有个人安检,每个进程都能进去,但进去以后才进行安检,只有检查通过了才能让它继续工作,不通过的只能踢出门外,另一条等待队列中排队,让其他人进去,直到符合要求才能重新进去。

要使得数据同步不出错,关键是同一时刻只能有一个线程进行读或写操作

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值