操作系统核心原理-4.线程原理(上):线程基础与线程同步
我们都知道,进程是运转中的程序,是为了在CPU上实现多道编程而发明的一个概念。但是进程在一个时间只能干一件事情,如果想要同时干两件或者多件事情,例如同时看两场电影,我们自然会想到传说中的分身术,就像孙悟空那样可以变出多个真身。虽然我们在现实中无法分身,但进程却可以办到,办法就是线程。线程就是我们为了让一个进程能够同时干多件事情而发明的“分身术”。
一、线程基础
1.1 线程概念
线程是进程的“分身”,是进程里的一个执行上下文或执行序列。of course,一个进程可以同时拥有多个执行序列。这就像舞台,舞台上可以有多个演员同时出场,而这些演员和舞台就构成了一出戏。类比进程和线程,每个演员是一个线程,舞台是地址空间,这样同一个地址空间中的所有线程就构成了进程。
在线程模式下,一个进程至少有一个线程,也可以有多个线程,如下图所示:
将进程分解为线程可以有效地利用多处理器和多核计算机。例如,当我们使用Microsoft Word时,实际上是打开了多个线程。这些线程一个负责显示,一个负责接收输入,一个定时进行存盘......这些线程一起运转,让我们感觉到输入和显示同时发生,而不用键入一些字符等待一会儿才显示到屏幕上。在不经意间,Word还能定期自动保存。
1.2 线程管理
线程管理与进程管理类似,需要一定的基础:维持线程的各种信息,这些信息包含了线程的各种关键资料。于是,就有了线程控制块。
由于线程间共享一个进程空间,因此,许多资源是共享的(这部分资源不需要存放在线程控制块中)。但又因为线程是不同的执行序列,总会有些不能共享的资源。一般情况下,统一进程内的线程间共享和独享资源的划分如下表所示:
1.3 线程模型
现代操作系统结合了用户态和内核态的线程模型,其中用户态的执行系统负责进程内部在非阻塞时的切换,而内核态的操作系统则负责阻塞线程的切换,即同时实现内核态和用户态线程管理。其中,内核态线程数量极少,而用户态线程数量较多。每个内核态线程可以服务一个或多个用户态线程。换句话说,用户态线程会被多路复用到内核态线程上。
1.4 多线程的关系
推出线程模型的目的就是实现进程级并发,因为在一个进程中通常会出现多个线程。多个线程共享一个舞台,时而交互,时而独舞。但是,共享一个舞台会带来不必要的麻烦,这些麻烦归结到下面两个根本问题:
(1)线程之间如何通信?
(2)线程之间如何同步?
上述两个问题在进程层面同样存在,在前面的进程原理部分已经进行了介绍,从一个更高的层次上看,不同的进程也共享着一个巨大的空间,这个空间就是整个计算机。
二、线程同步
2.1 同步的原因和目的
(1)原因
线程之间的关系是合作关系,既然是合作,那么久得有某种约定的规则,否则合作就会出问题。例如下图中,一个进程的两个线程因为操作不同步而造成线程1运行错误:
出现上述问题原因在于两点:一是线程之间共享的全局变量;二是线程之间的相对执行顺序是不确定的。针对第一点,如果所有资源都不共享,那就违背了进程和线程设计的初衷:资源共享、提高资源利用率。针对第二点,需要让线程之间的相对执行顺序在需要的时候可以确定。
(2)目的
线程同步的目的就在于不管线程之间的执行如何穿插,其运行结果都是正确的。换句话说,就是要保证多线程执行下结果的确定性。与此同时,也要保持对线程执行的限制越少越少。
2.2 同步的方式
(1)一些必要概念
① 两个或多个线程争相执行同一段代码或访问同一资源的现象称为竞争。
② 可能造成竞争的共享代码段或资源就被称为临界区。
③ 在任何时刻都能有一个线程在临界区中的现象被称为互斥。(一次只有一个人使用共享资源,其他人皆排除在外)
(2)锁
① 关于锁
当两个教师都想使用同一个教室来为学生补课,如何协调呢?进到教室后将门锁上,另外一个教室就无法进来使用教室了。即教室是用锁来保证互斥的,那么在操作系统中,这种可以保证互斥的同步机制就被称为锁。
例如,在.NET中可以直接使用lock语句来实现线程同步:
private object locker = new object(); public void Work() { lock (locker) { // 做一些需要线程同步的工作 } }
锁有两个基本操作:闭锁和开锁。很容易理解,闭锁就是将锁锁上,其他人进不来;开锁就是你做的事情做完了,将锁打开,别的人可以进去了。开锁只有一个步骤那就是打开锁,而闭锁有两个步骤:一是等待锁达到打开状态,二是获得锁并锁上。显然,闭锁的两个操作应该是原子操作,不能分开。
② 睡觉与叫醒
当对方持有锁时,你就不需要等待锁变为打开状态,而是去睡觉,锁打开后对方再来把你叫醒,这是一种典型的生产者消费者模式。用计算机来模拟生产者消费者并不难:一个进程代表生产者,一个进程代表消费者,一片内存缓冲区代表商店。生产者将生产的物品从一端放入缓冲区,消费者则从另外一端获取物品,如下图所示:
例如,在.NET中可以通过Monitor.Wait()与Monitor.Pulse()来进行睡觉和叫醒操作:
首先是消费者线程
public void ConsumerDo() { while (true) { lock(sync) { // Step1:做一些消费的事情 ...... // Step2:唤醒生产者线程 Monitor.Pulse(sync); // Step3:释放锁并阻止消费者线程 Monitor.Wait(sync); } } }
其次是生产者线程
public void ProducerDo() { while (true) { lock(sync) { // Step1:做一些生产操作 ...... // Step2:唤醒消费者线程 Monitor.Pulse(Dog.lockCommunicate); // Step3:释放锁并阻止生产者线程 Monitor.Wait(Dog.lockCommunicate); } } }
但是,在此种情形下,生产者和消费者都有可能进入睡觉状态,从而无法相互叫醒对方而继续往前推进,也就发生了系统死锁。如何解决?我们可以用某种方法将发出的信号累积起来,而不是丢掉。即消费者获得CPU执行sleep语句后,生产者在这之前发送的叫醒信号还保留,因此消费者将马上获得这个信号而醒过来。而能够将信号累积起来的操作系统原语就是信号量。
(2)信号量
信号量(Semaphore)是一个计数器,其取值为当前累积的信号数量。它支持两个操作:加法操作up和减法操作down。执行down减法操作时,请求该信号量的一个线程会被挂起;而执行up加法操作时,会叫醒一个在该信号量上面等待的线程。down和up操作在历史上被称为P和V操作,是操作系统中最重要的同步原语的两个基本操作。
有些房间,可以同时容纳n个人,比如厨房。也就是说,如果人数大于n,多出来的人只能在外面等着。这好比某些内存区域,只能供给固定数目的线程使用。这时的解决方法,就是在门口挂n把钥匙。
进去的人就取一把钥匙,出来时再把钥匙挂回原处。后到的人发现钥匙架空了,就知道必须在门口排队等着了。这种做法就叫做"信号量",用来保证多个线程不会互相冲突。
例如,在.NET中提供了一个Semaphore类来进行信号量操作,下面的示例代码演示了4条线程想要同时执行ThreadEntry()方法,但同时只允许2条线程进入:
class Program { // 第一个参数指定当前有多少个“空位”(允许多少条线程进入) // 第二个参数指定一共有多少个“座位”(最多允许多少个线程同时进入) static Semaphore sem = new Semaphore(2, 2); const int threadSize = 4; static void Main(string[] args) { for (int i = 0; i < threadSize; i++) { Thread thread = new Thread(ThreadEntry); thread.Start(i + 1); } Console.ReadKey(); } static void ThreadEntry(object id) { Console.WriteLine("线程{0}申请进入本方法", id); // WaitOne:如果还有“空位”,则占位,如果没有空位,则等待; sem.WaitOne(); Console.WriteLine("线程{0}成功进入本方法", id); // 模拟线程执行了一些操作 Thread.Sleep(100); Console.WriteLine("线程{0}执行完毕离开了", id); // Release:释放一个“空位” sem.Release(); } }
如果将资源比作“座位”,Semaphore接收的两个参数中:第一个参数指定当前有多少个“空位”(允许多少条线程进入),第二个参数则指定一共有多少个“座位”(最多允许多少个线程同时进入)。WaitOne()方法则表示如果还有“空位”,则占位,如果没有空位,则等待;Release()方法则表示释放一个“空位”。
不难看出,mutex互斥锁是semaphore信号量的一种特殊情况(n=1时)。也就是说,完全可以用后者替代前者。
但是,如果生产者或消费者将两个up/down操作顺序颠倒,也同样会产生死锁。也就是说,使用信号量原语时,信号量操作的顺序至关重要。那么,有木有办法改变这种情况,可不可将信号量的这些组织工作交给一个专门的构造来负责,解放广大程序员?答案是管程。
(3)管程
管程(Monitor)即监视器的意思,它监视的就是进程或线程的同步操作。具体来说,管程就是一组子程序、变量和数据结构的组合。言下之意,把需要同步的代码用一个管程的构造框起来,即将需要保护的代码置于begin monitor和end monitor之间,即可获得同步保护,也就是任何时候只能有一个线程活跃在管程里面。
同步操作的保证是由编译器来执行的,编译器在看到begin monitor和end monitor时就知道其中的代码需要同步保护,在翻译成低级代码时就会将需要的操作系统原语加上,使得两个线程不能同时活跃在同一个管程内。
例如,在.NET中提供了一个Monitor类,它可以帮我们实现互斥的效果:
private object locker = new object(); public void Work() { // 避免直接使用私有成员locker(直接使用有可能会导致线程不安全) object temp = locker; Monitor.Enter(temp); try { // 做一些需要线程同步的工作 } finally { Monitor.Exit(temp); } }
在管程中使用两种同步机制:锁用来进行互斥,条件变量用来控制执行顺序。从某种意义上来说,管程就是锁+条件变量。
About:条件变量就是线程可以在上面等待的东西,而另外一个线程则可以通过发送信号将在条件变量上的线程叫醒。因此,条件变量有点像信号量,但又不是信号量,因为不能对其进行up和down操作。
管程最大的问题就是对编译器的依赖,因为我们需要将编译器需要的同步原语加在管程的开始和结尾。此外,管程只能在单台计算机上发挥作用,如果想在多计算机环境下进行同步,那就需要其他机制了,而这种其他机制就是消息传递。
(4)消息传递
消息传递是通过同步双方经过互相收发消息来实现,它有两个基本操作:发送send和接收receive。他们均是操作系统的系统调用,而且既可以是阻塞调用,也可以是非阻塞调用。而同步需要的是阻塞调用,即如果一个线程执行receive操作,就必须等待受到消息后才能返回。也就是说,如果调用receive,则该线程将挂起,在收到消息后,才能转入就绪。
消息传递最大的问题就是消息丢失和身份识别。由于网络的不可靠性,消息在网络间传输时丢失的可能性较大。而身份识别是指如何确定收到的消息就是从目标源发出的。其中,消息丢失可以通过使用TCP协议减少丢失,但也不是100%可靠。身份识别问题则可以使用诸如数字签名和加密技术来弥补。
(5)栅栏
栅栏顾名思义就是一个障碍,到达栅栏的线程必须停止下来,知道出去栅栏后才能往前推进。该院与主要用来对一组线程进行协调,因为有时候一组线程协同完成一个问题,所以需要所有线程都到同一个地方汇合之后一起再向前推进。
例如,在并行计算时就会遇到这种需求,如下图所示:
参考资料
邹恒明,《操作系统之哲学原理》,机械工业出版社