计算机操作系统--进程

进程与线程

进程概念

程序:是一个指令序列。

为了方便操作系统管理,完成各程序并发执行,引入进程、进程实体的概念。

进程实体由程序段、数据段、PCB三部分组成。

进程控制块 (Process Control Block, PCB) 描述进程的基本信息和运行状态,所谓的创建进程和撤销进程,都是指对 PCB 的操作。

进程是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位,强调的是动态性。

进程的特征:动态性、并发性、独立性、异步性、结构性。

进程的组织

在一个系统中,通常有数十、数百乃至数千个PCB。为了能对他们加以有效的管理,应该用适当的方式把这些PCB组织起来,进程的组织方式有:

进程的状态与转换

进程的三种基本状态

  1. 运行态(Running):占有CPU,并在CPU上运行。
  2. 就绪态(Ready):已经拥有了除处理机之外所需要的资源,但由于没有空闲CPU,不能运行。
  3. 阻塞态(Blocked):因等待某一试件而不能运行。

另外两种状态:

  1. 创建态(New)
  2. 终止态(Terminated)

进程状态的转换:

运行态——>阻塞态 是一种进程自身作出的主动行为

阻塞态——>运行态 是不受进程自身控制的,是一种被动行为。

进程控制

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

利用原语实现进程控制,原语的特点是执行期间不允许中断,采用“关中断指令”和“开中断指令”实现。

相关的原语包括:

  • 进程创建原语
  • 进程撤销原语
  • 进程的阻塞原语和唤醒原语
  • 进程切换原语

无论哪个原语,要做的无非三类事情:

  1. 更新PCB中的信息(如修改进程状态标志、将运行环境保存到PCB、从PCB恢复运行环境)
  2. 将PCB插入合适的队列
  3. 分配/回收资源

进程通信

进程通信就是指进程之间的信息交换,各个进程拥有的内存地址空间相互独立,为了保证安全,一个进程不能直接访问另一个进程的地址空间。进程之间的信息交换采用了以下方法。

共享存储

两个进程对共享存储的访问必须是互斥的。

  1. 基于数据结构的共享: 比如共享空间里只能放 一个长度为10的数组。这种共享方式速度慢、限制多,是一种低级通信方式;
  2. 基于存储区的共享:在内存中画出一块共享存储区,数据的形式、存放位置都由进程控制, 而不是操作系统。相比之下,这种共享方式速度更快,是一种高级通信方式

管道通信

“管道”是指用于连接读写进程的一个共享文件,又名pipe文件,是一个缓冲区。

  1. 管道只能采用半双工通信,某一时间段内只能实现单向的传输。如果要实现双向同时通信,则需要设置两个管道。
  2. 各进程要互斥地访问管道。
  3. 数据以字符流的形式写入管道,当管道写满时,写进程的 write()系统调用将被阻塞,等待读进程将数据取走。当读进程将数据全部取走后,管道变空,此时读进程的read()系统调用将被阻塞
  4. 如果没写满,就不允许读。如果没读空,就不允许写。
  5. 数据一旦被读出,就从管道中被抛弃,这就意味着读进程最多只能有一个,否则可能会有读错数据的情况。

消息传递

线程

有的进程可能需要同时做很多事(QQ传文件、聊天、听歌),而传统的进程只能串行的执行一系列程序,为此引入了线程,来增加并发读。

线程是轻量级的进程,线程是一个基本的CPU执行单元,也是程序执行流的最小单位。引入线程后,进程只作为除CPU外的系统资源分配单位,线程则作为处理机的基本分配单元。

引入线程的变化:

  • 进程是资源分配的基本单位,线程是调度的基本单位;
  • 提高了并发性;
  • 线程间并发,如果是同一进程内切换,则不需要切换进程环境,系统开销小。

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

进程调度

调度的概念

在多道程序系统中,进程的数量往往是多于处理机的个数的,这样不可能同时并行地处理各个进程。处理机调度就是从就绪队列中按照一定的算法选择一个进程并将处理机分配给它运行,以实现进程的并发执行。

进程的调度分为三个层次:

  1. 高级调度(作业调度):按照某种规则,从后备队列中选择合适的作业将其调入内存,并为其创建线程。
  2. 中级调度(内存调度):按照某种规则,从挂起队列中选择合适的进程将其数据调回内存。
  3. 低级调度(进程调度):按照某种规则,从就绪队列中选择一个进程为其分配处理机。

进程调度的时机

进程调度(低级调度),就是按照某种算法从就绪队列中选择一个进程为其分配处理机。

当前运行的进程主动放弃处理机

  • 进程正常终止
  • 运行过程中发生异常而终止
  • 进程主动请求阻塞(等待I/O)

当前运行的进程被动放弃处理机

  • 分给进程的时间片用完
  • 有更紧急的事需要处理(如I/O中断)
  • 有更优先级的进程进入就绪队列

线程的切换过程包括对原来运行进程各种数据的保存,对新的进程各种数据的恢复。

进程调度、切换是有代价的,并不是调度越频繁,并发度就越高。

调度算法

评价调度算法的指标:

  • CPU利用率
  • 系统吞吐量
  • 周转时间
  • 等待时间
  • 响应时间

批处理系统

批处理系统没有太多的用户操作,在该系统中,调度算法目标是保证吞吐量和周转时间(从提交到终止的时间)。

1 先来先服务 first-come first-serverd(FCFS)

非抢占式的调度算法,按照请求的顺序进行调度。

有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。

2 短作业优先 shortest job first(SJF)

非抢占式的调度算法,按估计运行时间最短的顺序进行调度。

长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。

3 最短剩余时间优先 shortest remaining time next(SRTN)

最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。 当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。

4 高响应比优先 highest response ratio next(HRRN)

在每次调度时先计算各个作业/进程的响应比,选择响应比最高的作业/进程为其服务。

响应比 = (等待时间 + 要求服务时间)/ 要求服务时间

综合考虑了等待时间和运行时间(要求服务时间)

等待时间相同时,要求服务时间短的优先(SJF的优点)

要求服务时间相同时,等待时间长的优先(FCFS的优点)

对于长作业来说,随着等待时间越来越久,其响应比也会越来越大,从而避免了长作业饥饿的问题

交互式系统

交互式系统有大量的用户交互操作,在该系统中调度算法的目标是快速地进行响应。

1 时间片轮转

将所有就绪进程按FCFS的原则排成一个队列,每次调度时,把CPU时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把CPU时间分配给队首的进程。是一种抢占式的调度方法。

  • 因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间。
  • 而如果时间片过长,那么实时性就不能得到保证,时间片轮转退化为先来先服务算法,增大进程的响应时间。

2 优先级调度

为每个进程分配一个优先级,按优先级进行调度,可能导致饥饿

为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级

通常:

  • 系统进程优先级高于用户进程;
  • 前台进程优先级高于后台进程
  • 操作系统更偏好于I/O型进程(I/O繁忙型进程)

3 多级反馈队列

一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要交换 100 次。

多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1,2,4,8,…。进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换 7 次。

每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合,可能导致饥饿

进程同步

并发带来了异步性,有时需要通过进程同步解决这种异步问题

同步亦称直接制约关系,它是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而产生的制约关系。进程间的直接制约关系就是源于它们之间的相互合作。

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

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

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

进程互斥的软件实现方法

Peterson算法使用两个控制变量flag与turn。其中flag[n]的值为真,表示ID号为n的进程希望进入该临界区。标量turn保存有权访问共享资源的进程的ID号。

P0:
flag[0] = true;
turn = 1;
while (flag[1] == true && turn == 1)
{
// busy wait
} // critical section
flag[0] = false; // end of critical section

P1:
flag[1] = true;
turn = 0;
while (flag[0] == true && turn == 0)
{
// busy wait
} // critical section
flag[1] = false; // end of critical section

进程互斥的硬件实现方法

信号量机制

前面的进程互斥的软硬件实现方法都无法实现“让权等待”。

1965年,荷兰学者Dijkstra提出了一种卓有成效的实现进程互斥、同步的方法:信号量控制

用户进程可以通过使用操作系统提供的一对原语来对信号量进行操作,从而很方便的实现了进程互斥、进程同步。

信号量其实就是一个变量(可以是一个整数,也可以是更复杂的记录型变量),可以用一个信号量来表示系统中某种资源的数量,比如:系统中只有一台打印机,就可以设置一个初值为1的信号量。原语是一种特殊的程序段,其执行只能一气呵成,不可被中断。原语是由关中断/开中断指令实现的。

一对原语:wait(S)原语和signal(s)原语,常简称为P、V操作(来自荷兰语 proberen和 verhogen)。

整形信号量

信号量(Semaphore)是一个整型变量,可以对其执行 down 和 up 操作,也就是常见的 P 和 V 操作。

  • down : 如果信号量大于 0 ,执行 -1 操作;如果信号量等于 0,进程睡眠,等待信号量大于 0;
  • up :对信号量执行 +1 操作,唤醒睡眠的进程让其完成 down 操作。

down 和 up 操作需要被设计成原语,不可分割,通常的做法是在执行这些操作的时候屏蔽中断。

如果信号量的取值只能为 0 或者 1,那么就成为了 互斥量(Mutex) ,0 表示临界区已经加锁,1 表示临界区解锁。

typedef int semaphore;
semaphore mutex = 1;
void P1() {
    down(&mutex);
    // 临界区
    up(&mutex);
}

void P2() {
    down(&mutex);
    // 临界区
    up(&mutex);
}

整型信号量存在的问题在于:不能满足让权等待问题

记录型信号量

通过运行态和阻塞态的转换实现了让权等待原则。

[外链图片转存失败(img-KLS9VKfH-1565579227380)(http://picture.tjtulong.top/记录型信号量.jpg)]

信号量机制实现同步

经典问题

生产者消费者问题

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

因为缓冲区属于临界资源,因此需要使用一个互斥量 mutex 来控制对缓冲区的互斥访问。

为了同步生产者和消费者的行为,需要记录缓冲区中物品的数量。数量可以使用信号量来进行统计,这里需要使用两个信号量:empty 记录空缓冲区的数量,full 记录满缓冲区的数量。其中,empty 信号量是在生产者进程中使用,当 empty 不为 0 时,生产者才可以放入物品;full 信号量是在消费者进程中使用,当 full 信号量不为 0 时,消费者才可以取走物品。

#define N 100
typedef int semaphore;
semaphore mutex = 1;
semaphore empty = N;
semaphore full = 0;

void producer() {
    while(TRUE) {
        int item = produce_item();
        down(&empty);
        down(&mutex);
        insert_item(item);
        up(&mutex);
        up(&full);
    }
}

void consumer() {
    while(TRUE) {
        down(&full);
        down(&mutex);
        int item = remove_item();
        consume_item(item);
        up(&mutex);
        up(&empty);
    }
}

注意,不能先对缓冲区进行加锁,再测试信号量。也就是说,不能先执行 down(mutex) 再执行 down(empty)。如果这么做了,那么可能会出现这种情况:生产者对缓冲区加锁后,执行 down(empty) 操作,发现 empty = 0,此时生产者睡眠。消费者不能进入临界区,因为生产者对缓冲区加锁了,消费者就无法执行 up(empty) 操作,empty 永远都为 0,导致生产者永远等待下,不会释放锁,消费者因此也会永远等待下去。

吸烟者问题

多生产者多消费者问题

读者问题

允许多个进程同时对数据进行读操作,但是不允许读和写以及写和写操作同时发生。

一个整型变量 count 记录在对数据进行读操作的进程数量,一个互斥量 count_mutex 用于对 count 加锁,一个互斥量 data_mutex 用于对读写的数据加锁。

typedef int semaphore;
semaphore count_mutex = 1;
semaphore data_mutex = 1;
int count = 0;

void reader() {
    while(TRUE) {
        down(&count_mutex);
        count++;
        if(count == 1) down(&data_mutex); // 第一个读者需要对数据进行加锁,防止写进程访问
        up(&count_mutex);
        read();
        down(&count_mutex);
        count--;
        if(count == 0) up(&data_mutex);
        up(&count_mutex);
    }
}

void writer() {
    while(TRUE) {
        down(&data_mutex);
        write();
        up(&data_mutex);
    }
}

可能导致写者的饥饿问题,可以对再加一个P-V操作,避免写饥饿。

哲学家进餐问题

五个哲学家围着一张圆桌,每个哲学家面前放着食物。哲学家的生活有两种交替活动:吃饭以及思考。当一个哲学家吃饭时,需要先拿起自己左右两边的两根筷子,并且一次只能拿起一根筷子。

下面是一种错误的解法,考虑到如果所有哲学家同时拿起左手边的筷子,那么就无法拿起右手边的筷子,造成死锁。

#define N 5

void philosopher(int i) {
    while(TRUE) {
        think();
        take(i);       // 拿起左边的筷子
        take((i+1)%N); // 拿起右边的筷子
        eat();
        put(i);
        put((i+1)%N);
    }
}

为了防止死锁的发生,可以设置两个条件:

  • 必须同时拿起左右两根筷子;
  • 只有在两个邻居都没有进餐的情况下才允许进餐。
#define N 5
#define LEFT (i + N - 1) % N // 左邻居
#define RIGHT (i + 1) % N    // 右邻居
#define THINKING 0
#define HUNGRY   1
#define EATING   2
typedef int semaphore;
int state[N];                // 跟踪每个哲学家的状态
semaphore mutex = 1;         // 临界区的互斥
semaphore s[N];              // 每个哲学家一个信号量

void philosopher(int i) {
    while(TRUE) {
        think();
        take_two(i);
        eat();
        put_two(i);
    }
}

void take_two(int i) {
    down(&mutex);
    state[i] = HUNGRY;
    test(i);
    up(&mutex);
    down(&s[i]);
}

void put_two(i) {
    down(&mutex);
    state[i] = THINKING;
    test(LEFT);
    test(RIGHT);
    up(&mutex);
}

void test(i) {         // 尝试拿起两把筷子
    if(state[i] == HUNGRY && state[LEFT] != EATING && state[RIGHT] !=EATING) {
        state[i] = EATING;
        up(&s[i]);
    }
}

管程

使用信号量机制实现的生产者消费者问题需要客户端代码做很多控制,而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。

管程是一种特殊的软件模块,有这些部分组成,可以对比面向对象中的类:

  1. 局部于管程的共享数据结构说明;
  2. 对该数据结构进行操作的一组过程;
  3. 对局部于管程的共享数据设置初始值的语句;
  4. 管程有一个名字。

管程的基本特征

  1. 局部于管程的数据只能被局部于管程的过程所访问
  2. 一个进程只有通过调用管程内的过程才能进入管程访问共享数据:
  3. 每次仅允许一个进程在管程内执行某个内部过程(由编译器实现)。

管程实现生产者消费者(实质是对P-V操作的封装):

// 管程
monitor ProducerConsumer
    condition full, empty;
    integer count := 0;
    condition c;

    procedure insert(item: integer);
    begin
        if count = N then wait(full);
        insert_item(item);
        count := count + 1;
        if count = 1 then signal(empty);
    end;

    function remove: integer;
    begin
        if count = 0 then wait(empty);
        remove = remove_item;
        count := count - 1;
        if count = N -1 then signal(full);
    end;
end monitor;

// 生产者客户端
procedure producer
begin
    while true do
    begin
        item = produce_item;
        ProducerConsumer.insert(item);
    end
end;

// 消费者客户端
procedure consumer
begin
    while true do
    begin
        item = ProducerConsumer.remove;
        consume_item(item);
    end
end;

Java中类似管程的概念便是sychronized。

死锁

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

死锁、饥饿、死循环的区别:

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

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

什么时候发生死锁:

  1. 对系统资源的竞争。各进程对不可剥夺的资源(如打印机)的竞争可能引起死锁,对可剥夺的资源(CPU)的竞争是不会引起死锁的。
  2. 进程推进顺序非法。请求和释放资源的顺序不当,也同样会导致死锁。例如,并发执行的进程P1、P2 分别申请并占有了资源R1、R2,之后进程P1又紧接着申请资源R2,而进程P2又申请资源R1,两者会因为申请的资源被对方占有而阻塞,从而发生死锁。
  3. 信号量的使用不当也会造成死锁。如生产者-消费者问题中,如果实现互斥的P操作在实现同步的P操作之前,就有可能导致死锁。(可以把互斥信号量、同步信号量也看做是一种抽象的系统资源)。

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

死锁的处理方法:

  1. 预防死锁。破坏死锁产生的四个必要条件中的一个或几个。
  2. 避免死锁。用某种方法防止系统进入不安全状态,从而避免死锁(银行家算法)。
  3. 死锁的检测和解除。允许死锁的发生,不过操作系统会负责检测出死锁的发生,然后采取某种措施解除死锁。

预防死锁

从死锁的四个必要条件着手。

避免死锁

安全序列:

所谓安全序列,就是指如果系统按照这种序列分配资源,则每个进程都能顺利完成,只要找到一个安全序列,系统就是安全状态,安全状态可以有多个。如果分配了资源后,系统中找不到任何一个安全序列,系统就会进入了不安全状态,就可能发生死锁。

可以使用银行家算法来寻找安全序列:

数据结构:

长度为m的一维数组 Available表示还有多少可用资源;

n*m矩阵Max表示各进程对资源的最大需求数;

n*m矩阵Allocation表示已经给各进程分配了多少资源;

Max- Allocation=Need矩阵表示各进程最多还需要多少资源;

用长度为m的一位数组Request表示进程此次申请的各种资源数

银行家算法步骤:

  1. 检查此次申请是否超过了之前声明的最大需求数
  2. 检查此时系统剩余的可用资源是否还能满足这次请求
  3. 试探着分配,更改各数据结构
  4. 用安全性算法检查此次分配是否会导致系统进入不安全状态

安全性算法步骤:

检查当前的剩余可用资源是否能满足某个进程的最大需,如果可以,就把该进程加入安全序列,并把该进程持有的资源全部回收。不断重复上述过程,看最终是否能让所有进程都加入安全序列。

死锁的检验

资源分配图:

依次消除不与阻塞进程相连的边,直到无边可消,所谓不阻塞进程是指其申请的资源数还足够的进程,若资源分配图是不可完全简化的,说明发生了死锁。

消除死锁

  1. 资源剥夺法。挂起(暂时放到外存上)某些死锁进程,并抢占它的资源,将这些资源分配给其他的死锁进程。但是应防止被挂起的进程长时间得不到资源而饥饿
  2. 撤销进程法(或称终止进程法)。强制撤销部分、甚至全部死锁进程,并剥夺这些进程的资源。这种方式的优点是实现简单,但所付出的代价可能会很大。因为有些进程可能已经运行了很长时间,已经接近结束了,一且被终止可谓功亏一箦,以后还得从头再来。
  3. 进程回退法。让一个或多个死锁进程回退到足以避免死锁的地步。这就要求系统要记录进程的历史信息,设置还原点。

猜你喜欢

转载自blog.csdn.net/TJtulong/article/details/99292064