线程原理
线程原理
线程
线程
既然线程是进程的分身,那么每个线程自然在本质上是一样的,即拥有同样的程序文本。但由于是分身,自然也应该有不一样的地方,这就是线程执行时的上下文不一致。事实上,我们说线程是进程里面的一个执行上下文或者执行序列。显然,一个进程可以同时拥有多个执行序列。
在线程模式下,一个进程至少有一个线程,但也可以有多个线程。
将进程分解为线程还可以有效地利用多处理器和多核计算机。在没有线程的情况下,增加一个处理器并不能提高一个进程的执行速度。但如果分解为多个线程,则可以让不同的线程同时运转在不同的处理器上,从而提高了进程的执行速度。
线程管理
有进程后,要管理进程。那么有线程后,也要进行管理。而管理的基础也与进程管理的基础类似:就是要维持线程的各种信息。这些信息包含了线程的各种关键资料。存放这些信息的数据结构称为线程控制表或线程控制块。那么线程控制块里面到底包含哪些信息呢?
我们说过线程共享一个进程空间,因此,许多资源是共享的。这些共享的资源显然不需要存放在线程控制块里面,而是存放在进程控制块即可。但由于线程是不同的执行序列,总会有些不能共享的资源。而这些不被共享的资源和信息就需要存放在线程控制块里。
到底哪些资源是(同一进程的)不同线程所共享,哪些是不共享的呢?一般的评判标准是:如果某资源不独享会导致线程运行错误,则该资源就由每个线程独享;而其他资源都由进程里面的所有线程共享。
按照这个标准来划分,线程共享的资源有地址空间、全局变量、文件、子进程等。定时器、信号和占用CPU时间也可以共享。但程序计数器不能共享,因为每个线程的执行序列不一样。同理,寄存器也不能共享,栈也不能共享,这是线程的上下文(运行环境)。
线程模型的实现
在存储上,由于线程依附于进程而存在, 其存储解决方案无需额外设计,而是直接附于进程存储方案上。
线程的调度却与进程调度有稍许不同。由于线程是在进程的基础上产生的概念(进程里面的一个执行序列),其调度可以由进程负责。当然,我们也可以将线程的调度交给操作系统。而这两种不同的调度推手就形成了线程的两种实现:用户态实现和内核态实现。由进程自己管理就是用户态线程的实现,由操作系统管理就是内核态线程实现。用户态和内核态的判断以线程表所处的位置为依据:位于内核叫内核态实现,位于用户层叫用户态实现。
因为进程是在CPU上实现并发(多道编程),而CPU是由操作系统管理的,因此,进程的实现只能由操作系统内核来进行,而不存在用户态实现的情况,根本没有这种探讨的需要。但对于线程就不同了,因为线程是进程内部的东西,当然存在由进程直接管理线程的可能性。因此,线程存在着内核态与用户态两种实现可能。
内核态线程实现
前面说过,线程是进程的分身,是进程的不同执行序列。既然每个线程是不同的执行序列,则说明线程应该是CPU调度的基本单位。我们知道,CPU调度是由操作系统实现的。因此,让操作系统来管理线程似乎是天经地义的事情。
那么操作系统怎么管理线程呢?与管理进程一样,操作系统要管理线程,就要保持维护线程的各种资料,即将线程控制块存放在操作系统内核空间。这样,操作系统内核就同时保有进程控制块和线程控制块。而根据进程控制块和线程控制块提供的信息,操作系统就可以对线程进行各种类似进程的管理,如线程调度、线程的资源分配、各种安全措施的实现等。
由操作系统来管理线程有很多好处,最重要的好处是用户编程简单。因为线程的复杂性由操作系统承担,用户程序员在编程时无需管理线程的调度,即无需担心线程什么时候会执行、什么时候会挂起。另外一个重要好处是,如果一个线程执行阻塞操作,操作系统可以从容地调度另外一个线程执行。因为操作系统能够监控所有的线程。
那么内核态线程实现有什么缺点呢?首先是效率较低。因为线程在内核态实现,每次线程切换都需要陷入到内核,由操作系统来进行调度。而从用户态陷入到内核态是要花时间的。另外,内核态实现占用内核稀缺的内存资源,因为操作系统需要维护线程表。操作系统所占内存空间一旦装载结束后就已经固定,无法动态改变。由于线程的数量通常大大多于进程的数量,因此随着线程数量的增加,操作系统内核空间将迅速耗尽。
如果要建立进程线程,但内核空间不够了,怎么办?我们可以做的选择有:“杀死”别的进程;创建失败;让它等一下。前面说过,“杀死”别的进程是一件很不好的事情,因为将造成服务不确定性。宣称创建失败也很差。因为创建失败有可能意味着某个进程无法往前推进,这违反了我们前面说过的进程模型的时序推进要求。让创建者等一下,这要看创建的是什么进程和线程了。如果是系统进程线程,等一下可能意味着关键服务无法按时启动;如果是用户进程线程,等一下可能引起用户的强烈不满。而且,等多久谁也不知道。
那在内核空间满了后,应该怎么办呢?如果内核空间溢出,操作系统将停止运转。因为要创立的进程可能很重要,所以不能不创建。所以最好的结局是“死掉”。别人发现系统“死了”就会采取行动来补救。 如果操作系统还要运转,却不能正确地运转,那是很危险的事情。操作系统采取的这种行动在灾难应对领域称为“无害遽止”。
但上面两个缺点还不是最致命的。最致命的是内核态实现需要修改操作系统,这在线程概念提出之初是一件很难办到的事情。试想,如果你作为研究人员提出了线程概念,然后你去找一家操作系统研发商,要求其修改操作系统,加入线程的管理,结果会怎样?操作系统开发商会请你走开。有谁敢把一个还未经证明的新概念加入到对计算机影响甚大的操作系统里?除非我们先证明线程的有效性,否则很难说服他人修改操作系统。
用户态线程实现
线程在刚刚出现时,由于无法说服操作系统人员修改操作系统,其实现的方式只能是在用户态。(谁提出谁举证。)用户自己做线程的切换,自己管理线程的信息,而操作系统无须知道线程的存在。
那么在用户态如何进行线程调度呢?那就是用户自己写一个执行系统(runtime system)作调度器,即除了正常执行任务的线程外,还有一个专门负责线程调度的线程。由于大家都在用户态下运行,谁也不比谁占优势,要想取得CPU控制权只能靠大家的自愿合作。一个线程在执行完一段时间后主动把资源释放给别人使用,而在内核态下则无须如此。因为操作系统可通过周期性的时钟中断把控制权夺过来。在用户态实现情况下,执行系统的调度器(runtime scheduler)也是线程,没有能力强行夺走控制权,所以必须合作。
那么用户态实现有什么优点呢?有。首先是灵活性。因为操作系统无须知道线程的存在,所以在任何操作系统上都能应用;其次是线程切换快。因为切换在用户态进行,无须陷入到内核态。最后是不用修改操作系统,实现容易。
那么这种实现方式有什么缺点吗?有。首先,编程序变得很诡异。我们前面说过,用户态线程需要相互合作才能运转。这样,我们在写程序时,必须仔细斟酌在什么时候应该让出CPU给别的线程使用。而让出时机的选择对线程的效率和可靠性有很大的影响。这并不是一件容易的事。另外一个更为严重的问题是,用户态线程实现无法完全达到线程提出所要达到的目的:进程级多道编程。
如果在执行过程中一个线程受阻,它将无法将控制权交出来(因为受阻后无法执行交出CPU的指令了),这样整个进程都无法推进。操作系统随即把CPU控制权交给另外一个进程。这样,一个线程受阻造成整个进程都受阻,我们期望通过线程对进程实施分身的计划就失败了。这是用户态线程的致命弱点。
既然线程阻塞造成整个进程阻塞,解决的办法只有两种:一是不让线程阻塞;二是阻塞后想办法激活同一进程的另外线程。
-
第一种办法如何实现呢?首先来看线程阻塞的原因。
-
线程之所以阻塞是因为它执行了阻塞操作,如读写磁盘、收发数据包等。那我们就想,如果将这些操作改为非阻塞操作,就可以解决问题了。但是这种办法根本就行不通。首先,将所有系统调用改为非阻塞就得修改操作系统,而我们刚才说了,用户态线程实现就是不想修改操作系统;其次,就算操作系统的人员很仁慈, 帮你修改,那可以吗?不可以,因为很多系统调用的语义里面就包括阻塞,即阻塞是其正确运行的前提。使用这些系统调用的程序期望着阻塞。而修改系统调用的语义就会造成这些程序运行错误。所以这个建议行不通。
-
既然不能将阻塞操作修改为非阻塞操作,那我们可以不让线程调用阻塞操作。我们只需要在线程进行任何系统调用前,先确认一下该调用是否会发生阻塞,即我们写一个包裹(wrap),将系统调用包裹起来,用户程序使用系统调用时需通过这个包裹。而包裹里有一段代码,专门检查发出的系统调用会不会阻塞。如果会, 就禁止调用;否则,就放行。但这样做有个很大的缺点:一是,需要修改操作系统,将系统调用包裹起来;二是,这样做大大降低了线程的效率;三是, 这种做法有个关键前提条件。因为我们不让程序发出阻塞调用并不是要永远不让该线程运行,而是让它等待 一段时间。因此,本做法隐含的前提条件是你等待一段时间后该调用就会由阻塞调用变成非阻塞调用,否则的话,该程序就永远不能运转了。而这个前提假定却不一定成立。
当然了,有的调用在等待一段时间后再调用确实会变成非阻塞操作。但问题是,并不是所有的阻塞调用在等待一段时间后都会转变成非阻塞操作。比如读磁盘,你一调用就阻塞,但如果你不调用,就不会读磁盘,那么你在将来任何时候读磁盘仍将阻塞,这样该线程就永远无法推进。
-
-
第二种解决办法,即在进程阻塞后想办法激活受阻进程的其他线程。这种办法的实现必须依赖操作系统。因为线程阻塞后,CPU控制权已经回到操作系统手里。而要激活受阻进程的其他线程,唯一的办法是让操作系统在进行进程切换时先不切换,而是通知受阻的进程执行系统(即调用执行系统),并问其是否还有别的线程可以执行。如果有,将CPU控制权交给该受阻进程的执行系统线程,从而调度另一个可以执行的线程到CPU上。这种做法被称为调度器激活(scheduler activation)。因为我们所干的事情就是激活进程里面的调度器(执行系统)。
我们将这种做法称为“第二机会”。因为在一个进程挂起后,操作系统并不立即切换到别的进程,而是给该进程第二次机会,让其继续执行。如果该进程只有一个线程,或者其所有线程都已经阻塞,则控制权将再次返回给操作系统。而这次,操作系统就会切换到别的进程了。
这种办法似乎解决了阻塞线程阻塞进程的问题,但也有两个缺点
-
首先是需要修改操作系统,使得其在进行进程切换时,不是立即切换到别的进程,而是调用受阻进程的执行系统。但由于此种修改范围小,只需要对调度器程序做一个外科手术式的小改动即可,因而尚可以忍受。
-
但该做法还存在一个更为严重的缺陷:这种操作系统调用用户态执行系统的做法违反了我们所遵循的层次架构原则。因为这种调用属于所谓的up-call,即下层功能调用了上层功能(操作系统在下,执行系统在上)。 而平时用户程序使用操作系统服务的调用属于down-call,即上层程序调用下层服务。这种违反上下有别的做法使得操作系统的设计和管理都变得复杂,而且,由于调度器在第一次切换时总是选择阻塞的进程,这样也为黑客和各种攻击者提供了一个系统缺口。另外,这种层次结构的违反让习惯了上下有别的人类感到十分不快,因此,此种做法没有得到商用操作系统的认可。
-
现代操作系统的线程实现模型
鉴于用户态和内核态的线程模型都存在缺陷,因此现代操作系统将二者结合起来使用。用户态的执行系统负责进程内部线程在非阻塞时的切换;内核态的操作系统负责阻塞线程的切换。即我们同时实现内核态和用户态线程管理。其中内核态线程数量较少,而用户态线程数量较多。每个内核态线程可以服务一个或多个用户态线程。换句话说,用户态线程被多路复用到内核态线程上。
这样,在分配线程时,我们可将需要执行阻塞操作的线程设为内核态线程,而不会执行阻塞操作的线程设为用户态线程。这样我们就可以获得两种态势实现下的优点,而避免其缺点。
多线程的关系
从用户态进入内核态
什么情况会造成一个线程从用户态进入到内核态呢?
首先,如果在程序运行过程中发生中断或异常,系统将自动切换到内核态来运行中断或异常处理机制。图7-
7描述的就是中断导致态势切换的流程。异常处理的流程也与此相同或相似。
此外,程序进行系统调用也将造成从用户态进入到内核态的转换。其执行过程如下:
- 执行汇编语言里面的系统调用指令(如syscall)。
- 将调用的参数SYS_READ、file number, size存放在指定的寄存器或栈上(事先约好)。
- 当处理器执行到"syscall"指令时,察觉这是一个系统调用指令,将进行如下操作:
- 设置处理器至内核态。
- 保存当前寄存器(栈指针、程序计数器、通用寄存器)。
- 将栈指针设置指向内核栈地址。
- 将程序计数器设置为一个事先约定的地址上。该地址上存放的是系统调用处理程序的起始地址。
- 系统调用处理程序执行系统调用,并调用内核里面的READ函数。这样就实现了从用户态到内核态的转
换,并完成系统调用所要求的功能。
线程的困惑
那么线程到底是好还是坏呢?
从某种程度上说,线程提供了程序层面的并发性能。毫无疑问,并发的好处是显而易见的,既提高了系统的效率或者说吞吐率,又改善了用户感觉到的响应时间。
虽然线程的优势很明显,但带来的问题也是显而易见的,那就是系统运行的不确定性。虽然在多进程时,系统运行也存在一定的不确定性,但这种不确定性基本体现在程序执行的先后顺序上,而每个程序运行的结果基本是确定的。而线程的引入却带来了程序本身运行结果的不确定性:由于多线程的存在,就每个单一线程来看,其执行效率、执行正确性均存在不确定性。当然,通过使用同步机制,可以改善这种不确定性。但如果在多线程执行过程中出现异常,则情况就相当麻烦。另外,在多线程下,如果某个进程的某个线程创建了 一个子进程,那对于该进程的其他线程来讲意味着什么?这是一个众说纷纭、莫衷一是的话题。
如果读者对计算机硬件与体系结构熟悉,可能能够看出来,线程的机制非常类似于硬件的流水线机制。流水线也是提供并发,不过是指令级的(而不是程序级的)。并发当然提高了计算机的吞吐率,改善了用户响应时间。但问题是,在多流水线多梯级情况下,由于有许多指令同时在不同的流水线和梯级上执行,其之间存在的数据和指令依赖关系十分复杂。如果万一再发生异常,如何保存一个一致性的状态都成了问题。
更为重要的是,线程和流水线的管理十分复杂。读者已经看到,线程的同步机制繁杂,使得整个操作系统都变得更为复杂,从而增加整个系统的不可靠性。在硬件层,流水线也一样,需要许多复杂技术的支持,如多指令发射里的超长指令字与超标量计算等。这使得编译器或指令级结构或者两者同时变得复杂。复杂的东西其可靠性是很难保证的。
从某种程度上说,线程与流水线分别是软件层和硬件层不确定性的根源。流水线使得我们可以在硬件指令执行上并发,线程则使我们在软件指令执行上并发。但其带来的操作系统、编译系统和指令集结构的高度复杂是否值得,也许并不容易回答。
线程同步
为什么要同步
有两个线程同时运行,第一个线程在执行了一些操作后想检查当前的错误状态errno,但在其做出检查之前,线程2却修改了errno。这样,当第一个线程再次获得控制权后,检查结果将是线程2改写过的errno,而这是不正确的。
之所以出现上述问题,是基于两个原因:
- errno是线程之间共享的全局变量。
- 线程之间的相对执行顺序是不确定的。
因此,要解决上述问题有两个办法,即分别消除上述两个原因。消除第1个原因的办法就是限制全局变量, 给每个线程一个私有的errno变量。事实上,如果可以将所有的资源都私有化,让线程之间不共享,那么这种问题就不复存在。
但问题是,如果所有资源都不共享,那么还有必要发明线程吗?甚至也没有必要发明进程了。因为这样就违背了进程和线程设计的初衷:共享资源、提高资源利用率。因此,这种解决办法是不切实际的。那剩下的办法就是消除第2个原因,即让线程之间的相对执行顺序在需要的时候可以确定。
引入线程后,也引入了一个巨大的问题,即多线程程序的执行结果有可能是不确定的。而不确定则是我们人类非常反感的东西。那么如何在保持线程这个概念的同时,消除其执行结果的不确定性呢?答案是线程的同步。
线程同步的目的
线程同步的目的就是不管线程之间的执行如何穿插,其运行结果都是正确的。或者说,要保证多线程执行下结果的确定性。而在达到这个目标的同时,要保持对线程执行的限制越少越好。
除此之外,线程同步的另外一个目的涉及执行效率。除了前面说过的多线程执行的结果是不确定的之外,其执行效率也是不确定的。
那么到底什么是“同步”呢?同步就是让所有线程按照一定的规则执行,使得其正确性和效率都有迹可寻。线程同步的手段就是对线程之间的穿插进行控制。
锁的进化
两个或多个线程争相执行同一段代码或访问同一资源的现象称为竞争(race)。这个可能造成竞争的共享代码段或资源称为临界区(critical section)。
当然,我们知道两个线程不可能真的在同一时刻执行(单核情况)。但有可能在同一个时刻两个线程都在同 一段代码上。这个例子里竞争的是代码,是代码竞争。如果是两个线程同时访问一个数据就叫数据竞争。
要想避免竞争,就需要防止两个或多个线程同时进入临界区。要达到这一 点,就需要某种协调手段。
协调的目的就是在任何时刻都只能有一个人在临界区里,这称为互斥(mutual exclusion)。互斥就是说一次只有一个人使用共享资源,其他人皆排除在外,并且互斥不能违反前面给出的进程模型。因此,正确互斥需要满足4个条件:
- 不能有两个进程同时在临界区里面。
- 进程能够在任何数量和速度的CPU上正确执行。
- 在互斥区域外不能阻止另一个进程的运行。
- 进程不能无限制地等待进入临界区。
在操作系统里,这种可以保证互斥的同步机制称为锁。
锁的基本操作
锁有两个基本操作:闭锁和开锁。
闭锁操作有两个步骤,分别如下:
- 等待锁达到打开状态。
- 获得锁并锁上。
开锁操作很简单,就是一步:打开锁。
显然,闭锁的两个操作应该是原子操作,即不能分开。不然,就会留下穿插的空当,从而造成锁的功效的丧
失。
一把正常锁所应该具备的特性:
- 锁的初始状态是打开状态。
- 进临界区前必须获得锁。
- 出临界区时必须打开锁。
- 如果别人持有锁则必须等待。
锁的特性就是在别人持有锁的情况下需要等待。那有没有办法不用进行任何繁忙等待呢?答案就是睡觉和叫醒,即sleep和wakeup。
睡觉与叫醒
sleep和wakeup就是操作系统里的睡觉和叫醒操作原语。一个程序调用sleep后将进入休眠状态,将释放其所占用的CPU。一个执行wakeup的程序将发送一个信号给指定的接收进程,如wakeup(producer)就发送一个信号给生产者。
如果用某种方法将发出的信号累积起来,而不是丢掉,就可以解决可能发生的死锁问题。这个操作系统原语叫信号量。
信号量
信号量(semphore)可以说是所有原语里面功能最强大的。它不仅是一个同步原语,还是一个通信原语。而且,它还能作为锁来使用!
简单来说,信号量就是一个计数器,其取值为当前累积的信号数量。它支持两个操作:加法操作up和减法操作down,分别描述如下。
down减法操作:
- 判断信号量的取值是否大于等于1。
- 如果是,将信号量的值减去1,继续往下执行。
- 否则在该信号量上等待(线程被挂起)。
up加法操作:
- 将信号量的值增加1(此操作将叫醒一个在该信号量上面等待的线程)。
- 线程继续往下执行。
这里需要注意的是,down和up两个操作虽然包含多个步骤,但这些步骤是一组原子操作,它们之间是不能
分开的。
如果将信号量的取值限制为0和1两种情况,则获得的就是一把锁,也称为二元信号量(binarysemaphore),其操作如下。
二元信号量down减法操作:
- 等待信号量的值变为1。
- 将信号量的值设置为0。
- 继续往下执行。
二元信号量up加法操作:
- 将信号量的值设置为1。
- 叫醒在该信号量上面等待的第1个线程。
- 线程继续往下执行。
二元信号量具备锁的功能,实际上它与锁很相似:down就是获得锁,up就是释放锁。但它又比锁更为灵活,因为在信号量上等待的线程不是繁忙等待,而是去睡觉,等待另外一个线程执行up操作来叫醒。因此, 二元信号量从某种意义上说就是锁和睡觉与叫醒两种原语操作的合成。
使用信号量原语时,信号量操作的顺序至关重要。稍有不慎,就可能发生死锁。而这还是在只有3个信号量的情况下。如果一个程序使用10个、几十个信号量,程序员将很难搞清楚正确的顺序到底是什么,而写程序将变成一个巨大的挑战。事实上,如果一个程序的信号量繁多,死锁或者效率低下几乎是可以肯定的。
那有没有办法改变这种状况,使得编程序不是那么大的一个挑战呢?有办法。办法就是管程。
管程
如果能够将信号量的这些组织工作交给一个专门的构造来负责,程序员不就解脱了吗?于是乎我们发明了管程。管程的英文单词是monitor,即监视器的意思。它监视的就是进程或线程的同步操作。
管程是一个程序语言级别的构造,即它的正确运行由编译器负责保证。这就是计算机里面的一条哲学原理: 你不行的时候,把困难交给别人。
具体来说,管程就是一组子程序、变量和数据结构的组合。言下之意,把需要同步的代码用一个管程的构造框起来,即将需要保护的代码置于begin monitor和end monitor之间,即可获得同步保护。在任何时候只能有 一个线程活跃在管程里面。那谁来保证这一点呢?编译器。编译器在看到begin monitor和end monitor时知道其间的代码需要同步保护,在翻译成低级代码时就会将需要的操作系统原语添上,使得两个线程不能同时活跃于同一个管程内。
在管程里面使用两种同步机制:锁用来互斥,条件变量用来控制执行的顺序。从某种意义上说,管程就是锁加上条件变量。那么什么叫条件变量呢?条件变量就是线程可以在上面等待的东西,而另外一个线程则可以通过发送信号将在条件变量上的线程叫醒。因此,条件变量有点像信号量,但又不是信号量,因为不能对其进行up和down操作。
管程的中心思想是运行一个在管程里面睡觉的线程。但是在睡觉前需要把进入管程的锁或信号量释放,否则在其睡觉后别的线程将无法进入管程,就会造成死锁。本书前面说过,在临界区里面做的事情要越少越好, 那自然不能在里面睡觉了。但在这里恰好相反。这是因为,在正常情况下,只有一个线程在临界区里,因此,在临界区待的时间越长,别的线程等待的时间就越长。但在这里情况就不一样了。因为允许别的线程进入管程,所以我们可以睡觉。
实现锁的释放和睡觉这两件事情必须是原子操作,即中间不能有空当,否则将造成两个线程同时活跃在管程里,这样就违反了关于管程的约定。当然了,这种违反并不会造成程序错误,因为其中一个线程的下一步操作是睡觉,不会与另外的线程争夺共享资源。
管程里面的两个操作wait和signal的语义分别如下:
wait(x)以原子操作完成下述3个步骤:
- 释放锁。
- 将本线程挂在条件变量x的等待队列上。
- 睡觉,等待被叫醒。
signal则与我们前面讲过的一样,将等在指定条件变量上面的第1个线程叫醒。在叫醒方面,管程还提供另外 一个所谓的广播(broadcast)原语,其语义是将指定条件变量上面的所有等待线程全部叫醒。
这里需要注意的是,在一个线程调用wait、signal或者broadcast之时,该线程必须持有与管程相连的锁。
这里需要注意的是:如果一个线程发出释放等待线程的signal,则此时将有两个线程同时活跃于管程内。而这违反了关于管程的约定。为了防止出现这种问题,管程机制特别约定:signal语句是一个线程在管程里面的最后一个操作。这样,即使理论上有两个线程同时活跃于管程内,但实际上也只有一个线程活跃。因为另 一个线程的下一步操作已经在管程之外,从而维持我们关于管程的约定。
MESA和HOARE管程
前面说过,当一个线程发出signal后,在理论上将有两个线程同时活跃在管程内。但我们知道,管程只有一 把锁。如果两个线程同时活跃,那谁将持有管程的锁呢?
第1种办法自然的想法,由于发送signal的线程执行的是管程里面的最后一条语句,不如让其继续执行,从而转到管程外面(因为其下一步操作就是在管程外),这样管理里面不是只有一个线程了吗?即叫醒者继续持有锁,并在离开管程时将锁释放,此时被叫醒者将获得管程的锁,从而可以继续执行。这种处理方法自然, 但是保守。因为如果这样的话,线程就没有必要提前叫醒等待的线程。为什么不等到执行到管程外面再叫醒呢?
第2种办法是赋予被叫醒者优先级,即在发送signal时同时释放锁,让被叫醒者获得锁。从而在signal后,第1个运行的线程将是被叫醒的线程。叫醒者只能在被叫醒者运行完毕或由于其他原因释放锁之后才能继续运行。由于这种方式是HOARE提出的,因此这种给予被叫醒者优先的管程称为HOARE管程。
前面两种方法都是在管程设计时就确定了谁将有优先权:前者将优先权赋予叫醒者,后者则将优先级赋予被叫醒者。这两种方法因为都是在管程设计时就确定了,十分不灵活,它们限制了操作系统的作用。对于操作系统设计人员来说,我们希望下一步执行哪个线程由操作系统决定。即让两个线程竞争这把锁。这样,操作系统就可以在竞争中发挥作用,使用各种机制动态地调整每个线程获取锁的优先级。这种管程就称为MESA管程。
MESA管程的signal处理方式如下:叫醒者在发出signal后释放锁;被叫醒者与叫醒者同时竞争这把锁。谁先获得锁,谁先执行。
由于MESA管程给予了操作系统以重要角色,因此它获得了大多数操作系统的认可。
消息传递
管程最大的问题是对编译器的依赖。因为我们需要编译器将需要的同步原语加在管程的开始和结尾。而这是一个令操作系统人员不放心的选择。俗话说:“相信别人,就等着灾难的发生吧。”另外,研究编译的人也不想在这上面花费心血,他们有编译方面的许许多多的问题还没有解决。而且在实际中,多数的程序设计语言也并没有实现管程机制。
另外,管程只能在单台计算机上发挥作用。如果想在多计算机环境下(或者网络环境下)进行同步,那就需要其他机制了。这种其他机制就是消息传递。
消息传递是通过同步双方经过互相收发消息来实现。它有两个基本操作,发送send和接收receive。它们均是操作系统的系统调用,而且既可以是阻塞调用,也可以是非阻塞调用。
-send(destination,&message);
-receive(source,&message);
而同步需要的是阻塞调用。即如果一个线程执行receive操作,就必须等待收到消息后才能返回。也就是说, 如果调用receive,则该线程将挂起,在收到消息后,才能转入就绪。
生产者和消费者就这样通过消息的传送进行同步,既不会死锁,也不会繁忙等待。而且,无须使用临界区等机制。更为重要的是,它可以跨计算机进行同步,即可以对处于不同计算机上的线程实现同步。由于这些优点,消息传递是当前使用非常普遍的线程同步机制。(当然,它更是一种通信机制,记得前面讲过的消息队列吗?)。
那么消息传递有什么问题没有?有。最大的问题就是消息丢失和身份识别。消息在一台计算机内部传递时丢失的可能很低,但在网络间传输时丢失的可能就很大了,这是因为网络的不可靠性所致。而身份识别指的是如何确定收到的消息就是从你想要的对象那里发出的。
当然,通过设计各种网络协议,如TCP协议,可以将网络数据传输的可靠性提高。但即使是TCP,也不是100%可靠。身份识别则可以使用诸如数字签名和加密等技术来弥补。
使用消息传递的另外一个缺点就是效率。往返发送消息存在系统消耗。另外,数据传输也存在延迟。如果网络速度很慢怎么办呢?
栅栏
栅栏(barrier)就是一个障碍。到达栅栏的线程必须停止下来,直到除去栅栏后才能往前推进。该原语主要用来对一组线程进行协调。因为有时候一组进程协同完成 一个问题,所以需要所有进程都到同一个地方汇合之后一起再向前推进。
死锁应对
为什么发生死锁
在一个多道编程的环境里,一个系统里存在多个线程,而这些线程共享该计算机系统里的资源。因为资源竞争而造成系统无法继续推进就难以避免了。
这里所说的资源就是一个程序工作时需要的东西:磁盘驱动器、锁、信号量、数据表格等。资源既可以是硬件,如CPU、内存、磁盘等,也可以是软件,看不见摸不着,如锁、信号量等。根据资源是否可以抢占分为:可抢占资源和不可抢占资源。可抢占资源当然是可以从持有者手中强行抢夺过来的资源,且不会发生系统运行紊乱;不可抢占资源则不能从持有者手中强行抢夺。如果强行抢夺,则将造成系统运行错误。
当然,从绝对概念上说,没有什么资源是不可抢占的。因此,不可抢占只不过是相对意义上的概念。目标是保证每个程序都能正确运行。如果抢占一个线程所持有的资源后,还能找到某种方式让该程序正确运行下去,则该资源就是可抢占的;否则,就是不可抢占的。
死锁的描述
线程的执行需要资源,它们都要求以某种顺序来使用资源。如果请求被拒绝了,那就等待。线程使用资源的顺序通常如下:
- 请求资源。
- 使用资源。
- 释放资源。
线程在资源请求没有批准的情况下必须等待。这种等待有两种应对方式:一是阻塞等待;二是立即返回,执行别的事情,等以后再请求。当然也可以失败退出,终止线程。
如果线程采用第2种方式(立即返回),则不会发生死锁,因为没有等待。但如果采用第1种方式,则死锁就有可能发生。例如,如果有n个线程,T 1,……,T n,n个资源,R 1,……,R n。其中T i持有资源R i,但又请求资源R i+1,这样就将形成死锁。可以用有向图来表示资源的占用和需求关系。以方框代表资源,圆圈代表线程。从资源指向线程的实线表示该资源已被该线程获得,从线程指向资源的虚线表示该线程在等待该资源,由此可以画出图9-2。
图 9-2 n个线程因循环等待资源而形成死锁
这样画出来的图称为资源使用图。从图9-2中可以看出,每个线程都在等待某一个资源,因此没有线程可以
推进,从而形成死锁。
死锁的4个必要条件
-
条件1:死锁发生的必要条件是资源有限。即一个系统里面的资源数量有限,以致无法同时满足所有线程的资源需求。这个条件非常直观,如果每个线程都有足够资源同时推进,自然不会发生死锁。
这个条件也称为资源互斥条件。即资源不能共享,在一个时候只能由一个线程使用。这个和资源有限是等价的。如果资源可以同时被多个线程使用,则将不会发生死锁。
-
条件2:死锁的另外一个必要条件是持有等待。即一个线程在请求新的资源时,其已经获得的资源并不释放,而是继续持有。
-
条件3:死锁的另外一个条件是不能抢占。如果可以抢占一个资源,则也不会发生死锁。
-
条件4:就是我们已经提过的循环等待条件。这是死锁的最后一个条件。
死锁的应对
-
允许死锁发生
-
假装没有看见,不予理睬
此种策略就是操作系统不做任何措施,任由死锁发生。这看上去是一种非常糟糕的策略,很多人可能认为没有什么实际操作系统采取的是这种策略。但这种策略真的很糟糕吗?实际上,这种策略是大多数人在大多数情况下采取的策略。老子说过,无为而治,就是这个策略。你什么都不用做(实际上并不是什么都不做,而是尽量少做),事情慢慢就朝着有利的方向发展。
从哲学角度来说,世界上本没有问题,是你认为有问题,才有问题。死锁也一样。只有你认为它是问题时,它才是问题。而我们研究商业操作系统的人不认为这是什么大问题, 因为经过分析发现,死锁发生的频率不太高(当然这点有争议),所以不必管它。另外,防止死锁的代价很高,防止死锁比重启100次代价还高,因此不如直接重启。如果死锁发生,重启即可。这就是为什么Windows、Linux和其他商业操作系统都没有采取死锁防止措施,这就是为什么你的电脑经常死机的原因。
-
在死锁发生后,想办法予以解决
-
检测死锁
死锁的原因是资源竞争,只要我们对资源的拥有和对资源的请求都清楚了,问题就解决了。或者说,就是将线程的资源占用和需求关系用一个有向图表示出来,然后查看图中有没有循环。如果有,就发生死锁;如果没有,就没有发生死锁。
很显然,确定一个有向图是否含有循环并不是一件容易的事。事实上,这种检查算法的时间复杂性是n3。 如果在每次资源分配发生变化时,做一次这样的检查,系统效率将出现明显下降。因此,为了操作系统的效率,我们不会直接在图上进行操作。
一种效率较高的算法是利用矩阵。这种算法用到两个矩阵:一个叫资源分配矩阵,另一个叫资源等待矩阵。 矩阵的每一行代表一个线程,每一列代表一种资源。在资源分配矩阵中,行列交叉的数值代表该线程已经拥有该资源的数量;在资源等待矩阵中,行列交叉的数值代表特定行还需要特定资源的数量。
除此之外,我们还维持两个矢量:一个系统资源总量矢量,表示系统中所有资源的总数是多少;另一个是系统当前可用资源矢量,代表系统现在还有多少可用的资源。
有了上面的矩阵和矢量,我们就可以通过简单的矩阵运算来判断系统是否发生了死锁。怎样判断呢?先考虑什么时候发生死锁?每个线程都不能推进。什么时候不能推进?就是要求的资源不能满足。你把可用的资源拿来与资源等待矩阵的每一行进行比较,你就知道谁不能满足。如果减出来,每一个线程都有负数,那就是发生了死锁。
-
死锁的恢复
首先可以抢占。即将某个线程所占的资源强行拿走,分配给别的线程。当然了,被抢占的线程很有可能不能正确运行。不过这就是纠正死锁的代价。这种策略的一个重要考虑是选择哪个线程作为牺牲品,而这往往不是容易做出的决策。一种选择是终止占用资源最多的线程,以期尽可能多地释放资源,不过这种办法的后果很难预料。
另外一个选择是更进一步,不只是抢占某个线程的资源,而是将整个线程“杀掉”。这其实并不会更加残忍。 因为抢占一个线程的资源有可能已经造成该线程无法再正确运行了。所以,干脆“杀掉”。与前一种办法一 样,后果也难以预料。
最后一个办法是上翻(rollback),即将整个系统翻转到过去的某个状态,大家从那个状态重新来过。那么这就要定期记录系统。这是一件很麻烦的事情。对于一个负责的系统来说,上翻到一个可靠的状态十分困难。而且,更为重要的是,系统并不是在任何时候都可以上翻的。
那么死锁的检测与恢复这个办法可行吗?实际上根本行不通。在检测与恢复两个部分都存在巨大困难。首先,使用资源分配与等待矩阵来判断死锁是否可靠?如果减运算后每一项都有负数,死锁是不是就真的发生了?答案是否定的。
这种判断只能说是死锁有可能发生,但并不能肯定死锁会发生。因为也许某个线程因某种原因突然退出,从而不会发生死锁。这样,死锁没发生你却说它发生了,造成误判。另外,还可能出现死锁发生了你却说没发生。我们不是有n个线程,有可能你有几个线程已经死锁了,但是有的线程还能推进。这样从矩阵上判断并没有发生死锁。
第2个问题是,这个矩阵可能是一个巨大的矩阵。如果是多用户的话,线程数可能不计其数。因此,这个矩阵有可能非常大。并且,资源每发生一次变化,就要更新这个矩阵,这样所要花费的计算资源也将很高。不过这还不是关键的问题。
这种策略最致命的问题是,检查死锁的线程自己发生了死锁。此时,死锁检查程序已经无法推进,自然无法检查死锁是否发生。
-
-
-
不让死锁发生
-
通过生活中的仔细检点,避免难题出现
进行动态避免的原则也很简单:在每次进行资源分配时,必须经过仔细计算,确保该资源请求批准后系统不会进入死锁或潜在的死锁状态。潜在的死锁状态指的是尚未发生死锁的状态,但接下来的执行将一定产生死锁,因此这种状态又称为“不安全状态”,与之相对的状态称为安全状态。详细来说,安全状态是指从该状态开始,我们能够找到一种资源分配方法和顺序,使得所有线程都能获得其需要的资源,从而不会产生死锁。 而不安全状态则是从该状态开始,无论我们怎样,也不能找到一种资源分配方法和顺序来满足所有线程的资源需求。
定义了安全状态后,死锁动态避免的策略就很简单了,就是要防止系统进入不安全状态。手段就是每次需要进行资源分配时,就计算一下该分配是否会将系统带入不安全状态。如果是,就否决相关资源请求;否则, 就批准。
动态避免的优点是无需等待死锁的发生。而是在死锁有可能发生时采取先发制人的措施,断然拒绝可能引导系统进入潜在死锁的资源请求。
但动态避免的缺点也十分明显。这就是计算一个状态是否安全不是一件容易的事。但动态避免策略的另一个问题却是你再有耐心也解决不了的,我们怎么能够知道一个线程的最大资源需求呢?这不是需要预测将来吗?这当然是很困难的。
在无法准确计算的情况下,人类一般会超额估算自己的资源需求,这种超额估算带来的后果非常严重。首先是资源浪费,因为超额估算使得本来可以分配给更多人的资源被少数线程占用。另外一个更为严重的问题是超额估算可能造成死锁误判。因为安全和不安全状态的判断依据之一是最大资源需求。如果该数值估算过大,超过线程的实际资源需求,将造成在实际上安全的情况下,系统被判为不安全,从而造成可以执行的任务也得不到执行。
-
通过将发生死锁的必要条件消除,杜绝死锁的发生
-
消除死锁的必要条件
-
消除资源独占条件
消除这个条件有两个办法,将资源无限增加或把所有资源变为共享。
- 当然,我们在实际上并不需要将资源无限增加,而只要增加到能够满足所有线程的资源需要为止。但这 一点并不实际。因为资源是有限的,没有什么东西是无限的,而且增加资源就要增加成本,因此此计不行。
- 那将资源变为共享是否可行呢?这要看是什么资源。有的资源看上去是独占资源,但却可以通过间接来实现共享,例如磁盘和打印机。不过这种办法并不适合所有的资源。例如,键盘输入就无法共享。
-
消除保持和请求条件
消除这个条件的办法很简单,就是一个线程必须一次请求其所需要的所有资源,而不是一般情况下的请求一 点资源,做一点事情;到需要下一个资源的时候再请求一点,获得资源后再继续推进。由于一个线程一次就获得了其所需的所有资源,该线程自然就可以顺利执行,从而不会发生死锁。
这种办法的缺点就是一次将所有资源拿齐,太过浪费。因为有的资源要到最后才需要,但也得在一开始就占用。显然不利于资源的有效利用。这种做法的另一个问题就是在一开始就需要知道一个线程所需要的所有资源,这是很困难的。
一种变通的办法是还像以前那样请求资源,即在需要资源的时候才请求,但加上一个条件:如果请求的资源被拒绝,则该线程需将其现在已经拥有的资源也释放掉。这样,因为阻塞的线程不占用任何资源,死锁自然也得以消除。
这种办法避免了上述问题,但也存在缺点。主要是在获得某个资源失败后,需要释放已经占用的所有资源, 而这有可能造成问题。例如,如果一个线程已经获得一些锁,但在锁里的工作又没有做完。如果这时释放锁,很可能造成前功尽弃,从而造成浪费。
-
消除非抢占条件
即允许抢占资源。也就是说可以从一个线程手上将资源抢夺过来。典型的例子有CPU和内存空间。一个线程可以将CPU或内存空间从另一个线程手上抢过来,从而避免了因CPU和内存空间的竞争而造成死锁。而这恰恰是操作系统里面CPU调度和内存管理的一个重要功能。
不过,该策略也有局限性。因为不是所有的资源都可以被抢占而不产生不良后果。例如,锁就不能抢占。如果从一个线程手上将锁抢过来,后果有可能是不堪设想的。
-
消除循环等待条件
出现循环等待是因为线程请求资源的顺序是随机的,即一个线程可以先请求资源A再请求资源B,也可以先请求资源B再请求资源A。这样,如果两个线程按照不同的顺序请求A、B两个资源,死锁就有可能发生。但如果我们规定对A、B两个资源的使用必须按照先A后B的顺序请求,则死锁就不能发生。
在给定了这种顺序后,如一个线程只使用一种资源,则直接请求该资源即可;如果某个线程需要请求两个或以上的资源,必须按照给出的这个顺序获取资源。
银行家算法
银行家算法,顾名思义,是仿照银行发放贷款时采取的控制方式而设计的一种死锁避免算法(见图9-11)。 该算法的策略是实现动态避免死锁,通过对资源的仔细分配以避免死锁。其特点是可以超额批准客户的信用额度,即所有客户的信用额度之和可以超过银行的全部资本,这就是杠杆(leverage)。
我们前面说过,动态避免的缺陷就是需要知道你将来需要什么,而由于我们没有什么有效的办法计算出一个线程所需的资源额度,因此在实际的操作系统中没有采用这种动态避免方法。但是银行家却有这能力解决这个问题。他们通过一种复杂的公式计算出你的信用额度。大部分时候这种计算比较保守,可以避免银行进入死锁状态。
死锁、活锁与饥饿
资源饥饿指的是某个线程一直等不到它所需要的资源,从而无法向前推进。
在活锁状态下,处于活锁线程组里的线程状态可以改变,但是整个活锁组的线程无法推进。活锁可以用两个人过一条很窄的小桥来比喻:为了让对方先过,两个人都想给对方让路而闪身到一边,但由于两个人都同时进行此种动作,有可能两个人都同时运动到左边,然后又同时运动到右边。这样,虽然两个人的状态一直在变化,但却都无法往前推进。由此可见,活锁是死锁的通例。
锁的实现
同步原语均是原子操作。操作系统之所以能够构建锁之类的同步原语,原因就是硬件已经为我们提供了一些原子操作:中断禁止和启用、内存加载和存入、测试与设置指令。在这些硬件原子操作的基础上,我们便可以构建软件原子操作:锁、睡觉与叫醒、信号量等。
以中断启用与禁止来实现锁
要防止一段代码在执行过程中被其他进程插入,我们就要考虑在一个单处理器上,一个线程在执行中途被切换是通过什么途径来实现的。到现在我们知道,要切换进程,必须发生上下文切换,而发生上下文切换只能有两种可能:一是一个线程自愿放弃CPU而将控制权交给操作系统调度器,从而发生上下文切换;二是一个线程被强制放弃CPU而失去控制权。自愿放弃通过调用yield之类的操作系统系统调用来实现;而强制放弃则需通过中断来实现,操作系统主要是通过周期性的时钟中断来获得CPU控制权的。由于原语执行过程中,我们不会自动放弃CPU控制权。因此要防止进程切换,就要在原语执行过程中不能发生中断。由此,我们想到,通过禁止中断,并且不自动调用让出CPU的系统调用(如yield之类),就可以防止进程切换,就能将一 组操作变为原子操作。
接下来的一个问题是,如果禁止中断就能将一组操作变为原子操作,那还要锁干什么?不如直接在程序中进入临界区之前禁止中断,在离开临界区的时候再启用中断。
这种做法的问题是危险。因为将操作系统赖以工作的基础机制交给用户来控制是十分不明智的。万一用户的水平有限,禁止中断后忘了启用,这个系统就完了。而且,这就等于给黑客一个攻击的入口。
所以,用户程序不能进行中断的启用和禁止操作。理论上可以,但实际上不能。因此,合理的做法就是由操作系统提供一个锁给用户使用,而锁的正确性由操作系统保证。
lock(){
disable interrupts;
while (value != FREE){
enable interrupts;
disable interrupts;
}
value = BUSY;
enable interrupts;
}
闭锁的第一个操作是禁止中断,记住,这是一个硬件原子操作。如果成功禁止了中断,接下来就检查value是否等于FREE,如果是,就表明这个资源没有被其他进程占用,我们就将其设置为忙,然后启用中断(记住,这也是一个硬件原子操作)。这样就完成了闭锁操作。如果value不是FREE,则我们循环等待这个值变为FREE。但在循环等待的过程中,我们使用了一对操作,分别是启用中断和禁止中断。这是为何呢?
这是因为,如果value不是FREE,就说明有别的线程占用了value。我们需要等待占用value的线程使用完value后将其设置为FREE。因为我们禁止了中断,别的线程已经无法获得CPU来执行,自然无法将value设置为FREE。因此,我们加入了启用/禁止中断操作对,寄希望于在启用和禁止中间那个占用value的进程切换进来,完成操作后释放value。如果不这样,那就永远只能等在那里,别人也不会释放锁。
unlock(){
disable interrupts;
value = BUSY;
enable interrupts;
}
unlock的操作就是首先禁止中断,将value设为FREE,再启用中断.
这里的问题是,将value设置为FREE需要中断禁止的保护吗?当然需要,因为value=FREE这个赋值语句不是原子操作,所以需要禁止中断来保护。否则,值"FREE"可能在传往value的中途被冲掉,造成锁的释放失败。
以中断启用与禁止来实现锁的优点是原理简单,容易理解,也容易实现。但存在的问题也很明显。首先,频繁地禁止中断有可能造成对重要事件的处理不及时。其次,在锁的实现中留给其他进程获得CPU的机会也不大,这可能是个很大的问题。因为持有锁的进程可以切换的地方是在启用中断和禁止中断两句原子操作之间。由于是原子操作,因此硬件上只有一个操作,而在这两个原子操作之间恰好插入进来并不是一件概率很大的事情。当然,循环久了,总会有机会插入。但循环多久才发生插入是很难预料的事情。
不过,情况真是如此吗?如果读者自行编写代码来进行验证就会发现情况并非如此。读者可能发现的情况是对中断的响应并没有多大的影响,在启用和禁止之间发生线程切换也并不是什么小概率事件。但这是为什么呢?这个问题在本书稍后进行回答。
以测试与设置指令来实现锁
现代处理器基本上都提供一条所谓的“读-修改-写入”的原子指令,该操作以不可分割的方式执行如下两个操作:
- 将内存指定位置的存储单元的内容读到一个寄存器。
- 将新的值写入刚才的内存单元。
而测试与设置(test&set)指令就是一条类似的指令,但略有不同,它以不可分割的方式执行如下两个步骤:
- 设置操作:将1写入指定内存单元。
- 读取操作:返回指定内存单元里原来的值(写入新值1之前的内容)。
test_and_set(X){
tmp = X;
X = 1;
return tmp;
}
使用测试与设置指令实现lock
lock(){
while (test_and_set(value) == 1) {}
}
test_and_set(value)的操作是将1写入变量value里,并将写1之前value的值返回。如果锁是打开的,即value是0的话,该指令将value设置为1,获得锁并退出循环。如果锁是闭上的,即value的值是1,则返回的值为1,循环继续。该指令将value设置为1并不改变value的状态(value本来就是1)。该循环将一直持续到成功获得锁为止。
unlock(){
value = 0;
}
开锁的操作很简单,将value设置为0即可
也许读者会问,上面的value=0的赋值语句怎么不需要保护呢?难道不怕别人插在中间造成问题?答案是否定的。因为调用unlock的进程一定是已经获得锁的进程。别的进程由于没有锁,无法对value进行操作。当然了,这里我们要求所有的进程在调用unlock之前必须先调用lock,否则我们的这种保障就不能实现。
此种实现方式与上一节介绍的方式有相同之处,就是都使用了繁忙等待。但在这种实现方式下,其他进程切换过去的机会比上一节节的方式要大。
测试与设置强于中断启用和禁止。不过,需要指出的是,这种区别是很小的,因为中断禁止所禁止的并不是中断的发出,而是对中断的响应。由于中断一旦发出,就一直处于发出状态,此时只要中断被启用,将马上得到响应。因此,使用中断启用与禁止所留下的空当绰绰有余。
以非繁忙等待、中断启用与禁止来实现锁
前面介绍的两种锁的实现方式看上去简单,也很容易理解,但都有一个问题。这个问题就是繁忙等待,繁忙等待浪费资源,并且有可能造成优先级倒挂和死锁。改善的思路是不进行繁忙等待,而是在拿不到锁的时候去睡觉,等待别人的叫醒。 这样,锁的实现思路如下:
- 使用中断禁止,但不进行繁忙等待。
- 如果拿不到锁,等待进程放弃CPU并进入睡觉状态,以便持有锁的进程可以更好地运行。
- 当锁释放的时候将睡觉进程叫醒。
lock(){
disable interrupts;
if (value == FREE) {
value = BUSY;
}else {
add thread to queue of threads waiting for this lock;
switch to next run-able thread;
}
enable interrupts;
}
unlock(){
disable interrupts;
value = FREE;
if (any thread is waiting for this lock) {
move waiting thread from waiting queue to ready queue;
value = BUSY;
}
enable interrupts;
}
上述锁的实现程序乍一看似乎很有道理,可仔细一想却发现有问题。有什么问题呢?问题是,切换到别的进程的语句在启用中断指令前执行,由于切换到另一个进程后,该程序就无法再执行, 那么后面的中断启用指令自然就不能执行了。那这个有什么问题吗?当然有。因为我们是在中断处于禁止状态下切换到别的线程的,如果别的线程没有执行中断启用或者自动放弃CPU给另一个线程,系统将进入死锁
状态。因此,这个问题必须解决。
那么在lock程序里,什么时候启用中断呢?仔细分析可以发现,一共只有3个地方可以启用中断,分别是:
- 在将自己放到等待队列之前启用中断。
- 在将自己放到等待队列之后,但在切换到另一线程之前启用中断。
- 在切换到另一个线程之后启用中断。
前面分析已经表明,第3个地方没有意义,因为执行不到这条指令。
第1个地方:在将自己放到等待队列之前启用中断。这个机制能工作吗?自然不能。因为这种安排与我们前面讲过的用sleep和wakeup原语实现消费者-生产者同步时完全一样,将发生信号丢失而造成死锁。
第2个地方:把它加到等待队列之后,切换到别的进程之前,启用中断。这种办法也不行。因为加到等待队列之后,其实就是要sleep了,但是还有两句要执行,允许中断,再切换。有什么问题?你把自己加到等待队列之后,你在进程表里是什么状态?sleep。你在允许中断后又被抢占了,你变成了什么状态?变成就绪状态。你进了两个队列,又在等待队列,又在就绪对列,那到底在哪里呢?这就是很大的问题了。就算将来不引起严重后果,也已经违反了操作系统的规则,即任何时候一个进程只能处于一种状态。
当然,也许有读者认为,在启用中断后如果发生线程切换,当前线程进入就绪队列时,等待状态就抹掉了, 因此,不存在所谓的一个线程同时处于两种状态之说。但如果真是这样,那就相当于没有将自己放在等待队列了,自然后面的操作也不会正确。
那么问题出在什么地方呢?问题就是将自己放在等待队列和切换到别的线程这两个操作应该是一组原子操作,不能在中间中断。这也就是说,启用中断操作不能在中间。那么剩下的唯一可能就是闭锁操作不启用中断,而是留给别的线程去启用中断。
这听上去似乎很不负责任。不过除此之外,还有别的办法吗?如果一定要使用中断启用和禁止,并且不能繁忙等待,则这是唯一的办法。这种办法的工作原理如下:
-
等待线程在调用切换操作时将中断留在禁止状态。
-
下一个执行的线程负责在返回到用户代码前启用中断。
-
当唤醒一个等待线程时,中断仍然处于(又设置成)禁止状态。
也就是说,我们要求所有线程遵守下列约定:
- 所有线程承诺在调用线程切换时将中断留在禁止状态。
- 所有线程承诺在从切换返回时将中断重新启用。
在图10-9中,线程B目前持有锁。在切换前禁止中断,切换后,控制权到达线程A手里。线程A先启用中断, 然后返回到用户代码,在中断处于启用的状态下执行用户程序部分。然后线程A需要使用某种临界资源,因此调用lock。而lock的第1件事是禁止中断,然后进行锁的状态检查等操作。因为锁被线程B所持有,线程A自然进入等待状态。线程A在将自己放到等待队列后,调用switch切换线程。线程B获得CPU控制,相当于从switch返回,因此启用中断,并返回到用户代码。在执行完用户代码的工作后,线程B调用unlock函数来释放自己手中持有的锁。unlock做的事情就是将线程A从等待队列移到就绪队列。然后线程B再次调用yield来让出CPU。而yield的第1件事就是禁止中断,然后切换线程。线程A获得控制权后,相当于从switch返回,因此启动中断。此时线程A已经获得锁,可以进入临界区了。
这里需要注意的是,中断的启用和禁止是在系统调用(lock和yield)里面实现的,即由操作系统实现的。因此,用户并不会增加额外的负担。用户只管调用lock和yield等系统调用,而操作系统会确保什么时候禁止和启用中断。
这样我们就在无须繁忙等待的情况下,使用中断启用和禁止实现了锁的机制。那这个机制有什么缺点吗?显然,这是一个危险的系统。因为所有操作系统的系统调用都必须遵守一个约定,即在进入系统调用前必须禁止中断,而在返回到用户代码前则必须启用中断。而这将依赖于各个系统调用之间的自愿配合。这里无法强制。因此,这个机制对系统调用设计者的技术水平和信用都有很大的依赖。而我们知道,依赖别人是非常危险的。
以最少繁忙等待、测试与设置指令来实习锁
使用测试与设置来实现锁不能完全避免繁忙等待。因此,我们的目的就是尽可能降低等待的时间。
我们的中心思想是:只用繁忙等待来执行闭锁的操作。如果不能获得就放弃CPU。这样就要求我们使用一个额外的变量。
lock(){
while (test_and_set(guard)) {}
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;
}
}
这个程序和上一节的程序一样,只不过中断禁止操作变成了test_and_set操作,而中断启用操作则变成了将guard赋值0的操作。
为什么繁忙等待大大缩短了?这是因为我将等待对象从value改成了guard,而guard所保护的范围远远小于value要保护的临界区。因为
guard要防止的只是不要同时拿锁而已,一旦拿锁的动作完成(不管是否拿到锁),guard都将被设置为0。 而这个范围很小,在拿到锁的情况下,guard保护的只有if(value==FREE)的条件判断和value=BUSY的赋值语句。在没有拿到锁的情况下,guard保护的语句只有将自己加到等待队列的一段代码。拿到锁后的临界区操作并不由guard保护。不管临界区有多大,guard维持繁忙的时间不会受到丝毫影响。因此,一个进程在guard上的循环等待时间都几乎是恒定的,并且很短,只有几句语句,所以大大缩短了繁忙等待时间。
这种实现策略的根本思想就是循环等待不是等在lock,而是等在guard上。从而将临界区的工作时间从需要等待的时间中消除了,进而在实现锁的同时大大降低了繁忙等待时间。当然,如果同时出现3个以上线程同时竞争guard的情况,则有1个线程等待时间要长一些。但第3者多等也是应该的。而且就算是第3者,也不会等待很久时间。因为第1个释放guard之后,第2个得到guard,然后获得lock以后也释放了guard,所以第3者也不会等很久。你等多久,不就等几句语句的运行时间吗?所以这个等待时间可以忽略不计。
假如在将自己放在等待队列后突然发生线程切换,那么本线程也将同时处于等待和就绪两个队列。这个问题是怎么解决的呢?解决办法也很简单,就是将执行lock这个系统调用的进程的优先权提高,以使这种情况发生的概率降低。当然,完全避免是不可能的。这也就是为什么操作系统在运行中偶尔会出问题,如死锁。
unlock(){
while (test_and_set(guard)) {}
value = FREE;
if (any thread is waiting for this lock) {
move waiting thread from waiting queue to ready queue;
value = BUSY;
}
guard = 0;
}
unlock的操作非常简单明了,没有什么特别之处。先在guard上进行测试与设置,通过后即释放锁,再检查是否有线程等在该锁上。如果是,就直接把锁交给线程(通过移动等待线程到就绪队列和将value设置为繁忙)。然后将guard设置为0即可。
这里需要注意的是,我们把锁直接交给队列里等待的线程,因此,被唤醒的线程无须再竞争锁,而是直接进入临界区进行操作。这样做的效率显然高于再次竞争锁的办法。
中断禁止、测试与设置
我们讲解了如何使用中断禁止、测试与设置两种硬件原语来实现软件的锁原语。这两种方式比较起来,显然测试与设置更加简单。因此,其使用的范围更加普遍。除此之外,测试与设置方法还有一个优点,那就是可以在多CPU环境下工作,而中断启用和禁止则不能。
在单处理器上,如果禁止了中断,自然别的线程就无法获得CPU控制权。但在多CPU情况下,即使禁止了某个CPU的中断,另一个CPU却可以继续执行,即我们不能保证其他CPU不进行上下文切换。当然,我们可以同时修改几个CPU的中断启用/禁止位来防止其他CPU继续运行。这样禁止所有上下文切换了吗?确实防止了上下文切换,但是却付出了更大的代价。
这是因为,如果我们一条指令把所有CPU的中断都禁止了,则这些CPU之间的独立性将大打折扣。而使用多CPU的目的就算想让它们能够独立执行。
另外,就算我们想这样做,也实现不了。因为硬件不提供一条指令禁止所有中断,而使用多条指令来禁止所有CPU上的中断则不能保证原子性。所以不提倡这种方法。
那么多CPU环境下使用测试与设置怎么能工作呢?这是因为test&set是针对内存单元的,多CPU有全局内存,所以能够工作。我们在后面介绍旋锁的时候,将再次提到这一点。