线程调度与优先级
不论是在多处理器的计算机上还是在单处理器的计算机上,线程总是“并发”执行的。当线程数量小于等于处理器数量时(并且操作系统支持多处理器),线程的并发是真正的并发,不同的线程运行在不同的处理器上。彼此之间互不相干。但对于线程数量大于处理器数量的情况,线程的并发会收到一些阻碍,因为此时至少有一个处理器会运行多个线程。
在单处理器对应多线程的情况下,并发是一种模拟出来的状态。操作系统会让这些多线程程序轮流执行,每次仅执行一小段时间(通常是几十到几百毫秒),这样每个线程就“看起来”在同时执行。这样的一个不断在处理器上切换不同的线程的行为称之为线程调度(Thread Schedule)。在线程调度中,线程通常拥有至少三种状态,分别是:
- 运行(Running):此时线程正在执行。
- 就绪(Ready):此时线程可以立刻运行,但CPU已经被占用。
- 等待(Waiting):此时线程正在等待某一事件(通常是I/O或同步)发生,无法执行。
处于运行中的线程拥有一段可以执行的时间,这段时间称为时间片(Time Slice),当时间片用尽的时候,该线程将进入就绪状态。如果在时间片用尽之前线程就开始等待某事件,那么它将进入等待状态。每当一个线程离开运行状态时,调度系统就会选择一个其他的就绪线程继续执行。在一个处于等待状态的线程所等待的事件发生之后,该线程将进入就绪状态。这3个状态的转移如图1-9所示。
线程调度自多任务操作系统问世以来就不断地被提出不同的方案和算法。现在主流的调度方式尽管各不相同,但都带有优先级调度(Priority Schedule)和轮转法(Round Robin)的痕迹。所谓轮转法,即是之前提到的让各个线程轮流执行一小段时间的方法。这决定了线程之间交错执行的特点。而优先级调度则决定了线程按照什么顺序轮流执行。在具有优先级调度的系统中,线程都拥有各自的线程优先级(Thread Priority)。具有高优先级的线程会更早地执行,而低优先级的线程常常要等待到系统中已经没有高优先级的可执行的线程存在时才能够执行。在Windows中,可以通过使用:
BOOL WINAPI SetThreadPriority(HANDLE hThread, int nPriority);
来设置线程的优先级,而Linux下与线程相关的操作可以通过pthread库来实现。
在Windows和Linux中,线程的优先级不仅可以由用户手动设置,系统还会根据不同线程的表现自动调整优先级,以使得调度更有效率。例如通常情况下,频繁地进入等待状态(进入等待状态,会放弃之后仍然可占用的时间份额)的线程(例如处理I/O的线程)比频繁进行大量计算、以至于每次都要把时间片全部用尽的线程要受欢迎得多。其实道理很简单,频繁等待的线程通常只占用很少的时间,CPU也喜欢先捏软柿子。我们一般把频繁等待的线程称之为IO密集型线程(IO Bound Thread),而把很少等待的线程称为CPU密集型线程(CPU Bound Thread)。IO密集型线程总是比CPU密集型线程容易得到优先级的提升。
在优先级调度下,存在一种饿死(Starvation)的现象,一个线程被饿死,是说它的优先级较低,在它执行之前,总是有较高优先级的线程试图执行,因此这个低优先级线程始终无法执行。当一个CPU密集型的线程获得较高的优先级时,许多低优先级的线程就很可能饿死。而一个高优先级的IO密集型线程由于大部分时间都处于等待状态,因此相对不容易造成其它线程饿死。为了避免饿死现象,调度系统常常会逐步提升那些等待了过长时间得不到执行的线程的优先级。在这样的手段下,一个线程只要等待足够长的时间,其优先级一定会提高到足够让它执行的程度。
让我们总结一下,在优先级调度的环境下,线程的优先级改变一般有三种方式。
- 用户指定优先级。
- 根据进入等待转台的频繁程度提升或降低优先级。
- 长时间得不到执行而被提升优先级。
线程安全、同步与锁
一、什么是线程安全?
多线程程序处于一个多变的环境当中,可访问的全局变量和堆数据随时都可能被其它的线程改变。因此多线程程序在并发时数据的一致性变得非常重要。
二、线程安全的常用手段
为了避免多个线程同时读写同一个数据而产生不可预料的后果,我们需要将各个线程对同一个数据的访问同步(Synchronization)。所谓同步,即是指在一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。如此,对数据的访问被原子化了。
同步的最常见方法是使用锁(Lock)。锁是一种非强制机制,每一个线程在访问数据或资源之前首先试图获取(Acquire)锁,并在访问结束之后释放(Release)锁。在锁已经被占用的时候试图获取锁时,线程会等待,直到锁重新可用。
信号量
二元信号量(Binary Semaphore)是最简单的一种锁,它只有两种状态:占用与非占用。它适合只能被唯一一个线程独占访问的资源。当二元信号量处于非占用状态时,第一个试图获取该二元信号量的线程会获得该锁,并将二元信号量置为占用状态,此后其它的所有试图获取该二元信号量的线程将会等待,直到该锁被释放。
对于允许多个线程并发访问的资源,多元信号量简称信号量(Semaphore),它是一个很好的选择。一个初始值为N的信号量允许N个线程并发访问。线程访问资源的时候首先获取信号量,进行如下操作:
- 将信号量的值减1。
- 如果信号量的值小于0,则进入等待状态,否则继续执行。
访问完资源之后,线程释放信号量,进行如下操作:
- 将信号量的值加1。
- 如果信号量的值小于1,唤醒一个等待中的线程。
互斥量
互斥量(Mutex)和二元信号量很类似,资源仅同时允许一个线程访问,但和信号量不同的是,信号量在整个系统可以被任意线程获取并释放,也就是说,同一个信号量可以被系统中的一个线程获取之后由另一个线程释放。而互斥量则要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁,其他线程越俎代庖去释放互斥量是无效的。
线程访问资源的时候首先获取互斥量,进行加锁操作;访问完资源之后,线程释放互斥量,进行解锁操作。
临界区
临界区(Critical Section)是比互斥量更加严格的同步手段。在术语中,把临界区的锁的获取称为进入临界区,而把锁的释放称为离开临界区。
临界区和互斥量与信号量的区别在于,互斥量和信号量在系统的任何进程里都是可见的,也就是说,一个进程创建了一个互斥量或信号量,另一个进程试图去获取该锁是合法的。然而,临界区的作用范围仅限于本进程,其他的进程无法获取该锁。除此之外,临界区具有和互斥量相同的性质。
读写锁
读写锁(Read-Write Lock)致力于一种更加特定的场合的同步。对于一段数据,多个线程同时读取总是没有问题的,但假设操作都不是原子型,只要有任何一个线程试图对这个数据进行修改,就必须使用同步手段来避免出错。如果我们使用上述信号量、互斥量或临界区中的任何一种来进行同步,尽管可以保证程序正确,但对于读取频繁,而仅仅偶尔写入的情况,会显得非常低效。读写锁可以避免这个问题。对于同一个锁,读写锁有两种获取方式,共享的(Shared)或独占的(Exclusive)。当锁处于自由的状态时,试图以任何一种方式获取锁都能成功,并将锁置于对应的状态。如果锁处于共享状态,其它线程以共享的方式获取锁仍然会成功,此时这个锁分配给了多个线程。然而,如果有其它线程试图以独占的方式获取已经处于共享状态的锁,那么它将必须等待锁被所有的线程释放。相应地,处于独占状态的锁将阻止任何其它线程获取该锁,不论它们试图以哪种方式获取。读写锁的行为可以总结如表1-6所示。
条件变量
条件变量(Condition Variable)作为一种同步手段,作用类似于一个栅栏。对于条件变量,线程可以有两种操作,首先线程可以等待条件变量,一个条件变量可以被多个线程等待。其次,线程可以唤醒条件变量,此时某个或所有等待此条件变量的线程都会被唤醒并继续支持。也就是说,使用条件变量可以让许多线程一起等待某个事件发生,当事件发生时(条件变量被唤醒),所有的线程可以一起恢复执行。