【OS】Process Scheduling & Synchronization

三级调度模型.

  • 作业调度(也称为高级调度)
  • 进程调度(也称为低级调度)
  • 中级调度(实际上调度的对象也是进程)

关于进程与作业.

S i l b e r s c h a t z Silberschatz Silberschatz 《 O p e r a t i n g 《Operating Operating S y s t e m System System C o n c e p t s 》 Concepts》 Concepts N i n t h Ninth Ninth E d i t i o n Edition Edition中的叙述如下:

在讨论操作系统时,有个问题是如何称呼所有CPU活动。批处理系统执行作业Job,而分时系统使用用户程序User-Program或任务Task…所有这些活动在许多方面都相似,因此称为进程Process.
在本书中,作业进程这两个概念几乎可以互换使用…但许多OS的理论和技术是在那个,OS的主要活动被称为作业处理的阶段发展起来的,如果仅仅由于由于进程Process取代了作业Job就简单地避免使用有关作业Job的常用短语,例如作业调度,则会让人误解。

作业调度.

  • 按照某种调度算法,从后备队列中选择满足条件的作业,为其分配一定的资源,创建PCB,放入主存中的就绪队列。调度的间隔是三级调度中最长的,为几秒钟到几分钟,调度的频率较低,也被称为长程调度Long-Term

进程调度.

  • 按照某种调度算法,从就绪队列中选择满足一定条件的进程,分配CPU时间执行,大体上分为抢占式和非抢占式,调度间隔为几毫秒甚至更短,也被称为短程调度Short-Term

中级调度.

  • 将那些在主存中长期得不到运行的进程,也许是执行条件不满足,按照某种算法放入磁盘交换区,满足执行条件后再移入主存

调度准则.

  • 提高CPU利用率,40%(轻负荷)~90%(重负荷)
  • 提高吞吐量——单位时间内系统完成的进程数
  • 降低周转时间——作业从提交到完成的时间,当中包括但不限于后备队列延时、就绪队列延时、CPU执行时间、等待I/O时间
  • 降低等待时间——作业在就绪队列中等待的时间总和
  • 降低响应时间——用户通过键盘或鼠标提交一个请求开始,到显示出处理结果的时间间隔
  • 后面三个总的来说就是要让每个作业都尽可能快的完成

进程调度.

  • 进程的执行是由多个CPU执行区间和I/O执行区间交替组成的

会触发进程调度の情况.

  • 某个进程从运行状态Running切换到阻塞状态Waiting,例如等待I/O操作、等待子进程终止,这时就需要进程调度选择出另一个进程交付给CPU执行
  • 某个进程从运行状态Running切换到就绪状态Ready,例如发生中断,这时也需要进程调度选择出一个进程交付给CPU执行
  • 某个进程从阻塞状态Waiting切换到就绪状态Ready,例如I/O操作的完成,如果是抢占式调度算法,并且当前CPU处理的进程优先级低于此进程,那么进程调度就被触发,为这个就绪的高优先级进程分配CPU时间
  • 某个进程结束(执行完毕正常结束或是由于异常提前终止),也会触发进程调度选择一个进程分配CPU时间

抢占式与非抢占式调度.

  • 【非抢占式调度】CPU一旦分配给某个进程,该进程就会一直使用CPU直到进程终止或切换到阻塞状态
  • 【抢占式调度】会发生高优先级进程到来后,抢夺低优先级进程CPU时间的情况

调度算法.

同步与互斥.

进程同步Synchronism.

  • 进程同步是进程之间的直接制约关系。我们为了完成某个任务,创建了多个进程,这些进程之间需要协调它们的工作次序来传递信息。
  • 具体地说,有一个进程A运行到某一阶段后,需要它的伙伴进程B为它提供数据,在没有获得数据之前,A都会处于等待状态,当获得了数据之后才会被唤醒进入就绪状态。
  • 由此产生的制约关系称为进程同步,这样的制约关系来源于进程之间的合作。

进程互斥Mutual Exclusion.

  • 进程互斥需要引入临界资源概念。
  • 由于各进程要求共享资源,而临界资源一次仅允许一个进程使用,所以进程竞争使用这些资源,称为进程的互斥关系。

临界资源.

  • 指那些一次仅允许一个进程使用的资源,包括慢速设备和共享的变量、数据结构、缓冲区等
  • 在多道系统中,由于存在并发操作,为了保证程序的执行结果确定,必须对临界资源实行互斥访问
  • 【互斥访问】当进程A使用临界资源时,另一个想要使用该资源的进程B就必须进入等待状态,只有当进程A使用完临界资源并退出临界区以后,B才能解除等待状态

临界区.

  • 在进程中涉及临界资源的程序段称为临界区。

访问临界资源.

while(true)
{
    
    
	entry section 			//进入区,负责申请、判断
	critical section		//临界区
	exit section			//退出区
	remainder section		//剩余区
}

同步机制原则.

  • 【空闲让进】
  • 【忙则等待】
  • 【有限等待】
  • 【让权等待】如果进程的执行条件无法满足,可能是资源申请失败,进程无法进入临界区;那么此时该进程比较合理的选择就是阻塞自己,让出CPU时间,使CPU去执行那些能够推进的进程。

进程互斥の实现.

Peterson算法.

  • 该算法适用于两个进程交错执行临界区与剩余区,将这两个进程记为 P i P_i Pi P j P_j Pj,其中i=0或1,j=1-i.
  • Peterson算法要求这两个进程共享两个变量:int turnboolean flag[2],变量turn表示哪一个进程能够访问临界区,即turn==i表示进程
    P i P_i Pi能够访问临界区;数组flag表示哪个进程准备进入临界区,例如flag[i]==true,那么表示进程 P i P_i Pi准备进入临界区。
  • Peterson算法中 P i P_i Pi P j P_j Pj的结构几乎一致:
//P_i
while(true)
{
    
    
	flag[i]=true;
	//进程P_i准备访问临界区		
	turn=j;	
	//由于P_i尚未进入临界区,
	//所以设置turn的值,
	//使得如果P_j准备访问临界区
	//它能够进入			
						
	//测试条件,如果P_j不准备访问临界区	
	//那么会直接跳过while循环,P_i得以访问临界区
	//如果P_j准备访问临界区,
	//就根据turn的最终值来决定访问者
	while(flag[j]==true && turn == j)	
	{
    
    				
		//Waiting	
	}				
					
	Critical Section
	flag[i]=false
	Remainder Section
}

//P_j
while(true)
{
    
    
	flag[j]=true;
	turn=i;
	while(flag[i]==true && turn == i)
	{
    
    
		//Waiting
	}
	Critical Section
	flag[j]=false
	Remainder Section
}

分析.

  • 为了进入临界区,进程 P i P_i Pi首先设置flag[i]=true,并且设置turn=j表示如果进程 P j P_j Pj希望访问临界区,那么 P j P_j Pj能够进入。如果两个进程同时试图进入临界区,那么turn的值只有一个赋值语句会保持,其最终值决定了哪一个进程可以先进入临界区。
  • 不会存在两个进程同时都访问临界区的情况,因为如果两个进程都正在访问了临界区,则flag[0]=flag[1]=true,那么就意味着turn的值同时为0和1,否则不可能两个进程都跳出了while循环,然而这是不可能的。

硬件方法.

  • 许多计算机系统提供了特殊的硬件指令,用以原子地检查并修改某个字的内容以及交换两个字的内容。
//返回参数的布尔值,并将参数修改为true
TestAndSet(boolean* target)
{
    
    
	boolean rv= *target;
	*target = true;

	return rv;
}

//互斥实现:采用TestAndSet()
//若lock=true,说明临界区已被上锁,则无法跳出while循环;
//否则说明临界区无锁,跳过while循环的同时,对其上锁;
//因为本进程已经进入。
while(true)
{
    
    
	while(TestAndSet(&lock))
	{
    
    
		//do nothing
	}
	Critical Section
	lock=false;					//本进程已经访问完毕,释放锁
	Remainder Section
}

//交换参数的值,C语言经典指针示例
Swap(boolean* a,boolean* b)
{
    
    
	boolean temp=*a;
	*a=*b;
	*b=temp
}

//互斥实现:采用Swap()
//若lock=true,意味着临界区已上锁,while循环无法跳出
//否则Swap()之后使得key=false,跳出while循环,
//并且lock得到key的true值,完成上锁
while(true)
{
    
    
	key=true;
	while(key==true)
	{
    
    
		Swap(&lock,&key);
	}
	Critical Section
	lock=false;
	Remainder Section
}

互斥锁Mutex Lock.

  • 由于硬件解决方案过于复杂并且无法直接为程序员所使用,所以OS的设计者构建了能够实现解决临界区问题的软件工具,互斥锁就是其中最简单的。
  • 一个进程进入临界区时应该得到锁,退出临界区时应该释放锁。acquire()用于获得锁,release()用于释放锁。
acquire()
{
    
    
	while(!available)
	{
    
    
		//Waiting
	}
	available=false;
}

release()
{
    
    
	available=true;
}
  • 对于acquire()release()的调用必须是原子性的,因此互斥锁归根到底还是需要使用基于硬件的技术。
  • 互斥锁的主要缺点在于忙等待,观察代码发现如果锁不可用,那么进程就会原地踏步,不停地调用acquire(),这样的锁也被形象地称为自旋锁spinlock. 实际上前面的硬件方法也有这样的问题。
  • 自旋锁的一个好处在于没有上下文的切换,这可能是一个很大的开销。当进程普遍使用锁的时间较短时,自旋锁还是有其用途的。

信号量机制Semaphore.

  • 信号量S定义为一个整型变量,并且它除了初始化以外,只能进行两个原子操作:wait()signal().
  • 信号量机制于1965年由荷兰计算机科学家Dijkstra提出,所以最初的wait()signal()分别被称为PV,在荷兰语中代表proheren测试和verhogen增加。
wait(S)
{
    
    
	while(S<=0)
	{
    
    
		//Waiting
	}
	S--;
}

signal(S)
{
    
    
	S++;
}

信号量的分类.

  • 【二元信号量】这类信号量的值只能为0或1,因此二元信号量类似于互斥锁。
  • 【计数信号量】这类信号量的值不受限制,可以用于控制访问具有多个实例的某种资源,其初值为可用的资源数。当进程A想要使用资源时,需要对信号量进行wait操作以尝试减少信号量的计数;当进程A使用完资源后释放时,需要对信号量进行signal操作以增加信号量的计数。
  • 分析wait(S)的代码可以发现,当信号量的计数为0时,所有的资源实例都在使用中,此时再申请访问资源的进程C就会被阻塞,直到有进程释放了资源使得信号量计数大于零。

取消忙等待.

  • 前面的所有互斥实现方法,从硬件实现方法到自旋锁实现的互斥锁,再到上面给出的信号量wait()&signal()中,都存在着忙等待的问题,忙等待只会在那些进程使用临界资源时间很短的情况下具有较高的效率,所以我们还希望有一种方法,能够在进程无法访问资源时,选择阻塞自己而不是盲目等待。
  • 【信号量新定义】区别于前面提到的整型信号量
typedef struct
{
    
    
	int value;				//整型值
	struct process* list;	//进程链表
}semaphore;
  • 当一个进程A需要等待信号量S时,A就被加入到S的进程链表中,而操作signal(S)的效果就是从S的进程链表中取走被阻塞的进程A,并且加以唤醒。
  • 这样做的原因是进程A由于等待信号量S而被阻塞,当有另一个进程B执行操作signal(S)后,A就能够访问到原本被B占有的资源。
//若S->value>=0代表当前进程能够获得资源
//S->value<0代表资源耗尽
wait(semaphore* S)
{
    
    
	S->value--;				
	if(S->value<0)			
	{
    
    						
		insert(P,S->list);	//本进程应加入S的阻塞队列中
		block(P);			//并且阻塞自己
	}
}

//若此时S->value<=0,意味着资源在这之前已被耗尽
//也就意味着可能有进程阻塞在此,所以需要唤醒操作
//否则意味着即使本次signal()不执行
//下一个申请资源的进程也能够被满足
signal(semaphore* S)
{
    
    
	S->value++;
	if(S->value<=0)			
	{
    
    
		remove(P,S->list);
		wakeup(P);
	}
}
  • 【总结】wait(),即P操作,S≥0则继续,S<0则阻塞;signal(),即V操作,S>0则继续,S≤0则唤醒
    在这里插入图片描述

信号量の应用.

实现互斥.

  • 为每一个共享的临界资源设置一个二元信号量,初值为1.
semaphore mutex=1;

process_1()
{
    
    
	while(true)
	{
    
    
		wait(mutex);
		Critical Section
		signal(mutex);
		Remainder Section
	}
}

process_2()
{
    
    
	while(true)
	{
    
    
		wait(mutex);
		Critical Section
		signal(mutex);
		Remainder Section
	}
}

实现进程前驱关系.

在这里插入图片描述

  • 对于具有n条边的进程前驱图,定义n个信号量,初值均为0
semaphore a,b,c,d,e=0;

S1()
{
    
    
	DoSomething();
	signal(a);
	signal(b);
}

S2()
{
    
    
	wait(a)
	DoSomething();
	signal(c);
	signal(d);
}

S3()
{
    
    
	wait(b);
	wait(c);
	DoSomething();
	signal(e);
}

S4()
{
    
    
	wait(d);
	wait(e);
	DoSomething();
}

经典同步问题.

生产者-消费者问题(有界缓冲).

const int number=n;	
//这里的n代表一个常数,即缓冲池中缓冲区的个数,初始为空
//生产者会向缓冲池中的空缓冲区写入数据
//消费者会从缓冲池中的满缓冲区读取数据
//对称起来理解,虽然可能有些别扭:
//生产者制造满缓冲区,消费者制造空缓冲区

//信号量设置如下:
semaphore mutex=1;	//控制缓冲池的互斥访问
semaphore empty=n;	//记录空缓冲区个数
semaphore full=0;	//记录满缓冲区个数

Producer()
{
    
    
	while(true)
	{
    
    
		ProduceItem();
		//生产
		
		wait(empty);	
		//测试空缓冲区,若empty测试过程中<0,
		//则说明无空缓冲区,Producer进程必须阻塞在empty
		//否则说明还有空缓冲区,下一步申请进入缓冲池
		
		wait(mutex);
		//测试缓冲池的互斥访问信号量,申请进入缓冲池
		//若缓冲池中已有进程在操作,
		//则该Producer进程阻塞在mutex

		AddItem();
		//测试完成,Producer进程成功写入

		signal(mutex);
		//释放缓冲池的互斥信号量,使得其他进程可以进入

		signal(full);
		//增加满缓冲区的信号量,使得Consumer能够开始消费
	}
}

Consumer()
{
    
    
	while(true)
	{
    
    
		wait(full);
		wait(mutex);
		ConsumeItem();
		signal(mutex);
		signal(empty);
	}
}

更多信号量解决同步问题.

管程Monitor.

信号量的不足.

  • 临界区、进入区和退出区都由用户编写(多累啊);
  • 信号量操作原语分散在各程序代码中,由进程来执行,系统无法有效控制、管理;
  • P、V操作的不合理执行会导致死锁,例如哲学家问题中的情况。

何为管程.

  • Dijkstra于1965年提出了信号量Semaphore之后,于71年又提出了管程的概念,用于改善信号量机制在以上那些方面的不足。管程Monitor的思想是将所有进程对于某一临界资源的同步操作都集中起来,构成所谓的Monitor进程,凡是要访问临界资源的进程,都需要与Monitor进程进行沟通,由它来实现诸多进程的同步。
  • 【理解】信号量机制借助信号量的值和P、V操作的逻辑所构成的约束关系来实现互斥,而管程在我的理解中就是将这种约束关系统筹起来,使的约束关系更加直观、集中,让系统能够更好地控制管理诸多进程对于共享资源的访问。

管程的组成.

  • 局部于管程的共享变量说明(数据结构)
  • 局部于管程中数据的初始化语句
  • 对于管程中数据结构进行操作的一组过程
  • 【联想】管程的概念让我想起了抽象数据类型ADT,同样是数据+定义在数据上的一组操作,使用OOP的思想实现起来很自然。实际上,管程Monitor确实是一种ADT,它包括一组变量以及一组由程序员定义的、在管程内部互斥的操作组成。
monitor monitor-name
{
    
    
	//shared variable declarations
	procedure P1 () 
	{
    
    }
	procedure P2 ()
	{
    
    } 
	…
	procedure Pn () 
	{
    
    }
	initialization code () 
	{
    
    } 
}

管程中的条件变量condition.

  • condition x;每一个条件变量都有一个与之相关的队列,包含那些由于该条件而阻塞的队列(和为了消除busy-waiting而引入的结构体信号量很类似),对于条件变量仅有的操作是wait(),signal().
  • x.wait()表示通过本管程申请访问共享资源区的进程由于x条件不满足,需要被挂起,直到另一个进程调用x.signal()才解挂。
  • x.signal()恢复正好一个挂起的进程,如果这样的进程不存在,那么该调用无作用。注意到这里的signal()与信号量部分的V操作稍有区别,后者一定会影响信号量的状态。

一个细节.

  • 当操作x.signal()被进程P调用后,条件变量x上恰好有一个挂起的进程Q. 如果我们允许Q进程开始执行,那么P进程就必须等待,否则本管程内部会有两个进程同时执行,即使从概念上来说,P和Q都是可以继续执行的。这时存在两种可能性:
    ①【唤醒并等待】P唤醒Q之后Q先执行,P等待直到Q离开管程;
    ②【唤醒并继续】P继续执行,Q等待直到P离开管程。
  • 由于P此时已经在管程中执行,所以第二种选择似乎更为合理;但如果等到P执行完毕后,有可能使得Q满足的执行条件不再成立。

管程解决哲学家进餐问题.

在这里插入图片描述

//哲学家进餐问题的无死锁管程解答
//限制:当且仅当一个哲学家的左右两根筷子都能获得时
//他才会拿起筷子.

//每个哲学家用餐之前应调用操作PickUp(),
//这可能导致该哲学家进程被挂起;若PickUp()
//操作成功,那么该哲学家可以进餐;然后该哲学家
//调用操作PutDown().也就是说:
Philosopher.PickUp(i);
Eating();
Philosopher.PutDown(i);

monitor Philosopher
{
    
    
	enum{
    
    THINKING,EATING,HUNGRY} state[5];
	//表示哲学家状态的枚举数组

	condition self[5];
	//五位哲学家的条件变量数组
	
	//尝试进餐,拿起对应的i号筷子
	void PickUp(int i)
	{
    
    
		state[i]=HUNGRY;
		
		Test(i);
		//测试是否能够进餐

		//如果发现条件不满足i号哲学家进餐
		//i号哲学家需要等待(对应于i号进程挂起)
		if(state[i]!=EATING)
		{
    
    
			self[i].wait();
		}
	}
	
	//i号哲学家放下对应的i号筷子
	void PutDown(int i)
	{
    
    
		state[i]=THINKING;

		//测试左右相邻的哲学家是否由于
		//i号哲学家放下筷子而满足进餐条件
		Test((i+1)%5);	//Left.
		Test((i+4)%5);	//Right.
	}

	//测试i号哲学家的进餐条件是否满足
	void Test(int i)
	{
    
    
		//左右相邻哲学家都不在EATING状态,
		//并且自己是HUNGRY状态
		if(state[(i+1)%5]!=EATING &&
			state[(i+4)%5]!=EATING &&
			state[i]==HUNGRY)
		{
    
    
			state[i]=EATING;
			self[i].signal();
		}
	}

	Initialization()
	{
    
    
		for(int i=0;i<5;++i)
		{
    
    
			state[i]=THNIKING;
		}
	}
}
//这一解答面临的问题是,某个哲学家进程可能饿死.

更多管程解决同步问题.

猜你喜欢

转载自blog.csdn.net/weixin_44246009/article/details/108513478