操作系统核心原理-4.线程原理(上):线程基础与线程同步
前言
我们都知道,进程是运转中的程序,是为了在CPU上实现多道编程而发明的一个概念。但是进程在一个时间只能干一件事情,如果想要同时干两件或者多件事情,例如同时看两场电影,我们自然会想到传说中的分身术,就像孙悟空那样可以变出多个真身。虽然我们在现实中无法分身,但进程却可以办到,办法就是线程。线程就是我们为了让一个进程能够同时干多件事情而发明的“分身术”。
一、线程基础
1. 线程概念
线程是进程的“分身”,是进程里的一个执行上下文或执行序列。of course,一个进程可以同时拥有多个执行序列。这就像舞台,舞台上可以有多个演员同时出场,而这些演员和舞台就构成了一出戏。类比进程和线程,每个演员是一个线程,舞台是地址空间,这样同一个地址空间中的所有线程就构成了进程。
在线程模式下,一个进程至少有一个线程,也可以有多个线程,如下图所示:
将进程分解为线程可以有效地利用多处理器和多核计算机。例如,当我们使用Microsoft Word时,实际上是打开了多个线程。这些线程一个负责显示,一个负责接收输入,一个定时进行存盘…这些线程一起运转,让我们感觉到输入和显示同时发生,而不用键入一些字符等待一会儿才显示到屏幕上。在不经意间,Word还能定期自动保存。
2 线程管理
线程管理与进程管理类似,需要一定的基础:维持线程的各种信息,这些信息包含了线程的各种关键资料。于是,就有了线程控制块。
由于线程间共享一个进程空间,因此,许多资源是共享的(这部分资源不需要存放在线程控制块中)。但又因为线程是不同的执行序列,总会有些不能共享的资源。一般情况下,统一进程内的线程间共享和独享资源的划分如下表所示:
3 线程模型
现代操作系统结合了用户态和内核态的线程模型,其中用户态的执行系统负责进程内部在非阻塞时的切换,而内核态的操作系统则负责阻塞线程的切换,即同时实现内核态和用户态线程管理。其中,内核态线程数量极少,而用户态线程数量较多。每个内核态线程可以服务一个或多个用户态线程。换句话说,用户态线程会被多路复用到内核态线程上。
4 多线程的关系
推出线程模型的目的就是实现进程级并发,因为在一个进程中通常会出现多个线程。多个线程共享一个舞台,时而交互,时而独舞。但是,共享一个舞台会带来不必要的麻烦,这些麻烦归结到下面两个根本问题:
- 线程之间如何通信?
- 线程之间如何同步?
上述两个问题在进程层面同样存在,在前面的进程原理部分已经进行了介绍,从一个更高的层次上看,不同的进程也共享着一个巨大的空间,这个空间就是整个计算机。
二、线程同步
1 同步的原因和目的
-
原因:线程之间的关系是合作关系,既然是合作,那么久得有某种约定的规则,否则合作就会出问题。例如下图中,一个进程的两个线程因为操作不同步而造成线程1运行错误:
出现上述问题原因在于两点:一是线程之间共享的全局变量;二是线程之间的相对执行顺序是不确定的。针对第一点,如果所有资源都不共享,那就违背了进程和线程设计的初衷:资源共享、提高资源利用率。针对第二点,需要让线程之间的相对执行顺序在需要的时候可以确定。
-
目的:线程同步的目的就在于不管线程之间的执行如何穿插,其运行结果都是正确的。换句话说,就是要保证多线程执行下结果的确定性。与此同时,也要保持对线程执行的限制越少越少。
2 线程同步必知概念
- 竞争:两个或多个线程争相执行同一段代码或访问同一资源的现象
- 临界区:可能造成竞争的共享代码段或资源
- 互斥:在任何时刻都能有一个线程在临界区中的现象(一次只有一个人使用共享资源,其他人皆排除在外)
3 线程同步方式:
-
锁:操作系统中,保证互斥的同步机制就被称为锁
锁有两个基本操作:闭锁和开锁。很容易理解,闭锁就是将锁锁上,其他人进不来;开锁就是你做的事情做完了,将锁打开,别的人可以进去了。开锁只有一个步骤那就是打开锁,而闭锁有两个步骤:一是等待锁达到打开状态,二是获得锁并锁上。显然,闭锁的两个操作应该是原子操作,不能分开。
睡觉与叫醒:当对方持有锁时,你就不需要等待锁变为打开状态,而是去睡觉,锁打开后对方再来把你叫醒,这是一种典型的生产者消费者模式。用计算机来模拟生产者消费者并不难:一个进程代表生产者,一个进程代表消费者,一片内存缓冲区代表商店。生产者将生产的物品从一端放入缓冲区,消费者则从另外一端获取物品,如下图所示:
例如,在.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语句后,生产者在这之前发送的叫醒信号还保留,因此消费者将马上获得这个信号而醒过来。而能够将信号累积起来的操作系统原语就是信号量。
-
信号量:是一个计数器,其取值为当前累积的信号数量
它支持两个操作:加法操作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操作顺序颠倒,也同样会产生死锁。也就是说,使用信号量原语时,信号量操作的顺序至关重要。那么,有木有办法改变这种情况,可不可将信号量的这些组织工作交给一个专门的构造来负责,解放广大程序员?答案是管程。
-
管程(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操作。
管程最大的问题就是对编译器的依赖,因为我们需要将编译器需要的同步原语加在管程的开始和结尾。此外,管程只能在单台计算机上发挥作用,如果想在多计算机环境下进行同步,那就需要其他机制了,而这种其他机制就是消息传递。
-
消息传递:消息传递是通过同步双方经过互相收发消息来实现
它有两个基本操作:发送send和接收receive。他们均是操作系统的系统调用,而且既可以是阻塞调用,也可以是非阻塞调用。而同步需要的是阻塞调用,即如果一个线程执行receive操作,就必须等待受到消息后才能返回。也就是说,如果调用receive,则该线程将挂起,在收到消息后,才能转入就绪。
消息传递最大的问题就是消息丢失和身份识别。由于网络的不可靠性,消息在网络间传输时丢失的可能性较大。而身份识别是指如何确定收到的消息就是从目标源发出的。其中,消息丢失可以通过使用TCP协议减少丢失,但也不是100%可靠。身份识别问题则可以使用诸如数字签名和加密技术来弥补。
-
栅栏:顾名思义就是一个障碍,到达栅栏的线程必须停止下来,知道出去栅栏后才能往前推进
该院与主要用来对一组线程进行协调,因为有时候一组线程协同完成一个问题,所以需要所有线程都到同一个地方汇合之后一起再向前推进。
例如,在并行计算时就会遇到这种需求,如下图所示:
(转至:https://www.cnblogs.com/edisonchou/p/5037403.html ,感谢博主的总结归纳和分享)