进程与线程

进程

程序与进程

进程是计算机中处于运行中程序的实体。程序本身只是指令、数据及其组织形式的描述,进程才是程序(指令和数据)的真正运行实例。

进程结构一般由3部分组成:代码段、数据段和堆栈段。代码段用于存放程序代码数据,数个进程可以共享同一个代码段。数据段存放程序的全局变量、常量和静态变量。堆栈段中栈用于函数调用,它存放着函数的参数,它存放着函数的参数,函数内部定义的局部变量。堆栈段还包括了进程控制块(Process Control Block, PCB)。PCB处于进程核心堆栈的底部,不需要额外分配空间。PCB时进程存在的唯一标识,系统通过PCB的存在而感知进程的存在。

  • 进程是程序的一次执行
  • 进程是一个程序及数据在处理机执行时所发生的活动
  • 进程时系统进行资源分配和调度的独立单位。进程的独立运行由进程控制块PCB控制和管理。进程映像时静态的进程。程序段、相关数据、PCB三部分构成了进程映像。
  • 进程具有动态性(创建、活动、暂停、和终止、具有生命周期),并发性(多个进程在一段时间内同时运行),独立性(进程是一个独立运行、获得资源和接受调度的基本单位)、异步性(进程按照独自不可预知的速度前进)、结构性(每个进程都有一个PCB描述)

进程的特征

image-20201129200001614

进程的状态

三种基本状态

  • 运行态 占有CPU,并在CPU上运行。单核处理机环境下,每一时刻最多只有一个进程处于运行态。双核环境下可以同时又两个进程处于运行态
  • 就绪态 已经具备 运行条件,但由于没有哦空闲CPU,而暂时不能运行。进程已经拥有了除处理机之外所有需要的资源,一旦获得处理机,即可立即进入运行态开始运行。
  • **阻塞态 ** 又称等待态,因等待某一时间而暂时不能运行。如:等待操作系统分配打印机、等待读磁盘操作的结果。CPU是计算机中最昂贵的部件,为了提高CPU的利用率,需要先将其他进程需要的资源分配到位,才能得到CPU的服务

另外两种状态

  • 创建态 进程正在被创建,操作系统为进程分配资源 初始化PCB
  • **终止态 ** 进程正在从系统中撤销,操纵系统会回收进程拥有的资源,撤下PCB

进程的状态转换

image-20201129202447277

原语实现对进程的控制

进程控制: 进程控制的主要功能是对系统中的所有进程实施有效的管理,它具有创建新进程、撤销已有进程、实现进程状态转换等功能。简化理解:反正进程控制就是要实现进程状态转换

用原语实现进程控制。源于的特点是执行期间不允许中断,只能一气呵成。这种不可被中断的操作即云子操作。原语采用“关中断指令”和”开中断指令”实现

显然,关/开中断指令的权限非常大,必然只允许在核心态执行的特权指令

image-20201129203151073

进程控制的五种原语

学习技巧:进程控制会导致进程状态的转换。无论哪个原语,要做的无非三类事情:

  • 更新PCB中的信息(如修改进程状态标志、将运行环境保存到PCB、从PCB恢复运行环境)

    • 所有的进程控制原语一定都会修改进程状态标志
    • 剥夺当前运行进程的CPU使用权必然需要保存其运行环境
    • 某进程开始运行前必然要恢复期运行环境
  • 将PCB插入合适的队列

  • 分配/回收资源

进程的创建原语

在这里插入图片描述

进程的终止原语

image-20201129204154402

进程的阻塞和唤醒

image-20201129204332368

进程切换原语

image-20201129204418402

进程通信

顾名思义,进程通信就是指进程之间的信息交换。进程是分配系统资源的单位(包括内存地址空间),因此各进程拥有的内存地址空间相互独立。为了保证安全,一个进程不能直接访问另一个进程的地址空间。但是进程之间的信息交换又是必须实现的。为了保证进程间的安全通信,操作系统提供了一些方法

共享存储

image-20201129205215962

管道通信

image-20201129205333396

消息传递

image-20201129205444441

线程

image-20201129210311586

什么是线程

可以把线程理解为“轻量级进程线程” 是一个基本的CPU执行单元,也是程序执行流的最小单位。引入线程之后,不仅是进程之间可以并发,进程内的各线程之间也可以并发,从而进一步提升了系统的并发度,使得一个进程内也可以并发处理各种任务(如QQ视频、文字聊天、传文件)引入线程后,进程只作为除CPU之外的系统资源的分配单元(如打印机、内存地址空间等都是分配给进程的)。线程则作为处理机的分配单元。

线程带来的变化

image-20201129211655148

线程的属性

  • 线程是处理机调度的单位
  • 多CPU计算机中,各个线程可占用不同的CPU
  • 每个线程都有一个线程ID、线程控制块(PCB)
  • 线程也有就绪、阻塞、运行三种基本状态
  • 线程几乎不拥有系统资源
  • 同一进程的不同线程间共享进程的资源
  • 由于共享内存地址空间,同一进程中的线程间通信甚至无需系统干预
  • 同一进程中的线程切换,不会引起进程切换
  • 不同进程中的线程切换,会引起进程切换
  • 切换同进程内的线程,系统开销很小
  • 切换进程,系统开销较大

线程的实现方式

用户级线程与多对一模型

image-20201129212738423

用户级线程由应用程序通过线程库实现。所有的线程管理工作都由应用程序负责(包括线程切换)用户级线程中,线程切换可以在用户态下即可完成,无需操作系统干预。在用户看来,是有多个线程。但是在操作系统内核看来,并意识不到线程的存在。(用户线程对用户不透明,对操作系统透明)可以这样理解,“用户级线程”就是“从用户视角看能看到的线程”

多对一模型:

多个用户及线程映射到一个内核级线程。每个用户进程只对应一个内核级线程。

优点:用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高

缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高。多个线程不可在多核处理机上并行运行

内核级线程与一对一模型

image-20201129213319676

内核级线程的管理工作由操作系统内核完成。线程调度、切换等工作都由内核负责,因此内核级线程的切换必然需要在核心态下才能完成。可以这样理解,“内核级线程”就是“从操作系统内核视角看能看到的线程”

一对一模型:

一个用户及线程映射到一个内核级线程。每个用户进程有与用户级线程同数量的内核级线程。

优点:当一个线程被阻塞后,别的线程还可以继续执行,并发能力强。多线程可在多核处理机上并行执行。

缺点:一个用户进程会占用多个内核级线程,线程切换由操作系统内核完成,需要切换到核心态,因此线程管理的成本高,开销大。

特殊的组合方式与多对多模型

在同时支持用户级线程和内核级线程的系统中,由几个用户级线程映射到几个内核级线程的问题引出了“多线程模型”问题。

image-20201129213712898

重点重点重点:

操作系统只“看得见”内核级线程,因此只有内核级线程才是处理机分配的单位。

例如:左边这个模型中,该进程由两个内核级线程,三个用户级线程,在用户看来,这个进程中有三个线程。但即使该进程在一个4核处理机的计算机上运行,也最多只能被分配到两个核,最多只能有两个用户线程并行执行。

多对多模型:n用户及线程映射到m个内核级线程(n>=m)。每个用户进程对应m个内核级线程。

克服了多对一模型并发度不高的缺点,又克服了一对一模型中一个用户进程占用太多内核级线程,开销太大的缺点。

处理机的调度

当有一堆任务要处理,但由于资源有限,这些事情没法同时处理。这就需要确定某种规则来决定处理这些任务的顺序,这就是“调度”研究的问题。在多道程序系统中,进程的数量往往是多于处理机的个数的,这样不可能同时并行地处理各个进程。处理机调度,就是从就绪队列中按照一定的算法选择一个进程并将处理机分配给它运行,以实现进程的并发执行。

调度的三个层次

高级调度(作业调度)

image-20201129220048583

由于内存空间有限,有时无法将用户提交的作业全部放入内存,因此就需要确定某种规则来决定将作业调入内存的顺序。

高级调度(作业调度)。按一定的原则从外存上处于后备队列的作业中挑选一个(或多个)作业,给他们分配内存等必要资源,并建立相应的进程(建立PCB),以使它(们)获得竞争处理机的权利。

高级调度是辅存(外存)与内存之间的调度。每个作业只调入一次,调出一次。作业调入时会建立相应的PCB,作业调出时才撤销PCB。高级调度主要是指调入的问题,因为只有调入的时机需要操作系统来确定,但调出的时机必然是作业运行结束才调出。

中级调度(内存调度)

image-20201129220512620

引入了虚拟存储技术之后,可将暂时不能运行的进程调至外存等待。等它重新具备了运行条件且内存又稍有空闲时,再重新调入内存。

这么做的目的是为了提高内存利用率和系统吞吐量。暂时调到外存等待的进程状态为挂起状态。值得注意的是,PCB并不会一起调到外存,而是会常驻内存。PCB中会记录进程数据在外存中的存放位置,进程状态等信息,操作系统通过内存中的PCB来保持对各个进程的监控、管理。被挂起的进程PCB会被放到的挂起队列中。

中级调度(内存调度),就是要决定将哪个处于挂起状态的进程重新调入内存。
一个进程可能会被多次调出、调入内存,因此中级调度发生的频率要比高级调度更高。

低级调度(进程调度)

image-20201129220656794

低级调度(进程调度),其主要任务是按照某种方法和策略从就绪队列中选取一个进程,将处理机分配给它。进程调度是操作系统中最基本的一种调度,在一般的操作系统中都必须配置进程调度。进程调度的频率很高,一般几十毫秒一次。

进程的挂起态与七状态模型

image-20201129220739706

三层调度的联系与对比

image-20201129220929694

进程调度的时机

进程调度的方式

image-20201129221959790

进程的切换和过程

“狭义的进程调度”与“进程切换”的区别:

狭义的进程调度指的是从就绪队列中选中一个要运行的进程。(这个进程可以是刚刚被暂停执行的进程,也可能是另一个进程,后一种情况就需要进程切换)

进程切换是指一个进程让出处理机,由另一个进程占用处理机的过程。

广义的进程调度包含了选择一个进程和进程切换两个步骤。

进程切换的过程主要完成了:

  • 对原来运行进程各种数据的保存
  • 对新的进程各种数据的恢复
    (如:程序计数器、程序状态字、各种数据寄存器等处理机现场信息,这些信息一般保存在进程控制块)

注意:进程切换是有代价的,因此如果过于频繁的进行进程调度、切换,必然会使整个系统的效率降低,使系统大部分时间都花在了进程切换上,而真正用于执行进程的时间减少。

进程调度算法

先来先服务——FCFS

  • FCFS是一种最简单的调度算法,从后备作业队列中选择最先进入该队列作业调度
  • FCFS是不可剥夺算法,长作业会使后到的短作业长期等待
  • 算法简单,效率低,对长作业有利,有利于CPU繁忙性工作

image-20201129223227492

短作业优先——SJF

  • 从后备队列中选择一个或若干个估计运行时间最短的作业掉入内存运行
  • 对长作业不利,如果短作业源源不断,会使得长作业一直处于饥饿状态

image-20201129223802686

高响应比优先—HRRN

  • 该算法是对FCFS和SJF算法的一种平衡,计算每个作业的响应比
  • 响应比的计算为(等待时间 + 要求服务时间)/要求服务时间

image-20201129223954712

image-20201130195616108

时间片轮转调度算法(RR)

  • 时间片轮转算法适用于分时系统,系统讲所有就绪的进程按照到达时间排成一个序列,进程调度总是选择就绪队列中的第一个进程执行。但是仅能运行一个,如100ms

  • 受系统响应时间影响,队列进程数目,进程长短影响较大

image-20201130195840641

优先级调度算法

  • 优先级调度算法每次从后备队列中选取优先级最高的一个或几个作业
  • 优先级调度可以剥夺时占有,也可以非剥夺式占有

多级反馈队列调度算法

  • 多级反馈队列调度算法是时间片轮转调度算法和优先级调度算法的综合和发展
  1. 设置多个就绪队列,为各个队列赋予优先级,1,2,3等
  2. 赋予各个队列中时间片大小不同,优先级高时间片越小
  3. 一个进程进入内存后首先放入1级队列末尾,FCFS原则等待,如果其能够完成,则撤离系统,否则放入耳机队列的末尾,依次向下执行。
  4. 仅当1级队列为空时,调度程序调度2级队列中的进程,依次类推

image-20201130200117865

进程的同步与互斥

进程同步

  • 同步也称为直接制约关系。
  • 在多道程序环境下,进程是并发执行的,不同进程之间存在着不同的相互制约关系。为了协调进程之间的相互制约关系,如等待、传递信息等,引入了进程同步的概念。进程同步是为了解决进程的异步问题。
  • 一个简单的例子来理解这个概念。
  • 例如,让系统计算1 + 2x3,假设系统产生两个进程: 一个是加法进程,一个是乘法进程。要让计算结果是正确的,一定要让加法进程发生在乘法进程之后,但实际上操作系统具有异步性,若不加以制约,加法进程发生在乘法进程之前是绝对有可能的,因此要制定一定的机制去约束加法进程,让它在乘法进程完成之后才发生。

进程互斥

  • 互斥,亦称间接制约关系进程互斥指当一个进程访问某临界资源时,另一个想要访问该临界资源的进程必须等待。当前访问临界资源的进程访问结束,释放该资源之后,另一个进程才能去访问临界资源。
  • 在这里需复习一下临界资源的概念。
  • 我们把一个时间段内只允许一个进程使用的资源称为临界资源。许多物理设备(比如摄像头、打印机)都属于临界资源。此外还有许多变量、数据、内存缓冲区等都属于临界资源。
  • 对临界资源的访问,必须互斥地进行。

为了实现对临界资源的互斥访问,同时保证系统整体性能,需要遵循以下原则:

  • 空闲让进。临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区
  • 忙则等待。当已有进程进入临界区时,其他试图进入临界区的进程必须等待
  • 有限等待。对请求访问的进程,应保证能在有限时间内进入临界区(保证不会饥饿)
  • 让权等待。当进程不能进入临界区时,应立即释放处理机,防止进程忙等待。

信号量

信号量是一个整形变量,可以被定义为两个标准的原语wait(S),signal(S)即P,V操作

  • P操作 如果信号量大于0, 执行-1操作,如果等于0,执行等待信号量大于0
  • V操作 对信号量完成加1操作,唤醒睡眠的进程
1
2
3
4
5
6
7
8
9
10
11
12
typedef int semaphore
semaphore mutex = 1
void P1(){
P(&mutex);
//临界区
V(&mutex);
}
void P2(){
P(&mutex);
//临界区
V(&mutex);
}

管程

管程是由局部于自己的若干公共变量及其说明和所有访问这些公共变量的过程所组成的软件模块

  • 使用信号量机制时,进程自备同步操作,P(S)和V(S)操作大量分散在各个进程中,不易管理,易发生死锁。
  • 管程封装了同步操作,对进程隐蔽了同步细节,简化了同步功能的调用界面。一个时刻只能有一个进程使用。进程不能一直占用管程,不然其他程序都无法使用
  • 引入管程的目的:1. 把分散在各进程中的临界区集中起来进行管理;2. 防止进程有意无意的违反同步操作;3. 便于高级语言程序书写和验证。

经典同步问题

生产者-消费者问题

问题描述:使用一个缓冲区来保存物品,只有缓冲区没满,生产者才可以放入物品;只有缓冲区不空,消费者可以拿走物品

由于缓冲区输入临界资源,需要一个互斥量mutex来完成缓冲区的互斥访问

为了同步生产者和消费者的行为,需要记录缓冲区物品数量,数量可以用信号量表示,empty记录空缓冲区,full记录满缓冲区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# define N 100
typedef int semahpore
semaphore mutex = 1;
semaphore empty = N;
semaphore full = 0;

void producer(){
while(True){
int item = produceItem();
P(&empty);
P(&mutex);
Item.push(item);
V(&mutex);
V(&full);
}
}

void consumer(){
while(True){
P(&full);
P(&mutex);
int item = Item.top();
Item.pop();
consume(item);
V(mutex);
V(&empty())
}
}

读者写者问题

问题描述: 控制多个进程对数据进行读、写操作,但是不允许读-写和写-写操作同时进行

用一个count表示读进程数量,分别用read_mutex 和write_mutex 作为读锁和写锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef int semaphore
semaphore count = 0;
semaphore read_mutex = 1;
semaphore write_mutex = 1;

void read(){
P(&read_mutex);
count++;
if(count==1) P(&write_mutex);
V(&read_mutex);
read();
p(&read_mutex);
count--;
if(count==0) V(&write_mutex);
V(&read_mutex);
}

void write(){
P(&write_mutex);
write();
V(&write_mutex);
}

哲学家进餐问题

问题描述:五个哲学家围着一张圆桌,每个哲学家面前放着食物,哲学家有两种活动:吃饭与思考,吃饭时,他拿起左边及右边的筷子,并且一次只能拿一根

如果所有哲学家都拿左边的筷子,就会出现死锁,这样只需加一步,当哲学家拿起筷子时检查是否能同时拿起两根筷子,不然就等待

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef int semaphore
semaphore chop[5] = {1,1,1,1,1};
semaphore mutex = 1;

void process(){
while(true){
P(&mutex);
P(chop[i]);
P(chop[(i+1)%5]);
V(&mutex);
eat();
V(chop[i]);
V(chop[(i+1)%5]);
}
}

死锁

在并发环境下,各进程因竞争资源而造成的一种互相等待对方手里的资源,导致各进程都阻塞,都无法向前推进的现象,就是死锁,发生死锁后若无外力干涉,这些进程都将无法向前推进。

死锁、饥饿、死循环区别

死锁:各进程互相等待对方手里的资源,导致各进程都阻塞,无法向前推进的现象。

饥饿:由于长期得不到想要的资源,某进程无法向前推进的现象。比如:在短进程优先(SPF)算法中,若有源源不断的短进程到来,则长进程将一直得不到处理机,从而发生长进程“饥饿”。

死循环:某进程执行过程中一直跳不出某个循环的现象。有时是因为程序逻辑bug导致的,有时是程序员故意设计的。

image-20201130202012613

死锁产生的必要条件

产生死锁必须同时满足一下四个条件,只要其中任一条件不成立,死锁就不会发生。

  • 互斥条件:只有对必须互斥使用的资源的争抢才会导致死锁(如哲学家的筷子、打印机设备)。像内存、扬声器这样可以同时让多个进程使用的资源是不会导致死锁的(因为进程不用阻塞等待这种资源)。
  • 不剥夺条件:进程所获得的资源在未使用完之前,不能由其他进程强行夺走,只能主动释放。
  • 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源又被其他进程占有,此时请求进程被阻塞,但又对自己已有的资源保持不放。
  • 循环等待条件:存在一种进程资源的循环等待链,链中的每一个进程已获得的资源同时被下一个进程所请求。

注意!发生死锁时一定有循环等待,但是发生循环等待时未必死锁(循环等待是死锁的必要不充分条件)

如果同类资源数大于1,则即使有循环等待,也未必发生死锁。但如果系统中每类资源都只有一个,那循环等待就是死锁的充分必要条件了。

什么时候发生死锁

  • 对系统资源的竞争。各进程对不可剥夺的资源(如打印机)的竞争可能引起死锁,对可剥夺的资源(CPU)的竞争是不会引起死锁的。

  • 进程推进顺序非法。请求和释放资源的顺序不当,也同样会导致死锁例如,并发执行的进程P1
    P2分别申请并占有了资源R1、R2,之后进程P1又紧接着申请资源R2,而进程2又申请资源1两者会因为申请的资源被对方占有而阻塞,从而发生死锁

  • 信号量的使用不当也会造成死锁如生产者消费者问题中,如果实现互斥的P操作在实现同步的P操作之前,就有可能导致死锁。(可以把互斥信号量、同步信号量也看做是一种抽象的系统资源)

总之,对不可剥夺资源的不合理分配,可能导致死锁

死锁的处理

  • 预防死锁。破坏死锁产生的四个必要条件中的一个或几个。

  • 避免死锁。用某种方法防止系统进入不安全状态,从而避免死锁(银行家算法)

  • 死锁的检测和解除。允许死锁的发生,不过操作系统会负责检测出死锁的发生,然后采取某种措施解除死锁。

预防死锁

破坏互斥条件

image-20201130203852543

不剥夺条件

image-20201130203808687

破坏请求和保持条件

image-20201130211744625

破坏循环条件

image-20201130211807469

避免死锁

在并发程序中,避免了逻辑中出现复数个线程互相持有对方线程所需要的独占锁的的情况,就可以避免死锁。

下面我们通过“破坏”第四个死锁条件,来解决第一个小节中的死锁示例并证明我们的结论。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class DeadLockDemo2 {

public static void main(String[] args) {
// 线程a
Thread td1 = new Thread(new Runnable() {
public void run() {
DeadLockDemo2.method1();
}
});
// 线程b
Thread td2 = new Thread(new Runnable() {
public void run() {
DeadLockDemo2.method2();
}
});

td1.start();
td2.start();
}

public static void method1() {
synchronized (String.class) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程a尝试获取integer.class");
synchronized (Integer.class) {
System.out.println("线程a获取到integer.class");
}

}
}

public static void method2() {
// 不再获取线程a需要的Integer.class锁。
synchronized (String.class) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程b尝试获取Integer.class");
synchronized (Integer.class) {
System.out.println("线程b获取到Integer.class");
}

}
}

}
-----------------
线程a尝试获取integer.class
线程a获取到integer.class
线程b尝试获取Integer.class
线程b获取到Integer.class

在上面的例子中,由于已经不存在线程a持有线程b需要的锁,而线程b持有线程a需要的锁的逻辑了,所以Demo顺利执行完毕。

死锁的检测和解除

检测

image-20201130212101903

解除

image-20201130212121587