本文是《深入理解操作系统》第五章,从本章开始将开启第二个重要的主题:线程,这一章也是进程这一重要概念的延续。彻底理解线程对程序员来说极为关键,本章就从程序员的角度来讲解到底什么是线程、操作系统是如何实现线程。本文承接上文《程序员应如何理解线程》,以下为本篇目录:

  • 进程的实现:PCB
  • 在用户态实现线程
  • 在内核态实现线程
  • Linux中的线程实现
  • 总结

在来讲解线程是如何实现的之前,我们先来回顾一下进程是如何实现。


进程的实现:PCB

在操作系统中进程是通过进程控制块(PCB)来表示的,PCB中记录了一个进程的所有信息,包括进程的运行状态、调度优先级、地址空间、运行上下文信息、打开的文件等等。系统中每个进程都有一个与之对应的PCB,如图所示。该图展示的是仅支持进程的操作系统,我们可以看到操作系统中有两个进程,那么就有两个PCB与之对应。注意所有进程的地址空间中操作系统部分基本上是相同的(除去一些进程私有的数据之外,比如PCB),都是操作系统通过虚拟内存技术来实现的。在这里我们仅需要知道所有进程的操作系统系统部分基本上都是相同的,我们将在“操作系统如何管理内存”一章中详细讲解这是怎么一回事。

线程是如何实现的?_内核态


系统中所有的PCB都是通过类似队列的数据结构组织起来的。处于就绪状态的PCB放入就绪队列等待调度器进行调度;运行中的进程通过系统调用发起I/O请求后操作系统暂停该进程的运行,将其设置为阻塞态后放入到相应设备的等待队列中;而进程请求的I/O事件完成或者运行中的进程因时钟中断被暂停执行后都被放入到就绪队列等待调度器进行调度。此外进程被暂停运行后,操作系统将其运行的上下文信息保存到相应的PCB中,这样当该进程再一次具备了运行条件后操作系统就可以根据PCB中保存的上下文信息恢复该进程的运行,就好像该进程从来没有被暂停执行过一样,如果你已经记不清楚这一部分的知识,请参考进程一章。

在了解了进程是如何实现的之后,我们就来讲解一下线程是如何实现的。

本质上,线程有两种实现方式:一种是在用户态实现;另一种是在内核态实现。说白了就是第一种情况下,程序员自己来实现线程;第二种是操作系统来负责实现线程。我们首先来看如何在用户态实现线程。


在用户态实现线程

在这种实现方式下,线程的管理是进程自己来实现的,在这种情况下我们该如何来表示一个线程呢?要解决这个问题,我们可以借鉴一下进程是如何实现的。

同进程一样,线程也有自己的运行状态、优先级信息、上下文信息等等,要进行线程管理这些信息同样需要保存起来,我们将保存这些信息的结构称之为线程控制块,Thread Control Block ,TCB。TCB和进程控制块PCB的作用是一样的,只不过你可以简单的将线程控制块TCB理解为进程控制块PCB的一个子集,线程控制块中不保存进程持有的资源信息。线程控制块中仅仅包含线程的运行信息,比如程序计数器以及线程私有的栈信息(也就是线程上下文信息)、运行状态、优先级等等。有了这些信息我们就可以像通过PCB控制进程一样来控制线程了,比如线程的调度、线程的上下文切换等等。

因此我们可以看到,对线程进行管理的关键就在于TCB,TCB在哪里实现就决定了谁来管理线程,TCB在用户态实现(也就是进程中)就意味着需要程序员自己来管理线程;TCB在操作系统中实现就意味着操作系统来管理线程。

在这一小节我们来看看如何在用户态中实现线程。

如图所示,在这种情况下管理TCB的是进程本身,而操作系统对此一无所知。在这种情况下操作系统仅仅知道系统中存在两个进程。线程的创建、运行以及调度统统由程序员自己来完成,一般情况下,用户态实现的线程通常由有相应的线程库(Thread Library)来进行线程管理,程序员需要将其链接到自己的项目中来。当然如果你愿意的话也可以从头开始不借助任何线程库而是自己来进行线程管理。

线程是如何实现的?_开发语言_02


由于是在用户态实现的线程,因此操作系统是感知不到线程这个概念的,操作系统感知到的只有进程,因此当操作系统决定开始运行某个进程后,该进程就在运行期间来调度进程中的各个线程。需要注意的是,进程中的所有线程会进程分得的CPU时间片,假设一个进程获得的CPU时间是T,那么进程中的各个线程就共享T这一段CPU时间。在这段时间内,进程中的调度器可以自己来决定运行哪些线程,值得注意的是,这里出现了两种调度器:一种是操作系统实现的进程调度器;另一种是我们在用户态实现的线程调度器,操作系统调度器调度的是各个进程,在进程内部我们自己实现的调度器来调度各个线程。如图下图所示,在该图中有三个进程,通过操作系统中调度器的调度,CPU在三个进程之间快速切换(注意由于线程实现在了用户态,因此这里操作系统只能意识到进程,即只能调度进程),因此每个进程各自运行一段时间,在每个进程运行的时间里(时间片),进程中的线程调度器开始调度进程中的各个线程,如图中所示的那样,在进程A的时间片里,进程A的调度器运行了两个线程;在进程B的时间片内调度器只运行了一个线程,在进程C的时间片内调度器运行了四个线程。在这种实现方式下进程共享CPU时间,每个进程获得一个时间片后开始运行,进程中的线程共享该进程分得的时间片,进程中的调度器来决定哪些线程可以在进程分得的时间片内运行,也就是说进程中的调度器会对时间片再次划分,每个线程获得自己的时间片后开始运行,希望你还没有晕 😃

线程是如何实现的?_java_03


在用户态实现线程的好处是显而易见的,那就是我们可以在不支持线程的操作系统上使用线程。事实上早期的操作系统是不支持线程这个概念的,因此程序员可以选择使用线程库在用户态来管理线程。

这种方法的另外一个优势是线程创建、调度、切换等等都是发生在用户态,全程不需要操作系统参与,线程调度以及切换就和程序员写的普通函数一样都是进程自己的事情,不像进程那样进程的调度需要CPU切换到内核态后调度器才有机会工作,因此用户态下线程调度是非常快的。

此外由于线程是在进程中实现的,程序员自己对各个线程的重要程度是一清二楚的,而且线程调度器也是在进程中实现的,也就是说调度器也是可以受程序员自己控制的,因此我们可以充分利用这些信息来设置线程的优先级并优化调度器策略从而使得整个进程有更高的执行效率,这在内存态实现的线程中是无法做到的。

尽管在用户态实现线程有着诸多优点,但是,这种方法的缺点同样明显。由于操作系统能感知到的仅仅是进程,因此当线程通过系统调用发起I/O请求后,操作系统会将整个进程暂停运行,即使该进程中可能有其它线程还可以继续运行。

此外,当用户态下的线程开始运行后,如果没有发出I/O请求或进行系统调用的话那么该线程会一直运行下去,该进程中的其它线程没有机会运行,除非该线程主动释放CPU让其它线程运行,通常释放线程主动让出CPU是通过yield一类的函数来完成的。也就是说,不像进程,定时器中断产生后操作系统开始运行,调度器会根据时间来决定是不是暂停当前进程而去运行其它进程。也就是对用户态实现的线程来说,如果运行中的线程不愿意主动释放CPU的话,那么进程中的线程调度器根本就没有机会运行起来从而暂停当前线程去运行其它线程,因为在进程中没有像定时器中断这样的机制可以暂停当前正在运行的线程(现在你意识到了定时器中断的重要性了吧)。

在之前的章节中我们知道,程序大体上可以分为I/O密集型和CPU密集型。对于I/O密集型程序来说,频繁的I/O请求会阻塞整个进程,进程中的所有线程都得不到运行;而对于CPU密集型程序来说,使用用户态的多线程是没有意义的,原因就在于在进程的时间片内虽然有多个线程,但是这些线程是串行运行的,即使计算机中有其它空闲CPU,因为操作系统根本就感知不到进程中的线程,即操作系统没有办法把空闲的CPU分配给进程中的线程,而进程中的调度器又没有权限来这样做,因为这是只有在内核态下的操作系统才能做到的,单纯依赖用户态下的线程没有办法充分利用多核的。因此在CPU密集型程序中使用用户态多线程没有意义。

在了解了用户态下的线程实现后,我们来看一下在内核态实现线程。


在内核态实现线程

作为程序员,我们实际上需要重点理解线程的内核态实现方式。在平时的学习、工作或者文献资料中提到的“线程”实际上多指内核态下的线程,这也是需要我们重点掌握的。

在理解了用户态下线程的实现后,理解内核态线程就很容易了。无非就是把用户态的线程调度器以及相应的TCB放到操作系统当中就可以了,如图所示。系统中有多少个进程那么操作系统中就需要维护相应数量的PCB,同样的道理,进程中有几个线程,那么PCB中就需要维护同样数量的TCB。

线程是如何实现的?_内核态_04


只要操作系统本身支持线程,那么程序员使用线程时就不需要依赖线程库了,进程管理以及线程管理统统交给操作系统。

同用户态下的TCB一样,内核态线程对应的TCB中保存了线程的执行状态、程序计数器等寄存器信息、优先级等等,有了TCB操作系统就可以直接对线程进行调度了。不像用户态下的线程,线程的创建、调度以及销毁都是由程序员决定的,操作系统对其一无所知;而内核态下的线程,程序控制线程的创建和销毁,但是线程的调度是操作系统来决定的,不受程序员控制(当然在比如Linux这样的操作系统中运行程序员设置线程的优先级,但也仅此而已)。

由于操作系统可以直接对线程进行管理,因此内核态下的线程克服了用户态线程的种种缺点。

当进程中的某个线程因I/O或系统调用被暂停执行后,调度器可以选择该进程中其它就绪线程来运行(当然调度器也可以选择其它进程中的线程)。这样整个进程不至于因为其中的一个线程阻塞而被暂停。在用户态线程实现方式中,由于在进程的时间片内一次只能运行一个线程(原因就在于不管我们在进程内部创建多少线程,操作系统感知到的依然是进程,在这种情况下操作系统调度的是进程而不是进程中创建的线程,因为操作系统感知不到进程中的线程),这样即使其它线程具备运行的条件,我们也没有办法充分利用多核来加快进程的执行速度。但是在内核态线程实现方式中,由于操作系统可以直接调度线程,因此一个进程中的多个线程可以在多个CPU上同时运行,从而加快进程的处理速度。

在用户态线程实现方式中,如果线程不主动释放CPU的话那么该线程会在所属进程的时间片内一直运行;而在内核态线程实现方式中,由于操作系统直接调度线程,因此操作系统可以利用定时器产生的中断信号获得运行机会来决定是否暂停运行当前线程,就像调度进程那样,在这种实现方式下,程序员不需要关心线程运行时间长短的问题,一切都由操作系统来管理。

在这种调度方式下,CPU时间片的划分将不再以进程为单位,而是依据当前系统中的线程来划分的,调度器调度的不再是进程,而是线程。只不过这些线程都有各自所属的进程,如图所示。在图中我们可以看到,虽然系统中有三个进程(进程A中有2个线程、进程B中有1个线程、进程C中有4个线程),但CPU时间片的划分是按照七个线程来划分的,这和用户态下的线程有着本质的区别。在用户态实现方案下,操作系统调度的单位是进程并且对进程中的线程一无所知;在内核态实现方案下,操作系统中的调度器直接调度线程。

线程是如何实现的?_java_05


在内核态实现方案下,线程间进行切换的代价是要高于用户态是实现方案的,原因就在于线程切换涉及到了CPU的状态转换,CPU从用户态切换到内核态去执行调度器,调度器通过保存和恢复TCB中的数据进行线程切换。而用户态方案下线程调度器是直接实现在用户态下的,因此线程切换可以在用户态下完成,没有从用户态切换到内核态的性能开销。

虽然线程切换(内核态线程)和进程切换都需要从用户态切换到内核态,都需要保存恢复上下文信息,但是线程切换的性能开销要比进程切换小,同时线程创建与销毁的性能开销也小于进程的创建与销毁。

那么在这种实现方案下什么是线程切换?什么是进程切换呢?假设切换的两个线程为线程A和线程B,如果线程A和线程B同属于一个进程,那么这是线程切换;而如果线程A和线程B属于两个不同的进程,那么这时从线程A切换到线程B就是进程切换。显然同一个进程内线程切换的性能开销要小于分属于两个不同进程的线程切换的性能开销。

作为程序员值得注意的是,在内核态下实现线程是当前主流的实现方式,Windows、Linux、MacOS都是在内核态下实现线程。因此作为程序员,我们需要重点理解掌握内核态线程。

接下来我们看一下比较流行的操作系统是如何实现线程的。


Linux中的线程实现

Linux以一种非常独特但又优雅的方式实现了对线程的支持。

在上一章中我们知道了Linux中是用一个叫做task_struct的结构体来管理进程的。task_struct中保存了一个进程的所有信息,包括内存地址空间、打开的文件、调度优先级、运行状态、上下文信息等等等等,task_struct用来表示进程,那么Linux又是如何来表示线程的呢?

答案肯定是你想不到的。实际上对于Linux来说是没有线程这个概念的,Linux使用我们熟悉的标准的进程来实现线程,Linux没有为支持线程而新增任何数据结构,也就是说Linux中的进程和线程都是用task_struct这个结构体来表示的

我们已经知道了,在不支持线程的传统操作系统中,可以把进程看作只有一个线程,一个进程包含两部分,一部分是进程持有的资源,另一部分就是线程。Linux正是基于这一点来支持线程的,在Linux看来线程就是一个普通的进程,只不过这个进程和其它进程共享了地址空间等资源,所有共享同一个地址空间的task_struct组成了我们常说的进程,每个task_struct可以理解为我们常说的线程

由于在Linux中你既可以把一个task_struct视为进程也可以将其视为线程,因此Linux中将task_struct称为了light weight processes,简写为lwp,即轻量级进程,如图所示:

线程是如何实现的?_开发语言_06


Linux的这种线程实现方式和其它操作系统比如Windows有很大的不同。在Windows中有专属于管理线程的数据结构,在Windows看来,进程就是进程,线程就是线程,当然这页比较符合常理。事实上,一般操作系统资料中会严格区分进程和线程这两个概念,我们在本节的《在内核态实现线程》这一小节中也是这样讲解的,因此从操作系统实现原理上讲,我们会用PCB来表示进程,一个进程中有几个线程就有几个与之对应的TCB。但是原理是一回事,真正的实现可能就是另外一回事,Linux中不是按照一般的操作系统实现原理来实现线程的,在Linux中如果一个进程中有四个线程,那么Linux就用四个task_struct来表示该进程,只不过这四个task_struct共享了地址空间等概念上属于进程的资源。因此Linux“轻而易举”的用代码中本来已经存在的task_struct优雅的实现了对线程的支持,就好像武林高手只一个侧身就轻易的化解了在别人来看来难以招架的大招一样。


总结

啊本质上线程有两种实现方案,一种是实现在用户态,另一种实现在内核态。用户态下的线程不能被操作系统感知到,在这种实现方式下操作系统只能按照进程来进行调度,因此同一个进程中的线程无法充分利用多核来并行执行。而内核态下的线程克服了这一缺点,由于操作系统可以直接调度线程,因此可以充分利用多核并行执行线程从而加快进程的处理速度。在内核态下实现线程是当前主流的实现方式,作为程序员我们需要重点理解内核态下的线程。

在了解了线程实现的原理后,我们以Linux为例讲解了在实际的操作系统当中线程是如何实现的。Linux以一种截然不同的方式实现了对线程的支持,Linux下不区分进程和线程,在Linux看来一切都是task_struct,只不过一些task_struct共享了内存地址空间等概念上属于进程的资源。

在学习了什么是线程、如何理解线程以及线程是如何实现之后,我们来继续讲解一下线程的使用。

回专栏