文章目录
进程同步
背景
有限缓冲区解决生产者-消费者模型,当时老师留了一个问题,说“怎么才能使得BUFFER_SIZE
个缓冲项都能被使用?”。
很简单,加上一个counter
计数:
//生产者进程代码
while(true)
{
while(counter==BUFFER_SIZE);//忙等
buffer[in]=nextProduced;//生产该缓冲项
in=(in+1)%BUFFERSIZE;//循环数组---生产位指针
counter++;//缓冲区现有项++
}
//消费者进程代码
while(true)
{
while(counter==0);//忙等
nextConsumed=buffer[out];//消费该缓冲项
out=(out+1)%BUFFER_SIZE;//循环数组---消费位指针
counter--;//缓冲区现有项--
}
这将导致的问题是,代码执行顺序可能在编译器优化阶段被打断。注意,这里说的顺序打乱并不是说c
代码的顺序,而是编译成机器语言之后,汇编指令被打乱。
那么在两类进程(生产、消费),同时(几乎同时,但还是有先后)对counter
操作时,便会导致最终结果不唯一。因为一个简单的counter--/counter++
编译成汇编语言后是3
条指令(寄存器从变量取值、寄存器值++/--
、寄存器存值到变量)。
So
,多个进程并发访问和操作同一数据且执行结果与访问发生的特定顺序有关,称为竞争条件。
To avoid that, we need
进程同步。
临界区问题
What's called '临界区'?
若对于进程组 P 0 , P 1 , P 2 , . . . , P n − 1 {P_0,P_1,P_2,...,P_{n-1}} P0,P1,P2,...,Pn−1,每个进程都可能会对共享数据进程操作 [ 1 ] ^{[1]} [1],那么把执行这一操作的代码片段叫做临界区;此外,申请进入临界区的代码片段叫做进入区、临界区之后可有退出区,其余部分为剩余区。
//典型进程代码
do
{//进入区、退出区很重要
/* 进入区 */
临界区
/* 退出区 */
剩余区
}while(true)
临界区条件
对于临界区,需要满足:
- 互斥:同一时刻只能有一个进程 P i P_i Pi处于临界区执行。
- 前进:没有进程处于临界区时,任何有需要的进程可进入临界区。
- 有限等待:不能无限推迟进程的临界区请求,即其余进程不能无限抢占临界区。
处理操作系统内核的临界区问题的两种方式:
- 抢占内核:允许处于内核模式的进程被抢占。
- 非抢占内核:不允许处于内核模式的进程被抢占。
显然,非抢占内核的内核数据结构不会导致竞争问题,因为同一时刻只有一个进程处于内核模式,它会一直运行直到推出内核模式、阻塞或者自动退出CPU
控制。
软件同步:Peterson
算法
- 基于软件
- 只能适用两个进程
- 不能用于乱序执行的处理器
Peterson
算法适用于两个进程在临界区与剩余区交替执行。
Peterson
算法:共享数据
int turn;
bool flag[2];//初始化为false
-
turn
表示哪个进程可以进入临界区:e.g.
:turn==i
,那么 P i P_i Pi允许在其临界区执行。 -
flag
表示哪个进程想要进入临界区:e.g.
:flag[i]=ture
,即 P i P_i Pi想进入其临界区。
Peterson
算法:进程代码结构
//进程P_i的结构
do{
flag[i]=true;//表明自己想要进入临界区
turn=j;//先让对方进
while(flag[j]&&turn==j);//如果对方确实想进,则先让对方进,忙等
/*
临界区
*/
flag[i]=false;//经过临界区后,修改flag,以便让对方能够进去
/*
剩余区
*/
}while(true)
Peterson
算法:正确性证明
- 互斥成立:
- 前进要求满足:
- 有限等待要求满足:
硬件同步:锁
do{
/* 请求锁 */
临界区
/* 释放锁 */
剩余区
}while(true)
临界区问题在单处理器环境下,能够简单通过中断来实现——修改共享数据之前禁止中断,这样就能保证当前执行序列不被打乱。
但是在多处理器环境下,中断需要传递给所有处理器,这会比较耗时,导致进入每个临界区都会有延迟。
而且对于通过中断来更新系统时钟的,也会影响始终从而降低系统效率。
所以,原子指令被提供,以允许指令能够不可中断地执行。
TestAndSet实现互斥
//功能:返回target变量的初始值,然后把target修改成true
//特点:原子执行
//注意:以下是语义代码,并非实现代码
bool TestAndSet(bool target)
{
bool rv=*target;
*target=true;
return rv;
}
//进程语义代码
//功能:实现互斥
do{
while(TestAndSet(&lock));
//临界区
lock=false;
//剩余区
}while(true)
代码分析:
想要进入临界区的进程得先获得锁,即把空闲的锁lock==false
通过TestAndSet
修改成lock==true
。所以while
循环中,如果锁一开始就是空闲的,那么执行第一次TestAndSet
便可获得锁,进入临界区;但要是锁一开始被占有,即lock==1
,那么在锁被释放前,将一直执行while
循环,直到占有进程退出临界区,并释放锁lock=false
,该等待进程才能进入。
由此实现互斥。
Swap实现互斥
//功能:交换*a、*b
//特点:原子执行
//注意:以下是语义代码,并非实现代码
void Swap(bool *a, bool *b)
{
bool temp=*a;
*a=*b;
*b=temp;
}
//进程语义代码
//功能:实现互斥
do{
key=true;
while(key==true){
Swap(&lock,&key);
}
//临界区
lock=false;
//剩余区
}while(true)
代码分析:
- 这里操作变量增加了,但是共享变量还是只有
lock
。 - 局部变量
key
表示该进程是否想要进入临界区,当想要进入临界区时,先把key
置1
; - 然后获取锁:这里获取锁的方式是通过
Swap
,把key
的值,也就是1
赋给lock
,然后再把lock
的初始值赋给key
;如果一开始锁是空闲的,那么key
将得到lock
的初始值0
,退出while
忙等,进入临界区;如果一开始锁是被占有的,那么key
将保持1
,进程将一直忙等,直到占有进程释放锁lock=false
,该等待进程才能进入。
由此实现互斥。
TestAndSet实现有限等待&互斥
以上的方法都只是解决了“互斥”,并没有解决有限等待要求。
下面使用TestAndSet
的算法将同时满足所有临界区问题的三个要求:
//共享数据结构体
//初始化为false,表示所有进程处于“未等待状态”、锁处于“空闲状态”
bool waiting[n];
bool lock;
//功能:TestAndSet实现有限等待互斥
//注意:以下是语义代码,并非实现代码
do{
waiting[i]=true;//进程先排队
key=true;//表示该进程想进入临界区
while(waiting[i]&&key)//准备获取锁
{
key=TestAndSet(&lock);//锁本身空闲,或一旦有进程释放锁,key=false
}
waiting[i]=false;//进程Pi成功取得锁,不用再排队,准备进入临界区工作
//critical section
j=(i+1)%n;//初始j,从i的下一个开始
while((j!=i)&&!waiting[j])//判断进程队列里面,按照进程序号顺序查找,一旦发现进程Pj在排队,终止查找
{
j=(j+1)%n;
}
if(i==j)//如果找了一圈了,发现没有正在排队的进程,释放锁
{
lock=false;
}
else//找到了排队的进程Pj
{
waiting[j]=false;//直接修改waiting[j]的状态,这样Pj可以直接跳出获取锁的循环,进入临界区
}
}
代码分析:
- 这里需要有一个概念:就是现在不单单是一个进程在执行,而是n个进程都在执行,而且都进入了“获取锁”的while循环判断中。只不过每次只能有一个进入临界区,其他的都卡在循环中。
- 而且还要注意的是,从第一个进程获取锁开始,锁将不会再被释放,直到所有进程都不想再进入临界区。也就是说,当
CPU
的占有权在不同进程之间切换时,锁是不会被频繁的释放和获取的,锁的获取只发生在第一个进程进入临界区前、锁的释放只发生在最后一个进程退出临界区后。 - 这里之所以解决了循环等待,是由于在每一个进程执行完工作后,都会按照固定的顺序(此处是进程序列号),从该进程的下标开始遍历一次进程队列,把进入权交给第一个发现的等待中进程。因此,任何等待进入临界区的进程只需要等待
n-1
次。 - 对于满足第三个条件——“前进”的说明:因为进程在退出临界区时,会将锁释放
or
在不释放锁的前提下直接把进入权交给被选中的进程,所以这允许了等待进程进入临界区执行。
信号量
之前提到的使用硬件方法(TestAndSet()
和Swap()
指令)解决临界区问题问题,对于应用程序员使用起来比较复杂,为了解决这个问题,引出**信号量semaphore
**的同步工具。
**信号量S
**是个整数变量,除了初始化外,对于其修改只能通过两个标准原子操作:wait()
和signal()
来实现。
//wait()语义代码
//特点:原子级
wait(S)
{
while(S<=0)
; //no operation
S--;
}
//signal()语义代码
//特点:原子级
signal(S)
{
S++;
}
在wait()
和signal()
中,对信号量值的修改必须不可分地执行;此外对于wait()
中对信号量值的测试S<=0
和可能的修改S--
也必须不被中断地执行。
用法
通常信号量被区分为:计数信号量(值不受限制,可用来表示资源数)、二进制信号量(只能为0
或1
);
在使用中,wait()
和signal()
一般配对出现,比如在用于两个并发程序之间的拓扑关系时:
//synch初始化为:0
//说明:只有在S1被执行后才能执行S2,就算先执行P2,也会卡在wait(synch)处
//P1
S1;
signal(synch);
//P2
wait(synch);
S2;
实现
目前所提的信号量存在缺陷:wait()
中若信号量<=0
则会进入**忙等(busy waiting)
**;
所以当资源被完全占有时,其余所有想要进入临界区的进程都将在其进入临界区的代码中连续循环:
- 在单处理器系统中,这样的忙等浪费了
CPU
时间;这种信号量称为自旋锁,进程在等待锁时不进行上下文切换,which will cost a lot of time
。所以,如果锁占用的时间很短,那么其实是可以抵消上下文切换的开销的,那么即使在单处理器系统使用自旋锁也是有益处的。 - 自旋锁更常用于多处理器系统,因为当一个线程在一个处理器自旋时,操作系统可以调度其他进程在其他处理器上执行。
为了解决忙等这个问题,要对wait()
和signal()
进行修改。
wait()
:在信号量<=0
时,不在使用忙等策略,而是阻塞自己,该操作将把该阻塞进程放到一个由信号量维护的进程等待队列中,并将该进程状态修改为等待状态。signal()
:如果释放后S<=0
,说明此时等待队列不为空,特殊的,S=0
时说明等待队列还有最后一个挂起的进程,所以需要使用wakeup(P)
来唤醒进程。根据调度算法不同,CPU
可能会切换到唤醒的进程运行,也可能不会。
//定义信号量结构体
typedef struct{
int value;
struct process *list;//也可以为线程
}semaphore;
//修改后的wait()语义代码
wait(semaphore *S)
{
S->value--;//可负,绝对值表示等待资源的进程个数
if(S->value<0)
{
///add this process to S->list;
block();
}
}
//修改后的signal()语义代码
signal(semaphore *S)
{
S->value++;//先释放资源
if(S->value<=0)//再分配
{
///remove a process P from S->list;
wakeup(P);
}
}
Futher thinking...
进程的链表可以利用**进程控制块PCB
**中的一个链接域(暂时没有深入了解)来实现。故每个信号量包括一个整型值和一个PCB
链表的指针。
信号量的关键之处在于两条原子执行的指令wait()
和signal()
:
- 在单处理器环境下,可以简单的通过禁止中断来简单的实现。一旦禁止中断,只剩下当前运行程序运行,
CPU
执行指令序列只有该进程的指令,不包括来自其他进程的指令,直到中断恢复、调度器能够重新获得控制为止。 - 在多处理器环境下,若是同样的使用禁止中断策略,则必须把禁止中断传递到每一个处理器,否则,运行在不同处理器上的不同进程可能会以任意不同方式交织在一起执行。但是这样禁止所有处理器的中断不仅很困难,而且还会严重影响性能。
- 对于重新定义后的
wait()
和signal()
,其实并没有完全取消忙等,而是取消了应用程序进入临界区的忙等,将忙等限制在wait()
和signal()
的这些比较短的临界区内。这样忙等所需的时间很少。而应用程序的临界区可能很长,此时忙等极为低效。
死锁与饥饿
死锁问题将在下一章作为重点讲解。
经典同步问题
有限缓冲问题
介绍一种有限缓冲的通用解决结构。
假设缓冲池buffer
有n个**buffer item
、信号量mutex
用来提供对buffer
的互斥访问**,初始化为1
;
信号量**empty
、full
分别表示buffer
中的空槽(无buffer item
)、满槽(有buffer item
),初始化empty=n,full=0
**;
//生产者进程语义代码
//功能:向buffer中的一个空槽生产一个item
do{
///produce an buffer-item and store in "nextp"
wait(empty);//取一个空槽
wait(mutex);//取得空槽后,再获取对buffer的访问权,思考一下这两句代码的顺序
///load "nextp" to buffer
signal(mutex);//先释放buffer的互斥访问权
signal(full);//注意这里释放的是full,也就是每生产一个,满槽++
}
//消费者进程语义代码
//功能:从buffer中取出一个满槽并消费,使得buffer多出一个空槽、少一个满槽
do{
wait(full);//取一个满槽
wait(mutex);//取得满槽后,再获取对buffer的访问权,思考一下这两句代码的顺序
///take the buffer-item and store in "nextc"
signal(mutex);//取得满槽中的item后,先不消费,先释放对buffer的锁
signal(empty);//空槽++
///cosume the item in "nextc"
}
代码说明:
- 在讲信号用法的时候说了
wait()
和signal()
要成对出现,这里的成对出现是在两个不同的进程中,注意单个进程中wait()
和signal()
的信号量是不一样的。 - 不论是生产者进程/消费者进程,都遵循最小化锁的占用时间:生产者在已经生产出
buffer item
后,再申请信号量;消费者在释放信号量后,再消费获得的buffer item
。
读者-写者问题(多任务同步问题)
- 第一读者-写者问题:除非写者已经获得共享数据的访问权限,否则读者不会处于等待状态,直接读。
- 第二读者-写者问题:如果写者处于等待状态,那么不会有新的读者进入等待状态,为的是加快写者从“等待”->“写完成”的过程。
以上两个问题都可能导致饥饿:分别是写者饥饿、读者饥饿。
下面介绍对第一读者-写者问题的饥饿解决策略:
/*----------------------------------------第一读者-写者问题防止饥饿---------------------------------------*/
//共享数据结构
//mutex:读者间共享,实现对readcount访问的互斥
//readcount:读者间共享,统计正在读的进程数
//wrt:读者、写者共享,实现访问互斥,即二者只能有一方取得数据的访问
semaphore mutex,wrt;
int readcount;
//读者进程语义代码
do{
wait(wrt);
///do writing
signal(wrt);
}while(true);
//写者进程语义代码
do{
wait(mutex);//若第一个读者未取得wrt锁,则其余读者将在此处挂起
readcount++;
if(readcount==1)//第一个读者到达
wait(wrt);//先获取wrt锁,若被写者占有,则会在此处挂起
signal(mutex);//释放对readcount访问的权限
///do reading
wait(mutex);//读操作完成,读者退出,获取对readcount的权限
readcount--;
if(readcount==0)//最后一个读者退出时,返还wrt权限
signal(wrt);
signal(mutex);
}while(true);
代码说明:
- 若
wrt
锁被写者占有,此时n
个读者一次到达,那么第1
个读者将在wait(wrt)
处被挂起、其余n-1
个读者将在wait(mutex)
被挂起。 - 这里在读者进程中使用的策略是之前讲到解决循环等待问题的一个实例。
哲学家就餐问题
一种简单的解决方法是:每只筷子都用一个信号量来表示:
//共享数据结构
semaphore chopstick[5];
//哲学家i进程结构
do{
wait(chopstick[i]);
wait(chopstick[(i+1)%5]);
///do eating
signal(chopstick[i]);
signal(chopstick[(i+1)%5]);
///do thinking
}while(true);
代码说明:
- 很明显,这样只是单纯的解决了互斥的问题,而死锁和饥饿的隐患依然存在;
管程
印象里好像没有讲,个人看了一下有几个点没懂,先搁置一下。