diff --git a/thu_os/chp17.md b/thu_os/chp17.md index 3697e10..2a12892 100644 --- a/thu_os/chp17.md +++ b/thu_os/chp17.md @@ -71,15 +71,15 @@ local_irq_restore(unsigned long flags); ## 软件方法 -为了实现临界区的互斥访问,另一种想法是所有要进入临界区的进程共享一些访问标志位,首要进入临界区的进程通过设置这些标志位“通知”其他进程【我已经在临界区了嘻嘻嘻,你们现在不能进来】,这就好比在农村上厕所,又没有锁,只能在厕所外放置一条红丝带之类的东西来告知【已经有人了】。这种方法就是软件方法,它的本质其实是进程之间的通信,可以看到是不需要操作系统参与的,因此开销比较小。 +为了实现临界区的互斥访问,另一种想法是所有要进入临界区的进程共享一些访问标志位,首个进入临界区的进程通过设置这些标志位“通知”其他进程【我已经在临界区了嘻嘻嘻,你们现在不能进来】,这就好比在农村上厕所,又没有锁,只能在厕所外放置一条红丝带之类的东西来告知【已经有人了】。这种方法就是软件方法,它的本质其实是进程之间的通信,可以看到是不需要操作系统参与的,因此开销比较小。 我们可以根据这里的思想,才尝试着实现一下软件方法,为了简单起见,首先我们只考虑两个进程的情况。比如我就设置一个占用标志位`occupied`,一个进程在进入临界区之前先判断这个标志位,只有在`occupied == false`的时候再进入临界区,并且同时设置`occupied = true`。在退出临界区时在释放标志位,可以形成下面的伪代码: ```c -while(occupied == true); // wait for other processes to exit +while(occupied == true); // wait for other processes to exit occupied = true; critical_section -occupied = false; //exit +occupied = false; //exit ``` 看起来是很理想的,但是稍微分析可以发现,当`occupied = false`,如果两个进程同时想要进入临界区,在第一个进程通过了`while`循环后进行了调度,此时第二个进程也会通过`while`循环进入临界区,也就是说这种方案不满足`忙则等待`原则。 @@ -88,9 +88,9 @@ occupied = false; //exit ```c //code for process i -while(turn != i); // wait for my turn to enter +while(turn != i); // wait for my turn to enter critical_section; -turn = j; // exit +turn = j; // exit ``` 通过分析可以发现,采用这种策略后,两个进程只可能有一个可以通过`while`循环,因此不会出现上面两个进程同时进入临界区的情况,即满足了`忙则等待`条件。但是,考虑下面一种情况,当前`turn == j`但是进程`j`并不想进入临界区,此时进程`i`只能一直等待`j`进入了临界区后才能执行临界区代码,不满足`空闲则入`原则。简单说来就是占着茅坑不拉屎。 @@ -107,11 +107,11 @@ turn = j; // exit ```c //code for process i -flag[i] = true; // current proc want to enter +flag[i] = true; // current proc want to enter turn = j; -while((turn == j) && flag[j] == true); // wait for proc j +while((turn == j) && flag[j] == true); // wait for proc j critical_section; -flag[i] = false; //exit +flag[i] = false; //exit ``` 如果两个进程同时都想要进入临界区,它们都会首先设置自己的标志位表示想要进入临界区,实际上,这就是双标志后检查法,通过前面的分析,它会保证`忙则等待`原则,却不能保证`空闲则入`原则。而这里的`turn`就是为了保证`空闲则入`,如果两个进程都想要进入临界区,此时`turn`的值只能有一个,因此必然有一个进程可以通过`while`循环。可见,`Peterson`算法是利用双标志位解决临界资源的互斥访问,用`turn`解决饥饿现象。 @@ -144,4 +144,106 @@ flag[i] = false; 可以看到,软件方法来实现互斥访问非常复杂啊,设计算法很复杂,证明一个算法可以正确运行也很复杂,需要穷举所有可能的情况,从我的学习时间来看,研究这点内容用了一个下午就足以说明它有多复杂了...... -然后实现软件方法需要在进程之间共享数据,对于进程数量较多的情况,问题就更加复杂,想想都觉得烦!以及从上面的实现中可以看到,等待访问临界区的进程,并没有做到`让权等待`原则,而是在忙等待,这是需要消耗CPU时间的。实际上只有没有操作系统的参与,是不可能`让权等待`的。 +然后实现软件方法需要在进程之间共享数据,对于进程数量较多的情况,问题就更加复杂,想想都觉得烦!以及从上面的实现中可以看到,等待访问临界区的进程,并没有做到`让权等待`原则,而是在忙等待,这是需要消耗CPU时间的。实际上只要没有操作系统的参与,是不可能做到`让权等待`的。 + +## 高级抽象方法 + +高级抽象方法是操作系统提供的编程抽象,以简化进程之间资源互斥访问的解决方案的。为什么叫高级抽象方法呢?回顾前面的软件方法,我们自己设计的那几种算法均不奏效,其本质原因是进程在检查标志和设置标志之间可能会被打断,被操作系统调度。当操作系统介入进程之间的互斥访问时,就提供一些高级编程抽象,使得这些操作可以不被打断地执行,即原子操作。 + +高级抽象方法包括锁,信号量和条件变量。这里就只讨论锁机制,后面两种将在[进程管理(4):信号量与管程](chp18.md)中进行讨论。 + +什么是锁机制呢?就比如你去上厕所,前面那些方案,比如先检查有没有标志还是先设置自己的标志啊,都花里胡哨的,莫名其妙。锁机制就是我去上厕所看到锁没有锁上,我就进去然后把门锁上;锁上了我就等着。这样不就简单快捷地实现了互斥资源的访问了吗? + +和前面的软件方法相比,锁机制之所以可以这么简单,究其原因,是操作系统可以保证我检查锁是否锁上以及获得锁的动作是一气呵成的,中间不会被调度,因此就极大的简化了问题。 + +根据上面的叙述,锁机制应该具有两个基本的操作,即获得锁`lock.acquire()`与释放锁`lock.release()`,`lock.acquire()`的语意是在锁被释放前一直等待,锁被释放再得到锁。这样,使用了锁机制的临界区访问伪代码如下: + +```c +lock.acquire(); // wait for lock and acquire it +critical_section; +lock_release(); // exit +``` + +> 操作系统怎么实现锁机制呢? + +实现锁机制的关键在于保证检查锁的状态与获得锁这两个动作是原子操作,而这可以通过硬件来实现。在现代的CPU体系结构中,都提供一些特殊的原子操作指令,比如测试与置位指令`test_and_set`以及交换指令`exchange`,它们的伪代码如下: + +```c +bool test_and_set(bool *target){ + bool ret = *target; + *target = true; + return ret; +} + +void exchange(bool *a, bool *b){ + bool temp = *a; + *a = *b; + *b = temp; +} +``` + +通过这两个原子操作指令可以实现锁机制,下面首先叙述如果通过`ts`指令来实现锁。 + +> 使用原子指令实现自旋锁(`spin lock`) + +自旋锁这个概念是相对于互斥锁而言的。对于自旋锁,当一个进程不能得到锁资源时,并不会放弃CPU而进入阻塞状态,而是不断地在那里进行循环检测,因此称它为自旋锁。通过`ts`指令实现自旋锁的代码如下: + +```c +class Lock{ + bool value = 0; + void acquire(); + void release(); +} + +Lock::acquire(){ + while(test_and_set(&value)); //spin +} + +Lock::release(){ + value = 0; +} +``` + +可以看到,获得锁的操作正是通过`ts`指令来实现的。无论锁的状态如何,在执行了`ts`指令后,锁都是被占用了。只是,如果锁在一开始就被占用,进程就会反复不断地调用`ts`指令,循环判断锁的状态是否为空闲;一旦锁为空闲,则获得锁,并且可以退出循环进入临界区了。 + +利用`exchange`指令,也可以实现自旋锁,只需要让`lock::value`与`true`做交换,就可以实现与`ts`指令一样的语意。利用`exchange`指令实现自旋锁的代码如下: + +```c +Lock::acquire(){ + bool temp = true; + while(1){ //spin + exchange(&value, &temp); + if(temp == false) break; + } +} + +Lock::release(){ + value = 0; +} +``` + +可以看到,相对于前面的软件方法,引入了锁机制后可以方便地实现进程临界区的互斥访问,并且很容易可以证明该方法的正确性,并且可以适用于多处理器中任意数量的进程之间的互斥访问。 + +当然,锁机制也具有一定的缺点,比如这里的等待仍然是忙等待,而不是`让权等待`。此外,也可能会出现饥饿和死锁的现象,比如低优先级进程占用资源,而高优先级进程占用CPU并且请求资源,此时两方互不相让就会出现死锁。关于死锁问题的解决会在后面[进程管理(5):死锁](chp20.md)中进行讨论。 + +> 利用`ts`指令实现无忙等待锁 + +它的基本的思想和自旋锁是一样的,只是一旦进程不能进入临界区,则将它加入等待队列,并且进入阻塞状态。在一个进程释放了锁资源后,再挑选等待队列中的一个阻塞进程,并将它唤醒。具体的代码如下: + +```c +Lock:acquire(){ + while(test_and_set(&value)){ + add to waiting queue + wait(); // call schedule inside + } +} + +Lock:release(){ + value = 0; + if(!empty(waiting queue)) + pick up a proc + wakeup_proc(this proc); +} +``` + +需要注意的是,在`acquire`函数中,仍然是一个`while`循环而非`if`判断,这是因为当退出临界区的进程将另一个进程唤醒后,被唤醒进程并不一定就可以获得锁,也许在它之前执行的进程会优先请求进入临界区。在这种情况下,需要对锁是否空闲进一步进行判断。