zoukankan      html  css  js  c++  java
  • 操作系统中的同步互斥(锁与信号量)

    互斥

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

    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,然后依次唤醒就可以让四个线程严格按照顺序执行。

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

    欢迎留言以及邮件交流。

  • 相关阅读:
    Good Bye 2014 B. New Year Permutation(floyd )
    hdu 5147 Sequence II (树状数组 求逆序数)
    POJ 1696 Space Ant (极角排序)
    POJ 2398 Toy Storage (叉积判断点和线段的关系)
    hdu 2897 邂逅明下 (简单巴什博弈)
    poj 1410 Intersection (判断线段与矩形相交 判线段相交)
    HDU 3400 Line belt (三分嵌套)
    Codeforces Round #279 (Div. 2) C. Hacking Cypher (大数取余)
    Codeforces Round #179 (Div. 2) B. Yaroslav and Two Strings (容斥原理)
    hdu 1576 A/B (求逆元)
  • 原文地址:https://www.cnblogs.com/miachel-zheng/p/9351833.html
Copyright © 2011-2022 走看看