线程(Thread)

线程概念

那么线程是什么?我们知道,进程是运转的程序,是为了在CPU上实现多道编程而发明的一个概念。但是进程在一个时间只能干一件事情。 如果想同时干两件事,例如同时看两场电影,我们自然想到传说中的分身术,就像孙悟空那样同时变出多个真身。

当然,人在现实中进行分身是办不到的。但进程却可以办到,办法就是线程。线程就是我们为了让一个进程能够同时干多件事情而发明的“分身术”。

既然线程是进程的分身,每个线程自然在本质上是一样的,即拥有同样的程序文本。但由于是分身,自然也应该有不一样的地方,这就是线程执行时的上下文不一致。 事实上,我们说线程是进程里面的一个执行上下文,或者执行序列。显然,一个进程可以同时拥有多个执行序列。这就像舞台,舞台上可以有多个演员同时出场,而这些演员和舞台就构成了一出戏。类比进程和线程,每个演员是一个线程,舞台是地址空间,这个同一个地址空间里面的所有线程就构成了进程。
在线程模式下,一个进程至少有一个线程,但也可以有多个线程。

在这里插入图片描述

将进程分解为线程还可以有效利用多处理器和多核计算机。 在没有线程的情况下,增加一个处理器并不能让一个进程的执行速度提高。但如果分解为多个线程,则可以让不同的线程同时运转在不同的处理器上,从而提高了进程的执行速度。

线程管理

有进程后,要管理进程。那么有线程后,也要进行管理。而管理的基础也与进程管理的基础类似:**就是要维持线程的各种信息,这些信息包含了线程的各种关键资料。存放这些信息的数据结构称为线程控制表或线程控制块。**那么线程控制块里面到底包含哪些信息呢?

我们说过线程共享一个进程空间,因此,许多资源是共享的。这些共享的资源显然不需要存放在线程控制块里面,而是存放在进程控制块即可。但由于线程是不同的执行序列,总会有些不能共享的资源。 就像一家的兄弟姐妹。家里很多东西都是共享,如所有人同住父母的房子,共用冰箱,彩电,餐桌等。但有的东西则是每个人独享的,如衣服,日记本等。这些不被共享的资源和信息就需要存放在线程控制块里。

到底哪些资源可由(同一进程程的)不同线程所共享,哪些不可共享呢?这当然是仁者见仁智者见智。但也是有规律的。这个规律就是应当让共享的资源越多越好,因为这是我们发明线程的主要动机之一。由于我们发明线程的目的就是要经常协作,共享自然是我们的不懈追求。因此,一般的评判标准是:如果某资源不独享会导致线程运行错误,则该资源就由每个线程独享;而其他资源都由进程里面的所有线程共享。

线程的实现方式

既然线程是进程的构成部分,或者是进程的分身,那么由谁来管理线程就有两种选择:一是让进程自己来管理线程;二是让操作系统来管理线程,这种不同的选择就出现了内核态线程和用户态线程。这也是线程实现的两种方式。由进程自己管理就是用户态线程实现,由操作系统管理就是内核态线程实现。

细心的读者也许已经注意到,我们在讲述进程时没有提到过实现方式的问题,即是应该在用户态还是内核态实现的问题。这是因为进程是在CPU上实现并发(多道编程),而CPU是由操作系统管理的,因此,进程的实现只能由操作系统内核来进行,而不存在用户态实现的情况。根本没有这种探讨的需要。但对于线程就不同了,因为线程是进程内部的东西,当然存在由进程直接管理线程的可能性。这就是为什么我们要探讨线程内核态与用户态实现。

内核态线程实现

前面说过,线程是进程的分身,是进程的不同执行序列。既然每个线程是不同的执行序列,说明线程应该是CPU调度的基本单位。我们知道,CPU调度是由操作系统实现的。因此,让操作系统来管理线程似乎是天经地义的事情。

那么操作系统怎么管理线程呢?与管理进程一样,操作系统要管理线程就要保持维护线程的各种资料,即将线程控制块存放在操作系统内核空间。这样,操作系统内核就同时保有进程控制块和线程控制块。而根据进程控制块和线程控制块提供的信息,操作系统就可以对线程进行各种类似进程的管理,如线程调度﹑线程的资源分配,各种安全措施的实现等。

由操作系统来管理线程有很多好处,最重要的好处是用户编程保持简单。因为线程的复杂性由操作系统承担, 用户程序员在编程时无需管理线程的调度,,即无需担心线程什么时候会执行,什么时候会挂起。另外一个重要好处是如果一个线程执行阻塞操作,操作系统可以从容地调度另外一个线程执行。 因为操作系统够监控所有的线程。
在这里插入图片描述

那么内核态线程实现有什么缺点呢?有。首先是效率较低。因为线程在内核态实现,每次线程切换都需要陷入到内核,由操作系统来进行调度。而从用户态陷入到内核态是要花时间的。另外,内核态实现占用内核稀缺的内存资源,因为操作系统需要维护线程表。 操作系统所占内存空间一旦装载结束后就已经固定,无法动态改变。由于线程的数量通常大大高于进程的数量,那随着线程数量的增加,操作系统内核空间将迅速耗尽。

如果要建立进程线程,但内核空间不够了,怎么办?我们可以做的选择有:"杀死"别的进程;创建失败;让它等一下。 前面说过,"杀死"别的进程是一件很不好的事情,因为将造成服务不确定性。宣称创建失败也很差。因为创建失败有可能意味着某个进程无法往前推进,这违反了我们前面说过的进程模型的时序推进要求。让创建者等一下,这要看创建的是什么进程和线程了。如果是系统进程线程,等一下可能意味着关键服务无法按时启动;如果是用户进程线程,等一下可能引起用户的强烈不满。而且等多久谁也不知道。

那在内核空间满了后,应该怎么办呢?打一个战场上的比方就清楚了。如果战场上对手太历害了,想再调个师,结果没有,怎么办?投降。也就说,如果内核空间溢出,操作系统将停止运转。 因为要创立的进程可能很重要,又不能不创建。所以最好的结局是"死掉”。别人发现系统死了就会采取行动来补救。如果操作系统还要运转,却不能正确地运转,那是很危险得事情。操作系统采取的这种行动在灾难应对领域称为”无害速止”。

但上面两个缺点还不是最要命的。最要命的是内核态实现需耍修改操作系统,这在线程概念提出之初是一件很难办到的事情。试想,如果你作为研究人员提出了线程概念,然后你去找一家操作系统研发商,要求其修改操作系统,加入线程的管理,结果会怎样?操作系统开发商会请你走开。有谁敢把一个还未经证明的新概念加入到对计算机影响甚大的操作系统里?除非我们先证明线程的有效性,否则很难说服他人修改操作系统。

这样,就有了线程的用户态实现。

用户态线程实现

线程在刚刚出现时,由于无法说服操作系统人员修改操作系统,其实现的方式只能是在用户态。(谁提出谁举证。)那么用户态实现意味着什么呢?或者说用户态实现是什么意思呢?就是用户自己做线程的切换,自己管理线程的信息,而操作系统无需知道线程的存在。

那么在用户态如何进行线程调度呢?那就是用户自己写一个执行系统( runtime system )作调度器(runtime scheduler) ,即除了正常执行任务的线程外,还有一个专门负责线程调度的线程。 由于大家都在用户态下运行,谁也不比谁占优势,要想取得CPU控制权只能靠大家的自愿合作。一个线程在执行完一段时间后主动把资源释放给别人使用,而在内核态下则无需如此。因为操作系统可通过周期性的时钟中断把控制权夺过来。在用户态实现情况下,执行系统的调度器也是线程,没有能力强行夺走控制权。 所以必须合作。图描述的是用户态线程的实现示意。

在这里插入图片描述
那么用户态实现有什么优点呢?有。首先是灵活性。因为操作系统不用知道线程的存在,所以在任何操作系统上都能应用;第二个优点是线程切换快,因为切换在用户态进行,无需陷入到内核态。第三,不用修改操作系统,实现容易。

那么这种实现方式有什么缺点吗?有。首先编程序变得很诡异。我们前面说过,用户态线程需要相互合作才能运转。这样,我们在写程序时,必须仔细斟酌在什么时候应该让出CPU给别的线程使用。而让出时机的选择对线程的效率和可靠性有很大的影响。这并不是件容易的事。另外一个更为严重的问题是,用户态线程实现无法完全达到线程提出所要达到的目的:进程级多道编程。

如果在执行过程中一个线程受阻,它将无法将控制权交出来(因为受阻后无法执行交出CPU的指令了)。这样整个进程都无法推进。操作系统随即把CPU控制权交给另外一个进程。这样,一个线程受阻造成整个进程都受阻,我们期望的通过线程对进程实施分身的计划就失败了。这是用户态线程的致命弱点。

但是,作为线程的提出者,自然不愿意这么快就承认线程的概念破产。因此,总要想点办法来挽救。那有什么办法来挽救呢?既然线程阻塞造成整个进程阻塞,解决的办法只有两种:一是不让线程阻塞;二是阻塞后想办法激活同一进程的另外线程。

第一种办法如何实现呢?有几种办法。首先来看线程阻塞的原因。
线程之所以阻塞是因为它执行了阻塞操作,如读写磁盘、收发数据包等。 那我们就想,如果将这些操作改为非阻塞操作,不就解决问题了吗。但是这种办法根本就行不通。首先,将所有系统调用改为非阻塞就得修改操作系统,而我们刚才说了,用户态线程实现就是不想修改操作系统;第二,就算操作系统的人员很仁慈,帮你修改,那可以吗?可以,因为很多系统调用的语义里面就包括阻塞,即阻塞是其正确运行的前提。使用这些系统调用的程序期望着阻塞,而修改系统调用的语义就会造成这些程序运行错误。所以这个建议行不通。

既然不能将阻塞操作修改为非阻塞操作。那我们可以不让线程调用阻塞操作。我们只需要在线程进行任何系统调用前,先行确认一下该调用是否会发生阻塞,即我们写一个包裹( wrap),将系统调用包裹起来,用户程序使用系统调用时需通过这个wrap。wrap里有一段代码,专门检查发出的系统调用会不会阻塞。如果会,就禁止调用;否则,就放行。

因为我们不让程序发出阻塞调用并不是要永远不让该线程运行,而是让它等一段时间。因此,本做法隐含的前提条件是你等一段时间后该调用就会由阻塞调用变成非阻塞调用,否则的话,该程序就永远不能运转了。而这个前握假定却不一定成立。(而且并不是所有的阻塞调用在等待一段时间后都会转变成非阻塞。)

那就来分析第二种解决办法:即在进程阻塞后想办法激活受阻进程的其他线程。
这种办法的实现必须依赖操作系统。因为线程阻塞后,CPU控制权已经回到操作系统手里。 而要激活受阻进程的其他线程,唯一的办法是让操作系统在进行进程切换时先不切换,而是通知受阻的进程执行系统(即调用执行系统)。并问其是否还有别的线程可以执行,如果有,将CPU控制权交给该受阻进程的执行系统线程,从而调度另一个可以执行的线程到CPU上。这种做法称为调度器激活( Scheduler Activation ) ,因为我们所干的事情就是激活进程里面的调度器(执行系统)。

我们将这种做法称为"第二机会"。因为在一个进程挂起后,操作系统并不立即切换到别的进程。面是给该进程第二次机会,让其继续执行。如果该进程只有一个线程,或者其所有线程都已经阻塞,则控制权将再次返回给操作系统。而这次,操作系统就会切换到别的进程了。

这种办法似乎解决了阻塞线程阻塞进程的问题。但也有两个缺点:首先还是需要修改操作系统,使得其在进行进程切换时,不是立即切换到别的进程,而是调用受阻进程的执行系统。但由于此种修改范围小,只需要对调度器程序做一个外科手术式的小改动,因而尚可以忍受。

但该做法还存在一个更为严重的缺陷:这种操作系统调用用户态执行系统的做法违反了我们所遵循的层次架构原则,因为这种调用属于所谓的 up-call,即下层功能调用了上层功能(操作系统在下,执行系统在上)。 而平时用户程序使用操作系统服务的调用属于down-call,即上层程序调用下层服务。这种违反上下有别的做法使得操作系统的设计和管理都变得复杂,而且,由于调度器在第一次切换时总是选择阻塞的进程,这样也为黑客和各种攻击者提供了一个系统缺口。另外,这种层次结构的违反让习惯了上下有别的人类感到十分不快,因此,此种做法没有得到商用操作系统的认可。

现代操作系统的线程实现模型

鉴于用户态和内核态都存在缺陷,现代操作系统使用的是将二者结合起来。用户态的执行系统负责进程内部线程在非阻塞时的切换;内核态的操作系统负责阻塞线程的切换,即我们同时实现内核态和用户态线程管理。 其中内核态线程数量较少,而用户态线程数量多。每个内核态线程可以服务一个或多个用户态线程。换句话说,用户态线程被多路复用到内核态线程上。如,某个进程有5个线程,我们可以将5个线程分成两组,一组3个线程,另一组2个线程。每一组线程使用一个内核线程。这样,该进程将使用两个内核线程。如果一个线程阻塞,则与其同属于一组的线程皆阻塞,但另外一组线程却可以继续执行,如图所示。

在这里插入图片描述

这样,在分配线程时,我们可将需要执行阻塞操作的线程设为内核态线程,而不会执行阻塞操作的线程设为用户态线程。这样我们就可以获得两种态势实现下的优点,面避免其缺点。

多线程的关系

推出线程模型的目的就是为了实现进程级并发。因为在一个进程中通常会出现多个线程,否则,我们也没有必要研究什么线程了。就像看舞台剧,如果只跳上来一个人,从头演到尾,没有其他人上场,大部分观众都会觉得无聊。我们要看的是演员对不同人物的刻画,色彩斑斓。因此,研究线程就要研究多线程,多个线程共享一个舞台,时而交互,时而独舞。

但共享一个舞台会带来不必要的麻烦。就像人们共享资源时难免产生争端一样。线程在共享地址空间的过程中也会产生矛盾。这些矛盾归结为下面两个根本问题:

  • 线程之间如何通信
  • 线程之间如何同步

而上述两个问题在进程层面也同样存在。从一个更高的层次上看,不同的进程也共享着一个巨大的空间,这个空间就是整个计算机。因此,进程之间也会存在矛盾,而这些矛盾也体现在如何通信(沟通)和如何同步(协调)上。

接下来的两篇就分别对进程线程的通信和同步进行详细论述。

讨论:从用户态进入内核态

什么情况会造成一个线程从用户态进人到内核态呢?
首先,如果在程序运行过程中发生中断或异常,系统将自动切换到内核态来运行中断或异常处理机制。下图描述的就是中断导致态势切换的流程。异常处理的流程与此相同或相似。

此外,程序进行系统调用也将造成从用户态进入到内核态的转换。例如,一个C++程序调用函数cin。cin是一个标准库函数,它将调用read函数。而read则是由操作系统提供的一个系统调用。其执行过程如下:

  • 执行汇编语言里面的系统调用指令(如syscall》。

  • 将调用的参数 sys_read,file number,size存放在指定的寄存器或栈上(事先约好)。

  • 当处理器执行到“syscall”指令时,察觉这是一个系统调用指令,将进行如下操作:

    a 设置处理器至内核态。
    b 保存当前寄存器(栈指针,程序计数器,通用寄存器)。
    c 将栈指针设置指向内核栈地址。
    d 将程序计数器设置为一个事先约定的地址上。该地址上存放的是系统调用处理程序的起始地址。

  • 系统调用处理程序执行系统调用,并调用内核里面的read 函数。

这样,就实现了从用户态到内核态的转换,并完或系统调用所要求的功能。

  • 13
    点赞
  • 68
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值