多道程序设计技术是现代操作系统的基础。在进程并发执行时。各个协同进程运行次序的不同会导致不同的运行结果,从而出现运行错误。
数据不一致性
多道程序设计技术和多核处理器在观代操作系统中广泛应用,系统中的多个进程并发或并行执行已经成为常态。每个进程可在任何时候被中断,仅仅进程的部分代码片段可连续执行。
共享数据并发/并行访问:数据不一致性,又称不可再现性:同一进程在同一批数据上多次运行的结果不一样。
数据不一致性例子:有界缓冲
例子:n个缓冲区的有界缓冲问题
- 增加变量counter,初始化为0;
- 向缓冲区增加一项时,counter加1;
- 从缓冲区移去一项时,counter减1。
- 数据结构:
Shared data
#define BUFFER SIZE 10
typedef struct{
...
}item;
item buffer[BUFFER_SIZE];
int in=0;int out=0;
int counter=0;
生产者进程enter()
item nextProduced;
while(1){
while(counter==BUFFER_SIZE)
;/*do nothing*/
buffer[in]=nextProduced;
in=(in +1)%BUFFER_SIZE;
counter++;
}
消费者进程remove()
item nextConsumed;
while(1){
while(counter==0)
;/*do nothing*/
nextConsumed=buffer[out];
out=(out +1)%BUFFER_SIZE;
counter- -;
}
“counter++”的伪机器语言:
(SO)register1=counter
(S1)register1=register1+1
(S2)counter=register1
“counter–”的伪机器语言:
(S3)register2=counter
(S4)register2=register2-1
(S5)counter=register2
例题:
在有界缓冲问题中,生产者中的语句counter++和消费者中的语句counter—对应的伪机器指令如下:
“counter++”的伪机器语言:
(S0)register1 = counter
(S1)register1 = register1 + 1
(S2)counter = register1
“counter—”的伪机器语言:
(S3)register2 = counter
(S4)register2 = register2 – 1
(S5)counter = register2
假如当前counter=2,则可能存在()种不同的执行结果。
A. 1
B.2
C.3
D.以上都不对
C
运行错误例子:
初时counter=5:
S0:register1=counter {register1=5}
S1:register1=register1+1 {register1=6}
S3:register2=counter {register2=5}
S4:register2=register2-1 {register2=4}
S2:counter=register1 {counter=6}
S5:counter=register2 {counter=4}
解决方法:
规定这6个语句的运行次序,把counter++和counter–的语句分别作为一个整体来运行,也就是counter++或counter–的三个语句必须分别连续运行,不可中断。
例如:
原子操作:一个操作在整个执行期间不能被中断。
导致数据不一致性的原因:
竞争条件:多个进程并发访问同一共享数据,而共享数据的最终结果取决于最后操作的进程。
防止竞争条件方法:并发进程同步或互斥。
同步和互斥
同步:协调进程的执行次序,使并发进程间能有效地共享资源和相互合作,保证数据一致性。
协调执行次序。
互斥:进程排他性地运行某段代码,任何时候只有一个进程能够运行。
临界资源
-
临界资源是指一次只允许一个进程使用的资源,又称互斥资源、独占资源或共享变量。
-
共享资源:一次允许多个进程使用的资源。
-
临界资源例子:
许多物理设备都属于临界资源,如输入机、打印机、磁带机等,还有上例中的counter属于临界资源。
临界区
临界区是指涉及临界资源的代码段。
临界区是代码片段;
是进程内的代码;
每一个进程有一个或多个临界区;
临界区的设置方法由程序员确定。
若能保证诸进程互斥进入关联的临界区,可实现对临界资源的互斥访问。
临界区例子:
临界区使用准则
如何实现进程之间互斥使用临界区,从而保证数据的一致性呢?
- 互斥
假定进程Pi在某个临界区执行,其他进程将被排成在该临界区外;
有相同临界资源的临界区都需互斥;
无相同临界资源的临界区不需互斥。 - 有空让进
临界区内无进程执行,不能无限期地延长下一个要进临界区进程的等待时间。
意思就是每个离开临界区的进程应该开放临界区,让等待进入的进程可以进入。 - 有限等待
每个进程进入临界区前的等待时间必须有限,不能无限等待。
意思就是每个进程的临界区大小必须尽可能小,否则其他进程可能长时间等待。
访问临界区过程:
- 在进入区实现互斥准则,保证任何进程可以进入临界区;
- 在退出区实现有空让进准则;
- 每个临界区不能过大,从而实现有限等待准则。
信号量
信号量就是同步机制的一种实现方法。
早期硬件解决办法,程序设计人员认为太复杂。
后来提出了软件解决方法:
- 保证两个或多个代码段不被并发调用;
- 在进入关键代码段前,进程必须获取一个信号量,否则不能运行;
- 执行完该关键代码段,必须释放信号量;
- 信号量有值,为正说明它空闲,为负说明其忙碌。
整型信号量:
信号量S——整型变量
提供两个不可分割的[原子操作]访问信号量。
wait(S):
while S<=0 do no-op;
S--;
signal(S):
S++;
wait(S)又称为P(S),signal(S)又称为V(S)
整型信号量的问题是忙等。如果S<=0,该进程将不断重复执行while语句,在耗费CPU资源的同时,进程没有往前推进,造成了CPU的浪费。
忙等的解决方法是引入记录型信号量。记录型信号量增加了一个等待队列。当一个进程无法获得一个信号量时。马上释放CPU并把自己转换为等待状态。加入该信号量的等待队列。从而消除忙等。
记录型信号量定义:
typedef struct{
int value;
struct process *list;
}semaphore;
记录型信号量的wait操作:
Wait(semaphore *S){
S->value--;//将信号量S的值分别减1,申请一个S信号量
if(S->value<0){//该进程无法获得一个S信号量,加入等待队列S->list
add this process to list S->list;
block();
}
}
记录型信号量是先把信号量的值减1再判断。而整型信号量是先判断再减1。
目的是可以知道由于申请该信号量而被阻塞的进程数量。
当S是一个负数时,|S|表示S的等待队列中等待该信号量的进程数目。
记录型信号量的signal操作:
Signal(semaphore *S){
S->value++;//释放S信号量
if(S->value<=0){//有进程在等待S信号量
remove a process P from list S->list;//从队列首部遗弃一个进程P
wakeup(P);//唤醒等待的进程
}
}
记录型信号量的改进在于加入了阻塞和唤醒机制,消除了忙等。
信号量类型
-
计数信号量
变化范围:没有限制的整型值;
计数信号量=同步信号量 -
二值信号量
变化范围仅限于0和1的信号量(整型信号量);
二值信号量=互斥信号量
信号量的使用:
S必须置一次且只能置一次初值;
S初值不能为负数;
除了初始化,只能通过执行P、V操作来访问S,不能在代码中直接访问信号量S。
互斥信号量使用(在临界区):
Semaphore *S;//初始化为1
wait(S);//在临界区前执行wait操作,申请信号量,申请到进入临界区,申请不到就阻塞
CriticalSection() //临界区
signal(S);
同步信号量使用
同步信号量比较复杂。一般情况下,同步信号量的wait和signal操作位于两个不同进程内。
例子:P1和P2需要C1比C2先运行
semaphore s=0
P1:
C1;
signal(s); //把s的值设为1
P2:
wait(s); //申请到信号量s
C2;
使用同步和互斥机制来保证生产者和消费者在并发执行时的数据一致性。
三类经典同步问题:
- 生产者-消费者问题
共享有限缓冲区 - 读者写者问题
数据读写操作 - 哲学家就餐问题
资源竞争
生产者消费者问题
问题描述
生产者消费者问题的本质是如何实现生产者和消费者之间的同步和互斥。
生产者:{
...
生产一个产品
...
把产品放入指定缓冲区
}
消费者:{
...
从指定缓冲区取出产品
...
消费取出的产品
}
互斥分析基本方法:
①分析每个进程的代码,查找临界资源。
②划分临界区;
③定义互斥信号量并赋初值,一般互斥信号量的初值为1;
④在临界区前的进入区加wait操作,在临界区后的退出区加signal操作。
生产者消费者的互斥分析
生产者(多个):
buffer[in]=nextProduced;
in = (in+1) % BUFFER_SIZE;
counter++;
把产品放入指定缓冲区;
in:所有的生产者对in指针需要互斥;
counter:所有生产者消费者进程对counter互斥,否则会导致数据不一致性。
消费者(多个):
nextConsumed = buffer[out];
out = (out+1) % BUFFER_SIZE;
counter--;
从指定缓冲区取出产品;
out:所有的消费者对out指针需要互斥;
counter:所有生产者消费者进程对counter互斥。
根据以上的互斥分析可知,多个生产者由于共享变量in需要互斥访问生产者的临界区;多个消费者由于共享变量out需要互斥访问消费者的临界区;生产者和消费者由于共享变量counter需要互斥访问生产者和消费者的临界区。所以任何时候只有一个进程可以进入以上谈到的两个临界区中的一个。
增加互斥机制:
同步分析基本方法
①找出需要同步的代码片段(关键代码);
②分析这些代码片段的执行次序,谁先谁后,有没有一定的次序;
③根据次序分析。增加同步信号量并赋初始值;
④在关键代码前后分别加wait和signal操作。
同步分析较为困难。
生产者消费者的同步分析
- 分析关键代码
两者需要协同的部分:
生产者:把产品放入指定缓冲区(关键代码C1)
消费者:从满缓冲区取出一个产品(关键代码C2) - 分析C1和C2的运行次序
三种运行次序(不同条件下不同运行次序)
所有缓冲区空时,消费者必须等生产者生产出产品后才能消费即C1先于C2运行;
所有缓冲区满时,生产者必须要等消费者消费完才能生产产品即C2先于C2运行;
缓冲区有空也有满时,既可以先C1后C2,也可以先C2后C1。
算法描述
同步信号量定义
共享数据:
semaphore *full,*empty,*m; //full:满缓冲区数量 empty:空缓冲区数量
初始化:
full->value=0; empty->value=N; //N是缓冲区个数m->value=1;
读者写者问题
两组并发进程:读者和写者共享一组数据区进行读写;
要求:允许多个读者同时读;不允许读者、写者同时读写;不允许多个写者同时写。
第一类读者写者问题——读者优先
读者:
无读者、写者,新读者可读;
有写者等,但有其它读者在读,则新读者也可读;
有写者写,新读者等。
写者:
无读者和写者,新写者可写,阻止其他读者和写者进入;
有读者,新写者等待;
有其他写者,新写者等待。
解决办法
让所有的读者、写者进程的读写都互斥。
临界区在读者进程中是读操作,在写者进程中是写操作。
Semephore *W; //互斥信号量W
W->value=1;
这种模式要求读者之间也要互斥。违背了“有写者在等,但有其它读者在读时,则新读者可进入数据区读”这个要求。当一个读者获得信号量W进入数据区读后,后续的读者无法继续进入数据区,不能实现读共享。
解决方法:
读者进入数据区读时,区分第一个读者和其它读者;读者离开数据区时,区分最后离开的读者和其它读者。
当第一个读者进入数据区之后,应该不让写者进入数据区写,让其他读者可以进入读;
当一个读者离开数据区时,如果它不是最后一个读者时,可以直接离开数据区,否则,需要允许后面等待的写者进入数据区。
修改方法:
增加一个读者计数器rc,设置初始值为0;
if(rc==1)
是:执行P(W)。阻止写者进入数据区写。
if(rc==0)
不是:直接离开。
是:则需要执行V(W)。查看W的等待队列中是否有写者等待。如果有则唤醒一个等待的写进程。这样,写者就可以进入写了。
由于读者计数器rc可能被多个读者进程同时读,可能会导致数据一致性问题。rc是一个临界资源。
再增加一个互斥信号量M,设置初始值为1;
哲学家就餐问题
这是共享资源竞争的例子。把5根筷子分别看做5个互斥信号量。任意一个哲学家只有拿起左右两根筷子,也就是获得左右两个信号量后才能吃饭。吃完饭后,该哲学家应该放下拿起的两根筷子,也就是释放左右两个信号量。
把吃饭看成两个临界区,
semaphore *chopstick[5]; //初始值为1
哲学家i:
...
P(chopStick[i]); //拿左边筷子
P(chopStick[(i+1)%5]); //拿右边筷子
吃饭
V(chopStick[i]); //放下左边筷子
V(chopStick[(i+1)%5]); //放下右边筷子
...
对于任意一个哲学家i来说,在吃饭前要通过P操作,也就是wait操作,拿起自己左右的两个筷子。P(chopStick[i]) 拿左边筷子,P(chopStick[(i+1)%5])拿右边筷子。
V(chopStick[i])放下左边筷子,V(chopStick[(i+1) % 5])放下右边筷子。
这种解决方法存在死锁问题。如果5个哲学家同时拿起了左边的筷子,这5个哲学家之间对筷子存在循环等待,从而导致他们都无法吃饭,形成了死锁。如果给5个哲学家6根筷子,则不会有死锁发生。这种死锁会导致进程无法推进、资源无法使用,是操作系统必须要解决的。
为防止死锁的解决措施
- 方法1:最多允许4个哲学家同时坐在桌子周围;
具体解决方法:
- 声明一个同步信号量seat。初始值为4
- 规定每个哲学家必须申请到椅子坐下后才能拿筷子
- 在哲学家吃完饭后,必须从椅子上站起来离开,便于其它哲学家坐下吃饭。
semaphore *chopstick[5]; //初始值为1
semaphore *seat; //初始值为4
哲学家i:
...
P(seat); //看看4个座位是否有空
P(chopStick[i]); //拿左边筷子
P(chopStick[(i+1)%5]); //拿右边筷子
吃饭
V(chopStick[i]); //放下左边筷子
V(chopStick[(i+1)%5]); //放下右边筷子
V(seat); //释放占据的位置
...
- 方法2:仅当一个哲学家左右两边筷子都可用时,才允许他拿筷子;
- 两根筷子都空闲,则该哲学家可以拿起两根筷子吃饭;
- 只要有一根筷子在被其它哲学家使用,那么两根筷子都无法拿到。
哲学家分为3个状态:
int *state={Thinking, hungry, eating};
设置5个信号量,对应5个哲学家
semaphore *ph[5]; //初始值为0
void test(int i){
//判断是否饿了,左边,右边哲学家是否在吃饭
if(state[i]==hungry && state[(i+4)%5]!=eating && state[(i+1)%5!-eating){
state[i]=eating; //设置哲学家状态为eating
V(ph[i]); //ph[i]设置为1
3. 方法3:给所有哲学家编号,奇数号哲学家必须首先拿左边筷子,偶数号哲学家则反之。
信号量值S的含义
S>0:有S个资源可用
S=0:无资源可用
S<0:则|S|表示S等待队列中的进程个数
P(S):申请一个资源
V(S):释放一个资源
互斥信号量初始值:一般为1
同步信号量初始值:0-N的整数
信号量的使用
- P、V操作成对出现
互斥操作:P、V操作处于同一进程内
同步操作:P、V操作在不同进程内 - 两个一起的P操作的顺序至关重要
同步与互斥P操作一起时,同步P操作要在互斥P操作前 - 两个V操作的次序无关紧要
死锁:信号量使用不当
管程
信号量机制的问题
- 信号量机制的优点:
程序效率高,编程灵活; - 信号量机制的问题:
需要程序员实现,编程困难;
维护困难;
容易出错:
wait/signal位置错;
wait/signal不配对。 - 解决方法:
管程,由编程语言解决同步互斥问题;
管程定义
- Hansen的管程定义
一个管程定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据。 - 结构
monitor monitor-name{
//共享变量定义
//操作
public entry p1(…){…}
public entry p2(…){…}
…
//初始化代码
Initialization_code(…){…}
管程功能
- 互斥
- 管程中的变量只能被管程中的操作访问
- 任何时候只有一个进程在管程中操作
- 类似临界区
- 由编译器完成
- 同步
- 条件变量
- 唤醒和阻塞操作
x.wait():进程阻塞直到另外一个进程调用x.signal()
x.signal():唤醒另外一个进程
条件变量问题
- 管程内可能存在不止1个进程
如:进程P调用signal操作唤醒进程Q后 - 存在的可能
P等待直到Q离开管程(Hoare)
Q等待直到P离开管程(Lampson&Redl,MESA语言)
P的singal操作是P在管程内的最后一个语句(Hansen,并行Pascal)
Hoare管程
- 进程互斥进入管程
如果有进程在管程内运行,管程外的进程等待;
入口队列:等待进入管程的进程队列。 - 管程内进程P唤醒Q后
P等待,Q运行;
P加入紧急队列,紧急队列的优先级高于入口队列。 - condition x;
- x.wait()
紧急队列非空:唤醒第一个等待进程;
紧急队列空:释放管程控制权,允许入口队列进程进入管程;
执行该操作进程进入x的条件队列; - x.signal()
x的条件队列空:空操作,执行该操作进程继续运行;
x的条件队列非空:唤醒该条件队列的第一个等待进程;
执行该操作进程进入就紧急队列。
哲学家就餐Hoare管程解决方案
每个哲学家按照以下的顺序轮流调用操作pickup()和putdown()。
dp.pickup(i)
吃饭
dp.putdow(i)
Linux同步机制
- 使用禁止中断来实现短的临界区
- 自旋锁(spinlock)
调用wait操作不会引起调用者阻塞 - 互斥锁(Mutex)
- 条件变量(Condition Variable)
- 信号量(Semaphore)
Windows同步机制
- 事件(Event)
通过通知操作的方式来保持线程的同步 - 临界区(Critical Section)
- 互斥锁(Mutex)
- 自旋锁(Spinlock)
- 信号量(Semaphore)