文章目录
实现线程包的方法:
线程放在用户空间:
运行时系统:
一种介乎编译(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操作像是临界区门内有个人安检,每个进程都能进去,但进去以后才进行安检,只有检查通过了才能让它继续工作,不通过的只能踢出门外,另一条等待队列中排队,让其他人进去,直到符合要求才能重新进去。
要使得数据同步不出错,关键是同一时刻只能有一个线程进行读或写操作