同步与互斥
为了克服并发问题中的竞态条件引入了锁机制从而使得多个进程之间能够互斥的访问共享资源。
而为了实现进程间的同步又引入了信号量和管程等模型。
生产者-消费者问题
考虑这样一个问题,有两个进程共享一个公共的固定大小的缓冲区。其中一个是生产者进程,一个是消费者进程。生产者进程向缓冲区中写入数据,消费者进程从缓冲区中消费数据。当生产者将数据写入缓冲区的时,如果缓冲区已满则让其睡眠,等到消费者从缓冲区中取出一个或多个数据项时再唤醒它。同样的,当消费者试图从缓冲区中取数据,如果缓冲区为空则让其睡眠,等到生产者向其中放入一个新的数据再唤醒它。
#define N 100 /* 缓冲区 slot 槽的数量 */
int count = 0 /* 缓冲区数据的数量 */
// 生产者
void producer(void){
int item;
while(TRUE){
item = produce_item() /* 生成下一项数据 */
if(count == N) { /* 如果缓存区是满的,就会阻塞 */
sleep();
}
insert_item(item); /* 把当前数据放在缓冲区中 */
count = count + 1; /* 增加缓冲区 count 的数量 */
if(count == 1){ /* 缓冲区是否为空? */
wakeup(consumer);
}
}
}
// 消费者
void consumer(void){
int item;
while(TRUE){
if(count == 0){ /* 如果缓冲区是空的,就会进行阻塞 */
sleep();
}
item = remove_item(); /* 从缓冲区中取出一个数据 */
count = count - 1 /* 将缓冲区的 count 数量减一 */
if(count == N - 1){ /* 缓冲区满嘛? */
wakeup(producer);
}
consumer_item(item); /* 打印数据项 */
}
}
假设 sleep
和 wakeup
代表的是进程间的通信中的睡眠和唤醒原语。在不允许进程进入临界区之前执行 sleep
会阻塞进程而不是忙等待。
上面代码中会产生竞争条件,因为 count 是没加锁的共享资源。
有可能出现下面这种情况:缓冲区为空,此时消费者刚好读取 count 的值发现它为 0 。此时调度程序决定暂停消费者并运行生产者。生产者生产了一条数据并把它放在缓冲区中,然后增加 count 的值, 随后生产者调用 wakeup 来唤醒消费者。但是,消费者此时在逻辑上并没有睡眠(还未执行sleep就被切换了),所以 wakeup 信号会丢失。当消费者下次启动后,它会查看之前读取的 count 值,发现它的值是 0 ,然后在此进行睡眠。不久之后生产者会填满整个缓冲区从而睡眠,这样两个进程就会永远睡眠了。
引起上面问题的本质是 唤醒尚未进行睡眠状态的进程会导致唤醒丢失。如果它没有丢失,则一切都很正常。一种快速解决上面问题的方式是增加一个唤醒等待位(wakeup waiting bit)
。当一个 wakeup 信号发送给仍在清醒的进程后,该位置为 1 。之后,当进程尝试睡眠的时候,如果唤醒等待位为 1 ,则该位清除,而进程仍然保持清醒。但是,如果进程数量很多,就需要加入更多的唤醒等待位,从根本上说这其实没有解决问题。
信号量(semaphore)
初识信号量
信号量是E.W.Dijkstra在1965年提出的一种方法,它使用一个取值范围大于等于0的整型变量(sem)来表示系统资源的数量。它既可以实现互斥,又可以实现同步。上例中的显示器就是信号量的一种互斥的实现。
信号量通常有两种原子操作——P 和 V
- P
- sem 减1
- 若 sem<0,则进程将睡眠,否则继续执行
- V
- sem 加1
- 若 sem≤0,表示现在有一个或多个进程在该信号量上睡眠,则唤醒一个等待进程
举个生活的例子来解释下:
在火车上上厕所时,如果厕所是空,门上的显示器就会显示"无人",如果有人进去并把门锁上后,门上显示器就变为"有人",这样后来者看到门上显示"有人"就会在外等待而不会冒然闯进来。
这其实就是使用信号量实现的一种互斥方式,其中门上的显示器就扮演了"信号量"的角色。它的值为1,A看到显示器的标识为“无人”(sem=1)就进入厕所并锁上了门(A进行一次P操作,sem = 0);后面的B也想上厕所,但是看到了显示器上“有人”这个标识(B进行一次P操作,sem = -1),所以B会等待。当A出来时(A进行一次V操作,sem = 0),这时B就会被唤醒,继续执行了。
信号量的特征
- 信号量是被保护的整数变量
- 初始化完成后,只能通过 P 和 V 操作修改
- 由操作系统保证,PV操作是原子操作
- P 可能阻塞,V 不会阻塞
- 通常假定信号量是“公平的”
- 线程不会被无限期阻塞在 P 操作
- 假定信号量等待按先进先出排队
信号量的实现
classSemaphore {
int sem;
WaitQueue q;
}
Semaphore::P() {
sem--;
if (sem < 0) {
Add this thread t to q;
block(p);
}
}
Semaphore::V() {
sem++;
if (sem <= 0) {
Remove a thread t from q;
wakeup(t);
}
}
信号量的分类
- 可分为两种信号量
- 二进制信号量:资源数目为0或1。常用于实现互斥访问。
- 计数信号量:资源数目为任何非负值。常用于实现条件同步(一个进程等待另一个进程的事件发生)
信号量实现互斥访问
每个临界区设置一个信号量,其初值为1。这种实现互斥的信号量又叫做互斥量(mutex)。
mutex = new Semaphore(1);
必须成对使用P、V 操作,且PV操作不能次序错误、重复或遗漏
mutex->P(); // P操作保证互斥访问临界资源
Critical Section;
mutex->V(); //V操作在使用后释放临界资源
信号量实现条件同步
每个条件同步设置一个信号量,其初值为0
mutex = new Semaphore(0);
线程A先进行P操作后被阻塞,随后线程B执行完事件后进行V操作,唤醒线程A,这样就实现了线程A等待线程B的事件先发生。
用信号量解决有界缓冲区的生产者-消费者问题
对上例的生产者-消费者问题进行简单的归纳:
- 一个或多个生产者在生成数据后放在一个缓冲区里
- 单个消费者从缓冲区取出数据处理
- 任何时刻只能有一个生产者或消费者可访问缓冲区
问题分析
- 任何时刻只能有一个线程操作缓冲区(互斥访问)
- 缓冲区空时,消费者必须等待生产者(条件同步)
- 缓冲区满时,生产者必须等待消费者(条件同步)
代码实现
信号量
#define N 100 /* 定义缓冲区槽的数量 */
typedef int semaphore; /* 信号量是一种特殊的 int */
semaphore mutex = 1; /* 二进制信号量mutex,控制临界区的访问 */
semaphore empty = N; /* 计数信号量empty,统计 buffer 空槽的数量 */
semaphore full = 0; /* 计数信号量full,统计 buffer 满槽的数量 */
生产者
void producer(void){
int item;
while(1){
item = producer_item(); /* 产生放在缓冲区的一些数据 */
p(&empty); /* N表示可以由多个生产者进行P操作 */
p(&mutex); /* 进入关键区域 */
insert_item(item); /* 把数据放入缓冲区中 */
v(&mutex); /* 离开临界区 */
v(&full); /* 将 buffer 满槽数量 + 1 */
}
}
消费者
void consumer(void){
int item;
while(1){
p(&full); /* 缓存区满槽数量 - 1 */
p(&mutex); /* 进入缓冲区 */
item = remove_item(); /* 从缓冲区取出数据 */
v(&mutex); /* 离开临界区 */
v(&empty); /* 将空槽数目 + 1 */
consume_item(item); /* 处理数据 */
}
}
注意事项
**注意P操作的顺序:**实现互斥的P操作一定要在实现同步的P操作之后,否则可能引发“死锁”。
假设将生产者代码中的两个 p 操作交换一下次序,即先 p(&mutex) 后 p(&empty)。如果缓冲区完全满了, p(&mutex) 后 mutex 为0,p(&empty) 后为 -1,生产者将阻塞。这样一来,当消费者下次试图访问缓冲区时,再进行 p(&mutex) 时, 由于生产者阻塞未执行 v(&mutex) 操作,使得 mutex 的值此时为 0,消费者执行 p(&mutex) 后导致消费者也将阻塞。两个进程都将永远地阻塞下去,无法再进行有效的工作,这种不幸的状况称作死锁(dead lock)。
信号量的缺陷
- 理解和编写代码比较困难
- 容易出错(比如使用的信号量已经被另一个线程占用或忘记释放信号量)
- 不能够处理死锁问题
总的来说,直接使用信号量来编程对人的要求比较高。为了降低这种复杂度,计算机科学家引入了管程模型。
管程(monitor)
初识管程
管程是一个由过程、变量及数据结构等组成的一个集合。任何时候管程中只能有一个活跃的进程,正在管程中的进程可临时放弃管程的互斥访问,等待事件出现时恢复。
通常情况下,当进程调用管程中的程序时,该程序的前几条指令会检查管程中是否有其他活跃的进程。如果有的话,调用进程将被挂起,直到另一个进程离开管程才将其唤醒。如果没有活跃进程在使用管程,则该调用进程可直接进入。
通过临界区自动的互斥,管程比信号量更容易保证并行编程的正确性。但是管程是某些编程语言的特性,由编译器来识别并用某种方式对其互斥作出保证。JAVA有管程,但是C、Pascal 以及大多数其他编程语言都没有管程。
管程的使用
在生产者-消费者问题中,很容易将针对缓冲区满和缓冲区空的测试放到管程过程中,但是生产者在发现缓冲区满的时候如何阻塞呢? 为了使进程在无法继续运行时被阻塞,管程模型引入了条件变量以及相关的两个操作 wait
和 signal
。
当一个管程程序发现它不能运行时(例如,生产者发现缓冲区已满),它会在某个条件变量(如 full)上执行 wait
操作。这个操作造成调用进程阻塞,并且还将另一个以前等在管程之外的进程调入管程。另一个进程,比如消费者可以通过执行 signal
来唤醒阻塞的调用进程。如果在一个条件变量上有若干进程都在等待,则在对该条件执行 signal 操作后,系统调度程序只能选择其中一个进程恢复运行。
条件变量不是计数器。条件变量也不能像信号量那样积累信号以便以后使用。所以,如果向一个条件变量发送信号,但是该条件变量上没有等待进程,那么信号将会丢失。也就是说,wait 操作必须在 signal 之前执行。
wait 和 signal 操作看起来像是前面提到的 sleep 和 wakeup 。但是 sleep 和 wakeup 之所以会失败是因为当一个进程想睡眠时,另一个进程试图去唤醒它。使用管程则不会发生这种情况。管程程序的自动互斥保证了这一点,如果管程过程中的生产者发现缓冲区已满,它将能够完成 wait 操作而不用担心调度程序可能会在 wait 完成之前切换到消费者。甚至,在 wait 执行完成并且把生产者标志为不可运行之前,是不会允许消费者进入管程的。
管程的组成
- 锁:控制管程代码的互斥访问
- 0或者多个条件变量:管理共享数据的并发访问
由于管程是互斥的,进入不到管程的进程会被挂在entry queue上。进入管程中的进程在进行操作时,有可能对某个共享变量的操作由于某个条件得不到满足而无法进行,此时该进程就会被挂到该条件变量的等待队列上,并释放锁,使得其他入口队列中的进程进入管程。
条件变量
- 条件变量是管程内的等待机制
- 进入管程的线程因资源被占用而进入等待状态
- 每个条件变量表示一种等待原因,对应一个等待队列
- Wait()操作
- 将自己阻塞在等待队列中
- 唤醒一个等待者或释放管程的互斥访问
- Signal()操作
- 将等待队列中的一个(或全部)进程唤醒
- 如果等待队列为空,则等同空操作
条件变量的实现如下:
由于进入管程的进程一定是拥有锁的进程,所以在进行wait操作时,等待队列中的数量加1,并将进程添加到等待队列,同时会释放该进程一样的锁,这样再后面进行调度的时候,被选中的其他进程才能获得锁,进入管程中。在 signal 操作中,如果在条件变量的等待队列中没有任何进程,则该操作相当于什么也没做。
在管程中,整形变量 numWaiting 表示当前挂在条件变量上的等待队列中的进程数,而semaphore表示的是信号量的个数。
在管程中,整形变量增加后不一定会有对应的减法操作,但是信号量的PV操作是一定会成对出现,有加必有减。
用管程解决生产者-消费者问题
C++实现
Class BoundedBuffer {
…
Lock lock;
int count = 0;
Condition notFull, notEmpty;
}
BoundedBuffer 类就是一个管程,它定义了一把锁和两个条件变量,并用count来记录缓冲区的大小。
BoundedBuffer::Producer(c) {
lock->Acquire();
while (count == n)
notFull.Wait(&lock);
Add c to the buffer;
count++;
notEmpty.Signal();
lock->Release();
}
管程的定义决定了进入整个代码块时必须是互斥的,如果是用信号量则只需要在进行" Add c to the buffer;"操作的前后进行PV操作即可。
总体上和信号量的实现差不多,只是一些细节上有些不同。
BoundedBuffer::Consumer(c) {
lock->Acquire();
while (count == 0)
notEmpty.Wait(&lock);
Remove c from buffer;
count--;
notFull.Signal();
lock->Release();
}
Java实现
生产者
class Producer extends Thread {
private Our_monitor mon;
Producer (Our_monitor mon) {
this.mon = mon;
}
public void run(){ // run 包含了线程代码
int item;
while(true){ // 生产者循环
item = produce_item();
mon.insert(item);
}
}
private int produce_item(){...} // 实际生产
}
消费者
class Consumer extends Thread {
private Our_monitor mon;
Consumer (Our_monitor mon) {
this.mon = mon;
}
public void run( ) { // run 包含了线程代码
int item;
while(true){
item = mon.remove();
consume_item(item);
}
}
private int produce_item(){...} // 实际消费
}
管程
class Our_monitor {
static final int N = 100; // 定义缓冲区大小
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; // 找到下一个槽的为止
count = count + 1; // 缓冲区中的数目自增 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; // 设置待取出数据项的槽
count = count - 1; // 缓冲区中的数据项数目减 1
if(count = N - 1){
notify(); // 如果生产者睡眠,唤醒它
}
return val;
}
private void go_to_sleep() {
try{
wait( );
}catch(Interr uptedExceptionexc) {};
}
}
主应用
public class ProducerConsumer {
Our_monitor mon = new Our_monitor();
Producer p = new Producer(mon); // 初始化一个生产者线程
Consumer c = new Consumer(mon); // 初始化一个消费者线程
public static void mian(String[] args) {
p.start();
c.start();
}
}
Our_monitor 中包含了缓冲区、管理变量以及两个同步方法。当生产者在 insert 内活动时,它保证消费者不能在 remove 方法中运行,从而保证更新变量以及缓冲区的安全性,并且不用担心竞争条件。变量 count 记录在缓冲区中数据的数量。变量 lo 是缓冲区槽的序号,指出将要取出的下一个数据项。类似地,hi 是缓冲区中下一个要放入的数据项序号。允许 lo = hi,含义是在缓冲区中有 0 个或 N 个数据。
Java 中的同步方法与其他经典管程有本质差别:Java 没有内嵌的条件变量。
然而,Java 提供了 wait 和 notify 分别与 sleep 和 wakeup 等价。
Hansen 管程与 Hoare 管程
当调用signal唤醒等待队列中的进程后,管程中的进程就会有2个或以上,为了保持管程中的互斥性,此时应该保留哪个呢?
Brinch Hansen 和 Hoare 在对进程唤醒上有所不同,Hoare 建议让新唤醒的进程继续运行,而挂起其他进程。而 Brinch Hansen 建议让执行 signal 的进程必须先退出管程后再运行其他进程。通常采用 Brinch Hansen 的建议,因为它在概念上更简单,并且更容易实现。
Hansen 管程
Hansen-style :Producer(){
lock->acquire();
while (count == n) {
notFull.wait(&lock);
}
Add thing;
count++;
notEmpty.signal();
lock->release();
}
在 Hansen 管程中signal操作仅仅是一个提示,收到消息的进程需要重新检查条件
Hoare 管程
Hoare-style: Producer(){
lock->acquire();
if (count == n) {
notFull.wait(&lock);
}
Add thing;
count++;
notEmpty.signal();
lock->release();
}
虚假唤醒
注意到 Hansen 管程 和 Hoare 管程中的区别,前者用的while,后者用的if。这是由于他们不同的唤醒策略导致的。如果将 Hansen 管程模型中的while改为if则会出现虚假唤醒的情况。
假设存在生产者进程A、B,它们现在都被挂起在notFull条件变量的等待队列中,消费者C执行 count–,此时 count=n-1, 随后执行 signal 操作后释放锁并退出管程。进程A、B争夺锁,假设A抢到锁,进入管程。由于是 if 语句,所以程序直接向下执行,执行 count++后 count=n,然后释放锁退出管程;之后进程B进入管程,重新该过程,count 的最终值为 n+1。显然在进程A执行完后,由于count又恢复到了N,进程B进入管程后应该挂起等待,但由于使用的是 if 来检查条件而不是 while 从而导致进程B被虚假唤醒了。
管程的缺陷
与管程和信号量有关的另一个问题是,这些机制都是设计用来解决访问共享内存的一个或多个 CPU 上的互斥问题的。通过将信号量放在共享内存中并用 TSL
或 XCHG
指令来保护它们,可以避免竞争。但是如果是在分布式系统中,可能同时具有多个 CPU 的情况,并且每个 CPU 都有自己的私有内存,它们通过网络相连,那么这些原语将会失效。因为信号量太低级了,而管程在少数几种编程语言之外无法使用,所以还需要其他方法。
消息传递(messaage passing)
消息传递有两个原语 send 和 receive。它们像信号量而不像管程,是系统调用而不是语言级别。示例如下:
send(destination, &message);
receive(source, &message);
send 方法用于向一个给定的目标发送一条消息,receive 从一个给定的源接受一条消息。
如果没有消息,接受者可能被阻塞,直到接受一条消息或者带着错误码返回。
消息传递系统的设计要点
消息传递系统现在面临着许多信号量和管程所未涉及的问题和设计难点,尤其对那些在网络中不同机器上的通信状况。比如:
-
如何防止消息丢失?
- 为了防止消息丢失,发送方和接收方可以达成一致:一旦接受到消息后,接收方马上回送一条特殊的
确认(acknowledgement)
消息。如果发送方在一段时间间隔内未收到确认,则重发消息。
- 为了防止消息丢失,发送方和接收方可以达成一致:一旦接受到消息后,接收方马上回送一条特殊的
-
消费者如何区分新的消息和一条重发的老消息?
- 通常采用在每条原始消息中嵌入一个连续的序号来解决此问题。如果接受者收到一条消息,它具有与前面某一条消息一样的序号,就知道这条消息是重复的,可以忽略。
-
如何命名进程的问题,以便在发送或接收调用中清晰的指明进程?
-
身份验证的问题,客户端怎么知道它是在与一个真正的文件服务器通信?
用消息传递解决生产者-消费者问题
#define N 100 /* buffer 中槽的数量 */
void producer(void){
int item;
message m; /* buffer 中槽的数量 */
while(TRUE){
item = produce_item(); /* 生成放入缓冲区的数据 */
receive(consumer,&m); /* 等待消费者发送空缓冲区 */
build_message(&m,item); /* 建立一个待发送的消息 */
send(consumer,&m); /* 发送给消费者 */
}
}
void consumer(void){
int item,i;
message m;
for(int i = 0;i < N;i++){
send(producer,&m); /* 发送N个缓冲区 */
}
while(TRUE){
receive(producer,&m); /* 接受包含数据的消息 */
item = extract_item(&m); /* 将数据从消息中提取出来 */
send(producer,&m); /* 将空缓冲区发送回生产者 */
consume_item(item); /* 处理数据 */
}
}
假设所有的消息都有相同的大小,并且在尚未接受到发出的消息时,由操作系统自动进行缓冲。在该解决方案中共使用 N 条消息,这就类似于一块共享内存缓冲区的 N 个槽。消费者首先将 N 条空消息发送给生产者。当生产者向消费者传递一个数据项时,它取走一条空消息并返回一条填充了内容的消息。通过这种方式,系统中总的消息数量保持不变,所以消息都可以存放在事先确定数量的内存中。
如果生产者的速度要比消费者快,则所有的消息最终都将被填满,等待消费者,生产者将被阻塞,等待返回一条空消息。如果消费者速度快,那么情况将正相反:所有的消息均为空,等待生产者来填充,消费者将被阻塞,以等待一条填充过的消息。
屏障(barrier)
最后一个同步机制是准备用于进程组而不是进程间的生产者-消费者情况的。
在某些应用中划分了若干阶段,并且规定,除非所有的进程都就绪准备着手下一个阶段,否则任何进程都不能进入下一个阶段,可以通过在每个阶段的结尾安装一个屏障来实现这种行为。当一个进程到达屏障时,它会被屏障所拦截,直到所有的屏障都到达为止。屏障可用于一组进程同步,如下图所示
在上图中我们可以看到,有四个进程接近屏障,这意味着每个进程都在进行运算,但是还没有到达每个阶段的结尾。过了一段时间后,A、B、D 三个进程都到达了屏障,各自的进程被挂起,但此时还不能进入下一个阶段呢,因为进程 B 还没有执行完毕。结果,当最后一个 C 到达屏障后,这个进程组才能够进入下一个阶段。
屏障简而言之就是,筹齐龙珠才能召唤神龙。
避免锁:读-复制-更新(RCU)
类似读写锁
没看太懂,感觉类似读写锁
https://zhuanlan.zhihu.com/p/89439043?utm_source=wechat_session