-
进程通信的三个问题
(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(); }
3° 解释
每个线程进入临界区之前,都要卡在 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__可以解决互斥锁的实现问题,但是它们都有__忙等待__的固有缺陷:
1° 浪费CPU的时间
2° 优先级反转问题
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) 如果编程语言支持管程,那么管程比信号量更容易保证并行编程的正确性
chapter02_进程与线程_3_进程(线程)间通信
最新推荐文章于 2021-02-27 23:56:50 发布