Skip to content

线程

进程的分身术--进程

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

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

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

在线程模式下,一个进程至少有一个线程,但也可以有多个线程

线程模型下的单线程进程和多线程进程对比

将进程分解为线程还可以有效地利用多处理器和多核计算机。
在没有线程的情况下,增加一个处理器并不能提高一个进程的执行速度。
但如果分解为多个线程,则可以让不同的线程同时运转在不同的处理器上,从而提高了进程的执行速度。
例如,当我们使用文字处理软件如 Microsoft Word 时,实际上是打开了多个线程。
这些线程一个负责显示,一个接收输入,一个定时进行存盘。这些线程一起运转,让我们感觉到输入和显示同时发生,而不用键入一些字符,等待一会儿才显示到屏幕上。
在我们不经意间,文字处理软件还能自动存盘。当然,此项操作取决于系统当时的状况,有时我们会感觉到存盘时,计算机接收输入的速度慢了下来。
但在绝大多数情况下,一切都还是令人满意的。

文本处理进程的 3 个线程如图所示:

文本处理进程的3个线程

线程管理

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

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

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

按照这个标准被划分,线程共享的资源有地址空间、全局变量、文件、子进程等。
定时器、信号和占用 CPU 时间也可以共享。但程序计数器不能共享,因为每个线程的执行序列不一样。
同理,寄存器也不能共享,栈也不能共享,这是线程上下文(运行环境)。

一般情况下(同一进程的)线程间共享和独享资源的划分。

线程共享资源:地址空间、全局变量、打开的文件、子进程、闹铃、信号即信号服务程序、记账信息
线程独享资源:程序计数器、寄存器、栈、状态字。

线程模型的实现

线程模型在进程基础上提供第二次并发。
由于线程之间的共享远比进程之间的共享丰富,因此其在需要高度共享的环境下发挥着重要的作用。但线程这个模型如何实现呢?

与进程一样,线程本身也对应某种物理现实,也需要存储和调度。
在存储上,由于线程依附于进程而存在,其存储解决方案无需额外设计,而是直接附于进程存储方案上。

线程的调度却与进程调度有稍许不同。
由于线程是在进程的基础上产生的概念(进程里面的一个执行序列),其调度可以由进程负责。
当然,我们也可以将线程的调度交给操作系统。而这两种不同的调度推手就形成了线程的两种实现:用户态实现和内核态实现。
由进程自己管理就是用户态线程的实现,由操作系统管理就是内核态线程实现。
用户态和内核态的判断以线程表所处的位置为依据:位于内核叫内核态实现,位于用户层叫用户态实现。

进程是在 CPU 上实现并发(多道编程),而 CPU 是由操作系统管理的,因此,进程的实现只能由操作系统内核来进行,而不存在用户态实现的情况。

内核态线程实现

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

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

内核态线程的实现

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

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

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

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

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

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

用户态线程实现

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

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

用户态线程实现

那么用户态实现有什么优点呢?有。首先是灵活性。
因为操作系统无须知道线程的存在,所以在任何操作系统上都能应用;其次是线程切换快。
因为切换在用户态进行,无须陷入到内核态。最后是不用修改操作系统,实现容易。

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

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

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

第一种办法如何实现呢?有几种办法。首先来看线程阻塞的原因。

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

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

例如,在读写磁盘时,先执行一个小的检查程序,看看需要的数据是否可以迅速获得(如检查一下排在前面的磁盘读操作有多少),如果是,就放行该操作。
否则,先切换给别人,到不会阻塞了再调用。这样就可以在一定程度上缓解线程阻塞造成进程阻塞的问题。(但并没有完全解决,知道为什么吗?)
但这样做有个很大的缺点:一是,需要修改操作系统,将系统调用包裹起来;二是,这样做大大降低了线程的效率;三是,这种做法有个关键前提条件。
因为我们不让程序发出阻塞调用并不是要永远不让线程运行,而是让它等待一段时间。
因此,本做法隐含的前提条件是你等待一段时间后该调用就会由阻塞调用变成非阻塞调用,否则的话,该程序就永远不能运转了。
而这个前提假定却不一定成立。

当然了,有的调用在等待一段时间后再调用确实会变成非阻塞操作。
例如,你想从网络上接收一个数据包,但是发送方尚未发送,这个时候你如果使用 receive 系统调用(假如我们使用阻塞版的 receive)将发生阻塞。
但如果过一段时间后你再调用,这个包可能已经发出来了,你的调用就是非阻塞了。
但问题是,并不是所有的阻塞调用在等待一段时间后就会转变成非阻塞操作。
比如读磁盘,你一调用就阻塞,但如果你不调用,就不会读磁盘,那么你在将来任何时候读磁盘仍将阻塞,这样该线程就永远无法推进。

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

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

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

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

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

鉴于用户态和内核态的线程模型都存在缺陷,因此现代操作系统将二者结合起来使用。
用户态的执行系统负责进程内部线程在非阻塞时的切换;内核态的操作系统负责阻塞线程的切换。即我们同时实现内核态和用户态线程管理。
其中内核态线程数量较少,而用户态线程数量较多。每个内核态线程可以服务一个或多个用户态线程。

换句话说,用户态线程被多路复用到内核态线程上。例如,某个进程有 5 个线程,我们可以将 5 个线程分成两组,一组 3 个线程,另一组 2 个线程。
每一组线程使用一个内核线程。这样,该进程将使用两个内核线程。如果一个线程阻塞,则与其同属于一组的线程皆阻塞,但另外一组线程却可以继续执行。

线程的内核态与用户态混合实现

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

多线程的关系

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

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

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

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

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

什么情况下会造成一个线程从用户态进入到内核态呢?

首先,如果在程序运行过程中发生中断或异常,系统将自动切换到内核态来运行中断或异常处理机制。

下图描述的就是中断导致态势切换的流程。异常处理的流程也与此相同或相似。

中断导致程序运行从用户态切换到内核态:

中断导致程序运行从用户态切换到内核态

此外,程序运行系统调用也将造成从用户态进入到内核态的转换。
例如,一个 C++ 程序调用函数 cin。cin 是标准命令空间 STD 中的一个流对象,它调用 C 函数库里面的 scanf() 库函数,scanf() 库函数则进一步调用操作系统的 READ 函数来真正获取用户输入。
这里的 READ 函数实际上是由操作系统提供的一个系统调用(需要注意的是,很多高级语言的函数库里也包含名为 READ 的库函数,不过此 READ 非彼 READ)。其执行过程如下:

1) 执行汇编语言里面的系统调用指令(如 syscall)
2) 将调用的参数 SYS_READ、file number,size 存放在指定的寄存器或栈上(事先约好)。
3) 当处理器执行到“syscall”指定时,察觉这是一个系统调用指令,将进行如下操作:

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

4) 系统调用处理程序执行系统调用,并调用内核里面的 READ 函数。这样就实现了从用户态到内核态的切换,并完成系统调用所要求的功能。

讨论:线程的困惑-确定性与非确定性

线程是很有用的东西,因为它实现了进程内部的并发。但这个结论是真的吗?
线程让进程有了分身术,它在进程级别上实现了多道编程,使得一个进程可以同时做多件事情,提高了程序运行的效率,提高了硬件资源的利用率。
似乎线程带给我们的只有优点,或者说线程带给我们的优点远远大于其缺点。

但真的是这样吗?有人不这样认为。2006 年夏天,有人在 IEEE Computer 发表文章,说线程带给我们的优势有限,而其造成的缺憾却巨大,应该剔除掉,说这是万恶之源。

那么线程到底是好还是坏呢?

从某种程度上说,线程提供了程序层面的并发性能。毫无疑问,并发的好处是显而易见的,既提高了系统的效率或者说吞吐率,又改善了用户感觉到响应时间。
这有点像一个人烧菜的情况。在单线程情况下,我们只用一个锅,在任何时刻,只有一个菜在锅里烧,我们可以专注于这一个菜,发生烧糊、烧焦的概率不大。
但这种策略的时间效率也不高。如果我们同时用几个锅,一口锅烧红烧肉,一口锅烧汤,另一个口锅炒肚皮,则因为并发操作而提高了时间效率,即可以同时烧好 3 个菜。

虽然线程的优势很明显,但带来的问题也是显而易见的,那就是系统运行的不确定性。
虽然在多进程时,系统运行也存在一定的不确定性,但这种不确定性基本体现在程序执行的先后顺序上,而每个程序运行的结果基本是确定的。

而线程的引入却带来了程序本身运行结果的不确定性:由于多线程的存在,就每个单一线程来看,其执行效率、执行正确性均存在不确定性。
当然,通过使用同步机制,可以改善这种不确定性。但如果在多线程执行过程中出现异常,则情况就相当麻烦。
另外,在多线程下,如果某个进程的某个线程创建了一个子进程,那对于该进程的其他线程来讲意味着什么?这是一个众说纷纭、莫衷一是的话题。
用烧菜的例子来说,就是一般情况下我们也许能够掌控局势,使得每个菜都烧的不错。
但问题是,如果因为环境的问题,如火候、水量不对,或者烧菜者的协调和判断能力不足,造成红烧肉干底,汤溢出,则将造成手乱脚忙,顾此失彼,从而造成烧菜失败。