操作系统中的同步互斥(锁与信号量)

互斥

操作系统的同步与互斥可以从线程和进程两个角度进行理解。如果从线程的角度理解,这里本文以两个线程为例,需要考虑这两个线程是否属于同一个进程,对于不同进程的线程来说,它们本质上和从两个进程的角度进行理解是一样的,在之后讨论两个进程间的同步互斥时会详细说明。对于同一进程的两个线程,假设有这样一段代码。

int res, temp=0;
res = temp++;

上文的代码是通过C语言编写的,需要经过编译、链接之后才能执行,经过编译后,“res=temp++;”可能被翻译成如下的汇编指令。

load temp, reg1
store reg1, res
inc reg1
store temp, reg1

如果两个线程同时执行这样一段代码,在执行过程中,可能发生线程切换,导致一个线程没有全部执行完这4条指令,就将执行权限交到另一个线程的情况。考虑这样一种情况,线程1在执行完inc reg1之后发生线程切换,第二个线程开始执行,如果第二个线程正常执行完毕,将temp置为1,然后切回线程1,再次将temp置为1。其实这已经和我们的初衷不符,因为正常情况下,我们通常认为temp应该等于2,而且更重要的是,这个代码带有不确定性,如果两个线程执行时,temp可能为1也可能为2,res的值也不确定。

一种简单的做法是加锁,还是看一段代码。

int res,temp=0;
LOCK(p);
res = temp++;
UNLOCK(p);

这里假设p是一个全局变量,初始化为1,函数LOCK(p)可以理解为读取p的值,如果p>0则p执行自减操作,如果p=0则将当前线程睡眠一个固定的时间,然后再来查询p的值,这个过程可以表示为如下代码。

void LOCK(int p)
{
    while(1)
    {
        if(p > 0)
        {
            p--;
            return;
        }
        sleep(10);
    }
}

UNLOCK的代码同理,这里不详细写了。看到这里读者可能会发现,这段代码看似解决了以前的问题,但是带来了两个新的问题:

  1. 这段代码并不能真正让多线程正确工作,比如线程1执行时,假设p=1,那么(p>0)是成立的,但是如果恰巧执行完p>0以后线程切换,线程1让出执行权限给线程2,那么线程2在判断p>0时也是成立的,这时两个线程仍然同时进入到临界区(我们把不允许多线程同时执行的区域称为临界区或互斥区,下同),因此不能解决上述问题。

  2. 第二个问题是,即使多个线程不会同时进入到临界区,也会导致忙等待的问题。具体来说,如果线程1进入到临界区,这时切换到线程2,线程2可能也执行这段代码,当它试图执行LOCK(p)时,它会一直轮询p的状态,此时线程1没有执行,那么它这个时间片(线程2的执行时间)事实上是浪费了,如果线程2的优先级高于线程1,而且线程的调度算法是优先级高的线程总是先执行,那将产生可怕的后果,线程1永远也不能执行,因此永远也不会释放锁,而线程2永远在轮询,永远在浪费时间片。

显然,上述两个问题是不能回避的,这两个问题必须得到解决。针对第一个问题,事实上我们采用硬件提供的方法,由硬件确保查询和更改操作是原子操作,简单来说,就是判断(p>0)和执行p--这两个操作是原子操作,要么都做要么都不做,我记得C库会提供一个大致叫CompareAndChange的函数来完成这个操作。
针对第二个问题,要解决起来就复杂的多。首先,操作系统将线程分为三种状态,分别是就绪(Ready)、挂起(Suspend)、执行(Execute),事实上这三种状态在很多地方都会用到,这里只考虑在访问临界区时的应用。首先介绍一下这三种状态,就绪态的线程是指一个线程已经就绪,简单来说就是可以被调度执行,需要注意,同一时刻可能存在多个就绪态的线程,如果当前执行的线程执行完毕后,会从当前多个就绪线程中选取一个线程(一般选择优先级最高的)切换到执行态。执行态的线程在同一时刻只有一个(事实上执行态的线程个数取决于CPU核的个数,但又不仅仅取决于CPU核的个数,这里不详细讨论),挂起态比较特殊,这类线程往往是由于资源得不到满足而挂起,等到资源满足以后再被唤醒切换到就绪态。举个简单的挂起态的例子,比如一个线程想要读磁盘,那么它只需要发一个系统调用告诉内核,再由内核告诉磁盘读取指定区域的数据,但是这个读取是需要时间的,此时这个线程就被阻塞了,因此给它时间片也没用,所以它会被os挂起,当磁盘读取完成后,可以告诉内核,然后由内核再将上述挂起线程唤醒。

回到这个问题,当线程1执行了LOCK(p)之后进入到临界区以后,如果这时线程1让出执行权限,由线程2开始执行,那么当它执行到LOCK(p)时,它不会再去轮询p到状态,而是会将自己从执行态(因为此时线程2在执行,所以必然处于执行态)变为挂起状态。需要注意的是,无论线程2的优先级多么高,此时线程2再也没有执行的可能了。接下来,如果线程1执行完毕后,它会执行UNLOCK(p),那么此时UNLOCK(p)也不能仅仅做p++了,它需要唤醒线程2,也就是唤醒等待p的线程。此时p已经不仅仅是一个整数那么简单了,准备的说,p已经是一个信号量了,信号量肯定比一个整数要复杂很多,但从原理上讲,也不需要很复杂。那么一个信号量需要什么呢?我想它应该需要两样东西:

  1. 一个整数记录当前信号量的值,信号量的值不总是1,比如临界区的代码是操作打印机,而此时存在十个打印机,那么允许十个线程同时进入到临界区,因此信号量可以是10,当然大多数情况下信号量只有0、1两个取值。

  2. 应该有一个队列作为信号量的等待队列,简单来说,如果线程1在临界区中执行时让出执行权限,在线程1再次被调度执行以前,有线程2、线程3两个线程都试图进入临界区,因此这两个线程会进入到一个队列中,当信号量被线程1释放时,我们一般会唤醒先等待信号量的线程,假设线程2先试图访问这个临界区,那么就先唤醒线程2,等线程2再次执行完毕后再唤醒线程3.

如此,一个简单的信号量就设计完成了,对于信号量的操作,一般称为P和V操作,P相当于LOCK、V相当于UNLOCK。当然,现在的操作系统对于信号量的设计远没有这么简单,考虑的情况也要复杂很多,这只是一个简单的分析,如果有读者在这方面想要交流,欢迎发邮件给我。

同步

上述考虑的是互斥的情况,下面考虑同步的情况。首先,操作系统为什么要有同步操作?举个例子,福特是汽车行业的先驱,尽管汽车的发明者是benz(关于汽车的发明者,现在仍然争论不休,这里不详细说了),但是福特真正把汽车带进了千家万户,他最大的贡献就是发明了流水线作业,大幅度降低了汽车制造的成本。流水线作业的本质是每个人只负责一小部分,整个工厂像流水线一样完成汽车制造。对于计算机来说,我们考虑这样一种情况,假设一个音乐播放软件,首先需要有一个线程负责告诉磁盘把音乐读到内存中,然后另一个线程负责把内存中的数据发送到声卡处理。那么整个音乐播放就是一个同步问题,首先需要将数据读到内存,才能将数据发送给声卡,播放出我们可以听见的声音。如果将这个问题抽象一下,可以认为有A、B、C、D四个操作,需要按照A、B、C、D的顺序执行,对于这类问题,应用上述信号量的机制就可以很好解决。比如设计三个信号量,这里分别记为a,b,c。线程B等待信号量a,线程C等待信号量b,线程D等待信号量c,初始化阶段将三个信号量都设置为0,因此线程B、C、D都会阻塞。当线程A执行完毕后,唤醒B,然后依次唤醒就可以让四个线程严格按照顺序执行。

当然,这里考虑的仍然是非常简单的情况,读者可以考虑按照这种思路会出现哪些无法解决的问题??或者仍有哪些问题没有考虑到??

欢迎留言以及邮件交流。

猜你喜欢

转载自www.cnblogs.com/miachel-zheng/p/9351833.html