现代操作系统(2.进程与线程)

前言
本文是结合《现代操作系统》(Andrew S. Tanenbaum著)的摘要与上课ppt的笔记,自用。


2.1 进程

进程:计算机上所有可运行的软件,通常也包括操作系统,被组织成若干顺序进程。所以一个进程就是一个正在运行的程序的实例,包括程序计数器,寄存器和变量的当前值。

多道程序设计:从概念上说,每个进程有一个自己的虚拟cpu,实际上真正的CPU
在这些进程之间来回切换造成一种伪并行的感觉,这种快速切换就叫做多道程序设计。

作业运行的标志——被分配了内存,所以进程被创建的标志就是拥有内存使用权。进程是分配内存资源的单位。

2.1.1 进程模型

进程的特点

  • 线程依赖于进程,线程会共享进程申请的资源。

  • 线程是动态的,任务结束后会被撤销,执行途中可能会被暂停,所以它具有状态值。

  • 进程包含两个板块:
    1)进程控制
    2)进程存储区:存储代码、数据、用户栈

  • 进程是串行的;

2.1.2 进程的创建

进程创建的方式(以分配内存作为标识):

  • 一个批处理作业的初始化
  • 控制台(接收到用户请求)
  • 批处理(用户提交作业,创建作业对应的进程)
  • 系统初始化

守护进程:停留在后台处理诸多电子邮件、web页面、新闻、打印之类活动的进程。

可写的内存是不可以共享的;不可写的内存可以共享。

2.1.3 进程的终止

进程终止的方式(将内存占用变为空闲状态):

  • 正常结束
  • 代码异常,数据发生错误,任务终止
  • 出现其他问题被强制终止(例如用户可以进入控制台手动结束当前某些进程)
  • 被其他进程抢占(比如病毒)

2.1.4 进程的层次结构

进程只有一个父进程,但父进程可以有0个或多个子进程。

2.1.5 进程的状态

进程的状态

  • 运行状态——所需资源都已备好
  • 就绪状态——除了CPU其他资源都准备好了,所以这个状态会参与cpu分配过程
  • 等待/阻塞状态——除了CPU还缺乏其他资源,不参与cpu分配过程

2.1.6 进程的实现

进程状态的管理
系统初始化时有一个进程控制信息表(PCB:进程的系统空间;P区:系统相关的内容;U区:用户存储相关的内容),进程相关的控制信息会进入这个表保持就绪状态,进程相关的存储内容被调入进程的用户空间,该进程编号即为当前的PCB表的下标编号,编号大小与创建时间无关,但可以唯一区别进程,因为这个表是线性更新的,前进程结束后该编号就直接给当前的新进程。

如果将进程看作链表形的就绪队列,那么每个节点的数据域只保存进程的控制信息,控制信息中有进程在PCB表中对应的下标编号,从而方便cpu从PCB表找到对应进程,访问其详细的控制信息(PCB表每一个会留出一定区域保存该进程的存储区数据,比如用户栈等信息,这样一体化存储可以减少分隔存储带来的查找不利);同时PCB表格(类似一个数组线性结构),其空间大小时是强制写死的,当用户请求的进程数超过PCB编号允许的最大数值,会报错拒绝用户的进程申请。·
进程被中断后,被中断的进程总是返回到中断发生前完全相同的状态。

2.1.7 多道程序设计模型

windows分时系统:优先级更新
每一级优先级相同的序列进行轮换执行,执行过后该进程优先级降低,而等待时间长的进程相应调高其优先级

Linux:加了一个优先位标记,优先级执行固定,但是限制使用次数。

运行状态的进程描述:
进程的控制信息中有个标志位用以描述状态

进程入口时运行状态、进程出口时运行状态,在就绪进程(主程就绪,候选就绪,在就绪周期中查找下一个优先级最高的进程,更新候选进程),所以就绪进程会有状态的不一致

底层 = 中断 + 寻找最高优先级

进程空间受保护,只有子进程可以共享父进程的空间

进程的控制信息

  • 基本控制信息(进程编号、父进程、谁创建的)
  • 用户空间信息
  • 文件管理

中断信息保存的位置:系统栈,用户栈(现场信息),pcb(cpu从中调取要恢复的进程信息)

进程之间的关系:

  • 同步:互不干扰
  • 互斥:任务本身之间对于资源的竞争而产生的关系

2.2 线程

线程——轻量级进程。
线程是相对独立的一段代码段。线程依赖于进程,线程虽然各自有各自的堆栈存储现场信息(现场信息量不大,所以不同线程之间的通信容易实现,线程使用效率高),同时他们可以共享支配的进程空间。

线程状态:

  • 运行
  • 就绪
  • 阻塞、等待

守候机制:初始化时根据系统的能力创建若干个线程。

2.2.1 线程的使用

用户级的线程:对于操作系统而言是不可支配,透明的。
系统级的线程(轻量级进程):与进程类似,由操作系统提供系统调用,所以对于操作系统而言是已知,可支配的。并由于中断的支持,可以调整就绪任务,但系统开销变大。

举例一:
某个用户在共800页的文件中修改第一页的第一个语句后,接着打算在第600页进行另一个修改。
多线程处理步骤,假设字处理软件被编写成含有三个线程的程序:

  • 一个线程与用户交互
  • 第二个线程在得到通知时进行文档的重新格式化处理
  • 第三个线程周期性将RAM中的内容写到磁盘上

由于线程可以可以共享公共内存,,所以三个线程可以通过切换而完成对同一个文件的操作。当第一页的语句被删除掉,交互线程就立即通知格式化线程对整本书进行排版处理。同时交互线程继续监控键盘和鼠标,并相应诸如滚动页面等简单命令。同时后台线程进行运算,希望能够在用户提出新的查询请求前完成格式修改,进程三每隔若干分钟自动在磁盘保存整个文件。

举例二
万维网服务器:
高速缓存:web服务器将获得的大量访问的页面集合保存在内存中,避免到磁盘去调用这些页面。
问题:对页面的请求发给服务器,而所请求的页面发回给客户机,这是一个两个线程的处理过程:

  • 分派程序的线程从网络中读入工作请求,在检查请求之后,选择一个空转的(被阻塞的)工作线程,提交该请求。接着分派程序唤醒工作线程,将他从阻塞状态转为就绪状态。
  • 工作线程被分派程序唤醒后,检查有关请求是否在web高速缓存中。①若没有则从磁盘调入read操作,并且阻塞直到该磁盘操作完成(当阻塞在磁盘上操作时,为了完成更多工作,分派程序可能挑选另一个线程运行,也可能使把另一个当前就虚的工作线程投入运行);②若该请求存在,就将该页面返回给客户机。然后工作线程返回阻塞状态,等待新的请求。

举例三
处理极大量数据的应用:
读入一快数据,对其处理,然后再写出数据。

  • 输入线程:把数据读入到输入缓冲区;
  • 处理线程:从输入缓冲区中取出数据,处理数据,并将数据放到输出缓冲区;
  • 输出线程构造:将结果写入磁盘。

2.2.2 经典的线程模型

进程模型基于:资源分组处理与执行。
进程中的内容有:

  • 地址空间
  • 全局变量
  • 打开文件
  • 子进程
  • 即将发生的定时器
  • 信号与信号处理程序
  • 账户信息

线程中的内容有:

  • 一个程序计数器,用来记录接着要执行的指令。
  • 寄存器:用来保存线程当前的工作变量;
  • 堆栈:用来记录执行历史,因为每个线程会调用不同的过程。
  • 状态

进程用于资源的集中,而线程则是在cpu上被调度的执行的实体。

传统进程:
每个进程有自己的地址空间和单个控制线程,cpu在系统中运行时通过线程间的切换从而制造进程并行的假象。

现有进程:
所有线程都有完全一样的地址空间,共享进程的全局变量,可以访问进程地址空间中的每一个内存地址。一个线程可以读、写、清除另一个线程的堆栈,线程之间是合作关系,故没有保护。

2.2.3 POSIX线程(简单看看)

定义的线程包叫做pthread,每个线程都含有一个标识符、一组寄存器(包括程序计数器)、一组存储在结构中的属性(堆栈大小、调度参数以及其他线程需要的项目)

2.2.4 在用户空间中实现线程

单线程的多线程化需要注意:

  • 全局变量的处理
  • 任务之间关系的处理

实现线程包的两种方式:①用户空间;②内核。

  • 用户空间:
    • 内核不知晓线程包,相当于单线程进程,好处是用户级线程包可以在不支持线程的操作系统上实现。
    • 用户空间管理线程时,需要有专门的线程表来跟踪该进程中的线程。
    • 用户级线程的优点:允许每个进程有自己定制的调度算法。
    • 在用户级线程中,运行时系统时系统始终运行自己进程中的线程,直到内核剥夺它的CPU(或者没有可运行的线程存在)为止。
    • 用户级线程容易发生页面故障(某个程序调用或者跳转到了一条不在内存上的指令),内核由于不知道有线程存在,就会把整个进程阻塞直到磁盘I/O完成为止,这样同时也限制了其他可以正常运转的线程。

2.2.5 在内核中实现线程

  • 内核中有用来记录系统中所有线程的线程表。
  • 内核的线程表保存了每个线程的寄存器、状态和其他信息。(此点同用户空间)
  • 能够阻塞线程的调用都以系统调用的形式实现:当一个线程阻塞时,内核根据其选择,可以运行同一个进程中另一个线程(若有一个就绪线程)或者运行另一个进程中的进程。
  • 内核中创建和撤销线程的开销很大,内核级线程速度慢。

2.2.6 混合实现

混合实现=用户级线程+内核级线程

2.2.7 调度程序激活机制

调度程序激活工作的目标是模拟内核线程的功能,但是为线程包提供通常在用户空间中才能实现的更好的性能和更大的灵活性。
机制思路:

  • 调度程序激活机制,内核给进程安排一定数量的虚拟处理器,并且让(用户空间)运行时系统将线程分配到处理器上。
  • 当内核了解到一个线程被阻塞后(由于执行了一个阻塞调用或者产生了一个页面故障),内核通知该进程的运行时系统,并且在堆栈中以参数形式传递有问题的线程编号和所发生事件的一个描述。
  • 内核通过一个已知的起始地址启动运行时系统,从而发出了通知。这个机制也被称为上行调用
  • 激活成功后,运行时系统重新调度线程。将当前线程标记为阻塞,从就绪表中取出另一个线程启动运行。
  • 当内核知道原来的线程又可以运行的时候,又再次通知运行时系统。
  • 运行时系统此时按照自己的判断,决定立即重启动被阻塞的线程或者把它放入就绪表稍后运行。

由此我们可以看到,调度程序激活机制是上行调用的一个信赖基础。
并且注意,一般层次系统内在结构有一基本原理:“n层提供n+1层可调用的特定服务,但n层不能调用n+1层中的过程”,而上行调用显然不遵守这一原理。

2.2.8 弹出式线程

弹出式线程:一个消息的到达导致系统创建一个处理该消息的线程。
弹出式线程的好处:

  • 由于线程是为了处理当前新的任务请求,故没有历史状态(不涉及相应的寄存器、堆栈等信息的调用),所以线程彼此之间完全一样,可以在消息到达之后快速被处理(当然使用弹出式线程之前需要计划约定进程中线程的执行顺序)。
  • 在使用弹出式线程时需要额外考虑一下,这个线程是应该运行在哪里比较好。将线程放在内核相对会比较容易,并且弹出式线程在内核空间中运行得更加快捷,因为它可以更容易地访问所有的表格和I/O设备;但是因为在内核,如果该线程出问题,危害性将比用户空间的线程大,因为没办法抢占当前线程只能等待分配,从而导致进来的信息丢失。

2.2.9 使单线程代码多线程化

面临的问题解决方式
1. 各个线程所依附的全局变量不统一为每个线程赋予私有的全局变量
2. 库调用并不像进程那样允许同步,库调用往往不可重入在库内部一个固定缓冲区进行消息组合,然后陷入内核将其发送
3. 内存分配(比如指针指向不定)为每个过程提供一个包装器,该包装器设置一个二进制位从而标志某个库处于使用中。在调用未完成之前,任何试图使用该库的其他线程会被阻塞。
4. 信号不统一(由于用户级线程中的内核并不知道线程的存在,所以相应的信号无法发送到对应的线程)书上没有明确给出适用的解决方式,得具体问题具体分析
5. 堆栈的管理(也是因为内核不了解具体进程情况的话,就不能掌控相应堆栈的自动增长)可能需要重新定义系统调用的语义

2.3 进程间的通信

通信方式:①高级通信;②低级通信
设置通信方式可以避免进程间冲突和阻塞。

2.3.1 竞争条件

定义:
在一些操作系统中,协作的进程可能会共享一些彼此都能读写的公用存储区,而此时可能会出现两个或多个进程读写某些共享数据,但最终结果却取决于进程运行的精确时序,不能将写入内容全部有效保存的情况。

2.3.2 临界区

互斥的概念:以某种手段确保当一个进程在使用一个共享变量或文件时,其他进程不能做同样的操作。
临界区的概念:对共享内存进行访问的程序片段称作临界区域或临界区。
为了有效避免竞争条件的出现,需要满足以下四个条件以达到公平的标准:

  1. 任何两个进程不能同时处于其临界区
  2. 不应对CPU的速度和数量做任何假设(意味着我们的算法具有通用性,不应该对使用环境具有约束)
  3. 临界区外运行的进程不得阻塞其他进程(也意味着任何进程在使用资源前不可预订)
  4. 不得使进程无限期待进入临界区(意味着一定能够在有限时间内获得资源的支配权)

2.3.3 忙等待的互斥

以下介绍几种互斥方案:

2.3.3.1 屏蔽中断

在每个进程刚刚进入临界区之后立即屏蔽所有中断(包括时钟中断),并在快离开前打开中断。
但这样并不好,将屏蔽权将给用户进程可能会导致整个系统因此而终止。所以屏蔽中断对于操作系统本身而言有用,但是对于用户进程则不是一种合适的通用互斥机制。

2.3.3.2 锁变量

设置共享锁变量(初始值为0),当进程想要进入其临界区就先测试这把锁,若锁值为0,则将该进程设置为1并进入临界区;若锁值为1,则该进程等待直至其值表内0;
但是在——“若一个进程读出锁变量的值当前为0,在它将锁变量设置为1之前另一个进度被调度运行,更早一步将锁变量设置为1,此时就会有两个进程进入临界区”情况中,锁变量不再适用。

2.3.3.3 严格轮换法

设置一个整型变量turn,turn标志当前哪个进程进入临界区,并检查或更新共享内存。
举例:


while(true){
    
    while(turn != 0);//循环等待
    critical_region();//临界区
    turn = 1;
    noncritical_region();
  }
while(true){
    while(turn != 1);//循环等待
    critical_region();//临界区
    turn = 0;
    noncritical_region();
  }

假设当前两个进程同时访问:进程0、进程1。
初始turn为0,所以进程0可以进入临界区,进程1发现turn为0,所以在一个等待循环中不停的测试turn,看其值何时变为1。
忙等待:连续测试一个变量直到某个值出现为止。但这种方式浪费CPU时间,所以忙等待一般用于测试时间非常短的情形。与锁变量类似,但用于忙等待的锁称为自旋锁。忙等待不适合用在一个进程比另一个慢了很多的情况下。

2.3.3.4 Peterson解法
int turn;					  //现在轮到谁?
int interested[2];			  //所有值初始化为0(false)

void enter_region(int process)//在进入临界区前调用
{
	int other = 1 - process;
	interested[process] = true;
	turn = process;
	while ( turn == process && interested[other] == true);
}

void leave_region(int process)//在进入临界区后调用
{
	interested[process] = false;
}

大致流程:

  • 一开始没有进程处于临界区
  • 进程0调用enter_region,它通过设置其数组元素和将turn置为0来标识它希望进入临界区,此时进程1可能会有两种状态(我们假设两个进程的时间差特别小):
    • ①进程1不想进入临界区,那正好对于进程1不用管;而对于进程0的while ( turn == process && interested[other] == true)为0,所以进程0的enter_region也返回,直接进入临界区
    • ②进程1也想进入,于是它也调用enter_region将自己的进程号存入turn,此时进程1会重写turn为1,那么此时对于两个进程而言,它们的enter_region差不多都运行到了while语句,此时turn=1,进程0的while语句中turn==0语句为假从而直接退出进入临界区,而进程1的while语句为真从而进入循环(也就是进入等待区),直到进程0退出后将interested[0]改为false使得进程1的while为假退出从而进入临界区。

这样一来就满足了先到先得原则
不过这一算法在进程是抢占式的并且有优先级的时候会发生死锁——例如有两个进程H,L(H优先级较高,所以调度规则规定只要H处于就绪态它就可以运行)。某一时刻,当L处于临界区而H想进入时,H变到就绪态,由于H优先级高,H会开始调度执行忙等待,但由于H就绪时L不会被调度,也就无法离开临界区,H会永远忙等待下去而进不了临界区。这一结果被称为优先级反转

2.3.3.5 TSL指令

TSL指令是一种需要硬件支持的方案。
其工作如下所述:
TSL RX,LOCK

  • 它将一个存储器字读到一个寄存器中,然后在该内存地址上存一个非零值。读数和写数操作保证是不可分割的,即该指令结束之前其他处理机均不允许访问该存储器字。执行TSL指令的CPU将锁住内存总线以禁止其他CPU在本指令结束之前访问内存。

为了使用TSL指令,我们必须用一个共享变量lock来协调对共享内存的访问。当lock为0时,任何进程都可以使用TSL指令将其置为1并读写共享内存。当操作结束时,进程用一条普通的MOVE指令将lock重新置为0。

这条指令如何被用来防止两个进程同时进入临界区呢?解决方案示于下述代码(汇编语言形式)。

enter_region: 
      tsl register,lock //复制lock到寄存器,并将lock置为1 
	  cmp register,#0 //lock等于0吗? 
	  jne enter_region //如果不等于0,已上锁,再次循环 
	  ret //返回调用程序,进入临界区 

用TSL指令上锁和清除锁

  • 第一条指令将lock原来的值拷贝到寄存器中并将lock置为1;
  • 随后这个原先的值与0相比较。
  • 如果它非零,则说明先前已被上锁,则程序将回到开头并再次测试。经过或长或短的一段时间后它将变成0(当前处于临界区中的进程退出临界区时),于是子例程返回,并上锁。
  • 清除这个锁很简单,程序只需将0存入lock即可,不需要特殊的指令,如下所示:
leave_region: 
		move lock , #0 //置lock为0 
		ret //返回调用程序 

2.3.4 睡眠与唤醒

上文提到过优先级反转,为了解决这种情况,于是引入通信原语,它们在无法进入临界区时将阻塞而不是忙等待。最简单的就是sleep和wakeup。
sleep是一个将引起调用进程阻塞的系统调用,即被挂起,直到另外一个进程将其唤醒。
wakeup调用有一个参数,即要被唤醒的进程。

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

两个进程共享一个公共的固定大小的缓冲区,其中一个是生产者:将信息放入缓冲区;另一个是消费者:从缓冲区中取出信息。当缓冲区已满,使生产者睡眠,当缓冲区已空,使消费者睡眠。
需要具备的标识符:

  • count:跟踪缓冲区中的数据项
  • 唤醒等待位

#define N 100							//缓冲区中的槽数目
int count = 0;							//缓冲区中的数据项数目
void producer(void)
{
	int item;
	while(TRUE)							//无限循环
	{
		item = produce_item();			//产生下一新数据项
		if(count == N)					//如果缓冲区满就休眠
		sleep();						//休眠挂起
		insert_item(item);				//将新的数据项放入缓冲区
		count = count + 1;				//缓冲区数据项计数加1
		if(count == 1)					//缓冲区为空
		wakeup(consumer);				//唤醒消费者
	}
}
 
void consumer(void)
{
	int item;
	while(TRUE)						//无限循环
	{								
		if(count == 0)				//如果缓冲区空,则进入休眠状态
			sleep();
		item = remove_item();
		count = count - 1;			//缓冲区数据项计数减1
		if(count == N - 1)			//缓冲区满
			wakeup(producer);
		consume_item(item);			//打印数据项
	}

这里解释一下为什么要有唤醒等待位:

假设某一时刻,缓冲区为count=0。此时调度程序会暂停消费者并启动生产者。生产者回向缓冲区加入一个数据项,count=1。

  • ①如果没有唤醒等待位

    调度程序会推断认为由于count原先是0,所以此时消费者一定是睡眠状态,于是它会让生产者条用wakeup唤醒消费者。

    但这里会出现时间差:消费者进程在wakeup信号发出之前会检测到count等于0,并在还没有运行sleep()时由于消费者进程时间片到期,消费者进程从runnable运行状态变成runnable可运行状态。
    (注意:一个任务在自己的时间片内就是RUNNING运行状态,当时间片过期,线程就又变成RUNNABLE可运行状态,并且操作系统将另一个线程分配给处理器)

    这个时候生产者调度运行,count变为1时,生产者试图唤醒消费者,此时wakeup对于消费者来说唤醒是无效的——因为消费者根本没有运行到sleep(),从而导致wakeup信号丢失,所以说在逻辑上并未睡眠。

    当时间片被转到消费者时,这时候消费者进程被调度,执行完sleep()后,消费者进程才真正进入逻辑上的睡眠。尽管count已经非零了,但是再也不会有唤醒信号了,它将永远沉睡。归根到底是因为这里对count的访问不是原子性的。
    (原子性:一组相关联的操作要么不间断地执行、要么都不执行)

  • ②有唤醒等待位:

    当一个wakeup信号发送给一个清醒的进程信号时,该位置置1,随后,当进程在逻辑上应该要睡眠时,因为该唤醒等待位为1,则将该位置清除,而该进程仍然保持清醒,唤醒等待位实际是wakeup的一个小仓库

2.3.5 信号量

针对于上述原有sleep-wakeup的非原子性存在的问题,E…Dijkstra提出了信号量方法:信号量就是使用一个整型变量来累计唤醒次数供以后使用。当信号量大于等于0时表示空闲资源余量,信号量小于等于0表示等待队列的长度(这也就是为什么下列up操作加一之后信号量仍然为0的原因)。
相对应的sleep变为了down操作,wakeup变为了up操作

down操作:检查其保存的唤醒信号值是否大于0,大于0则减1,为0则睡眠;同时检查数值、修改变量值以及可能发生的睡眠操作均是单一、不可分割的原子性操作(用户不允许访问,由操作系统的系统调用掌控)——不允许其他进程访问该信号量。
up操作:对信号量值加1。注意,对一个在信号量上睡眠的进程,执行一次up操作后,该进程信号量仍旧是0,只是up操作上睡眠的进程少了一个。

临界区初始值为1。

用信号量解决生产者-消费者问题
定义信号量的注意事项:
①本身是描述资源的,且是针对独占资源的;
②信号量必须要有初值,用以标注空闲资源

使用三个信号量:

  • full:初值为0,记录充满的缓冲槽数目
  • empty:初值为缓冲区中槽的总数目,记录空的缓冲槽数目
  • mutex:初值为1,确保生产者和消费者不会同时访问缓冲区

详细代码中值得关注的两句是顺序(一定要注意先判断资源是否空闲):

void producer(void){
...
while(条件){
	down(&empty)
	down(&mutex)
	...
	up(&full)}
}

void consumer(void){
...
while(条件){
	down(&full)
	down(&mutex)
	...
	up(&empty)}
}

mutex用于互斥,它保证任一时刻只有一个进程读写缓冲区和相关的变量。而信号量full和empty用于实现同步,以保证某种事件的顺序要么发生或不发生,在本例中,他们保证当缓冲区满的时候生产者停止运行,缓冲区空的时候停止运行,这跟互斥不同。

2.3.6 互斥量

互斥量是信号量的一个简化,去除了计数能力,只控制互斥的过程。互斥量是一个可以处于两态之一的变量:解锁和加锁(0——解锁;其他所有值——加锁;初始值为1)
互斥量使用分为两个过程:

  • 当一个线程(或进程)需要访问临界区时,调用mutex_lock获得互斥量mutex
  • 如果该互斥量当前是解锁的(临界区可用),调用成功,调用线程可以自由进入该临界区;
  • 如果互斥量已经加锁,调用线程被阻塞,直到在临界区中的线程完成并调用mutex_unlock。如果多个线程被阻塞在该互斥量上,将随机选择一个线程并允许他获得锁。
mutex_lock: 
      tsl register,mutex //将互斥信号量复制到寄存器,并且将互斥信号量置为1 
	  cmp register,#0 //互斥信号量等于0吗? 
	  jze ok//如果互斥信号量为0,它被解锁,所以返回
	  call thread_yield//互斥信号忙;调度另一个线程
	  jmp mutex_lock //稍后再试
   ok:ret //返回调用程序,进入临界区 

mutex_unlock: 
		move mutex , #0 //置mutex为0 
		ret //返回调用程序 

观察上诉代码我们可以看到他和TSL指令中的enter_region很相似,但区别在于enter_region进入临界区失败之后会重复测试锁(忙等待)。而mutex_lock取锁失败后,会调用thread_yield将CPU放弃给另一个线程,从而没有忙等待。

注意:
共享一个公共地址空间的两个进程仍旧有各自的打开文件、定时器以及其他一些单个进程的特性,而在单个进程中的线程,则共享进程全部的特性。另外,共享一个公共地址空间的多个进程绝不会拥有用户级线程的效率,这一点是不容置疑的,这是因为内核还同其管理密切相关。

2.3.6.1 快速用户区互斥量futex

futex是Linux的一个特性,它实现了基本的锁(类似互斥锁)。其包含两个部分:

  • 一个内核服务:提供一个等待队列,它允许多个进程在一个锁上等待,他们将不会运行,除非内核明确对他们解除阻塞。至少这样可以在没有锁竞争时不需内核参与其中,进而减少了开销。
  • 一个用户库
2.3.6.2 pthread中的互斥量与条件变量

pthread的基本机制是使用一个可以被锁定和解锁的互斥量来保护临界区(互斥量可以有属性,但是这些属性只在某些特殊的场合下使用;并且这些互斥锁不是强制性的,而是由程序员来保证线程正确地使用它们)。
一些与互斥量相关的pthread调用

pthread_mutex_init;//创建一个互斥量
pthread_mutex_destroy;//撤销一个已经存在的互斥量
pthread_mutex_lock;//获得一个锁或阻塞
pthread_mutex_trylock;//获得一个锁或失败
pthread_mutex_unlock;//释放一个锁

pthread还提供了一种同步机制:条件变量。(互斥量在允许或阻塞对临界区的访问上十分有用,条件变量则允许线程由于一些未达到的条件而阻塞)

  • 条件变量(不像信号量)不会存在内存中,如果将一个信号量传递给一个没有线程在等待的条件变量,那么这个信号会丢失。
  • 条件变量与互斥量经常一起使用。

一些与条件变量相关的pthread调用

pthread_cond_init;//创建一个条件量
pthread_cond_destroy;//撤销一个条件变量
pthread_cond_wait;//阻塞以等待一个信号
pthread_cond_signal;//向另一个线程发信号来唤醒他
pthread_cond_broadcast;//向多个线程发信号来让它们全部唤醒

举个例子体会线程、互斥量、条件变量之间的关联:
在生产者-消费者模型中,如果生产者发现缓冲区中没有空槽可以使用了,它不得不阻塞起来直到有一个空槽可以使用。生产者使用互斥量可以进行原子性检查而不受到其他线程的干扰。但是当发现缓冲区已经满了以后,生产者需要一种方法来阻塞自己并在以后被唤醒(这一过程由条件变量完成)。

2.3.7 管程(Moniters)

2.3.7.1 管程的相关概要简介
  • 管程是一个由过程、变量及数据结构等组成的一个集合,他们组成一个特殊的模块或软件包。
monitor example
   integer i;
   condition c;
   
   procedure proceducer();
   .
   .
   .
   end;

   procedure consumer();
   .
   .
   .
   end;
end monitor;
  • 管程包含:
    (1)多个彼此可以交互并共用资源的线程
    (2)多个与资源使用有关的变量
    (3) 一个互斥锁
    (4) 一个用来避免竞态条件的不变量

  • 管程的理解总述:
    (1)在任一时刻管程中只能有一个活跃进程,这一特性使管程能有效地完成互斥,注意:对管程的实现互斥由编译器负责。编译器处理管程调用的方法之一为:当一个进程调用管程过程时,该过程中的前几条指令将检查在管程中是否有其他的活跃进程。如果有,调用进程将被挂起,直到另一个进程离开后管程将其唤醒。如果没有活跃进程在使用管程,则该调用才能进入。
    (2)管程的共享变量在管程外部不可见,外部只能通过调用管程中的外部子程序访问共享变量。
    (3)管程中有等待/唤醒机制,等待时释放管程的互斥权。 在管程入口处的等待队列称为入口等待队列,由于进程会执行唤醒操作,因此可能有多个等待使用管程的队列,这样的队列称为紧急队列,它的优先级高于等待队列。
    (4)管程是一种高级的同步原语。进程可在任何需要的时候调用管程中的过程,但他们不能在管程之外声明的过程中直接访问管程内的数据结构。一个管程的程序在运行一个线程前会先取得互斥锁,直到完成线程或是线程等待某个条件被满足才会放弃互斥锁。若每个执行中的线程在放弃互斥锁之前都能保证不变量成立,则所有线程皆不会导致竞态条件成立。
    (5)管程是一个编程语言概念,编译器必须要识别出管程并用某种方式对互斥做出安排。C、Pascal及多数其他语言都没有管程,所以指望这些编译器来实现互斥规则是不可靠的。不过在Java中,只要将关键字synchronized加入到方法声明中,Java保证一旦某个线程执行该方法,就不允许其他线程执行该方法,就不允许其他线程执行该类中的任何其他方法,在一定程度上保证了交错执行。

2.3.7.2 管程的特征
  1. 模块化。
    管程是一个基本的软件模块,可以被单独编译。
  2. 抽象数据类型。
    管程中封装了数据及对于数据的操作,这点有点像面向对象编程语言中的类。
  3. 信息隐藏。
    管程外的进程或其他软件模块只能通过管程对外的接口来访问管程提供的操作,管程内部的实现细节对外界是透明的。
  4. 使用的互斥性。
    任何一个时刻,管程只能由一个进程使用。进入管程时的互斥由编译器负责完成。
2.3.7.3 enter过程、leave过程、条件型变量c、wait(c) 、signal(c)
  1. enter过程
    

一个进程进入管程前要提出申请,一般由管程提供一个外部过程:enter过程。如Monitor.enter()表示进程调用管程Monitor外部过程enter进入管程。

  1. leave过程
    

当一个进程离开管程时,如果紧急队列不空,那么它就必须负责唤醒紧急队列中的一个进程,此时也由管程提供一个外部过程—leave过程,如Monitor.leave()表示进程调用管程Monitor外部过程leave离开管程。

  1. 条件型变量c
    

条件型变量c实际上是一个指针,它指向一个等待该条件的PCB队列。如notfull表示缓冲区不满,如果缓冲区已满,那么将要在缓冲区写入数据的进程就要等待notfull,即wait(notfull)。相应的,如果一个进程在缓冲区读数据,当它读完一个数据后,要执行signal(notempty),表示已经释放了一个缓冲区单元。

下面补充一些背景知识

条件等待队列
某一时刻,进入管程的某个进程在对资源的操作过程中发现条件不成熟, 那么它就不能够继续对资源进行相应的操作。
以生产者、 消费者模型为例的话:好比生产者正想要完成把数据放到缓冲区里这个动作,但此时发现缓冲区满了,那显然把数据放到缓冲区这个动作就不能完成了,所以生产者(也就是上文提到的这个进程)就应该等,等条件成熟(比如生产者会在full这个条件变量上执行wait操作),该操作会导致调用进程自身阻塞进入条件等待队列,同时管程会把等在管程之外的另一个进程调入管程。

紧急等待队列
正如上文提到的,某一个进程的执行环境不合适的时候,就会调用 wait 操作,等在某个条件变量上, 而当一个进程等在条件变量上的时候,它应该把管程的互斥权放开,也就是把门打开,让管程外想进入管程的进程进入。
在后面进来的伙伴进程运转过程中,伙伴进程发现条件成熟了,所以它就调用 signal 函数,去唤醒等在这个条件变量上的一个进程。如果唤醒的是刚才等待的这个进程, 那么在管程里头,同时就有两个进程存在了。按照HOARE 管程的语义,管程只允许一个活进程,所以后面一个进程唤醒了前面一个进程之后,它会等待前面的进程执行,而后面做好事的这个进程就等在管程中的紧急等待队列里。

入口等待队列
因为管程是互斥进入的, 如果一个进程已经调用了管程当中的某一个过程去做相应的操作, 那么后续的进程就不能再进入管程了。我们可以类比管程有个门,如果一个进程已经进到管程里头来并开始做相应的操作时,那么其他还想进管程的,就在门口排队,这样的队列我们称之为入口等待队列。

上诉的三个队列,JAVA中都有,Hansen只有入口等待队列和内部等待队列。
在这里插入图片描述

  1. wait(c)
    

wait(c)表示为进入管程的进程分配某种类型的资源,如果此时这种资源可用,那么进程使用,否则进程被阻塞,进入紧急队列。

  1. signal(c)
    

signal(c)表示进入管程的进程使用的某种资源要释放,此时进程会唤醒由于等待这种资源而进入紧急队列中的第一个进程。

2.3.8 消息传递

这种进程间通信的方法使用两条原语send和receive,它们像信号量而不像管程,是系统调用而不是语言成分。

消费者申请空闲节点,它的并发处理能力逊于生产者消费者模型

2.3.9 屏障

是一种同步机制
在某一个节点需要互相等待对方的进程
eg:
f(x)=fA(x)+fB(x)+fC(x)+fD(x)
必须ABCD四个进程都结束了,才能确定参数x的值,不能中途更改访问x,否则就会出错。
应对实际情况中计算速度不一致的问题。

2.3.10 避免锁:读——复制——更新

参考来源
加锁会导致:死锁现象的出现,锁资源消耗。
最快的锁是根本没有锁。但没有锁的情况,我们不允许对共享数据结构进行并发读写和访问。
试想假设进程A正在对一个数字数组进行排序,而进程B正在计算其均值。因为A在数组中将数值前后来回移动,所以B可能多次遇到某些数值,而某些数值则根本没有遇到过,得到的结果可能是任意值,而它几乎肯定是错的。

然而,在某些情况下,我们可能需要多个写操作可以并发执行。所以,Linux内核引入了读-拷贝-更新技术(英文是Read-copy update,简称RCU),它是另外一种同步技术,主要用来保护被多个CPU读取的数据结构。RCU允许多个读操作和多个写操作并发执行(窍门在于确保每个读操作要么读取旧的数据版本,要么读取新的数据版本,但绝不能是新旧数据的一些奇怪组合)。更重要的是,RCU是一种免锁算法,也就是说,它没有使用共享的锁或计数器保护数据结构(这儿还是主要指的读操作是无锁算法。而对于多个写操作来说,仍然需要使用lock保护避免多个CPU的并发访问。所以,其使用场合也是比较严格的,多个写操作中的锁开销不能大于读操作采用无锁算法省下的开销)。

举例说明,读操作从根部到叶子遍历整个树。在图的上半部分,加入一个新的节点X。为了实现这一操作,我们要让这个节点在树中可见之前使它“恰好正确”:我们对节点X中的所有值进行初始化,包括它的子节点指针。然后通过原子写操作,使X成为A的子节点。所有的读操作都不会读到前后不一致的版本。在图的下半部分,我们接着移除B和D。首先,将A的左子节点指针指向C。所有原本在A中的读操作将会后续读到节点C,而永远不会读B和D。也就是说,它们将只会读到新版数据。同样,所有当前在B和D中的读操作将继续依照原始的数据结构指针并且读取旧版数据。所有操作均正确进行,我们不需要锁住任何东西。而不需要锁住数据结构就能移去B和D的主要原因就是读-复制-更新(Read-Copy-Update,RCU),将更新过程中的移除和再分配过程分离开来。

在这里插入图片描述
但只要还不能确定没有对B和D更多的读操作,我们就不能真正释放它们。RCU谨慎地决定读操作持有一个数据结构引用的时间,在这段时间之后,就能安全地将内存回收。

  • 读者通过读端临界区访问数据结构,它可以包含任何代码,只要该代码不阻塞或者休眠。这样的话,就知道了需要等待的时长。(因此我们也可以知道一个简单的判断上下文是否切换的方式就是查看所有的线程是否执行完毕)
  • 定义一个任意时间段的宽限期(grace period),在这个时期内,每个线程至少有一次在读端临界区之外。因此我们可以设置等待至少一个宽限期的时间段后进行回收空间,这样就比较合理。

2.4 调度

调度程序:当有多个进程或线程同时竞争CPU时,操作系统进行处理,选择一个进入运行。

2.4.1 调度简介

CPU是稀缺资源,好的调度程序可以提高性能和用户满意程度。

2.4.1.1 进程行为

某些I/O活动可以看作计算,当一个进程等待外部设备完成工作而被阻塞时,才是I/O活动。
计算密集型:某些进程花费了绝大多数时间在计算上。
I/O密集型:某些进程在等待I/O上花费了绝大多数时间。

2.4.1.2 何时调度

调度决策发生的情况主要为下面几种:

  • 再创建一个新进程之后,需要决定是运行父进程还是子进程。
  • 在一个进程退出时,没有就绪进程,通常会运行一个系统提供的空闲进程。
  • 当一个进程阻塞在I/O和信号量上或由于其他原因阻塞时,需要选择另一个进程运行。
  • I/O中断发生时,必须做出调度决策

非抢占式调度算法:挑选一个进程,然后让该进程运行直至被阻塞(阻塞在I/O上或等待另一个进程),直到该进程自动释放CPU。
抢占式调度算法:挑选一个进程,并且让该进程运行某个固定时段的最大值。如果在该时段结束时,该进程仍在运行,它就被挂起,而调度程序挑选另一个进程运行(如果存在一个就绪进程的话)。

2.4.1.3 调度算法分类

在不同的系统中,调度程序的优化是不同的,可以大致分为下面三种环境:
(1)批处理——多用于商业领域,处理薪水册、存货清单等周期性作业。批处理的用户端不会等待一个短请求的快捷回应。
(2)交互式
(3)实时

2.4.1.4 调度算法的目标

①保证公平性:相似的进程应该得到相似的服务
②保持系统的所有部分尽可能忙碌
③周转时间(是指一个批处理作业提交时刻开始直到该作业完成时刻为止的统计平均时间)尽可能小;注意能使吞吐量最大化的调度算法不一定就有最小的周转时间
④需要一定的系统强制执行策略
⑤保证均衡性

在这里插入图片描述

2.4.2 批处理系统中的调度

2.4.2.1 先来先服务

非抢占式调度算法。
原则就是进程按照他们请求CPU的顺序使用CPU。用一个单一队列或单链表就可以记录所有的就绪进程,每次选取队列头部进程即可。
优点:易于理解,便于在程序中运用
缺点:不适用于大量I/O密集型任务,会对CPU造成时间浪费。

2.4.2.2 最短作业优先

非抢占调度算法。
原则是最短作业优先级最高(只有在所有作业都可以同时运行的情形下,最短作业优先算法才是最优化的)。

2.4.2.3 最短剩余时间优先

抢占式算法。
原则是调度程序总是选择剩余运行时间最短的哪个进程运行,如果新的进程比当前运行进程需要更少的时间,当前进程就被挂起,而运行新的进程。

2.4.3 交互式系统中的调度

时间片:每个进程被分配一个时间段,这个时间段被称为时间片,即允许该进程在该时间段中运行。

2.4.3.1 轮转调度

如果在时间片结束时该进程还在运行,则将剥夺CPU并分配给另一个进程。如果该进程在时间片结束前阻塞或结束,则CPU立即进行切换。
注意:时间片设的太短会导致过多的进程切换,降低了CPU效率,而设得太长有可能引起对短的交互请求的响应时间变长。所以时间片设为20~50ms通常是一个比较合理的折中。

2.4.3.2 优先级调度

为了防止高优先级进程无休止的运行下去,调度程序可能在每个时钟滴答(即每个时钟中断)降低当前进程得优先级。如果这一行为导致该进程的优先级低于次高优先级的进程,则进行进程切换。另一个方法是给每个进程赋予一个允许运行的最大时间片,当用完这个时间片时,次高优先级的进程便获得运行机会。

2.4.3.3 多级队列

原则:属于最高优先级类的进程运行一个时间片,属于次高优先级的进程运行2个时间片,再次一级运行4个时间片。以此类推,当一个进程用完分配的时间片后,它就被分到下一类.

2.4.3.4 最短进程优先

根据进程过去的行为进行推测,并执行估计运行时间最短的那一个。
评估规则为:假设某个终端上每条命令的估计运行时间为T0,现在假设测量到其下一次运行时间为T1,我们对两个值进行加权评估 aT0+(1-a)T1;通过设置a的值,可以决定时尽快忘掉老的运行时间还是在一段长时间内史中始终记住它们(通过当前测量值进行加权平均而得到下一个估计值的技术被称为老化)。

2.4.3.5 保证调度

这一算法是基于公平原则的调度算法。即每个进程看作等价,平分CPU的时间,保证处理机分配的公平性,若有n个进程同时运行,公平的情况下每一个进程应该获得处理机时间的1/n。
算法实现:

  • 为了实现所做的保证,系统必须跟踪各个进程自创建以来已使用了多少CPU时间。
  • 计算每个进程应获得的CPU时间(也就是1/n)
  • 计算进程已获得CPU时间与预测获得时间的比值比较个进程该时间的比率。比如进程A的比率为0.5(当前执行之间只为预期的一半),进程B的比率为2(当前执行时间为预期的2倍)
  • 调度程序应该选择比率最小的进程优先执行,将CPU分配给它,直到超过最接近它的进程比率为止。
2.4.3.6 彩票调度

为进程提供各种系统资源(如CPU时间)的彩票,一旦需要作出一项调度决策时,就随机抽出一张彩票,拥有该彩票的进程获得该资源。在应用到CPU调度时,系统可以掌握每秒钟50次的一种彩票,作为奖励每个获奖者可以获得20ms的CPU时间。(拥有彩票f份额的进程大约得到系统资源的f份额)

2.4.3.7 公平分享调度

这一调度算法主要针对用户,而不是进程。目标是使用户能获得相同的CPU时间。大致如下:

  • 时间片轮转算法让每个进程轮流运行一个时间片,对进程很公平,但如果每个用户拥有的进程数不同,则可能对用户不公平。

  • 调度是以进程为基本单位的,所以必须考虑每一个用户所拥有的进程数目。

  • 例如:用户1拥有4个进程A、B、C、D,用户2只有一个进程X,为了保证两个用户能获得相同的CPU时间,需要强制执行如下的调度顺序。
    A X B X C X D X A X ……

2.4.4 实时系统中的调度

实时调度算法的分类:
①按实时任务性质(即对时间约束的强弱程度)

  • 硬实时调度:必须满足任务截止期要求,错过后果严重。
  • 软实时调度算法:期望满足任务截止期要求,错过可容忍,

②按调度方式

  • 非抢占式调度算法
    • 非抢占式轮转调度算法:用于工业生产的群控系统中。
    • 非抢占式优先调度算法:用于有一定时间要求的实时控制系统之中。
  • 抢占式调度算法(按抢占发生的时间)
    • 基于时钟中断抢占的优先权调度算法
    • 立即抢占的优先权调度算法

2.4.5 策略和机制

我们可以尽量将调度算法以某种形式参数化,而参数由用户进程填写。

2.4.6 线程调度

计算机通常只有一个CPU,在任意时刻只能执行一条机器指令,每个线程只有获得CPU的使用权才能执行指令。所谓多线程的并发运行,其实是指从宏观上看,各个线程轮流获得CPU的使用权,分别执行各自的任务。在运行池中,会有多个处于就绪状态的线程在等待CPU,JAVA虚拟机的一项任务就是负责线程的调度,线程调度是指按照特定机制为多个线程分配CPU的使用权。

2.5 经典的IPC问题

2.5.1 哲学家就餐问题(互斥线程)

在这里插入图片描述

1.问题描述:
五个哲学家共用一张圆桌,在圆桌上有五个碗和五只筷子。平时,哲学家进行思考,饥饿时便试图取用其左右最靠近他的筷子,只有在他拿到左右两只筷子时才能进餐。进餐完毕,放下筷子继续思考。

2.解决思路:
​ 因为是五位哲学家,并且每位哲学家各自做自己的事情(思考和吃饭),因此可以创建五个线程表示五位哲学家,五个线程相互独立(异步),分别编号为0到4。同时,有五根筷子,每根筷子只对其相邻的两位哲学家是共享的,因此这五根筷子可以看做是五种不同的临界资源(不能理解为一种资源有5个,因为每根筷子只能被固定编号与其相邻的哲学家使用),并对五根筷子分别编号为0~4。其中第i号哲学家左边的筷子编号为i,则其右边的筷子编号就应该为(i + 1) % 5。筷子是临界资源,因此当一个线程在使用某根筷子的时候,应该给这根筷子加锁,使其不能被其他进程使用。一个哲学家的大致动作流程即为如下:

void philosopher (void* arg) {
    while (1) {
        think();//每位哲学家先思考
        hungry();//当某位哲学家饥饿的时候
        pthread_mutex_lock(&chopsticks[left]);//拿起他左边的筷子
        pthread_mutex_lock(&chopsticks[right]);//拿起他右边的筷子
        eat();//然后进餐
        pthread_mutex_unlock(&chopsticks[left]);//进餐完毕,放下他左右的筷子并进行思考
        pthread_mutex_unlock(&chopsticks[right]);
    }
}

在这其中筷子就类比为我们计算机中的临界资源,类似之前进程对临界区访问权限的处理——当一位哲学家拿起他左右的筷子的时候,就会对他左右的筷子进行加锁,使其他的哲学家不能使用;当该哲学家进餐完毕后,放下了筷子,意味着临界区解锁,此时才允许其他的哲学家可以使用。
但这样会发生死锁,因为假设在某一时间,五个哲学家都同时饿了,于是他们都拿起来左边的筷子,当他们想去拿右边的筷子时,会发现已经被上锁,所以等待、申请、等待、申请循环,没有人放手也没有人能够拿到两只筷子。

3.利用信号量的解决方案
①​ 设置信号量机制r
同时只允许四位哲学家拿起同一边的筷子,设置一个信号量r(初始值为4),每当一位哲学家希望拿起左边筷子时,先down操作对r减1,当前四位哲学家都成功拿起左筷子时r值变为0,这时若第五位哲学家也去拿左筷子,r就会变为-1,然后函数中的判断条件r<0为真就会使得第五位哲学家的线程被阻塞,从而第五位哲学家无法成功拿起左筷子,避免了死锁情况的发生。当然在最后,每一位哲学家吃饱饭放下左筷子时,执行up操作对r加1。代码展示:

void philosopher (void* arg) {
    while (1) {
        think();
        hungry();
        P(&r);//大黑书上讲到P可以理解为down操作,P是Dij提出者在其论文中的表示
        pthread_mutex_lock(&chopsticks[left]);
        pthread_mutex_lock(&chopsticks[right]);
        eat();
        pthread_mutex_unlock(&chopsticks[left]);
        V(&r);//V可以理解为up操作
        pthread_mutex_unlock(&chopsticks[right]);
    }
}

②奇偶数判别法
规定奇数号哲学家先拿起他左边的筷子,然后再去拿他右边的筷子,而偶数号的哲学家则相反,这样的话总能保证一个哲学家能获得两根筷子完成进餐,从而释放其所占用的资源,代码如下:

void philosopher (void* arg) {
    int i = *(int *)arg;
    int left = i;
    int right = (i + 1) % N;
    while (1) {
        think();
        hungry();
        if (i % 2 == 0) {//偶数哲学家,先右后左
            pthread_mutex_lock(&chopsticks[right]);
            pthread_mutex_lock(&chopsticks[left]);
            eat();
            pthread_mutex_unlock(&chopsticks[left]);
            pthread_mutex_unlock(&chopsticks[right]);
        } else {//奇数哲学家,先左后又
            pthread_mutex_lock(&chopsticks[left]);
            pthread_mutex_lock(&chopsticks[right]);
            eat();
            pthread_mutex_unlock(&chopsticks[right]);
            pthread_mutex_unlock(&chopsticks[left]);
        }
    }
}

利用AND信号量实现
AND信号量是一种思想,即在对进程请求的多个资源进行分配时,首先检查这些资源是否都是空闲的,如果的确都是空闲的,则将资源全部分配给该进程,否则一个资源也不分配。
在这道题中先统一一下相关变量的含义:

  • i:哲学家编号
  • a[i]:每个哲学家的一个状态标识数组 (思考状态也就是初始值为0,饥饿状态值为1,就餐状态值为2)
  • b[i]:哲学家的信号量,0表示当前哲学家挂起,1表示允许进餐
  • r:临界区的互斥量信号,始终限制只有一个进程允许访问,r非0即1在一定程度上保证了test()的原子性
  • test():检查资源操作,也就是看左右两边的哲学家是否在就餐或者有就餐请求。

哲学家请求筷子时,首先检查他相邻的两个哲学家是否在就餐,只有相邻两个哲学家都没有就餐的时候才为他分配左右两根筷子(也就是说我们应该有一个原子性的操作procedure test去检查当前这个哲学家的左右两个相邻哲学家是否是就餐状态),信号量数组b[i]表示i哲学家对应的左右两根筷子都分配给i哲学家。实现的伪代码如下:

int a[5]={0},i;
Semaphore b[5]={0},r;//b[i]指哲学家信号量,0是挂起,1是允许就餐
procedure test(int i){//判断能否就餐,测试必须是原子性操作
  if(a[i]==1 && a[(i-1)%5]!=2 && a[(i+1)%5]!=2{//这个哲学家相邻都不处于就餐状态
     a[i]=2;//那么当前的哲学家可以吃饭
     V(b[i]);
  }
}
procedure philosopher(int i){
   think();
   P(r);//先用信号量r控制当前临界区状态,r从1变为0,表示原先没有请求时空间上锁,但现在有请求了就解锁
   a[i]=1;//当前科学家饿了
   test[i];//进入判断函数,如果失败的话,V(r)里有挂起操作会使b[i]=0为挂起状态
   V(r);//判断允许进餐,那么r由0变为1上锁不允许其他进程访问临界区
   P(b[i]);//i哲学家对应的左右两根筷子都分配给i哲学家,更新哲学界状态为1-就餐状态
   put_up_fork(i);//拿起左边筷子
   put_up_fork((i+1) mod 5);//拿起右边筷子
   eat();//就餐
   put_down_fork(i);//放下筷子
   put_down_fork((i+1) mod 5);
   P(r);//i哲学家吃完了,要解放对临界区的上锁状态,r由1变为0
   a[i]=0;//i哲学家恢复到思考状态
   //下面两步涉及唤醒操作,当前哲学家吃饱喝足它在离开前,要去看一看周围两个是否有就餐请求(为什么不看其他的呢,因为只有左右两位跟他有资源请求冲突),有的话就进入对应进程V(r)给r加1;没有的话自己就离开
   test((i-1)%5);//i的吃饭操作结束,检查ta左边的哲学家是否有吃饭请求
   test((i+1)%5);//i的吃饭操作结束,结束ta右边的哲学家是否有吃饭请求
   V(r);//设计相关信号量的更新
}

④管程实现

2.5.2 读者-写者问题

用管程实现比信号量实现简单得多,但是管程效率太低
对于数据的访问:读存;写擦
内存资源本身是共享资源,但是如果正在完成写操作就会变成独占资源。
①读/读:同时
②读/写:互斥
③写/写:互斥

读者优先
写者优先
读写公平调用:读者到达,写者正等待,读者在写者之后被挂起,而不是立即允许进入;写者到达而前有读者正等待,写者只要等待这个读者完成,而不必等待后面到来的读者。(不过这个方案并发度和效率比较低)

2.6 有关进程与线程的研究

作业执行的标志:分配内存空间。
进程需要保存其创建者信息,因为创建这决定了进程是否被执行。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值