前言
本文主要简单总结以下《现代操作系统》第二章《进程与线程》的主要内容
一、进程
1.由来
为了方便操作系统管理,完成各程序的并发执行,引入进程、进程实体等概念
.
.
2.定义
-
进程实体: 程序段+数据段+PCB。一般把进程实体简称为进程。它是一种映像,是静态的。
-
进程:是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位,是动态的。
3.进程的组成
-
**程序段:**存放要执行的代码
-
**数据段:**存放程序运行过程中处理的各种数据
-
PCB: 进程控制块,用来描述进程的各种信息,操作系统所需数据都放在PCB中,主要包括以下信息:
1)进程描述信息
2)进程控制和管理信息
3)资源分配清单
4)处理机相关信息
注:PCB是进程存在的唯一标志。它相当于给原本的程序加上了一个标签,这个标签里存放着操作系统管理这个程序所需要的的所有信息,这样操作系统就能更好的管理程序,完成各程序的并发执行。
4.进程的状态
.
.
5.进程特征
- 动态性:最基本的特征
- 并发性
- 独立性:进程是能独立运行、独立获得资源、独立接受调度的基本单位
- 异步型:各进程以不可预知的速度向前推进,可能导致运行结果的不确定性
- 结构性
6.进程组织
- **链接:**按进程状态把PCB分为多个队列,操作系统持有各个队列的指针
- **索引:**根据进程状态的不同建立几张索引表,操作系统持有指向各个索引表的指针
二、线程
1.由来
线程概念试图实现的是,共享一组资源 的多个线程的执行能力,以便这些线程可以为完成某一任务而共同工作。线程更加提高了并发度。
对比进程概念,进程试图实现用某种方法把相关的资源集中在一起以便更容易的管理。
.
.
.
2.定义
它让一个进程由几个线程组成,相当于一个轻量级的进程,它让进程内的各线程也可并发。它是一个 基本的CPU执行单元,替代进程成为 程序执行流 的最小单位。
注:引入线程后,进程只作为除CPU之外的系统资源的分配单元,线程成为调度的基本单位。
进程用于把资源集中到一起,而线程则是在CPU上被调度执行的实体。
.
.
2.线程组成
同进程一样,每个线程都有一个专属线程ID,叫做线程控制块 TCB。
具体内容如下:
共享: 由于一个进程里的所有线程都有完全一样的地址空间,各个线程都可以访问进程地址空间里的每一个内存地址,所以对于进程拥有的资源,各个线程是共享的。
堆栈:每个线程都有自己的堆栈,用来记录执行历史,其中每一帧保存了一个已经调用的但是还没有从中返回的过程。通常每个线程会调用不同的过程,从而有一个各自不同的执行历史,这就是为什么每个线程都要有自己的堆栈。
.
.
3.实现方式
(1)在用户空间实现线程
概述:
这种方法就是把整个 线程包 放在 用户空间,内核 对线程包一无所知。从内核角度考虑,就是按照正常方式管理,即单线程进程。
其通用结构如下(a :
其中,每个进程都有专用的线程表,用来跟踪该进程中的线程,它记录了各个线程的属性(如程序计数器、堆栈等)。这个线程表由 运行时系统 管理,它是一个 管理线程 的过程的集合。
用户级线程的优点:
-
这种方法最明显的优点就是 用户级线程包 可以在不支持线程的操作系统上实现。
-
线程调度非常便捷:在线程完成运行时,例如,它释放CPU来运行另外一个线程时,可以把该线程的信息保存在线程表中,进而,它可以调用线程调度程序来选择另一个要运行的线程。以上保存该线程状态的过程和调度程序都只是 本地过程,所以启动他们比进行内核**调用效率更高。**另一方面,不需要陷入内核,不需要上下文切换,也不需要对内存高速缓存进行刷新。所以线程调度非常便捷。
-
允许每个线程有自己定制的调度算法。
-
具有较好的扩展性。内核空间的内核线程需要一些固定表格空间和堆栈空间,如果内核线程的数量非常大,就会出现问题。
.
用户级线程的问题
-
不利于实现阻塞系统调用:在用户级线程中,一个线程要实现系统调用,由于内核不知道用户线程的存在,就会阻塞所有线程(),尽管其他线程是可以正常运行的,来完成调用。这个问题与缺页中断有些类似。
-
如果一个线程开始运行(占用CPU),该进程中的其他线程就不能运行。在一个单独的进程中,没有时钟中断,所以不可能轮转调度的方式轮流运行线程,除非线程自愿放弃CPU,否则将一直运行下去。
.
.
.
(2)在内核实现线程
概述:
内核级线程就是把线程包放进了内核空间,在内核中设置了线程表,这个线程表和 用户级线程表 装的信息一样。当某个线程希望创建一个新线程或撤销一个已有线程时,它进行一个系统调用(由于在内核里面,所以不需要运行时系统),这个系统调用通过对线程表的更新完成对线程的创建和撤销工作。
内核级线程的优点
解决了用户级线程的很多问题。比如用户级线程的不利于阻塞系统调用的问题,内核线程不需要任何新的、非阻塞型系统调用,能够阻塞线程的调用都通过系统调用实现,所以当一个线程阻塞,内核可以根据选择运行同一个进程的另一个线程或其他进程的线程。
内核级线程的问题
如用户级线程优点中所提到的那样,内核级线程的系统调用代价较大,开销比用户级线程明显更多。它还会产生一些其他的问题,这里不多介绍了。
.
.
(3)用户级+内核级
.
.
.
三、进程间通信
要实现进程之间的通信,主要需要解决三个问题:
一个进程如何把信息传递给另一个
确保两个或更多的进程在关键活动中不会进行交叉(竞争问题)
保证正确的顺序
比如进程A 产生数据而进程B打印数据,那么进程B必须在A产生数据前等待
这三个问题的前两个对线程同样适用,故对于进程通信的解决方法,对线程同样适用。
以下几点围绕这三个问题展开。
1. 信息传递方式
(1)共享存储器系统
-
基于数据结构的共享
-
基于存储区的共享
(2)管道通信
-
管道:在内存中开辟一个大小固定的缓冲区,用于连接读写进程的一个 共享文件
-
特点:
1)某一时间段只能单向传输。若要同时双向传输,则要设置两个管道。
2)各进程要互斥的访问管道
4)管道没写满就不允许读,没读空就不允许写。 -
结构:
.
.
(3)消息传递系统
- 直接传递方式(消息缓冲):消息直接挂到接收进程的 消息缓冲队列上。
发送原语:Send(A,m1)
接收原语:Receive(B,m2)
void send(receiver,a)
{ getbuf(a.size, i);
i.sender=a.sender;
i.size=a.size;
i.text=a.text;
i.next=0;
getid(pcbset, receiver, j);
P(j.mutex);
insert(j.mq,i);
V(j.mutex);
V(j.sm);
}
void receive(b)
{ P(j.sm);
P(j.mutex);
remove(j.mq, i);
V(j.mutex);
b.sender= i.sender;
b.size= i.size;
b.text= i.text;
releasebuf(i);
}
数据结构实现:
typedef struct message buffer
{
sender: //发送者进程标识符
size: //消息长度
text: //消息正文
next: //指向下一个消息缓冲区的指针
}
typedef struct message block
{
∶
mq: //消息队列队首指针
mutex: //消息队列互斥信号量,因为队列是临界资源,必须互斥。
sm: //消息队列资源信号量
∶
}
具体实现过程:
.
.
- 间接传递方式:消息先发到中间实体(信箱)中
.
.
2.处理竞争问题(实现互斥)
竞争条件
当两个或多个进程读写某些共享数据,而最后的结果取决于进程运行的 精确时序,称为竞争条件。
解决竞争问题
(1)解决竞争就是实现互斥
-
进程互斥:进程互斥是由于各进程共享、竞争同一临界资源,从而造成这些进程之间的一种间接制约,这些进程之间并没有逻辑联系。
-
进程同步:进程同步是相互合作的进程之间,为了保证结果的正确性,必须达成的速度上的协调,是有逻辑联系的直接制约。
-
对比:进程互斥问题中,各个进程需要的是同一种资源;而进程同步问题,相互合作的两个进程需要的资源是不同的,但一个进程所需的资源由对方进程产生。
(2) 实现方法
a. 设置临界区
-
定义:把对共享内存进行访问的程序片段称为 临界区
-
实现互斥:让两个进程不处于同一临界区
-
临界区访问控制要求:互斥条件、空则让进、有限等待、没有假设(不应对进程的推进顺序、速度,以及CPU的速度和数量做任何假设)
简易代码实现:
Process {
while (true) {
Do other work;
进入临界区(Enter Critical Section)
Access shared variables; // Critical Section;
(Access on critical I/O devices;)
退出临界区(Leave Critical Section)
Do other work ;
}
}
.
.
b.以忙等方式实现互斥
关中断(屏蔽中断)
-
方法:(在单CPU系统中)使每个进程刚刚进入临界区之后立即屏蔽所有中断,并在就要离开之前再打开中断。 由于CPU只有在发生时钟中断或其他中断时才会进行进程切换,所以,中断屏蔽后,CPU将不会被切换到其他进程,不用担心其他进程来竞争共享内存的操作。
-
缺点: 把屏蔽中断的能力交给用户进程是不明智的。而且,若系统是多CPU,则屏蔽中断仅仅对执行 disable 指令那个CPU有效,其他CPU仍将继续运行并可以访问内存,屏蔽一个CPU的中断不会阻止其他CPU干预第一个CPU所做的操作。
-
结论:屏蔽中断对于操作系统本身而言是一项很有用的技术,但对于用户进程则不是一种合适的通用互斥机制
.
.
锁变量
-
方法:设置一个共享变量,0表示临界区没有进程,1表示有。以此来作为判断进程能不能进入临界区的标准。
-
简易代码实现:
//Initially lock is 0
// EnterCriticalSection
While (lock);
lock = 1;
access shared variable;
// LeaveCriticalSection
Lock = 0;
-
缺点:仍然可能发生竞争。比如,假设一个进程读出锁变量的值发现它是0,而恰好在它将其设置为1前,另一个进程被调度,将其设为1,而当第一个进程再次运行时,它同样也会将锁设为1,这样,临界区就有两个进程了。
-
结论:再想其他办法
.
严格轮换法
-
方法:设置一个整型变量turn,初始值为0,用来记录轮到哪个进程进入临界区,并检查或更新内存。开始时,进程0检查turn,发现其值为0,进入临界区。进程1也发现其值为0,于是 忙等,不断循环测试turn,当进程0离开临界区并修改turn为1时,进程1进入临界区。当进程1离开时,turn被设为0。
-
简易代码实现:
(a)为进程0,(b)为进程
while(TRUE)//进程0
{
while(turn!=0);//循环
critical_region();//进程0做临界区的工作
turn=1;
noncritical_region();//进程0做非临界区的工作
}
while(TRUE)//进程1
{
while(turn!=1);//循环
critical_region();//进程1做临界区的工作
turn=0;
noncritical_region();//进程1做非临界区的工作
}
-
缺点:当一个进程比另一个进程慢了很多时,这个方法并不好。比如,若进程1比进程0慢很多,当进程0结束临界区的工作后,turn=1,此时进程1仍在非临界区工作,且要工作很久,所以进程0只能继续循环,等待进程1做完去临界区然后修改turn=0。这使得进程0被一个临界区外的进程阻塞。这显然不够好。
-
结论:再想其他办法
.
Peterson解法
-
方法:通过设置一个flg[ ]来标记希望进入临界区的进程,和一个while循环来避免竞争。当进程想进入临界区,必须先调用enter_region()函数,它首先通过设置 flg 数组和 turn 的值来标识该进程想进入临界区,然后再经过 while 循环条件的判断,来避免竞争。若没有竞争条件,就进入临界区,做完工作后想出来要先调用 leave_region()函数,把flg[ ]数组设置回来。
-
简易代码:
#define FLASE 0
#define TRUE 1
#define N 2 //两个进程,进程0和进程1
int turn; //记录进程号,类似于严格轮换法
int flg[N];//初始值都为FLSE
void enter_region(int process)
{
int other=1-process;//另一个进程号
flg[process]=TRUE;//设置标记,表示想进临界区
turn=process;//记录进程号
while(turn==process&&flg[other]==TRUE);//判断另一个进程是不是在临界区,在就一直忙等
}
void leave_region(int process)
{
flg[process]=process;//离开临界区前标记flg[ ]
}
-
缺点:会造成忙等(while循环),浪费CPU时间
-
结论:可行但忙等造成CPU时间的浪费
.
.
TSL指令
-
方法:前面的方法都是软件形式实现的,这个方法需要 硬件支持。它主要运用一条 TSL指令,执行该指令的CPU将锁住内存总线,以禁止其他CPU在本指令结束前访问共享内存。这样一来,就解决了屏蔽中断方法的最大弊端。具体做法是,进程进入临界区前调用enter_region,判断共享变量lock的值,若为0则使用TSL指令将其设置为1(执行TSL期间不会允许其他CPU的干扰,避免了竞争),然后读写共享内存,操作结束后调用leave_region()设置lock的值重新为0。如果lock为1说明已有进程在临界区,忙等。
-
简易代码实现:
enter_region://以下这些代码是汇编代码
TSL REGISTER,LOCK //复制锁的内容到寄存器,并把锁设置为1
CMP REGISTER,#0 //判断锁的值是不是0
JNE enter_region //若不是0,说明临界区有进程了,循环,忙等
RET //锁是0,返回调用者,进入临界区
leace_region:
MOVE LOCK,#0 //把锁重新设为0
RET //返回调用者
TSL指令还有一个可替代指令 XCHG 。XCHG指令能 原子性的交换两个位置的内容。它本质上和TSL的解法一样。
enter_region:
MOVE REGISTER,#1 //在寄存器中方放一个1
XCHG REGISTER,LOCK //XCHG指令能原子性的交换两个位置(寄存器,锁变量)的内容
CMP REGISTER,#0 //判断锁变量是否为0
JNE enter_region //若不是0,说明临界区已有进程,因此循环,忙等
RET
leave_region:
MOVE LOCK,#0 //离开前让锁设为0
RET //返回调用者
-
缺点:也是忙等,会浪费CPU时间
-
结论:可行,但是不够好
.
小结
以上的解法中Peterson解法和TSL或XCHG解法都是正确的,但是,它们都有忙等的缺点。
这些解法的本质都是:当一个进程想进入临界区时,先检查是否允许进入,若不允许,则该进程将原地等待(忙等),直到允许为止。
这种方法的弊端在于:
1)忙等浪费CPU时间
2)可能会引起 优先级翻转 。
比如,如果有两个进程H和L。H优先级更高。调度规则规定,只要H处于就绪态它就可以运行。在某一时刻,L处于临界区,此时H变到就绪态,准备运行。由于L还在临界区,所以H忙等。但由于H优先级更高,H处于就绪态L就不会被调度,故L无法离开临界区,H就将永远忙等下去。
.
.
c. 以睡眠和等待方式实现互斥
总思想
当进程无法进入临界区时将阻塞,进入睡眠(挂起),而不是忙等。在可以进入临界区时,将被唤醒。这需要两个系统调用: sleep、wakeup。这两个函数是两个进程间通信原语。
应用实例
用sleep和wakeup处理 生产者-消费者问题
-
问题概述:(以1个生产者和1个消费者为例)两个进程共享一个公共的固定大小的缓冲区。其中一个进程是生产者,向缓冲区放数据,另一个是消费者,从中取数据。问题在于,当缓冲区已满,生产者还想放入数据和当缓冲区已空,消费者还想取数据的情况,应该怎么办。
-
方法:缓冲区满,则让生产者睡眠,等消费者取出数据后再唤醒;缓冲区空,让消费者睡眠,等生产者放入数据再唤醒。但是还要解决随之而来的竞争问题。故设置一个变量count,来记录缓冲区数据数。对于生产者,放入数据前将检查count是否达到缓冲区最大容量,是则睡眠。对消费者,取出数据前先检查count是否为0,是则睡眠。每个进程进入临界区前同时也要检查另一个进程是否应被唤醒,应该则唤醒。
-
代码实现
#define N 100 //缓冲区中槽的数目(最多放的数据数)
int count=0; //用以记录缓冲区数据数目
void producer(void)
{
int item;
while(TRUE)
{
item=producer_item(); //产生下一新数据项
if(count==N)sleep(); //先判断缓冲区满否,满就睡眠
insert_item(item); //不满就放入数据
count+=1;
if(count==1)wakeup(consumer); //还要检查另一个进程是否要被唤醒
}
}
void consumer(void)
{
int item;
while(TRUE)
{
if(count==0)sleep();
item=remove_item(); //取出数据项
count-=1;
if(count==N-1)wakeup(producer); //只要缓冲区非空,consumer就取出;只要非满,producer就放入
consume_item(item); //打印数据项
- 缺点:尽管已经试图用count来避免竞争,但是,对count的访问也会产生竞争问题。
比如,缓冲区空,消费者读到count=0,调度程序决定 暂停 消费者,生产者加入数据,count+1,然后生产者推断刚刚count=0故消费者一定在睡眠,故调用wakeup()唤醒消费者。但实际上,消费者只是被暂停了,并未睡眠,故wakeup信号丢失。当消费者下次运行时,它将读取暂停前读到的count,发现是0,于是睡眠,而生产者一直放数据直到放满,最后两个进程将永远睡眠下去。
.
.
d. 用信号量实现 进程互斥和进程同步
信号量
一个信号量可以描述一种资源的分配情况,信号量中的两个成员变量分别表示当前可用的资源数量和指向该资源等待队列的指针。其 value 实际上就是一个 int 变量,用来累计唤醒次数。取值可为0或为正值。
typedef struct semaphore //信号量
{ int value;
PCB *p;
}
.
一个信号量有2个操作
Down(P), Up(V)(分别为一般化的sleep和wakeup)
1)Down (P): 操作对应于资源的申请 。对一个信号量执行down,即检查其值是否为大于0,大于则减一(即用掉一个保存的唤醒信号)并继续,若为0则进程睡眠。
2)Up (V):操作对应于资源的释放(产生) 。对一个信号量执行Up,即将其值加1
注意:信号量的2个操作是不可中断的原子操作
void Down(s) //P(s)
struct semaphore s;
{
s.value=s.value-1;
if(s.value<0) block(s.p);
}
void Up(s) //V(s)
struct semaphore s;
{
s.value=s.value+1;
if(s.value<=0) wakeup(s.p);
}
//UP:首先S.value加1,表示释放一个资源,如果S.value <= 0,那么说明原来的S.value < 0,阻塞队列中是由进程的,于是唤醒该队列中的一个进程。那么,为什么S.value > 0时不唤醒进程呢,很简单,说明原来的S.value==0,阻塞队列中没有进程了
//V操作相当于发送一个信号说临界资源可用了
.
用信号量实现进程同步
struct semaphore empty,full=1,0; //设置两个信号量
number x,y,buffer;
cobegin void cp(void)
{ while(TRUE) {
compute next number into x;
P(empty);
buffer=x;
V(full); }
}
void pp(void)
{ while(TRUE) {
P(full);
y=buffer;
V(empty);
print y; }
}
coend
.
.
用信号量解决 进程互斥问题
主要解决一些经典的IPC问题,比如生产者消费者、哲学家进餐、读着写者、放水果、读文件以及一些变形延伸题。
参见另一位博主文章 用信号量解决进程的同步与互斥 写的很清楚详细
.
.
.
总结
本文主要总结了一下进程与线程部分的相关知识,包括相关概念、进程间通信(通信方
式、解决竞争实现进程互斥同步)等,进程间通信是非常重要的一块,涉及到的编程题不
论是在本科学习还是在考研当中,都是重头戏,应当先弄清再模仿然后多练习~
若有错漏,欢迎指正~