并发执行的程序会出现很多问题,一个简单的例子是生产者-消费者问题。
生产者生产资源(执行count++
)的同时消费者消耗(count--
),count
的初值是5,理想结果是count=5
,但如果同时执行两条语句,对于生产者和消费者,他们的count
都是5,那么最后根据恢复的情况不同(生产者后执行完或消费者后执行完),最后会导致count
还有4、6两种情况。
这是并发带来的问题,即多个进程并发访问和操作同一数据且执行结果与访问发生的特定顺序有关,称为竞争条件(race condition),为了避免这一个问题,需要确保一段时间只有一个进程能操作变量count
需要避免这些问题,因此需要进程同步(Process synchronization)和协调(coordination)
临界区(critical section)
是指多个进程中能够改变共同变量,更新同一个表,访问同一个文件的代码区段,当其中一个进程进入临界区的时候,没有任何其他进程能够进入临界区。
临界区问题
是设计一个协议,让进程能够协作。每个进程必须通过请求进入临界区,实现这一请求的代码称为进入区(entry section),临界区之后会有退出区(exit section),其他代码称为剩余区(remainder section),其通用结构如下。
临界区问题的答案需求
在操作系统的临界区问题中,通过抢占内核和非抢占内核两种方式来处理,很显然非抢占内核不会导致竞争条件,而对于抢占内核,则需要精心设计以避免竞争条件
该算法是一个经典的基于软件的临界区问题的解答,但只适用于两个进程在临界区和剩余区间交替执行。
Peterson算法在两个进程间共享两个数据,turn表示哪个进程可以进入临界区,flag表示哪个进程想进入临界区。
int turn;
boolean flag[2];
在该算法中的两个进程,假设为pi和pj,当使用pi时候,用pj来表示另一个进程,即j=1-i
。
该算法在进程i中的结构如下:
do{
flag[i] = TRUE;
turn = j;
while(flag[j] && turn == j);
//临界区
flag[i] = FALSE;
//剩余区
}while(TRUE);
这个算法其实是两个进程之间互相“谦让”的:如果对方想进,并且turn是对方的,那么自己就进入等待区。
如果两个进程同时想进入临界区,那么会同时改变turn的值,但turn最后只会保持为一个值,也就是竞争是平等的,但如果争不过,那就不争了
通过一些论证可以说明Peterson算法的解答是正确的(也即满足了互斥成立,前进要求满足,有限等待要求满足)
Peterson算法是基于软件的解答,而硬件也可以有一些机制来解决临界区问题。
其中,单处理器和多处理器的情况不太一样:
因此,现代计算机系统提供了特殊的硬件指令来相对简单的解决临界区问题,现在将这种指令的概念抽象成代码,可以描述成下文的形式:
//TestAndSet()指令的定义
boolean TestAndSet(boolean *target){
boolean rv = * target;
*target = TRUE;
return rv;
}
//使用TestAndSet的互斥实现
do{
while(TestAndSetLock(&lock));
//临界区
lock = FALSE:
//剩余区
}while(TRUE);
//Swap() 指令的定义
void Swap(boolean *a,boolean *b){
boolean temp = *a;
*a = *b;
*b = temp;
}
//使用Swap()指令的互斥实现
do{
key = TRUE;
while(key == TRUE)
swap(&lock,&key);
//临界区
lock = FALSE;
//剩余区
}while(TRUE);
在这里,课本提到,上述的两个方法解决了互斥,但没有解决有限等待的问题,我一直没有相同,在查阅其他的文章之后有人谈到他的理解,是在两个进程的时候解决了有限等待问题,但多个进程的时候没有办法解决有限等待问题。
欢迎有其他想法的人评论区留言讨论一下。
另外还有一个基于TestAndSet()
的算法,这个算法满足了临界区问题的三个要求。
//操作系统概念第七翻译版-P172页
//共有变量(默认均为FALSE)
boolean waiting[n];
boolean lock;
//算法实现
do{
waiting[i] = TRUE;
key = TRUE;
while(waiting[i] && key)
key = TestAndSet(&lock);
waiting[i] = FALSE;
//临界区
j=(j+1) % n;
while((j!=i) && !waiting[j] )
j = (j+1)%n;
if(j == i)
lock = FALSE;
else
waiting[j] = FALSE;
//剩余区
}while(TRUE);
一些问题经典到几乎所有的同步方案都会用这些问题来测试,因此在介绍同步方案之前先提前介绍一下这些问题
也叫有限缓冲问题,在本文开头简单已经介绍过这个问题。
一个数据库可以被多个并发进程共享,其中有的是读者(读取数据库),有的是写者(修改数据库),只有读者显然不会产生问题,但一个写者和其他线程同时访问共享对象就可能出问题。
为了确保不会产生这样的混乱,要求写者对共享数据库有排他的访问。这一问题称为读者-写者问题。
这个问题根据读者写者优先度不同有两个变种:
第一读者-写者问题可能会导致写者饥饿;第二读者-写者问题可能会导致读者饥饿。也因此提出了其他没有饥饿问题的变种读者-写者问题,这里不做讨论。
假设有五位哲学家围坐在一张圆形餐桌旁,他们只做两件事:吃饭,或者思考。吃东西的时候,他们就停止思考,思考的时候也停止吃东西。餐桌中间有饭,每两个哲学家之间有一只筷子。他们遵循以下规则:
一般解决哲学家就餐问题有以下方法:
之后,就是一些同步方案。
可以理解为不同的同步方案是方法的不同实现
基于硬件的解决方案(硬件同步一节提到的TestAndSet()
和Swap()
)对于应用程序员来说过于复杂,为了解决临界区问题,可以采用叫做信号量(semaphore)同步工具。
定义整数变量S
和方法wait()
,signal()
,并规定只有这两个方法能访问S
,这两个方法定义如下:
//wait()的定义
wait(S){
while(S<0);//不执行任何操作的无限循环
S--;
}
//signal的定义
signal(S){
S++;
}
信号量一般有两种,一种是没有值域的计数信号量(整型),一种是二进制信号量(只有0和1),操作系统能够区分这两种信号量。其中二进制信号量也叫互斥锁,因为他们可以提供互斥。
信号量的用途有多种,比如解决互斥问题,解决资源申请问题,解决同步问题
解决互斥问题用到的是二进制信号量,实现如下:
do{
wait(mutex);
//临界区
signal(mutex);
//剩余区
}while(TRUE);
通过将一个信号量初始化为一种可用资源的数量,在申请资源前,调用wait
,在释放资源后,调用signal
,如果资源数目不够,则调用wait
时,会被阻塞,直到有资源为止
通过如下形式的代码,可以控制两个进程的代码执行的先后顺序(下图只有在进程a
执行完S1
后,进程b
才能执行S2
):
//进程a
do something(S1);
signal(synch);
//进程b
wait(synch);
do something(S2);
上述所使用的信号量及相关方法有一个主要缺点:忙等待(busy waiting),这种形式在实际多道程序的设计中是在浪费CPU时钟(CPU cycles),因为这本可以被其他的进程有效利用。
这种类型的信号量也叫自旋锁(spinlock),它的缺点很明显(忙等待),但也有其优点:自旋过程中不进行上下文切换,而上下文切换可能要花费相对较多的时间。
因此,自旋锁比较适合锁的占用时间比较短的情况。
之前提到的信号量有忙等待的缺点,为了克服这一缺点,可以通过修改wait()
和signal()
的定义,让一个进程在等待的时候,不是进入自旋,而是阻塞自己,将自己加入到信号量等待队列中,切换成等待状态,并转到CPU调度程序,选择另一个进程来执行。
具体参考操作系统概念第七版-P176
当一组内的每个进程都等待一个事件,而这个事件只能由另一个处于等待的进程完成时,就称这一组进程处于死锁(deadlock)状态。
可以得到,当一个多个线程之间的信号量不止一个的时候,就可能出现死锁。
与死锁相关的问题将在第七章详细讨论。
另一个相关问题是无限期阻塞(indefinite blocking),也叫饥饿(starvtion),即进程在信号量内无限期等待。
由于使用信号量的过程中,wait()
和signal()
必须是成对出现的,而且对其出现顺序也有要求,所以一旦程序员在编程中 手抖了 ,把两个方法写错名字或者交换了顺序,那么就会导致一些奇奇怪怪的错误,而且由于是多线程,这类错误不一定每次都会发生,这就很容易导致神秘代码的出现。
上面这一段我觉得没什么必要,甚至提出来感觉有点滑稽,但为了引出管程这一概念,类似的说法被放在了书中管程一节的开头,但这种错误确实还是可能发生的,因此我保留了这一段话,并把它提到了信号量这一节中。
这个问题可以有一个通用的解决结构,生产者为消费者增加缓冲项,消费者为生产者减少缓冲项,其具体实现代码如下:
//生产者
do{
//在nextc中生产一个物品
wait(empty);
wait(mutex);
//添加到缓冲区
signal(mutex);
signal(full);
}while(TRUE);
//消费者
do{
wait(full);
wait(mutex);
//从buffer中移动一个物体到nextc
signal(mutex);
signal(empty);
//从nextc中消费物体
}
这里仅介绍第一读者-写者问题的解决方案:
//涉及数据
semaphore wrt,mutex;
int readcount;
//写者进程结构
do{
wait(wrt);
//写
signal(wrt);
}while(TRUE);
//读者进程结构
do{
wait(mutex);
readcount++;
if(readcount == 1)
wait(wrt);
signal(mutex);
//读
wait(mutex);
readcount -- ;
if(readcount == 0)
signal(wrt);
signal(mutex);
}while(TRUE);
详细部分参考操作系统概念第七版-P179
mutex
和wrt
初始化为1,readcount
初始化为0,其用途为:
wrt
为读者和写者进程共享。它供写者作为互斥信号量,但只被第一个进入临界区和最后一个进入临界区的读者使用。mutex
确保更新readcount
时的互斥readcount
用于跟踪有多少个进程正在读对象读写锁在以下情况最有用:
//共享数据:
semaphore chopstick[5];//均初始化为1
//第i个哲学家的进程结构
do{
wait(chopstick[i]);
wait(chopstick[(i+1) % 5];
//吃
signal(chopstick[i]);
signal(chopstick[(i+1) % 5]);
//思考
}while(TRUE);
注意:这一解决方案会导致死锁,如5个哲学家同时拿左边的筷子时。
不会导致死锁的解决方案可以有许多种,如:
哲学家就餐问题还需要确保不会饿死,没有死锁的解决方案不一定能保证没有饿死。
管程(monitor)是一种基本的,高级的同步构造,是为了处理信号量一节最后提出的一些错误而创造出来的,能更好的解决临界区问题。
管程在功能上和信号量及PV操作类似,属于一种进程同步互斥工具,但是具有与信号量及PV操作不同的属性。
PV操作:P表示通过的意思,V表示释放的意思。也就是
wait()
和signal()
类型封装了私有数据和操作数据的公有方法,而管程就是一种类型
管程类型 的表示包括:
另一个对管程类型的结构的概括如下:
一个简单的管程结构如下所示:
monitor
{
//共有变量声明
procedure P1(...){
do something
}
procedure P2(...){
do something
}
procedure P3(...){
do something
}
//...
procedure Pn(...){
do something
}
initialization code(...){
do something
}
}
管程是互斥进入的,所以当一个进程试图进入一个巳被占用的管程时它应当在管程的入口处等待,因而在管程的入口处应当有一个进程等待队列,称作入口等待队列。
该结构还比较简单,为了能处理一些特定的同步方案,需要一些额外的同步机制,这些可以通过条件(condition)结构
提供。
condition x,y;
condition
包含wait()
和signal()
方法,x.wait()
意味着调用该操作的进程会挂起,直到另一进程调用x.signal()
没有调用
x.wait()
的情况下调用x.signal()
没有作用,这是条件结构与信号量不同的一个地方
假设有一个悬挂进程Q
与条件变量x
相关联,现在当执行进程P
调用了x.signal()
的时候,进程Q
会被允许重新执行,这时在概念上Q
和P
都可以继续在管程中执行,由于任一时刻,管程中只能有一个活跃进程。所以需要处理,办法有:
P
等待,直到Q
离开管程或等待另一个条件Q
等待,直到P
离开管程或等待另一个条件P
执行x.signal()
的时候,会直接离开管程,则Q
会立即开始执行折中方案也就是规定:
signal()
操作必须为进程中的最后一个可执行操作(Pascak语言中采取这个方案)
这个方案是一个无死锁的解决方案,要求一个哲学家在两根筷子都可用的时候才能拿起筷子。
//引入的数据结构
enum{THINKING,HUNGRY,EATING} state[5];
//声明条件变量
condition self[5];
方案遵循以下原则:
- 哲学家i
只有两个邻接不就餐的时候才能将state[i]
设置为EATING
即:state[(i+4)%5) != EATING && state[(i+1)%5] != EATING
- 哲学家i
在饥饿但不满足条件时可以通过self[i]
延迟自己
具体代码和解释如下:
monitor dp
{
enum{THINK,HUNGRY,EATING} state[5];
condition self[5];
void pickup(int i){
state[i] = HUNGRY;
test(i);
if(state[i] != EATING)
self[i].wait();
}
void putdown(int i){
state[i] = THINKING;
test((i+4)%5);
test((i+1)%5);
}
void test(int i){
if((state[(i+4)%5] != EATING) &&
(state[i] != HUNGRY) &&
(state[(i+1) %5] != EATING)) {
state[i] = EATING;
self[i].signal();
}
}
initialization_code() {
for(int i=0 ; i<5;i++)
state[i] = THINKING;
}
}
哲学家就餐通过管程dp控制:每个哲学家在就餐之前,必须调用操作pickup()
,这个操作有可能会挂起该哲学家进程;在成功执行该操作后,该哲学家可以就餐;接着,他可以调用putdown()
,开始思考。
dp.pickup(i)
//do something
dp.putdown(i)
这一方案确保了不会产生死锁,但不能确保哲学家不会饿死。
临界区的互斥确保临界区原子的执行,即如果两个临界区并发执行,那么就相当于两个临界区按某个次序执行
执行单个逻辑功能的一组指令或操作称为事务(transaction),处理(原子)事务的主要问题就是无论计算机是否执行失败,都要保证事务的原子性。
从用户观点看,事务是一系列的读写操作,并最终以
commit
或abort
结束
commit
表示事务已经成功执行
abort
表示因各种逻辑错误,事务必须停止执行。
确保事务的原子性需要认识每种存储介质的属性,包括相对速度,容量和容错能力。
本节只关心在易失性存储下事务的原子性
确保原子性的一种方法是在非易失性存储介质上记录有关对数据修改的描述信息。一般为:先记日志后操作
系统在稳定存储介质上维护一个被称为日志的数据结构,每个日志记录了一个事务写出的单个操作。
每一条日志记录具有如下域:
另外还有一些特殊日志记录用于记录处理事务的重要事件,如开始事务和事务的提交或放弃。
在开始执行前
被写到日志中,在每个Ti
的写操作前都要将相关操作先记录在日志中,最后在提交后
被写到日志中。
必须确保在把日志存储写到稳定存储前,不能执行真正的更新操作。
恢复算法采用两个步骤:
undo(Ti)
:将Ti
更新的所有数据的值恢复到原来值redo(Ti)
:将Ti
更新的所有数据的值设置成新值所有的新值和旧值都可以在日志记录中找到
如果事务Ti
失败,那么使用undo(Ti)
恢复记录,如果系统崩溃,则可以按照如下的方式恢复:
但没有
,那么事务需要撤销(undo)要注意
undo(Ti)
和redo(Ti)
的幂等性,也即调用一次和调用多次的效果对一个事务Ti
来说是一样的。
如果日志记录的时间很长,那么每一次恢复的检索操作,或者系统崩溃后的重做操作都是很大的额外开销
为了降低额外开销,引入检查点(checkpoint)的概念,即除了每次更新日志外,系统还要定时的执行检查点并执行如下操作:
这样就可以让系统简化恢复行为。在系统崩溃后,
的所有事
物T
都可以不用重做