在os的内部,不同的进程之间其实还是会有互相交互的实时运作,这些实时运作的调度会影响系统内部某些资源数据的共享问题。
什么场景下os的执行会有数据共享问题
例如a进程访问了x001这块内存区域做了些许修改,然后cpu分片给了b进程继续执行,接下来b进程也对这块内存区域做了修改,这会导致一些共享的内存区域中的数据发生变化。不好确保接下来的a进程继续访问内存区域的时候不受影响。
即然并行有这么多风险,为什么还要使用并行计算这种模式呢
并行计算的优势在于:
共享资源
多个不同的任务共用相同的内存资源,可以减少开销。
任务处理高效
io操作和cpu的计算可以重叠,多个处理器将程序分成多个部分执行。
模块化设计
例如一份大的计算任务,可以拆分为多个小的计算任务然后并行计算,提高执行效率。
操作系统内部的一些基本概念
临界区
访问共享资源的代码我们一般称之为临界区,这里我介绍一个例子来解释下什么是临界区:
例如这段程序代码当中,假设线程A执行A模块,线程B执行B模块,A,B线程同时并发执行的时候,对于 i 的加减操作就是一个对于临界区的访问操作。
具体的执行结果可能是A先执行完毕,也有可能是B先执行完毕,还有一种就是谁也没有结束执行,一直在执行。(例如先执行一轮codeA的i+1,接着时间片切换到执行codeB的i-1,不断循环导致没有一方能结束)
互斥
当一个进程在访问临界区的时候,不允许其他的进程访问共享资源,此时临界区只允许有一个进程存在。
死锁
两个或者以上的进程,在相互等待对方完成特定任务而最终没法将自身任务执行下。
饥饿
一个可以执行的进程,一直被调度器所忽略,导致一直处于可执行状态但是却没有执行。
ps:通常当多个进/线程访问相同的内存资源区域时候就会出现数据安全性访问的问题,我们一般称之为竞态条件的不确定性。
共享资源访问的安全性问题
为了避免临界区访问的时候出现竞态问题,重点还是得设计一种机制,每次都只允许有一个请求访问到公共资源模块才行。
信号量
信号量的本质可以理解为一个整形的数字(sem),对于这个数字的访问,程序内部只是提供了两个原子操作,分别是:
-
P():sem -1,如果sem<0,进入等待状态。
-
V():sem +1,如果sem<=0,则同时会唤醒一个等待的P。
可能观看文字还不太好接受这个概念,下边我通过一张绘图来介绍下:
如上图的右边有两个箭头代表可以同时处理的任务数目,(由于我们一开始设置的sem初始值为2,所以允许同时有2个请求进入临界区)图中的左边则是一条等待任务处理的队列,其内部包含了正在待执行的任务信息。每当有一个请求进入的时候,就会执行一次sem-1的P()操作。
下边,假设当有两个请求同时进入了临界区,则需要执行两次的P()操作,对应的sem也就变成了0
此时如果再有更多的请求进来的话,则执行P()操作,sem继续减少,此时sem<0,新的请求就会进入堵塞状态,需要进入到等待队列WaitQueue中来。
随着request的处理完毕之后,会执行V()操作,sem继续增加,此时sem>=0,那么这时候会顺带唤醒队列里的请求进入临界区。
通过这几张图的解释,相信你应该已经对基本的信号量有了一个清晰的认识。
发明信号量这个名词的科学家其实是一名荷兰人(大名鼎鼎的荷兰计算机科学家 Dijkstra ),P和V分表都是荷兰用语,代表的意思是V verhoog 增加,P prolaag 减少。当sem<=0的时候,再执行P()操作会引起堵塞情况,通常我们的等待队列都是采用了FIFO的设计思路,从而保证后续对于请求的唤醒具有公平性。
信号量的一些应用场景
信号量一般分为三种类型:
二进制类信号量
sem为0或者1,当sem==1则允许请求进入临界区。
一般/计数信号量
sem可以取任何非负数,当sem==0的时候就会堵塞。
条件信号量
按照不同的条件来控制临界区的访问。
二进制类信号量伪代码思路
初始化信号量:
mutex = new Semaphore(0);
对mutex元素做操作:
mutex -> P();
......
mutex -> V();
在操作系统底层,二进制类信号量有什么具体的应用场景吗?
假设有这么一个案例,如下图所示:
线程A需要执行一段methodA,线程B需要执行methodB,对于函数A,B内部分别要求,method_A_2执行之前,需要依靠method_B_1先执行完成。(例如method_A_2需要访问某些资源信息,method_B_1中负责对一些资源信息进行初始化操作)
此时利用mutex->P()和mutex->V()去协调同一个二进制信号量的值就很好地实现了两个线程之间的通信。进程也是类似的案例(在linux系统中线程本质就是轻量级的线程)
ps:关于进程的互相通信部分可以参考之前的文章:OS中的进程
二进制类信号量的sem值只会有0和1,但是在一些更加复杂的条件下,这类设计就存在一定的局限性了。
下边我们再来看一个案例,经典的生产者和消费者问题:
假设有一个共享缓冲区,生产者专门往其中写入数据,消费者专门读取这块缓冲区的数据内容。
场景条件:
1.一次只能有一个线程访问Buffer这个临界区。
2.当Buffer写满了之后Producer需要等待Consumer去进行消费。
3.当Buffer清空之后Consumer需要等待Producer写入新的数据。
这里面如果采用了之前的二进制信号量机制来设计就没法满足一次有多个生产者进入到buffer区域,同时及时是将二进制信号量的sem调大似乎也并不满足当前只允许一种类型的角色访问Buffer临界区的设计。
所以这个时候可以采用条件信号量来进行管理,例如下边这段伪代码:
Class Buffer {
mutex = new Semphore(1);
fullBuffer = new Semphore(0);
emptyBuffer = new Semphore(n); //n对应consumer的数量
}
Buffer::Deposit(c){
emptyBuffer -> P();
mutex -> P();
//将数据写入到Buffer中
mutex -> V();
fullBuffer -> V();
}
Buffer::Remove(c){
fullBuffer -> P();
mutex -> P();
//将Buffer中的数据移除
mutex -> V();
emptyBuffer -> V();
}
这段代码我大概解释下,假设我们设置了10个消费者,并且初始化Buffer中的数据正好满足10次的消费请求。那么在执行Remove函数的时候,fullBuffer信号量的值就会从sem=10一直做sem-1操作。此时如果当sem==0,并且没有生产者去触发Deposit则会导致消费者进入等待环节。
同时二进制信号量mutex的使用也能保证同一时刻只能有一个线程访问Buffer区域,能够较好地控制对于某块内存区域的写入和移除操作。
所以说合理地使用多个信号量可以处理一些较为复杂的约束场景。但是直接地使用信号量其实很容易会导致一些异常的发生,所以直接上手的难度比较高,因此os底层也基于信号量的机制封装了一套操作机制,这套机制我们统一称之为管程。
管程
对于管程的理解,大致可以认为是基于信号量的基础做了一层封装,专门用于访问一些共享变量的函数,让调用方使用起来更加简单和清晰。例如JDK内部对于并发模块的实现wait(),notify(),notifyAll()这些函数就是采用了管程技术来控制的。
管程 英文名为monitor,翻译过来就是指监视器的意思,一次只允许有一个进程访问临界区,如果同时有多个访问,则将多余的访问挂起。
管程的组成
-
一把锁
-
0或者多个条件变量
一把锁
管程为了保证对于共享资源的访问一次只能有一个元素,所以才引入了锁的机制,没有抢到锁的请求则需要进入到入口等待队列。
0或者多个条件变量
当获取到了锁的请求进入到了临界区模块中之后,有可能还需要做多个条件的判断,如果没有满足其中的某一条条件则需要进入到条件等待队列中。
说了这么多,你可能还是对管程没有一个特别清晰的认识,下边我还是打算通过绘图的方式来和你解释一下:
管程的内部其实本质还是信号量来实现,当获取到锁成功的请求会进入到管程的内部,其余则会留在入口处的等待队列中。进入内部的线程会依次判断是否满足条件A,条件B,条件C,如果均满足则正常执行程序中的函数,正常访问临界区的资源,否则会进入到条件队列中进行等待。
假设线程T1进入了管程中,发现不满足条件C,进入到了条件等待队列C中,之后线程T2进入到了管程中并且通知线程T1,告之已满足条件C,那么线程T1需要重新进入入口队列重新等待。
这里我罗列一段Java程序的简单代码案例来介绍下:
public class BlockedQueue<T>{
final Lock lock =
new ReentrantLock();
// 条件变量:队列不满
final Condition notFull =
lock.newCondition();
// 条件变量:队列不空
final Condition notEmpty =
lock.newCondition();
// 入队
void enq(T x) {
lock.lock();
try {
while (队列已满){
// 等待队列不满
notFull.await();
}
// 省略入队操作...
//入队后,通知可出队
notEmpty.signal();
}finally {
lock.unlock();
}
}
// 出队
void deq(){
lock.lock();
try {
while (队列已空){
// 等待队列不空
notEmpty.await();
}
// 省略出队操作...
//出队后,通知可入队
notFull.signal();
}finally {
lock.unlock();
}
}
}
代码中的Condition有多个,分别是notEmpty和notFull,你会发现他们的定义都和os底层的信号量非常相似。