原语:从0到1,从硬件指令集到OS原语,锁原语的哲学

  在道家的世界观中,无极生太极,是这个世界的从0到1。

  天地之道,以阴阳二气造化万物。天地、日月、雷电、风雨、四时、于前午后,以及雄雌、刚柔、动静、显敛,万事万物,莫不分阴阳。人生之理,以阴阳二气长养百骸。经络、骨肉、腹背、五脏、六腑,乃至七损八益,一身之内,莫不合阴阳之理。这一理论建立至今凡两三千年,仍在为人们描述万象。人与自然之间存在着互动的关系。人与天地相参,与日月相应,一体之盈虚消息,皆通于天地,应于物类。

  是故,易有太极,是生两仪,两仪生四象,四象生八卦,八卦定吉凶,吉凶生大业。---《易传·系辞上传》

  世界尚有起源,计算机更如是。

  

   如果说世界的起源是太极,计算机语言的起源便是中央处理器。我们使用的上层语言如JAVA、C的实现都是来源于操作系统提供的系统函数,而操作系统的函数则依赖于底层硬件提供的指令集

  如果没有硬件提供的原子操作,只有软件上层是不可能设计出原子操作的(这是借用教材的说法,但其实我觉着硬件指令都属于硬件原语。因为中断便是CPU在当前执行的硬件指令的指令周期的最后一个时钟周期去检测中断引脚,因此无论如何,当前的硬件指令可以执行到底)。就像没有地基,无法建造高楼一样。操作系统之所以能构建出锁之类的原子操作,就是因为硬件已经为我们提供了一些原子操作:中断禁止和启用(ir E/D)、内存加载和存入(L/S)、测试与设置(test&set)、比较与设置(CAS)。

  比如禁止中断这个操作是一个硬件步骤,中间无法插入别的操作。同样,中断启用、内存加载、内存存入等均为一个硬件步骤,中间无法插入别的操作。

  在这些硬件原子操作之上,我们便可以构建出软件的原子操作:锁、挂起与唤醒、信号量等。

  但是正如太极的诞生并不在于它提前预想到了两仪对四项的意义,硬件提供这些原子操作不是因为它们预见到研究操作系统的人将来有这个需要。而是硬件设计师需要这些原子操作来对其设计进行各种测试,操作系统对其的利用不过是个副产品而已。但是这并不是说明它不重要,恰恰相反,两仪是太极的副产品,却是四象八卦的根基,这也是我们需要认真学习操作系统的原因。

  我们下面来看一下操作系统层面“锁”原语的实现,作为我们理解操作系统原语与硬件原语关系的引子:

  锁原语

  要防止一段代码在执行过程中被其它进程插入,我们就要考虑一下在单处理器上,线程在执行过程中被插入的原因。

  我们知道在进程被切换时必然发生上下文的切换,而发生上下文的切换只有两种可能:

  1.线程被强制放弃CPU而失去控制权。

  2.线程自愿放弃CPU,如调用了yieId等系统调用。

  第二种情况我们不去考虑,因为我们既然要实现操作系统层的原语,自然不会自己发起打断原语运行的系统调用。那么我们看一下第一种情况。

  线程在执行过程中被强制打断切换到其它线程只有一种情况,那就是中断。操作系统通过周期性的时钟中断来获得CPU的控制权进行线程调度、外部中断的发起使CPU强制陷入中断处理程序。

  那么保证原语不被打断的运行(运行过程中不发生线程切换),我们需要在原语的发生过程中不能发生中断事件。

  一组操作不自动发起让出CPU的系统调用(如yieId),并且执行过程中不会被打断切换到其它线程,这样一组操作就变成了原子操作(单核环境)。

  如果说这样可以构建操作系统层的原子操作,为什么不将中断的禁止和启用函数提供出来由用户直接按自己的需要构建原子操作呢。这种做法理论上是可行的,但是却是危险的,将操作系统赖以工作的基础机制交给用户管理,万一用户水平有限,没有正确的在禁止中断后进行启用,对系统的破坏将是灾难性的。而且这也等于给黑客提供了一个攻击的入口。

  所以不能讲中断的禁止和启用交给用户,而由操作系统封装出锁原语提供给用户。锁原语的重点在于上锁需要两个步骤:检查当前锁状态、未加锁则上锁。而我们需要这两个步骤是一个原子操作,因为如果两个步骤的执行可以被打断,在两个步骤之间有其它线程更改了锁状态的话,会造成我们之前检查的锁状态是有误的,那么后面的一系列操作都将是有误的。实现锁原语,就是要实现检查与对锁操作的原子性,两个操作之间不可以有空隙

  我们来看一下如何通过中断禁止原语实现锁原语:

lock(){
     disable interrupts; //禁止中断原语
     while(value!=free){  //判断锁是否空闲
          enable interrupts;  //开启中断原语
          disable interrupts; //禁止中断原语
     }  
     value=busy;  //加锁
     enable interrupts;//开启中断原语
}   

  上述代码保证了两个步骤的原子性,重点便在这个while循环。如果锁被别人持有,那么我们先开中断再关中断,先开是给其它线程获得CPU去释放锁的机会,再关中断是为了保证下次检查如果锁已经是空的,那么跳出while循环时中断是关着的,也就是while中的判断与跳出循环后的加锁是原子的。

  而释放锁就没有这么复杂了,只需要  关中断---释放锁---开中断  即可,因为我们默认,释放锁的线程一定是得到了锁的,在锁的保护范围内进行释放锁操作。我们不需要进行锁状态检查,只需要保证释放锁这一个语句的原子性。

  那么既然是只需要保证释放锁这一个语句的原子性,中断保护还有必要吗?答案是肯定的,释放锁在操作系统中是一个语句,但是在硬件层却是好多指令组成的,并不是原子操作。硬件层的指令才可以保证不被中断打断,否则我们必须开启中断保护来保证操作的原子性

  开锁操作:

unlock(){
    disable interrupts;
    value=FREE;
    enable interrupts;
}    

  这样我们便可以通过开锁闭锁来保证一个代码块的线程安全了:

  lock();
  doSomeThing(){

  }
  unlock();

  加锁解锁之间的操作只能由获得锁的线程来执行。JAVA栈的同学看到这里可能会觉着,这不就是JAVA层面的synchronized(){}吗。没错,synchronized也是用类似的方法保证了代码块的同步性,但是比例子肯定要复杂的多,因为synchronized是通过对象的monitor来保证代码的同步的。正是我们开头提到的依赖关系,操作系统通过硬件原语构造了自己的原语,语言通过操作系统原语搭建起了自己的同步机制,剩下的便是我们应用层程序员通过语言提供的同步机制来构建多线程的应用世界了。但值得注意的是,synchronized虽然与上述锁的实现机制类似,但并不是调用的操作系统层级的锁原语,因为目前操作系统还没有提供多核环境下的锁原语(最后会提一提),多核环境的同步是基于共享内存、总线锁、test&set、内存加载和载入等多核环境下的原语实现的。

  上面所说的方法只是实现锁原语的一种方式。随着硬件的发展,硬件提供的指令集越来越丰富,操作系统也有了更多的方法来构建自己的原语,就比如我们下面说的另一种方法:

  通过测试与设置指令(test&set)实现锁原语

  我们先看一下测试与设置指令:该指令不可分割的执行如下两个步骤:

  1. 将1写入指定内存单元。

  2. 返回指定内存单元里原来的值。

  想象一下,对于一个共享变量,我们必须保证检查与进入的动作的线程排他性才可以实现正确的加锁或后续动作。否则其它线程对变量值的修改会造成我们的判断结果是错误的。t&s指令实现这一点的方式是先加锁再判断,这样如果我们成功获取了锁,其它线程不会进入后续动作。而我们如果没有获取锁,因为本身锁的状态就是加锁的,所以我们加锁对锁的状态没有影响。判断-加锁-进入同步代码区需要是线程排他的,先尝试加锁后进行判断,一旦加锁成功则后续的判断-进入同步代码区与前面的加锁将是线程排他的,将加锁部分放在第一步保证成功后后续步骤的原子性是一个很有意思的思路。但是这要求一旦加锁失败将没有对应的动作,否则加锁失败的后续处理与前面的判断无法保证是线程排他的,因为加锁失败后续的处理代码并没有被锁住

  这样我们锁的实现便成为了:

lock(){
     while(test_and_set(value)==1){}
}    

  我们将锁的值写为1,并返回原来的值。如果原来的值本身就是1则循环等待其变为0,如果原来为0则因为已经写入为1,完成加锁操作。

  不可分割的写入与返回原值保证了判断与加锁的原子性。

   上面两种实现看起来已经充分的保证了锁原语的原子性,但是还存在一个问题。那就是不管是中断保护还是t&s原语,在没有获取到锁使线程都是在一个while循环中等待锁的释放,也就是说线程占用着CPU的硬件资源却除了等待没有做任何事,这极大的浪费了计算资源,也降低了程序运行的效率。这种锁没有释放我就一直等你释放的方式叫做繁忙等待。在严重的情况下,繁忙等待甚至会造成线程优先级倒挂死锁

  如果想进一步优化,那么我们需要将繁忙等待变为非繁忙等待。改善的思路就是在拿不到锁的时候我阻塞,不会被线程调度程序调度。而持有锁的线程在释放锁的时候来叫醒我。非繁忙等待锁的实现思路如下:

  如果拿不到锁,线程放弃CPU并变为阻塞状态,以便可以运行的线程更好的运行。

  当释放锁的时候,将因为等待而阻塞的线程唤醒为就绪状态,由线程控制模块进行调度来竞争锁。

  我们来看下列代码:

lock(){
    while(test_and_set(guard)=1){}
    if(value==free){
       value=busy;
       guard=0;  
    }else{
       add thread to queue of threads waiting for this lock;
       guard=0;
       switch to next run-able thread; 
    }
}

  在这里,与之前的中断保护思路一样,如果锁被别的线程持有,那么陷入该锁的繁忙阻塞队列。而如果锁是没有被持有的,那么加锁。但与之前不同的是,这里增加了一个guard变量,并在该变量上进行繁忙等待。加锁的过程是这样的:

  如果guard为1,则循环等待;

  如果guard为0,检查锁的值是否为free,如果是则加锁后把guard置为0;

  否则在加入等待队列后将guard变为0并让出CPU。

  释放锁的时候,先检查guard,如果没有人持有,则叫醒等待的线程并将锁置为1,然后将guard置为0;

  也就是说,我们使用guard变量来给判断与加锁的指令组合加锁。这一点小小的改变,带来了深远的影响。虽然我们依然在这里使用了循环等待,但是我们循环等待的是对锁的操作,而不是原来对加锁区域的操作。我们缩小了循环等待的范围,以为着我们循环等待的时间将大大减少,而需要长时间等待的同步代码区则有等待-唤醒机制实现加锁。这种非繁忙等待与繁忙等待组合的加锁方式在保证判断与加锁原子性的同时大大提高了运行效率。

  判断锁的状态---->如果锁被持有则繁忙等待到同步代码区执行完并释放锁  变为了

  循环等待对锁操作的锁的释放---->判断锁状态--->如果被别的线程持有则进入锁的等待队列。

  但是这样做依然存在一个问题,那就是       guard=0;switch to next run-able thread;    部分,释放锁的锁(有点别扭,但事实如此)的动作在切换线程的动作之前进行,实时上我们必须这样做,因为我们只有在运行时才可以释放锁,如果线程切换的指令在前,我们释放锁的指令永远也不会执行。我们可以想象,如果线程A在执行到guard=0时忽然发生线程切换,而此时持有锁的线程运行并释放锁,唤醒了我们的线程A,A执行下一句:switch to next run-able thread;  好的,这下A线程在等待队列了,但是不会再有线程唤醒它,因为持有锁的线程已经释放锁并唤醒过它一次了,这就造成了信号量丢失

  这可真是一个老大难的问题,目前我们的做法是将A线程的优先级提高,尽可能的避免这种情况的发生,但完全避免是不可能的,这也是操作系统会偶尔出问题的原因。

  释放锁的方法也很简单:

unlock(){
    while(test_and_set(guard)=1){}
    value=free;
    if(any thread is waiting for this lock){
        move waiting thread from waiting queue to ready queue;
        value=busy;
    }
    guard=0;  
}

       上面便是我们目前使用较多的锁原语的实现机制,但正如前面所说,随着硬件的发展及指令集的丰富,操作系统将有越来越多的方法来实现锁原语

  上述的演变过程非常值得我们玩味,我们应当注意一个机制从提出到为了响应需求而不断优化的演变过程,吸收为自己的经验,下面我们来回顾一下整个机制的演变过程:

  1. 因为引入了多道编程,我们需要实现一个代码块对于线程的排他访问,来达到含有对共享变量操作的代码块被正确执行的目的,即同一时间只能有一个线程执行代码块中的指令。

  2. 我们因此引入了“锁”的概念,线程加锁成功后进入代码块,加锁不成功则阻塞在加锁方法上等待其它线程释放锁,这就可以保证代码块对线程的排他访问。

  3. 为了达到目的,对“锁”操作也要保证在多道编程下的原子性,我们需要检查锁的状态与加锁两个操作是一组原子操作,中间不能够被打断,我们通过中断保护或test&set硬件原语的支持实现了这两个动作的原子性。

  4. 为了提升效率,我们期望将阻塞在加锁方法时的繁忙等待改为非繁忙等待。

  5. 实现非繁忙等待,我们需要将“把当前线程放入等待队列”与“切换到可执行线程”是一组原子操作(换句话说,我们需要从“判断锁的状态”一直到“切换到其它线程”是一组原子操作)。而这会带来一个问题,那就是如果使用中断保护,那就需要在切换到其它线程后启用中断,而这是无法做到的(必须有“切换到其它线程”这一动作是因为如果我们不主动执行切换到其它线程,而由时钟中断将当前线程强制让出CPU的话,当前线程会出现在就绪队列中(时钟中断的处理程序写死的))。需要同步代码块的第一条指令必须是启用中断,这太过依赖开发者的水平与信用,给系统带来了非常大的风险。而之前我们已经将当前线程放入了等待队列,这就使一个线程同时拥有了两种状态,会造成运行时的错误。

  6. 于是我们在切换到其它线程前启用中断(在上述例子中是在切换到其它线程之前释放guard),它造成的问题就是“切换到其它线程”这一动作被排除到了原子性操作组之外,而在它们之间被打断的话,会造成信号量丢失问题。所以我们通过提高当前线程的优先级来尽量避免这一问题的发生。

  我们所做的,就是在通过硬件原语来保证“锁”的原子性。而随着将繁忙等待变为非繁忙等待,对锁的操作越来越多,甚至其中包含着线程切换的指令,因此我们不得不做出让步将一部分指令移出原子操作组之外,通过提高优先级的方式进行最大限度的补偿。

  我们来对比一下两种方式:

  中断保护:相比另一种方式更加简便,但是只适用于单核环境。因为如果是多核环境,需要发出信号使其它CPU也禁止中断,这将不再是原子操作,而且也让多核心在一定程度上失去了各个核心的独立性(多核的初衷就是让它们可以独立执行)。就算我们这样做了,也将付出极大的代价(保证各个核心的中断是一个原子操作),因此不提倡使用。

  test&set原语:实现相对复杂,且在多核环境下也可以工作。因为即使是多核,各个核心也在使用共享内存,而该指令针对的就是内存单元。在多核环境下,test&set原语会结合总线锁来保证同一时间只有一个核心可以访问共享内存,从而保证该原语在多核环境下的原子性。

  操作系统在硬件指令集的基础上构建起了一个安全高效的硬件使用机制,而我们在操作系统的基础上构建更加繁茂的应用大厦。值得一提的是,由于多核心技术还比较年轻(相对于单核心),在同步方面的实现还没有一个统一的标准,不同的操作系统将会有不同的实现方式,比如windows与linux提供的多核心原子指令集就完全不同,linux提供了总线锁(置换、比较与置换、原子递增)、原子算数操作、原子位操作,而windows提供了互锁操作、执行体互锁操作。

  目前操作系统还没有为多核环境提供锁操作,因为代价较大。而像JAVA中提供的对同步代码区的锁操作(synchronized)在多核环境下也是基于共享内存的,即对象的monitor,这也是效率较低的原因(当然更大的原因是需要改变线程状态,即需要进行系统调用进行用户态核心态的切换来阻塞和唤醒线程),在使用前应当进行合理的设计。

   由此看来,“锁”的语义便是保证上锁部分对线程访问的排他性。而硬件的指令集为我们提供了两种实现思路:

  不要打断我的执行(基于中断保护,适用于单核环境,多核环境下不打断你其它核心也可以执行同步代码块)。

  可以打断我的执行,但我退出上锁部分前,其它线程不能执行上锁的部分(基于test&set指令,以来共享内存。单核多核均适用,实现较复杂)。

  另外需要着重说的是,对于基于共享变量的锁操作,我们要做的是保证判断状态与后续动作的线程排他性。因为后续动作都是基于当前状态的,如果之间被打断并有其它线程改变了共享变量的状态,那么对于之后的操作来说,我们的判断是错误的。我们前面所做的一切,都是在尽量通过硬件指令来保证判断状态与后续动作的线程排他性。

  比如对于繁忙等待,在没有获取到锁时我们只需要进行下一次循环判断。而非繁忙等待,在没有获取到锁时我们需要将线程加入等待队列并等待被唤醒。因此繁忙等待我们只需要保证判断-获得锁的线程排他性,而非繁忙等待,我们需要保证判断-获取锁判断-进入等待队列等后续动作的线程排他性。一段拥有线程排他性的代码,在多道编程的情况便是原子的。

  保证状态的赋值、判断与基于状态的动作是线程排他的,以此保证基于状态的动作的正确性,是我们实现锁原语时的根本。

posted @ 2019-11-24 01:22  牛有肉  阅读(2199)  评论(0编辑  收藏  举报