chapter02_进程与线程_3_进程(线程)间通信

  • 进程通信的三个问题

    (1) 一个进程如何把信息传递给其他进程

    (2) 如何确保多个进程活动时不会出现交叉

    (3) 如果进程间需要按顺序进行,如何保证进程执行的顺序

  • 线程通信的两个问题

    (1) 由于多线程共享同一地址空间,所以不存在问题(1),但是问题(2)和(3)都存在

    (2) 接下来讨论解决进程通信三个问题的方法,但是方法对于线程通信也适用

  • 竞争条件

    (1) 定义

    多个进程读写某些共享数据,而最后的结果取决于进程运行的精确顺序

    (2) 示例

    打印机目录有很多槽位,进程A发现第一个空槽位的序号是7,但是此时调度程序切换到进程B,进程B发现第一个空槽位的序号是7,于是它执行向空槽位7写入要打印文件名的操作 B.txt;调度程序此时切换到进程A,进程A也向空槽位7写入要打印的文件名 A.txt

    (3) 竞争条件可能会发生在共享内存、共享文件、共享资源的情况

    (4) 基本概念

    互斥: 以某种手段确保当一个进程在使用一个共享变量或文件时,其他进程不能做同样的操作

    临界区: 对共享内存进行访问的__程序片段__

    (5) 避免发生竞争条件的__条件__

    1° 任意两个进程不能同时处于同一个临界区

    2° 不应对CPU的速度和数量进行任何假设

    3° 临界区外运行的进程不能阻塞其他进程

    4° 不能使进程无限期等待进入临界区

  • 实现互斥的几种方案

    (1) 屏蔽中断

    1° 操作:一个进程进入临界区后,屏蔽所有中断(包括时钟中断),这样在这个进程离开临界区之前,根本不会发生进程的切换

    2° 问题

    I. 屏蔽中断交给用户进程可能会引发严重问题

    II. 对于多核处理器,屏蔽中断只能屏蔽当前线程对应的处理器,其他处理器上的进程还是可以进入临界区

    3° 结论: 这种方案不靠谱

    (2) 锁变量

    1° 操作:用一个变量记录是否临界区已经被进入过,进入就置为1,只有为0时才能进入临界区

    2° 问题:和"竞争条件"的示例问题一样,解决不了竞争条件的问题

    3° 结论: 这种方案不靠谱

    (3) 严格轮换法(忙等待)

    1° 操作: 用一个共享变量记录轮到哪个进程进入临界区, 通过while()循环的方式轮询这个变量的值

    2° 伪代码

    进程0

      while (true) {
    
          while (lock != 0);
    
          critical_region_0();
          lock = 1;
          non_critival_region_0();
      }
    

    进程1

      while (true) {
    
          while (lock != 1);
    
          critical_region_1();
          lock = 0;
          non_critival_region_1();
      }
    

    其中, critical_region()代表临界区的操作,non_critival_region()代表非临界区的操作

    3° 问题: 这种方式隐含的思想是,一个线程执行完毕后,必须让下一个线程执行,然后才能重新开始执行这个线程。导致的问题就是,如果某一个线程的non_critival_region()时间很长,那么其他线程需要等待很长时间。 --> 不符合"避免发生竞争条件的条件3: 临界区外运行的进程不能阻塞其他进程"

    4° 结论:只有在等待时间非常短时,可以用这种方案;否则不能用

    (4) Peterson解法

    1° 操作: 用一个共享变量记录轮到哪个进程访问临界区,一个共享数组记录各个进程是否有意愿进入临界区

    2° 伪代码

      int turn;             //记录轮到哪个进程访问临界区
      int interested[2];    //初始化为false,某位置为true代表对应的进程有意愿进入临界区
    
      void enter_critical_region(int process_id) {
          
          int another_process_id = 1 - process_id;
    
          interested[process_id] = true;    //代表当前进程对进入临界区感兴趣
    
          turn = process_id;            //代表当前进程认为进入临界区的应该是自己
    
          while ( turn == process_id 
               && interested[another_process_id] == true );  // while循环用于判断能否进入临界区
      }
    
      void leave_critical_region(int process_id) {
    
          interested[process_id] = true;    //代表当前进程对进入临界区不感兴趣
      }
    

    进程0

      while (true) {
    
          enter_critical_region(0);
    
          critical_region_0();
    
          leave_critical_region(0);
    
          non_critival_region_0();
      }
    

    进程1

      while (true) {
    
          enter_critical_region(1);
    
          critical_region_0();
    
          leave_critical_region(1);
    
          non_critival_region_0();
      }
    

    解释

    每个线程进入临界区之前,都要卡在 while ( turn == process_id && interested[another_process_id] == true ) 这里判断一下: 如果其他进程没有提出对临界区访问的申请,则后面的为false,当前线程进入临界区; 如果两个进程都提出临界区访问的请求,那么turn会被置为后进入的那个线程的值,这样先进入的那个线程就会满足 turn != process_id,它就会率先进入临界区;

    同时,这个算法还解决了__严格轮换法__中存在的非临界区其他进程阻塞当前进程的问题,因为while循环的判断条件有两个, 如果只有 turn == process_id 判断,那么问题依然存在;但是添加了 interested[another_process_id] == true 判断之后,在其他进程离开临界区之后,interested[another_process_id]就会变成false,从而当前进程进入了临界区;

    同时,这个算法没有引入“饥饿等待”的问题(就是一个进程一直得不到机会进入临界区,饿死在临界区入口)。因为即便当前进程比另一个进程优先级低,另一个进程率先通过临界区,完成了一系列操作,再次进入临界区入口;但此时由于优先级高的那个进程是后来到临界区的那个进程,所以turn的值会被修改为后来的进程id,这样后来的进程会在临界区入口等待,先来的那个优先级低的也会被调度到,然后顺利通过while循环

    4° 结论

    好算法!!用纯软件的方式解决了互斥锁的实现

    (5) TSL(Test And Set Lock)指令

    1° 操作

    从内存中读取一个变量lock的值,到寄存器中,如果读到的值为0则进入临界区,并将内存中的值写入1;读到的是0则忙等待。

    这个操作看起来__锁变量__方法一样,不能解决问题;但是这个方案事实上是__屏蔽中断__方案的加强版,它__由硬件支持__,保证了读操作的指令结束以前,内存总线会被锁住,其他处理器无法访问。

    2° 伪代码

    enter_region:

      copy_to_register_and_set_lock_value();   // 从内存中读变量lock的值到寄存器中,并将内存中lock的值置为1
      if ( register_value != 0 ) {         //如果读到的值不是0,那么重复这个循环,回到enter_region开始的地方
          jump_to_enter_region();
      }
      return;
    

    leave_region:
    reset_lock_value(); //将内存中的lock值置为0
    return;

    3° 结论

    解决了互斥锁的实现,但是需要硬件支持

  • 睡眠与唤醒

    (1) 虽然 Peterson算法 和 __TSL__可以解决互斥锁的实现问题,但是它们都有__忙等待__的固有缺陷:

    浪费CPU的时间

    优先级反转问题

    A线程优先级高,B线程优先级低,如果调度规则是总是保证优先级高的获得执行的机会,那么会出现当A离开临界区,B在临界区中时,由于总是调度A执行,A将永远忙等待下去

    (2) 解决方法是使用 sleep和wakeup,sleep是当前线程无法进入临界区时将阻塞,直到另外一个线程将它唤醒

    (3) 典型问题:生产者-消费者问题

    两个进程共享一个缓冲区,缓冲区大小固定,生产者负责将信息放入缓冲区,消费者负责从缓冲区中读信息。如果缓冲区已满,则生产者线程将会阻塞;如果缓冲区为空,则消费者线程将会阻塞。

    (4) 对于"生产者-消费者问题"的直接解决方式是:

    用一个变量count记录缓冲区中信息的数目,如果缓冲区已满,则生产者sleep,等待消费者wakeup它;如果缓冲区已空,则消费者sleep,等待生产者wakeup它。

    这样做的__问题__是:读count时没有做任何保护,同样会导致竞态条件的发生(wakeup的信息可能会丢失)

    —>最终的解决办法是:信号量!!!(其实用条件变量也行)

  • 信号量

    (1) 信号量的取值要么为0,要么一个正数值

    (2) 对信号量可以有2种操作:down和up

    down: 检查信号量的值是否大于0,若大于0则减一,并继续进程;若等于0,则进程睡眠,此时down操作尚未结束

    up: 将信号量的值加一。如果存在睡眠的进程,则选择一个进程允许它完成down操作(这样信号量的值又变成了0,但是睡眠的进程数目少了一个)

    (3) 读取信号量的值、修改信号量的值、睡眠操作、唤醒操作 均为原子操作.

    (4) 通过上面的 TSL 方式,保证同一时刻只有一个CPU的一个进程可以对信号量进行操作

    (5) 使用信号量解决"生产者-消费者"问题

    有三个信号量:

      mutex_lock: 保证只有一个进程进入缓冲区
      empty: 记录空槽的数量
      full:记录已有槽的数量
    
      // 初始化信号量
      mutex_lock = 1;
      empty = N;
      full = 0;
    

    生产者进程

      void producer() {
    
          while (true) {
              
              item = produce_item();   //产生数据
              down(&empty);            //对empty进行down操作
              down(&mutex_lock);       //对缓冲区加锁
              insert_item(item);       //将数据放入缓冲区
              up(&mutex_lock);         //释放缓冲区锁
              up(&full);               //对full进行up操作
          }
      }
    

    消费者进程

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

    注意顺序很重要,例如producer中的 down(&empty)和down(&mutex_lock)不能反了,否则当缓冲区恰好满了的时候会发生__死锁__

    (6) 信号量可以用于实现同步,它和忙等待方式实现的互斥是不一样的

  • 互斥量

    (1) 互斥量即__二元信号量__,取值只能为0或1,用来实现锁

    (2) 用 TSL 实现互斥量

    (尝试)加锁

      mutex_lock:
          TSL REGISTER, MUTEX    //将互斥量从内存读到寄存器中,并将互斥量的值置为1
          CMP REGISTER, 0        //判断寄存器中的值是否为0
          JZE ok                 //如果是0,则跳转到ok
          CALL thread_yield      //如果不是0,则执行线程yield
          JMP mutex_lock         //当调度重新回到当前线程时,重新做一下上述操作
      ok: RET                    //退出
    

    解锁

      mutex_unlock: 
          MOV MUTEX, 0           //将0放入内存中MUTEX变量的位置
          RET                    //退出
    

    和上面 TSL 中 enter_region的区别

    enter_region是忙等待,会重复测试锁,但是由于进程存在时钟超时,所以迟早会调度其他进程;

    mutex_lock如果用于线程,没有时钟超时中断,所以采取的方式是如果加锁失败,则让出当前线程,直到再次被调度时再测试锁.

    (3) 互斥量可以用于同步线程。基本思路是用一个互斥量来保护每个临界区,当互斥量被解锁时,选择一个线程进入并重新加锁。

    (4) 有些情况下还提供了互斥量的特殊操作__tryLock__:

    当加锁失败时,不会阻塞该线程,而是返回错误码给函数调用者,这样函数调用者就有了更高的灵活性(例如用tryLock实现忙等待)

  • 条件变量

    (1) 作用:当线程未达到一些条件时阻塞,直到另一个线程向它发信号(同一个条件变量)

    (2) 条件变量和互斥量往往一起使用.通常一个线程锁住了互斥量,然后如果它发现不满足某个条件,则线程被阻塞,__并且释放它上面的互斥量__给其他线程,直到它被某个其他线程唤醒。

    因此,互斥量和条件变量一并作为参数

    (3) 互斥量 + 条件变量解决"生产者-消费者"问题

      MutexLock lock;                    //互斥锁
      ConditionVariaty cond_c, cond_p;   //两个条件变量,分别用于唤醒消费者和生产者线程
    
      buffer = N;
      maxBuffer = N;
    

    生产者

      void producer() {
    
          while (true) {
    
              pthread_mutex_lock(&lock);    //加锁
              while (buffer >= N) {
                  pthread_cond_wait(&cond_p, &lock);  //等待
              }
              insert_item();
              buffer++;
              pthread_cond_signal(&cond_c);    //唤醒消费者
              pthread_mutex_unlock(&lock);     //释放缓冲区的锁
          }
      }
    

    消费者

      void consumer() {
    
          while (true) {
    
              pthread_mutex_lock(&lock);    //加锁
              while (buffer <= 0) {
                  pthread_cond_wait(&cond_c, &lock);  //等待
              }
              consume_item();
              buffer--;
              pthread_cond_signal(&cond_p);    //唤醒消费者
              pthread_mutex_unlock(&lock);     //释放缓冲区的锁
          }
      }
    

    判断是否要等待用while的原因:

    是很多情况不知道唤醒者使用了pthread_cond_broadcast还是pthread_cond_signal(可以类比notify()和notifyAll()),因此如果用if的话,那可能会出现很多生产者线程都被唤醒,其中一个线程生产一个,导致缓冲区再次充满,而其他生产者线程被调度时则继续向下执行,这样就会发生错误。因此使用while判断防止__伪唤醒__

  • 管程

    (1) “信号量”部分的"生产者-消费者"问题,如果没有注意到加锁、解锁的顺序问题,很容易造成__死锁__。为了减少程序员使用出错的可能性,引入__管程__来方便程序的编写

    (2) 管程的定义

    一个管程是一个由__过程__、变量、__数据结构__组成的一个集合,它们组成一个特殊的模块或软件包

    (3) 一个管程中只能有一个活跃进程(线程)

    (4) 进程可以调用管程中的过程,但是不能直接访问管程中的变量、数据结构

    (5) 进入管程时的互斥由__编译器__负责,通常的做法是用__互斥量__实现

    (6) 在管程中引入__条件变量__可以有效解决wait和signal

    (7) 一个管程的示例

      Monitor ExampleMonitor
    
          Integer i;
    
          Condition c;
    
          Procedure producer()
          Begin
              ...
          End
    
          Procedure consumer()
          Begin
              ...
          End
    

    (8) 管程中的wait/signal 与 普通的 sleep/wakeup区别

    普通的sleep/wakeup有可能会触发__竞态条件__,即线程切换时导致wakeup信号丢失;管程中的wait/signal和sleep/wakeup操作类似,但是由于管程保证了管程中只有一个活跃的进程(线程),所以不会发生线程切换,也就不会产生竞态条件的问题

    (9) 管程属于编程语言层面。但是有一些真正的编程语言支持管程,例如__Java__

    synchronized – 互斥锁

    wait() – 相当于sleep

    notify() – 相当于wakeup

    (10) 如果编程语言支持管程,那么管程比信号量更容易保证并行编程的正确性

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值