说明信号量之前,我们先说一下信号
进程同步,也就是一个进程需要和另外一个进程合作,那么肯定会发生阻塞,因为需要等待另一个进程的信号。
//生产者进程
while(true)
{
if(counter == BUFFER_SIZE)
sleep();
...
if(counter==1)
wakeup("消费者")
}
//消费者进程
while(true)
{
if(counter == 0)
sleep();
...
if(counter == BUFFER_SIZE - 1)
wakeup("生产者")
}
从上面两个程序我们可以看出,counter就是两个进程的信号,当counter == BUFFER_SIZE的时候,生产者就会进入阻塞状态,而当消费者消费了一个元素,counter-1之后发现counter == BUFFER_SIZE-1,就会唤醒一个生产者,这在只有两个进程时可能有用,但是当有多个生产者阻塞时,永远只有一个会被唤醒,其他的生产者将会永远的阻塞在那边,因此我们才需要信号量,我们不能用counter来作为信号,我们需要信号量来告诉我们有多少给消费者或生产者被阻塞,然后根据信号量来唤醒。
信号量
根据上面的例子,我们引入信号量sem, sem表示有多少个资源可以使用
缓存区满,P1执行,P1 sleep sem = -1
P2执行, P2 sleep sem = -2
C执行一次循环, wakeup P1 sem = -1
C再执行一次循环,wakeup P2 sem = 0
C再执行一次循环, sem = 1
P3执行, sem = 0
根据上面我们就可以看到,由一个信号量,我们就能完成进程的唤醒与睡眠
信号量的实现
struct semaphore
{
int value; //记录资源个数
PCB *queue; //阻塞队列
}
P(semaphore s); //消费资源
V(semaphore s); //产生资源
P(semaphore s)
{
s.value--;
if(s.value < 0)
{
sleep(s.queue);
}
}
V(semphore s)
{
s.value++;
if(s.value <= 0)
{
wakeup(s.queue);
}
}
用信号量解生产者-消费者问题
int fd = open("buffer.txt");
write(fd, 0, sizeof(int));
write(fd, 0, sizeof(int));
semaphore full = 0;
semaphore empty = BUFFER_SIZE;
semaphore mutex = 1;
Producer(item)
{
P(empty);
P(mutex); //互斥信号量
V(mutex);
V(full);
}
Cousumer()
{
P(full);
P(mutex); //互斥信号量
V(mutex);
V(empty);
}
说明信号量临界区保护时,我们先说明为何需要临界区保护
用一个简单的例子说明一下
empty = -1;
Producer(item)
{
P(empty);
...
}
//生产者P1
register = empty;
register = register - 1;
empty = register;
//生产者P2
register = empty;
register = register - 1;
empty = register;
加入现在出现这样一个调度
P1.register = empty;
P1.register = P1.register - 1;
P2.register = empty;
P2.register = P2.register - 1;
empty = P1.register;
empty = P2.register;
那么最后的结果将会是empty = -2,显然不符合实际情况,为了避免这种情况,我们引出锁的概念
我们希望当有一个进程正在执行对empty进行修改的代码的时候,其他进程不能执行他们的那段对empty修改的代码,也就是对empty进行上锁,当一个进程发现empty被锁了之后,就原地打转,等待这个锁被解开,这时候再执行对empty修改的代码,就不会出现前面的那种错误了
我们把每一个进程对empty进行修改的那段代码称为临界区
临界区(Critical Section)
临界区:一次只允许一个进程进入的该进程的那一段代码
读写信号量的代码一定是临界区
保护临界区,就是要写两段代码,一段是进入临界区的代码,一段是退出临界区的代码
临界区保护原则
Peterson算法
结合了标记和轮转两种思想
flag[0]=true; flag[1]=true;
turn=1; turn=0;
while(flag[1]&&turn==1); while(flag[0]&&turn==0);
临界区 临界区
flag[0]=false; flag[1]=false;
剩余区 剩余区
while(flag[1] && turn==1) //表示当轮到对面时且对面正好要进临界区,那么当前进程就原地打转,否则就进入临界区,这样就满足了有空让进且有限等待,因为当当前进程进入后,会把turn修改为对方
Peterson算法只能满足两个进程,因此,我们需要其他算法
面包店算法
依然是使用了标记和轮转的结合
轮转:每个进程都获得一个序号
标记:进程离开时序号为0,不为0的序号即标记
choosing[i] = true; num[i] = max(num[0],...,num[n-1])+1; //取最大的号+1
choosing[i] = false; for(j=0; j
choosing[i]=false
的作用是防止多个进程同时领号
面包店算法虽然是正确的,但是有些复杂,如果进程不断的领号,就有可能造成溢出,因此,我们寻找一些更好的方法
临界区保护的另一类解法
为什么需要保护临界区?,因为进程总是会切换来切换去,导致临界区被错误的修改,而进程切换肯定需要中断,中断后才能执行进程切换,因此我们可以再临界区前关闭中断,临界区结束后开启中断,这样对empty的修改肯定是正确的
cli();
临界区
cli();
剩余区
这种方法对多核cpu无效
临界区保护的硬件原子指令法
之前我们提到了锁机制,也就是希望将对empty修改的代码进行上锁,不允许多个进程同时执行它,所以锁其实也是一个信号量,一个值为1或0的信号量,但是如果我们依旧采用之前的方式对锁这个信号量进行修改,那这个锁就没有任何意义了,因此硬件原子指令法,就是把这个锁的修改变成原子性的,就是不允许被中断,只能一次性执行完,这就是硬件原子指令法
boolean
TestAndSet(boolean &x)
{
boolean rv = x;
x = true;
return rv;
}
/*这段代码是一次性执行完的*/
while(TestAndSet(&lock));
临界区
lock = flase;
剩余区
总结下来就是一句话:用临界区保护信号量,用信号量实现进程同步
//用户态程序
main()
{
sd = sem_open("empty");
}
sem.c中
typedef struct{
char name[20];
int value;
task_struct * queue;
}
sys_sem_open(char *name)
{
在semtable中找到和name一样的信号量
如果没有,则创建一个
返回对应的下标
}
for(i=1 to 5)
sem_wait(sd);
write(fd,&i,4);
sys_sem_write(int fd)
{
cli();
if(semtable[sd].value-- < 0)
{
设置自己为阻塞态
加入semtabel[sd].queue中
schedule();
}
sti();
}
linux 0.11中的信号量实现
bread(int dev, int block)
{
struct buffer_head * bh;
ll_rw_block(READ, bh);
wait_on_buffer(bh);
}
//启动磁盘读之后进入睡眠
lock_buffer(buffer_head *bh)
{
cli();
while(bh->b_lock)
sleep_on(&bh->b_wait);
bh->b_block = 1;
sti();
}
临界区保护
void sleep_on(struct task_struct **p)
{
struct task_struct *tmp;
tmp = *p;
*p = current;
current->state = TASK_UNINTERRUPTIBLE;
schedule();
if(tmp)
tmp->state=0;
}
这里先提出一个问题,在lock_buffer
里边,为什么while(bh->b_lock)
中使用的是while
而不是if
;
下面我们来解释一下这段代码
struct task_struct *tmp;
tmp = *p;
*p = current;
*p指向队列的头部
tmp = *p
,*p指向的是下一个进程的PCB,将下一个进程的PCB赋给tmp;*p = current
,current指向当前进程的PCB,将当前进程的PCB赋给*p,也就是队列的头部也许你会问,那队列之前的头部跑到哪里去了?
struct task_struct *tmp;
我们知道,在c语言中,定义一个局部变量,是保存在栈里边的,这是在内核中的代码,因此,tmp
就保存在当前进程的内核栈中,在当前进程的内核栈中,我们就能找到下一个进程,这就很隐蔽的形成了一个队列,同理,在下一个进程的内核栈中,同样可以找到下下的进程的PCB,也就是tmp
;
linux 0.11如何唤醒队列?
static void read_intr(void) //磁盘中断
{
...
end_request(1);
}
end_request(int uptodate)
{
...
unlock_buffer(CURRENT->bh);
}
unlock_buffer(struct buffer_head *hd)
{
bh->b_lock = 0;
wake_up(&bh->b_wait);
}
wake_up(struct task_struct **p)
{
if(p && *p)
{
(**p).state = 0;
*p = NULL;
}
}
我们先看一下这三行代码
schedule();
if(tmp)
tmp->state=0;
这三行代码,实现了将队列的每一个进程都唤醒的功能,那么,这三行代码是如何实现将所有进程都唤醒的功能的。
我们看一下wake_up函数
wake_up(struct task_struct **p)
{
if(p && *p)
{
(**p).state = 0;
*p = NULL;
}
}
当一个进程被唤醒后,它会在哪里执行?
显然,肯定是从进入睡眠的下一处代码开始执行,下一处代码刚好就是前面的那三行代码
schedule();
if(tmp)
tmp->state=0;
当这一个进程被唤醒后,它会去查看是否有下一个进程,如果有,那么就将其唤醒,同理下一个进程被唤醒后,也会去唤醒下下个进程,这样就实现了将所有进程都唤醒的功能。
将所有进程都唤醒有什么用吗?为什么不唤醒一个就可以了?
我们重新回到之前的问题
lock_buffer(buffer_head *bh)
{
cli();
while(bh->b_lock)
sleep_on(&bh->b_wait);
bh->b_lock = 1;
sti();
}
在lock_buffer
里边,为什么while(bh->b_lock)
中使用的是while
而不是if
;
我们将所有进程都唤醒,因为优先级高的进程可能晚进队列,但是,你要让优先级高的先执行,那么就得把所有进程都唤醒,让schedule来决定执行哪一个进程,再将其他被唤醒的进程重新进入睡眠,这就是为什么要使用while
的原因。
死锁的4个必要条件
死锁处理方法
死锁预防
破坏死锁形成的必要条件
* 在进程执行前,一次性申请所有需要的资源
缺点1:需要预知未来,编程困难
缺点2:许多资源分配后很长时间才使用,资源利用率低
* 对资源类型进行排序,资源申请必须按序进行,不会出现环路等待
缺点:造成资源浪费
死锁避免
检测每个资源请求,如果造成死锁就拒绝
如果系统中的所有进程存在一个可完成的执行序列P1,P2,...,Pn,则称系统处于安全状态
安全序列:P1,p2,...,Pn
如何找出这个安全序列?
//银行家算法
int Available[1..m]; //每种资源剩余数量
int Allocation[1..n,1..m]; //已分配资源数量
int Need[1..n,1..m]; //进程还需的各种资源数量
int Work[1..m]; //工作向量
bool Finish[1..n]; //进程是否结束
Work = Available; Finish[1..n] = false;
while(true)
{
for(i=1; i<=n; i++)
{
if(Finish[i]==false && Need[i]<=Work)
{
Work = Work + Allocation[i];
Finish[i] = true;
break;
}
else
{
goto end;
}
}
End:
for(i=1; i<=n; i++)
{
if(Finish[i]==false)
return "deadlock";
}
}
时间复杂度T(n) = O(mn^2);
死锁检测和恢复
检测到死锁出现时,让一些进程回滚,让出资源
每次申请都执行O(mn^2),效率低。发现问题再处理
定时检测或者是发现资源利用率低时检测
Finish[1..n] = false;
if(Allocation[i] == 0)
Finish[i] = True;
...//银行家算法
for(i=1; i<=n; i++)
if(Finish[i]==false)
deadlock = deadlock + {i};
选择哪些进程回滚?优先级?占用资源多的?
如何实现回滚?那些已经修改的文件怎么办?
死锁忽略
无视死锁,重启大法
总而言之:死锁处理的代价太大,而且编程困难,效率低,所有大部分操作系统采用的都是死锁忽略的方法。