操作系统进程与线程(较生疏)

操作系统进程与线程

  • 摘录自微信公众号一位大佬的文章方便自己复习(大佬也在csdn,多多包容),文末给出文章链接。

严格轮询法

第三种互斥的方式先抛出来一段代码,这里的程序是用 C 语言编写,之所以采用 C 是因为操作系统普遍是用 C 来编写的(偶尔会用 C++),而基本不会使用 Java 、Modula3 或 Pascal 这样的语言,Java 中的 native 关键字底层也是 C 或 C++ 编写的源码。对于编写操作系统而言,需要使用 C 语言这种强大、高效、可预知和有特性的语言,而对于 Java ,它是不可预知的,因为它在关键时刻会用完存储器,而在不合适的时候会调用垃圾回收机制回收内存。在 C 语言中,这种情况不会发生,C 语言中不会主动调用垃圾回收回收内存。有关 C 、C++ 、Java 和其他四种语言的比较可以参考 链接

进程 0 的代码

while(TRUE){  while(turn != 0){    /* 进入关键区域 */    critical_region();    turn = 1;    /* 离开关键区域 */    noncritical_region();  }}

进程 1 的代码

while(TRUE){  while(turn != 1){    critical_region();    turn = 0;    noncritical_region();  }}

在上面代码中,变量 turn,初始值为 0 ,用于记录轮到那个进程进入临界区,并检查或更新共享内存。开始时,进程 0 检查 turn,发现其值为 0 ,于是进入临界区。进程 1 也发现其值为 0 ,所以在一个等待循环中不停的测试 turn,看其值何时变为 1。连续检查一个变量直到某个值出现为止,这种方法称为 忙等待(busywaiting)。由于这种方式浪费 CPU 时间,所以这种方式通常应该要避免。只有在有理由认为等待时间是非常短的情况下,才能够使用忙等待。用于忙等待的锁,称为 自旋锁(spinlock)

进程 0 离开临界区时,它将 turn 的值设置为 1,以便允许进程 1 进入其临界区。假设进程 1 很快便离开了临界区,则此时两个进程都处于临界区之外,turn 的值又被设置为 0 。现在进程 0 很快就执行完了整个循环,它退出临界区,并将 turn 的值设置为 1。此时,turn 的值为 1,两个进程都在其临界区外执行。

突然,进程 0 结束了非临界区的操作并返回到循环的开始。但是,这时它不能进入临界区,因为 turn 的当前值为 1,此时进程 1 还忙于非临界区的操作,进程 0 只能继续 while 循环,直到进程 1 把 turn 的值改为 0 。这说明,在一个进程比另一个进程执行速度慢了很多的情况下,轮流进入临界区并不是一个好的方法。

这种情况违反了前面的叙述 3 ,即 位于临界区外的进程不得阻塞其他进程,进程 0 被一个临界区外的进程阻塞。由于违反了第三条,所以也不能作为一个好的方案。

Peterson 解法

荷兰数学家 T.Dekker 通过将锁变量与警告变量相结合,最早提出了一个不需要严格轮换的软件互斥算法,关于 Dekker 的算法,参考 链接

后来, G.L.Peterson 发现了一种简单很多的互斥算法,它的算法如下

#define FALSE 0#define TRUE  1/* 进程数量 */#define N     2                                                    /* 现在轮到谁 */int turn;                    /* 所有值初始化为 0 (FALSE) */int interested[N];                                            /* 进程是 0 或 1 */void enter_region(int process){                      /* 另一个进程号 */  int other;                                                          /* 另一个进程 */  other = 1 - process;                  /* 表示愿意进入临界区 */  interested[process] = TRUE;                          turn = process;  /* 空循环 */  while(turn == process         && interested[other] == true){} }void leave_region(int process){  /* 表示离开临界区 */  interested[process] == FALSE;                 }

在使用共享变量时(即进入其临界区)之前,各个进程使用各自的进程号 0 或 1 作为参数来调用 enter_region,这个函数调用在需要时将使进程等待,直到能够安全的临界区。在完成对共享变量的操作之后,进程将调用 leave_region 表示操作完成,并且允许其他进程进入。

现在来看看这个办法是如何工作的。一开始,没有任何进程处于临界区中,现在进程 0 调用 enter_region。它通过设置数组元素和将 turn 置为 0 来表示它希望进入临界区。由于进程 1 并不想进入临界区,所以 enter_region 很快便返回。如果进程现在调用 enter_region,进程 1 将在此处挂起直到 interested[0] 变为 FALSE,这种情况只有在进程 0 调用 leave_region 退出临界区时才会发生。

那么上面讨论的是顺序进入的情况,现在来考虑一种两个进程同时调用 enter_region 的情况。它们都将自己的进程存入 turn,但只有最后保存进去的进程号才有效,前一个进程的进程号因为重写而丢失。假如进程 1 是最后存入的,则 turn 为 1 。当两个进程都运行到 while 的时候,进程 0 将不会循环并进入临界区,而进程 1 将会无限循环且不会进入临界区,直到进程 0 退出位置。

管程

为了能够编写更加准确无误的程序,Brinch Hansen 和 Hoare 提出了一个更高级的同步原语叫做 管程(monitor)。他们两个人的提案略有不同,通过下面的描述你就可以知道。管程是程序、变量和数据结构等组成的一个集合,它们组成一个特殊的模块或者包。进程可以在任何需要的时候调用管程中的程序,但是它们不能从管程外部访问数据结构和程序。下面展示了一种抽象的,类似 Pascal 语言展示的简洁的管程。不能用 C 语言进行描述,因为管程是语言概念而 C 语言并不支持管程。

monitor example    integer i;    condition c;    procedure producer();  ...    end;        procedure consumer();    .    end;end monitor;

管程有一个很重要的特性,即在任何时候管程中只能有一个活跃的进程,这一特性使管程能够很方便的实现互斥操作。管程是编程语言的特性,所以编译器知道它们的特殊性,因此可以采用与其他过程调用不同的方法来处理对管程的调用。通常情况下,当进程调用管程中的程序时,该程序的前几条指令会检查管程中是否有其他活跃的进程。如果有的话,调用进程将被挂起,直到另一个进程离开管程才将其唤醒。如果没有活跃进程在使用管程,那么该调用进程才可以进入。

进入管程中的互斥由编译器负责,但是一种通用做法是使用 互斥量(mutex)二进制信号量(binary semaphore)。由于编译器而不是程序员在操作,因此出错的几率会大大降低。在任何时候,编写管程的程序员都无需关心编译器是如何处理的。他只需要知道将所有的临界区转换成为管程过程即可。绝不会有两个进程同时执行临界区中的代码。

即使管程提供了一种简单的方式来实现互斥,但在我们看来,这还不够。因为我们还需要一种在进程无法执行被阻塞。在生产者-消费者问题中,很容易将针对缓冲区满和缓冲区空的测试放在管程程序中,但是生产者在发现缓冲区满的时候该如何阻塞呢?

解决的办法是引入条件变量(condition variables) 以及相关的两个操作 waitsignal。当一个管程程序发现它不能运行时(例如,生产者发现缓冲区已满),它会在某个条件变量(如 full)上执行 wait 操作。这个操作造成调用进程阻塞,并且还将另一个以前等在管程之外的进程调入管程。在前面的 pthread 中我们已经探讨过条件变量的实现细节了。另一个进程,比如消费者可以通过执行 signal 来唤醒阻塞的调用进程。

Brinch Hansen 和 Hoare 在对进程唤醒上有所不同,Hoare 建议让新唤醒的进程继续运行;而挂起另外的进程。而 Brinch Hansen 建议让执行 signal 的进程必须退出管程,这里我们采用 Brinch Hansen 的建议,因为它在概念上更简单,并且更容易实现。

如果在一个条件变量上有若干进程都在等待,则在对该条件执行 signal 操作后,系统调度程序只能选择其中一个进程恢复运行。

顺便提一下,这里还有上面两位教授没有提出的第三种方式,它的理论是让执行 signal 的进程继续运行,等待这个进程退出管程时,其他进程才能进入管程。

条件变量不是计数器。条件变量也不能像信号量那样积累信号以便以后使用。所以,如果向一个条件变量发送信号,但是该条件变量上没有等待进程,那么信号将会丢失。也就是说,wait 操作必须在 signal 之前执行

下面是一个使用 Pascal 语言通过管程实现的生产者-消费者问题的解法

monitor ProducerConsumer        condition full,empty;        integer count;        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;        count := 0;end monitor;procedure producer;begin            while true do      begin                   item = produce_item;                  ProducerConsumer.insert(item);      endend;procedure consumer;begin             while true do            begin                        item = ProducerConsumer.remove;                        consume_item(item);            endend;

读者可能觉得 wait 和 signal 操作看起来像是前面提到的 sleep 和 wakeup ,而且后者存在严重的竞争条件。它们确实很像,但是有个关键的区别:sleep 和 wakeup 之所以会失败是因为当一个进程想睡眠时,另一个进程试图去唤醒它。使用管程则不会发生这种情况。管程程序的自动互斥保证了这一点,如果管程过程中的生产者发现缓冲区已满,它将能够完成 wait 操作而不用担心调度程序可能会在 wait 完成之前切换到消费者。甚至,在 wait 执行完成并且把生产者标志为不可运行之前,是不会允许消费者进入管程的。

尽管类 Pascal 是一种想象的语言,但还是有一些真正的编程语言支持,比如 Java (终于轮到大 Java 出场了),Java 是能够支持管程的,它是一种 面向对象的语言,支持用户级线程,还允许将方法划分为类。只要将关键字 synchronized 关键字加到方法中即可。Java 能够保证一旦某个线程执行该方法,就不允许其他线程执行该对象中的任何 synchronized 方法。没有关键字 synchronized ,就不能保证没有交叉执行。

下面是 Java 使用管程解决的生产者-消费者问题

public class ProducerConsumer {  // 定义缓冲区大小的长度  static final int N = 100;  // 初始化一个新的生产者线程  static Producer p = new Producer();  // 初始化一个新的消费者线程  static Consumer c = new Consumer();          // 初始化一个管程  static Our_monitor mon = new Our_monitor();   // run 包含了线程代码  static class Producer extends Thread{    public void run(){                                                      int item;      // 生产者循环      while(true){                                                                item = produce_item();        mon.insert(item);      }    }    // 生产代码    private int produce_item(){...}                          }  // run 包含了线程代码  static class consumer extends Thread {    public void run( ) {                                                       int item;      while(true){        item = mon.remove();                consume_item(item);      }    }    // 消费代码    private int produce_item(){...}                          }  // 这是管程  static class Our_monitor {                                        private int buffer[] = new int[N];    // 计数器和索引    private int count = 0,lo = 0,hi = 0;                private synchronized void insert(int val){      if(count == N){        // 如果缓冲区是满的,则进入休眠        go_to_sleep();                                                      }      // 向缓冲区插入内容            buffer[hi] = val;                         // 找到下一个槽的为止      hi = (hi + 1) % N;                       // 缓冲区中的数目自增 1       count = count + 1;                                                  if(count == 1){        // 如果消费者睡眠,则唤醒        notify();                                                                  }    }    private synchronized void remove(int val){      int val;      if(count == 0){        // 缓冲区是空的,进入休眠        go_to_sleep();                                                      }      // 从缓冲区取出数据      val = buffer[lo];                      // 设置待取出数据项的槽      lo = (lo + 1) % N;                          // 缓冲区中的数据项数目减 1       count = count - 1;                                                  if(count = N - 1){        // 如果生产者睡眠,唤醒它        notify();                                                                  }      return val;    }    private void go_to_sleep() {      try{        wait( );      }catch(Interr uptedExceptionexc) {};    }  }}

上面的代码中主要设计四个类,外部类(outer class) ProducerConsumer 创建并启动两个线程,p 和 c。第二个类和第三个类 ProducerConsumer 分别包含生产者和消费者代码。最后,Our_monitor 是管程,它有两个同步线程,用于在共享缓冲区中插入和取出数据。

在前面的所有例子中,生产者和消费者线程在功能上与它们是相同的。生产者有一个无限循环,该无限循环产生数据并将数据放入公共缓冲区中;消费者也有一个等价的无限循环,该无限循环用于从缓冲区取出数据并完成一系列工作。

程序中比较耐人寻味的就是 Our_monitor 了,它包含缓冲区、管理变量以及两个同步方法。当生产者在 insert 内活动时,它保证消费者不能在 remove 方法中运行,从而保证更新变量以及缓冲区的安全性,并且不用担心竞争条件。变量 count 记录在缓冲区中数据的数量。变量 lo 是缓冲区槽的序号,指出将要取出的下一个数据项。类似地,hi 是缓冲区中下一个要放入的数据项序号。允许 lo = hi,含义是在缓冲区中有 0 个或 N 个数据。

Java 中的同步方法与其他经典管程有本质差别:Java 没有内嵌的条件变量。然而,Java 提供了 wait 和 notify 分别与 sleep 和 wakeup 等价。

通过临界区自动的互斥,管程比信号量更容易保证并行编程的正确性。但是管程也有缺点,我们前面说到过管程是一个编程语言的概念,编译器必须要识别管程并用某种方式对其互斥作出保证。C、Pascal 以及大多数其他编程语言都没有管程,所以不能依靠编译器来遵守互斥规则。

与管程和信号量有关的另一个问题是,这些机制都是设计用来解决访问共享内存的一个或多个 CPU 上的互斥问题的。通过将信号量放在共享内存中并用 TSLXCHG 指令来保护它们,可以避免竞争。但是如果是在分布式系统中,可能同时具有多个 CPU 的情况,并且每个 CPU 都有自己的私有内存呢,它们通过网络相连,那么这些原语将会失效。因为信号量太低级了,而管程在少数几种编程语言之外无法使用,所以还需要其他方法。

消息传递

上面提到的其他方法就是 消息传递(messaage passing)。这种进程间通信的方法使用两个原语 sendreceive ,它们像信号量而不像管程,是系统调用而不是语言级别。示例如下

send(destination, &message);receive(source, &message);

send 方法用于向一个给定的目标发送一条消息,receive 从一个给定的源接受一条消息。如果没有消息,接受者可能被阻塞,直到接受一条消息或者带着错误码返回。

消息传递系统的设计要点

消息传递系统现在面临着许多信号量和管程所未涉及的问题和设计难点,尤其对那些在网络中不同机器上的通信状况。例如,消息有可能被网络丢失。为了防止消息丢失,发送方和接收方可以达成一致:一旦接受到消息后,接收方马上回送一条特殊的 确认(acknowledgement) 消息。如果发送方在一段时间间隔内未收到确认,则重发消息。

现在考虑消息本身被正确接收,而返回给发送着的确认消息丢失的情况。发送者将重发消息,这样接受者将收到两次相同的消息。

img

对于接收者来说,如何区分新的消息和一条重发的老消息是非常重要的。通常采用在每条原始消息中嵌入一个连续的序号来解决此问题。如果接受者收到一条消息,它具有与前面某一条消息一样的序号,就知道这条消息是重复的,可以忽略。

消息系统还必须处理如何命名进程的问题,以便在发送或接收调用中清晰的指明进程。身份验证(authentication) 也是一个问题,比如客户端怎么知道它是在与一个真正的文件服务器通信,从发送方到接收方的信息有可能被中间人所篡改。

用消息传递解决生产者-消费者问题

现在我们考虑如何使用消息传递来解决生产者-消费者问题,而不是共享缓存。下面是一种解决方式

/* buffer 中槽的数量 */#define N 100                                                    void producer(void){  int item;  /* buffer 中槽的数量 */  message m;                                                      while(TRUE){    /* 生成放入缓冲区的数据 */    item = produce_item();                            /* 等待消费者发送空缓冲区 */    receive(consumer,&m);                                /* 建立一个待发送的消息 */    build_message(&m,item);                            /* 发送给消费者 */    send(consumer,&m);                                  }}void consumer(void){  int item,i;  message m;  /* 循环N次 */  for(int i = 0;i < N;i++){                            /* 发送N个缓冲区 */    send(producer,&m);                                  }  while(TRUE){    /* 接受包含数据的消息 */    receive(producer,&m);                                /* 将数据从消息中提取出来 */      item = extract_item(&m);                        /* 将空缓冲区发送回生产者 */    send(producer,&m);                                    /* 处理数据 */    consume_item(item);                                  }}

假设所有的消息都有相同的大小,并且在尚未接受到发出的消息时,由操作系统自动进行缓冲。在该解决方案中共使用 N 条消息,这就类似于一块共享内存缓冲区的 N 个槽。消费者首先将 N 条空消息发送给生产者。当生产者向消费者传递一个数据项时,它取走一条空消息并返回一条填充了内容的消息。通过这种方式,系统中总的消息数量保持不变,所以消息都可以存放在事先确定数量的内存中。

如果生产者的速度要比消费者快,则所有的消息最终都将被填满,等待消费者,生产者将被阻塞,等待返回一条空消息。如果消费者速度快,那么情况将正相反:所有的消息均为空,等待生产者来填充,消费者将被阻塞,以等待一条填充过的消息。

消息传递的方式有许多变体,下面先介绍如何对消息进行 编址

  • 一种方法是为每个进程分配一个唯一的地址,让消息按进程的地址编址。
  • 另一种方式是引入一个新的数据结构,称为 信箱(mailbox),信箱是一个用来对一定的数据进行缓冲的数据结构,信箱中消息的设置方法也有多种,典型的方法是在信箱创建时确定消息的数量。在使用信箱时,在 send 和 receive 调用的地址参数就是信箱的地址,而不是进程的地址。当一个进程试图向一个满的信箱发送消息时,它将被挂起,直到信箱中有消息被取走,从而为新的消息腾出地址空间。

实时系统(real-time)

实时系统(real-time)是一个时间扮演了重要作用的系统。典型的,一种或多种外部物理设备发给计算机一个服务请求,而计算机必须在一个确定的时间范围内恰当的做出反应。例如,在 CD 播放器中的计算机会获得从驱动器过来的位流,然后必须在非常短的时间内将位流转换为音乐播放出来。如果计算时间过长,那么音乐就会听起来有异常。再比如说医院特别护理部门的病人监护装置、飞机中的自动驾驶系统、列车中的烟雾警告装置等,在这些例子中,正确但是却缓慢的响应要比没有响应甚至还糟糕。

实时系统可以分为两类,硬实时(hard real time)软实时(soft real time) 系统,前者意味着必须要满足绝对的截止时间;后者的含义是虽然不希望偶尔错失截止时间,但是可以容忍。在这两种情形中,实时都是通过把程序划分为一组进程而实现的,其中每个进程的行为是可预测和提前可知的。这些进程一般寿命较短,并且极快的运行完成。在检测到一个外部信号时,调度程序的任务就是按照满足所有截止时间的要求调度进程。

实时系统中的事件可以按照响应方式进一步分类为周期性(以规则的时间间隔发生)事件或 非周期性(发生时间不可预知)事件。一个系统可能要响应多个周期性事件流,根据每个事件处理所需的时间,可能甚至无法处理所有事件。例如,如果有 m 个周期事件,事件 i 以周期 Pi 发生,并需要 Ci 秒 CPU 时间处理一个事件,那么可以处理负载的条件是

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-auR7I9Q8-1585667689679)(data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==)]

只有满足这个条件的实时系统称为可调度的,这意味着它实际上能够被实现。一个不满足此检验标准的进程不能被调度,因为这些进程共同需要的 CPU 时间总和大于 CPU 能提供的时间。

举一个例子,考虑一个有三个周期性事件的软实时系统,其周期分别是 100 ms、200 m 和 500 ms。如果这些事件分别需要 50 ms、30 ms 和 100 ms 的 CPU 时间,那么该系统时可调度的,因为 0.5 + 0.15 + 0.2 < 1。如果此时有第四个事件加入,其周期为 1 秒,那么此时这个事件如果不超过 150 ms,那么仍然是可以调度的。忽略上下文切换的时间。

实时系统的调度算法可以是静态的或动态的。前者在系统开始运行之前做出调度决策;后者在运行过程中进行调度决策。只有在可以提前掌握所完成的工作以及必须满足的截止时间等信息时,静态调度才能工作,而动态调度不需要这些限制。


https://mp.weixin.qq.com/s?__biz=MzU2NDg0OTgyMA==&mid=2247485619&idx=1&sn=819fffc4380b4e976f541def5ed805f3&chksm=fc45f540cb327c560e4eb5747183faec42fcc77c6061effaf36e28faef689f920a54d5a78eeb&mpshare=1&scene=23&srcid=&sharer_sharetime=1584867987037&sharer_shareid=62176c76fc3347ff8d2c4423b4f73d24#rd

42fcc77c6061effaf36e28faef689f920a54d5a78eeb&mpshare=1&scene=23&srcid=&sharer_sharetime=1584867987037&sharer_shareid=62176c76fc3347ff8d2c4423b4f73d24#rd

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值