万字长文带你还原进程和线程

咱们日常说的进程和线程更多的是基于编程语言的角度来讲的,那么你真的了解什么是线程和进程吗?那么咱们就从操做系统的角度来了解一下什么是进程和线程。html

进程

操做系统中最核心的概念就是 进程,进程是对正在运行中的程序的一个抽象。操做系统的其余全部内容都是围绕着进程展开的。进程是操做系统提供的最古老也是最重要的概念之一。即便可使用的 CPU 只有一个,但它们也支持(伪)并发操做。它们会将一个单独的 CPU 变换为多个虚拟机的 CPU。没有进程的抽象,现代操做系统将不复存在。程序员

全部现代的计算机会在同一时刻作不少事情,过去使用计算机的人可能彻底没法理解如今这种变化,举个例子更能说明这一点:首先考虑一个 Web 服务器,请求都来自于 Web 网页。当一个请求到达时,服务器会检查当前页是否在缓存中,若是是在缓存中,就直接把缓存中的内容返回。若是缓存中没有的话,那么请求就会交给磁盘来处理。可是,从 CPU 的角度来看,磁盘请求须要更长的时间,由于磁盘请求会很慢。当硬盘请求完成时,更多其余请求才会进入。若是有多个磁盘的话,能够在第一个请求完成前就能够连续的对其余磁盘发出部分或所有请求。很显然,这是一种并发现象,须要有并发控制条件来控制并发现象。web

如今考虑只有一个用户的 PC。当系统启动时,许多进程也在后台启动,用户一般不知道这些进程的启动,试想一下,当你本身的计算机启动的时候,你能知道哪些进程是须要启动的么?这些后台进程多是一个须要输入电子邮件的电子邮件进程,或者是一个计算机病毒查杀进程来周期性的更新病毒库。某个用户进程可能会在全部用户上网的时候打印文件以及刻录 CD-ROM,这些活动都须要管理。因而一个支持多进程的多道程序系统就会显得颇有必要了。算法

在许多多道程序系统中,CPU 会在进程间快速切换,使每一个程序运行几十或者几百毫秒。然而,严格意义来讲,在某一个瞬间,CPU 只能运行一个进程,然而咱们若是把时间定位为 1 秒内的话,它可能运行多个进程。这样就会让咱们产生并行的错觉。有时候人们说的 伪并行(pseudoparallelism) 就是这种状况,以此来区分多处理器系统(该系统由两个或多个 CPU 来共享同一个物理内存)shell

再来详细解释一下伪并行:伪并行是指单核或多核处理器同时执行多个进程,从而使程序更快。 经过以很是有限的时间间隔在程序之间快速切换CPU,所以会产生并行感。 缺点是时间可能分配给下一个进程,也可能不分配给下一个进程。编程

咱们很难对多个并行进程进行跟踪,所以,在通过多年的努力后,操做系统的设计者开发了用于描述并行的一种概念模型(顺序进程),使得并行更加容易理解和分析,对该模型的探讨,也是本篇文章的主题浏览器

进程模型

在进程模型中,全部计算机上运行的软件,一般也包括操做系统,被组织为若干顺序进程(sequential processes),简称为 进程(process) 。一个进程就是一个正在执行的程序的实例,进程也包括程序计数器、寄存器和变量的当前值。从概念上来讲,每一个进程都有各自的虚拟 CPU,可是实际状况是 CPU 会在各个进程之间进行来回切换。缓存

如上图所示,这是一个具备 4 个程序的多道处理程序,在进程不断切换的过程当中,程序计数器也在不一样的变化。安全

在上图中,这 4 道程序被抽象为 4 个拥有各自控制流程(即每一个本身的程序计数器)的进程,而且每一个程序都独立的运行。固然,实际上只有一个物理程序计数器,每一个程序要运行时,其逻辑程序计数器会装载到物理程序计数器中。当程序运行结束后,其物理程序计数器就会是真正的程序计数器,而后再把它放回进程的逻辑计数器中。服务器

从下图咱们能够看到,在观察足够长的一段时间后,全部的进程都运行了,但在任何一个给定的瞬间仅有一个进程真正运行

在咱们接下来的讨论中,咱们假设只有一个 CPU 的情形。固然,这个假设一般放到如今不会存在了,由于新的芯片一般是多核芯片,包含 2 个、4 个或更多的 CPU。可是如今,一次只考虑一个 CPU 会便于咱们分析问题。所以,当咱们说一个 CPU 只能真正一次运行一个进程的时候,即便有 2 个核(或 CPU),每个核也只能一次运行一个线程。

因为 CPU 会在各个进程之间来回快速切换,因此每一个进程在 CPU 中的运行时间是没法肯定的。而且当同一个进程再次在 CPU 中运行时,其在 CPU 内部的运行时间每每也是不固定的。进程和程序之间的区别是很是微妙的,可是经过一个例子可让你加以区分:想一想一位会作饭的计算机科学家正在为他的女儿制做生日蛋糕。他有作生日蛋糕的食谱,厨房里有所需的原谅:面粉、鸡蛋、糖、香草汁等。在这个比喻中,作蛋糕的食谱就是程序、计算机科学家就是 CPU、而作蛋糕的各类原谅都是输入数据。进程就是科学家阅读食谱、取来各类原料以及烘焙蛋糕等一系例了动做的总和。

如今假设科学家的儿子跑过来告诉他,说他的头被蜜蜂蜇了一下,那么此时科学家会记录出来他作蛋糕这个过程到了哪一步,而后拿出急救手册,按照上面的步骤给他儿子实施救助。这里,会涉及到进程之间的切换,科学家(CPU)会从作蛋糕(进程)切换到实施医疗救助(另外一个进程)。等待伤口处理完毕后,科学家会回到刚刚记录作蛋糕的那一步,继续制做。

这里的关键思想是认识到一个进程所需的条件,进程是某一类特定活动的总和,它有程序、输入输出以及状态。单个处理器能够被若干进程共享,它使用某种调度算法决定什么时候中止一个进程的工做,并转而为另一个进程提供服务。另外须要注意的是,若是一个进程运行了两遍,则被认为是两个进程。

进程的建立

操做系统须要一些方式来建立进程。在很是简单的系统中,或者操做系统被设计用来运行单个应用程序(例如微波炉中的控制器),可能在系统启动时,也须要全部的进程一块儿启动。但在通用系统中,然而,须要有某种方法在运行时按需建立或销毁进程,如今须要考察这个问题,下面是建立进程的方式

  • 系统初始化
  • 正在运行的程序执行了建立进程的系统调用(好比 fork)
  • 用户请求建立一个新进程
  • 初始化一个批处理工做

启动操做系统时,一般会建立若干个进程。其中有些是前台进程(numerous processes),也就是同用户进行交互并替他们完成工做的进程。一些运行在后台,并不与特定的用户进行交互,可是后台进程也有特定的功能。例如,设计一个后台进程来接收发来的电子邮件,这个进程大部分的时间都在休眠可是只要邮件到来后这个进程就会被唤醒。还能够设计一个后台进程来接收对该计算机上网页的传入请求,在请求到达的进程唤醒来处理网页的传入请求。进程运行在后台用来处理一些活动像是 e-mail,web 网页,新闻,打印等等被称为 守护进程(daemons)。大型系统会有不少守护进程。在 UNIX 中,ps 程序能够列出正在运行的进程, 在 Windows 中,可使用任务管理器。

除了在启动阶段建立进程以外,一些新的进程也能够在后面建立。一般,一个正在运行的进程会发出系统调用以建立一个或多个新进程来帮助其完成工做。当能够轻松地根据几个相关但相互独立的交互过程来共同完成一项工做时,建立新进程就显得特别有用。例如,若是有大量的数据须要通过网络调取并进行顺序处理,那么建立一个进程读数据,并把数据放到共享缓冲区中,而让第二个进程取走并正确处理会比较容易些。在多处理器中,让每一个进程运行在不一样的 CPU 上也可使工做作的更快。

在许多交互式系统中,输入一个命令或者双击图标就能够启动程序,以上任意一种操做均可以选择开启一个新的进程,在基本的 UNIX 系统中运行 X,新进程将接管启动它的窗口。在 Windows 中启动进程时,它通常没有窗口,可是它能够建立一个或多个窗口。每一个窗口均可以运行进程。经过鼠标或者命令就能够切换窗口并与进程进行交互。

交互式系统是以人与计算机之间大量交互为特征的计算机系统,好比游戏、web浏览器,IDE 等集成开发环境。

最后一种建立进程的情形会在大型机的批处理系统中应用。用户在这种系统中提交批处理做业。当操做系统决定它有资源来运行另外一个任务时,它将建立一个新进程并从其中的输入队列中运行下一个做业。

从技术上讲,在全部这些状况下,让现有流程执行流程是经过建立系统调用来建立新流程的。该进程多是正在运行的用户进程,是从键盘或鼠标调用的系统进程或批处理程序。这些就是系统调用建立新进程的过程。该系统调用告诉操做系统建立一个新进程,并直接或间接指示在其中运行哪一个程序。

在 UNIX 中,这仅有一个系统调用来建立一个新的进程,这个系统调用就是 fork。这个调用会建立一个与调用进程相关的副本。在 fork 后,一个父进程和子进程会有相同的内存映像,相同的环境字符串和相同的打开文件。一般,子进程会执行 execve 或者一个简单的系统调用来改变内存映像并运行一个新的程序。例如,当一个用户在 shell 中输出 sort 命令时,shell 会 fork 一个子进程而后子进程去执行 sort 命令。这两步过程的缘由是容许子进程在 fork 以后但在 execve 以前操做其文件描述符,以完成标准输入,标准输出和标准错误的重定向。

在 Windows 中,状况正相反,一个简单的 Win32 功能调用 CreateProcess,会处理流程建立并将正确的程序加载到新的进程中。这个调用会有 10 个参数,包括了须要执行的程序、输入给程序的命令行参数、各类安全属性、有关打开的文件是否继承控制位、优先级信息、进程所须要建立的窗口规格以及指向一个结构的指针,在该结构中新建立进程的信息被返回给调用者。除了 CreateProcess Win 32 中大概有 100 个其余的函数用于处理进程的管理,同步以及相关的事务。下面是 UNIX 操做系统和 Windows 操做系统系统调用的对比

UNIX Win32 说明
fork CreateProcess 建立一个新进程
waitpid WaitForSingleObject 等待一个进程退出
execve none CraeteProcess = fork + servvice
exit ExitProcess 终止执行
open CreateFile 建立一个文件或打开一个已有的文件
close CloseHandle 关闭文件
read ReadFile 从单个文件中读取数据
write WriteFile 向单个文件写数据
lseek SetFilePointer 移动文件指针
stat GetFileAttributesEx 得到不一样的文件属性
mkdir CreateDirectory 建立一个新的目录
rmdir RemoveDirectory 移除一个空的目录
link none Win32 不支持 link
unlink DeleteFile 销毁一个已有的文件
mount none Win32 不支持 mount
umount none Win32 不支持 mount,因此也不支持mount
chdir SetCurrentDirectory 切换当前工做目录
chmod none Win32 不支持安全
kill none Win32 不支持信号
time GetLocalTime 获取当前时间

在 UNIX 和 Windows 中,进程建立以后,父进程和子进程有各自不一样的地址空间。若是其中某个进程在其地址空间中修改了一个词,这个修改将对另外一个进程不可见。在 UNIX 中,子进程的地址空间是父进程的一个拷贝,可是确是两个不一样的地址空间;不可写的内存区域是共享的。某些 UNIX 实现是正文区在二者之间共享,由于它不能被修改。或者,子进程共享父进程的全部内存,可是这种状况下内存经过 写时复制(copy-on-write) 共享,这意味着一旦二者之一想要修改部份内存,则这块内存首先被明确的复制,以确保修改发生在私有内存区域。再次强调,可写的内存是不能被共享的。可是,对于一个新建立的进程来讲,确实有可能共享建立者的资源,好比能够共享打开的文件呢。在 Windows 中,从一开始父进程的地址空间和子进程的地址空间就是不一样的。

进程的终止

进程在建立以后,它就开始运行并作完成任务。然而,没有什么事儿是永不停歇的,包括进程也同样。进程迟早会发生终止,可是一般是因为如下状况触发的

  • 正常退出(自愿的)
  • 错误退出(自愿的)
  • 严重错误(非自愿的)
  • 被其余进程杀死(非自愿的)

多数进程是因为完成了工做而终止。当编译器完成了所给定程序的编译以后,编译器会执行一个系统调用告诉操做系统它完成了工做。这个调用在 UNIX 中是 exit ,在 Windows 中是 ExitProcess。面向屏幕中的软件也支持自愿终止操做。字处理软件、Internet 浏览器和相似的程序中总有一个供用户点击的图标或菜单项,用来通知进程删除它锁打开的任何临时文件,而后终止。

进程发生终止的第二个缘由是发现严重错误,例如,若是用户执行以下命令

cc foo.c

为了可以编译 foo.c 可是该文件不存在,因而编译器就会发出声明并退出。在给出了错误参数时,面向屏幕的交互式进程一般并不会直接退出,由于这从用户的角度来讲并不合理,用户须要知道发生了什么并想要进行重试,因此这时候应用程序一般会弹出一个对话框告知用户发生了系统错误,是须要重试仍是退出。

进程终止的第三个缘由是由进程引发的错误,一般是因为程序中的错误所致使的。例如,执行了一条非法指令,引用不存在的内存,或者除数是 0 等。在有些系统好比 UNIX 中,进程能够通知操做系统,它但愿自行处理某种类型的错误,在这类错误中,进程会收到信号(中断),而不是在这类错误出现时直接终止进程。

第四个终止进程的缘由是,某个进程执行系统调用告诉操做系统杀死某个进程。在 UNIX 中,这个系统调用是 kill。在 Win32 中对应的函数是 TerminateProcess(注意不是系统调用),

进程的层次结构

在一些系统中,当一个进程建立了其余进程后,父进程和子进程就会以某种方式进行关联。子进程它本身就会建立更多进程,从而造成一个进程层次结构。

在 UNIX 中,进程和它的全部子进程以及后裔共同组成一个进程组。当用户从键盘中发出一个信号后,该信号被发送给当前与键盘相关的进程组中的全部成员(它们一般是在当前窗口建立的全部活动进程)。每一个进程能够分别捕获该信号、忽略该信号或采起默认的动做,即被信号 kill 掉。

这里有另外一个例子,能够用来讲明层次的做用,考虑 UNIX 在启动时如何初始化本身。一个称为 init 的特殊进程出如今启动映像中 。当 init 进程开始运行时,它会读取一个文件,文件会告诉它有多少个终端。而后为每一个终端建立一个新进程。这些进程等待用户登陆。若是登陆成功,该登陆进程就执行一个 shell 来等待接收用户输入指令,这些命令可能会启动更多的进程,以此类推。所以,整个操做系统中全部的进程都隶属于一个单个以 init 为根的进程树。

相反,Windows 中没有进程层次的概念,Windows 中全部进程都是平等的,惟一相似于层次结构的是在建立进程的时候,父进程获得一个特别的令牌(称为句柄),该句柄能够用来控制子进程。然而,这个令牌可能也会移交给别的操做系统,这样就不存在层次结构了。而在 UNIX 中,进程不能剥夺其子进程的 进程权。(这样看来,仍是 Windows 比较)。

进程状态

尽管每一个进程是一个独立的实体,有其本身的程序计数器和内部状态,可是,进程之间仍然须要相互做用。一个进程的结果能够做为另外一个进程的输入,在 shell 命令中

cat chapter1 chapter2 chapter3 | grep tree

第一个进程是 cat,将三个文件级联并输出。第二个进程是 grep,它从输入中选择具备包含关键字 tree 的内容,根据这两个进程的相对速度(这取决于两个程序的相对复杂度和各自所分配到的 CPU 时间片),可能会发生下面这种状况,grep 准备就绪开始运行,可是输入进程尚未完成,因而必须阻塞 grep 进程,直到输入完毕。

当一个进程在逻辑上没法继续运行时,它就会被阻塞,好比进程在等待可使用的输入。还有多是这样的状况:因为操做系统已经决定暂时将 CPU 分配给另外一个进程,所以准备就绪的进程也有可能会终止。致使这两种状况的因素是彻底不一样的:

  • 第一种状况的本质是进程的挂起,你必须先输入用户的命令行,才能执行接下来的操做。
  • 第二种状况彻底是操做系统的技术问题:没有足够的 CPU 来为每一个进程提供本身私有的处理器。

当一个进程开始运行时,它可能会经历下面这几种状态

图中会涉及三种状态

  1. 运行态,运行态指的就是进程实际占用 CPU 运行时
  2. 就绪态,就绪态指的是可运行,但由于其余进程正在运行而终止
  3. 阻塞态,除非某种外部事件发生,不然进程不能运行

逻辑上来讲,运行态和就绪态是很类似的。这两种状况下都表示进程可运行,可是第二种状况没有得到 CPU 时间分片。第三种状态与前两种状态不一样是由于这个进程不能运行,CPU 空闲或者没有任何事情去作的时候也不能运行。

三种状态会涉及四种状态间的切换,在操做系统发现进程不能继续执行时会发生状态1的轮转,在某些系统中进程执行系统调用,例如 pause,来获取一个阻塞的状态。在其余系统中包括 UNIX,当进程从管道或特殊文件(例如终端)中读取没有可用的输入时,该进程会被自动终止。

转换 2 和转换 3 都是由进程调度程序(操做系统的一部分)引发的,而进程甚至不知道它们。转换 2 的出现说明进程调度器认定当前进程已经运行了足够长的时间,是时候让其余进程运行 CPU 时间片了。当全部其余进程都运行事后,这时候该是让第一个进程从新得到 CPU 时间片的时候了,就会发生转换 3。

程序调度指的是,决定哪一个进程优先被运行和运行多久,这是很重要的一点。已经设计出许多算法来尝试平衡系统总体效率与各个流程之间的竞争需求。

当进程等待的一个外部事件发生时(如一些输入到达),则发生转换 4。若是此时没有其余进程在运行,则马上触发转换 3,该进程便开始运行,不然该进程会处于就绪阶段,等待 CPU 空闲后再轮到它运行。

使用进程模型,会让咱们更容易理解操做系统内部的工做情况。一些进程运行执行用户键入的命令的程序。另外一些进程是系统的一部分,它们的任务是完成下列一些工做:好比,执行文件服务请求,管理磁盘驱动和磁带机的运行细节等。当发生一个磁盘中断时,系统会作出决定,中止运行当前进程,转而运行磁盘进程,该进程在此以前因等待中断而处于阻塞态。这样能够再也不考虑中断,而只是考虑用户进程、磁盘进程、终端进程等。这些进程在等待时老是处于阻塞态。在已经读如磁盘或者输入字符后,等待它们的进程就被解除阻塞,并成为可调度运行的进程。

从上面的观点引入了下面的模型

操做系统最底层的就是调度程序,在它上面有许多进程。全部关于中断处理、启动进程和中止进程的具体细节都隐藏在调度程序中。事实上,调度程序只是一段很是小的程序。

进程的实现

咱们以前提过,操做系统为了执行进程间的切换,会维护着一张表格,即 进程表(process table)。每一个进程占用一个进程表项。该表项包含了进程状态的重要信息,包括程序计数器、堆栈指针、内存分配情况、所打开文件的状态、帐号和调度信息,以及其余在进程由运行态转换到就绪态或阻塞态时所必须保存的信息,从而保证该进程随后能再次启动,就像从未被中断过同样。

下面展现了一个典型系统中的关键字段

第一列内容与进程管理有关,第二列内容与 存储管理有关,第三列内容与文件管理有关。

存储管理的 text segment 、 data segment、stack segment 更多了解见下面这篇文章

程序员须要了解的硬核知识之汇编语言(全)

如今咱们应该对进程表有个大体的了解了,就能够在对单个 CPU 上如何运行多个顺序进程的错觉作更多的解释。与每一 I/O 类相关联的是一个称做 中断向量(interrupt vector) 的位置(靠近内存底部的固定区域)。它包含中断服务程序的入口地址。假设当一个磁盘中断发生时,用户进程 3 正在运行,则中断硬件将程序计数器、程序状态字、有时还有一个或多个寄存器压入堆栈,计算机随即跳转到中断向量所指示的地址。这就是硬件所作的事情。而后软件就随即接管一切剩余的工做。

全部的中断都从保存寄存器开始,对于当前进程而言,一般是保存在进程表项中。随后,会从堆栈中删除由中断硬件机制存入堆栈的那部分信息,并将堆栈指针指向一个由进程处理程序所使用的临时堆栈。一些诸如保存寄存器的值和设置堆栈指针等操做,没法用 C 语言等高级语言描述,因此这些操做经过一个短小的汇编语言来完成,一般能够供全部的中断来使用,不管中断是怎样引发的,其保存寄存器的工做是同样的。

当中断结束后,操做系统会调用一个 C 程序来处理中断剩下的工做。在完成剩下的工做后,会使某些进程就绪,接着调用调度程序,决定随后运行哪一个进程。随后将控制权转移给一段汇编语言代码,为当前的进程装入寄存器值以及内存映射并启动该进程运行,下面显示了中断处理和调度的过程。

  1. 硬件压入堆栈程序计数器等

  2. 硬件从中断向量装入新的程序计数器

  3. 汇编语言过程保存寄存器的值

  4. 汇编语言过程设置新的堆栈

  5. C 中断服务器运行(典型的读和缓存写入)

  6. 调度器决定下面哪一个程序先运行

  7. C 过程返回至汇编代码

  8. 汇编语言过程开始运行新的当前进程

一个进程在执行过程当中可能被中断数千次,但关键每次中断后,被中断的进程都返回到与中断发生前彻底相同的状态。

线程

在传统的操做系统中,每一个进程都有一个地址空间和一个控制线程。事实上,这是大部分进程的定义。不过,在许多状况下,常常存在在同一地址空间中运行多个控制线程的情形,这些线程就像是分离的进程。下面咱们就着重探讨一下什么是线程

线程的使用

或许这个疑问也是你的疑问,为何要在进程的基础上再建立一个线程的概念,准确的说,这实际上是进程模型和线程模型的讨论,回答这个问题,可能须要分三步来回答

  • 多线程之间会共享同一块地址空间和全部可用数据的能力,这是进程所不具有的
  • 线程要比进程更轻量级,因为线程更轻,因此它比进程更容易建立,也更容易撤销。在许多系统中,建立一个线程要比建立一个进程快 10 - 100 倍。
  • 第三个缘由多是性能方面的探讨,若是多个线程都是 CPU 密集型的,那么并不能得到性能上的加强,可是若是存在着大量的计算和大量的 I/O 处理,拥有多个线程能在这些活动中彼此重叠进行,从而会加快应用程序的执行速度

多线程解决方案

如今考虑一个线程使用的例子:一个万维网服务器,对页面的请求发送给服务器,而所请求的页面发送回客户端。在多数 web 站点上,某些页面较其余页面相比有更多的访问。例如,索尼的主页比任何一个照相机详情介绍页面具备更多的访问,Web 服务器能够把得到大量访问的页面集合保存在内存中,避免到磁盘去调入这些页面,从而改善性能。这种页面的集合称为 高速缓存(cache),高速缓存也应用在许多场合中,好比说 CPU 缓存。

上面是一个 web 服务器的组织方式,一个叫作 调度线程(dispatcher thread) 的线程从网络中读入工做请求,在调度线程检查完请求后,它会选择一个空闲的(阻塞的)工做线程来处理请求,一般是将消息的指针写入到每一个线程关联的特殊字中。而后调度线程会唤醒正在睡眠中的工做线程,把工做线程的状态从阻塞态变为就绪态。

当工做线程启动后,它会检查请求是否在 web 页面的高速缓存中存在,这个高速缓存是全部线程均可以访问的。若是高速缓存不存在这个 web 页面的话,它会调用一个 read 操做从磁盘中获取页面而且阻塞线程直到磁盘操做完成。当线程阻塞在硬盘操做的期间,为了完成更多的工做,调度线程可能挑选另外一个线程运行,也可能把另外一个当前就绪的工做线程投入运行。

这种模型容许将服务器编写为顺序线程的集合,在分派线程的程序中包含一个死循环,该循环用来得到工做请求而且把请求派给工做线程。每一个工做线程的代码包含一个从调度线程接收的请求,而且检查 web 高速缓存中是否存在所需页面,若是有,直接把该页面返回给客户,接着工做线程阻塞,等待一个新请求的到达。若是没有,工做线程就从磁盘调入该页面,将该页面返回给客户机,而后工做线程阻塞,等待一个新请求。

下面是调度线程和工做线程的代码,这里假设 TRUE 为常数 1 ,buf 和 page 分别是保存工做请求和 Web 页面的相应结构。

调度线程的大体逻辑

while(TRUE){
  get_next_request(&buf);
  handoff_work(&buf);
}

工做线程的大体逻辑

while(TRUE){
  wait_for_work(&buf);
  look_for_page_in_cache(&buf,&page);
  if(page_not_in_cache(&page)){
    read_page_from_disk(&buf,&page);
  }
  return _page(&page);
}

单线程解决方案

如今考虑没有多线程的状况下,如何编写 Web 服务器。咱们很容易的就想象为单个线程了,Web 服务器的主循环获取请求并检查请求,并争取在下一个请求以前完成工做。在等待磁盘操做时,服务器空转,而且不处理任何到来的其余请求。结果会致使每秒中只有不多的请求被处理,因此这个例子可以说明多线程提升了程序的并行性并提升了程序的性能。

状态机解决方案

到如今为止,咱们已经有了两种解决方案,单线程解决方案和多线程解决方案,其实还有一种解决方案就是 状态机解决方案,它的流程以下

若是目前只有一个非阻塞版本的 read 系统调用可使用,那么当请求到达服务器时,这个惟一的 read 调用的线程会进行检查,若是可以从高速缓存中获得响应,那么直接返回,若是不能,则启动一个非阻塞的磁盘操做

服务器在表中记录当前请求的状态,而后进入并获取下一个事件,紧接着下一个事件可能就是一个新工做的请求或是磁盘对先前操做的回答。若是是新工做的请求,那么就开始处理请求。若是是磁盘的响应,就从表中取出对应的状态信息进行处理。对于非阻塞式磁盘 I/O 而言,这种响应通常都是信号中断响应。

每次服务器从某个请求工做的状态切换到另外一个状态时,都必须显示的保存或者从新装入相应的计算状态。这里,每一个计算都有一个被保存的状态,存在一个会发生且使得相关状态发生改变的事件集合,咱们把这类设计称为有限状态机(finite-state machine),有限状态机杯普遍的应用在计算机科学中。

这三种解决方案各有各的特性,多线程使得顺序进程的思想得以保留下来,而且实现了并行性,可是顺序进程会阻塞系统调用;单线程服务器保留了阻塞系统的简易性,可是却放弃了性能。有限状态机的处理方法运用了非阻塞调用和中断,经过并行实现了高性能,可是给编程增长了困难。

模型 特性
单线程 无并行性,性能较差,阻塞系统调用
多线程 有并行性,阻塞系统调用
有限状态机 并行性,非阻塞系统调用、中断

经典的线程模型

理解进程的另外一个角度是,用某种方法把相关的资源集中在一块儿。进程有存放程序正文和数据以及其余资源的地址空间。这些资源包括打开的文件、子进程、即将发生的定时器、信号处理程序、帐号信息等。把这些信息放在进程中会比较容易管理。

另外一个概念是,进程中拥有一个执行的线程,一般简写为 线程(thread)。线程会有程序计数器,用来记录接着要执行哪一条指令;线程还拥有寄存器,用来保存线程当前正在使用的变量;线程还会有堆栈,用来记录程序的执行路径。尽管线程必须在某个进程中执行,可是进程和线程完彻底全是两个不一样的概,而且他们能够分开处理。进程用于把资源集中在一块儿,而线程则是 CPU 上调度执行的实体。

线程给进程模型增长了一项内容,即在同一个进程中,容许彼此之间有较大的独立性且互不干扰。在一个进程中并行运行多个线程相似于在一台计算机上运行多个进程。在多个线程中,多个线程共享同一地址空间和其余资源。在多个进程中,进程共享物理内存、磁盘、打印机和其余资源。由于线程会包含有一些进程的属性,因此线程被称为轻量的进程(lightweight processes)多线程(multithreading)一词还用于描述在同一进程中多个线程的状况。

下图咱们能够看到三个传统的进程,每一个进程有本身的地址空间和单个控制线程。每一个线程都在不一样的地址空间中运行

下图中,咱们能够看到有一个进程三个线程的状况。每一个线程都在相同的地址空间中运行。

当多个线程在单 CPU 系统中运行时,线程轮流运行,在对进程进行描述的过程当中,咱们知道了进程的多道程序是如何工做的。经过在多个进程之间来回切换,系统制造了不一样的顺序进程并行运行的假象。多线程的工做方式也是相似。CPU 在线程之间来回切换,系统制造了不一样的顺序进程并行运行的假象。

可是进程中的不一样线程没有不一样进程间较强的独立性。同一个进程中的全部线程都会有彻底同样的地址空间,这意味着它们也共享一样的全局变量。因为每一个线程均可以访问进程地址空间内每一个内存地址,所以一个线程能够读取、写入甚至擦除另外一个线程的堆栈。线程之间为何没有保护呢?既不可能也没有必要。这与不一样进程间是有差异的,不一样的进程会来自不一样的用户,它们彼此之间可能有敌意,由于彼此不一样的进程间会互相争抢资源。而一个进程老是由一个用户所拥有,因此操做系统设计者把线程设计出来是为了让他们 相互合做而不是相互斗争的。线程之间除了共享同一内存空间外,还具备以下不一样的内容

上图左边的是同一个进程中每一个线程共享的内容,上图右边是每一个线程中的内容。也就是说左边的列表是进程的属性,右边的列表是线程的属性。

线程概念试图实现的是,共享一组资源的多个线程的执行能力,以便这些线程能够为完成某一任务而共同工做。和进程同样,线程能够处于下面这几种状态:运行中、阻塞、就绪和终止(进程图中没有画)。正在运行的线程拥有 CPU 时间片而且状态是运行中。一个被阻塞的线程会等待某个释放它的事件。例如,当一个线程执行从键盘读入数据的系统调用时,该线程就被阻塞直到有输入为止。线程一般会被阻塞,直到它等待某个外部事件的发生或者有其余线程来释放它。线程之间的状态转换和进程之间的状态转换是同样的

每一个线程都会有本身的堆栈,以下图所示

在多线程状况下,进程一般会从当前的某个单线程开始,而后这个线程经过调用一个库函数(好比 thread_create)建立新的线程。线程建立的函数会要求指定新建立线程的名称。建立的线程一般都返回一个线程标识符,该标识符就是新线程的名字。

当一个线程完成工做后,能够经过调用一个函数(好比 thread_exit)来退出。紧接着线程消失,状态变为死亡,不能再进行调度。在某些线程的运行过程当中,能够经过调用函数例如 thread_join ,表示一个线程能够等待另外一个线程退出。这个过程阻塞调用线程直到等待特定的线程退出。在这种状况下,线程的建立和终止很是相似于进程的建立和终止。

另外一个常见的线程是调用 thread_yield,它容许线程自动放弃 CPU 从而让另外一个线程运行。这样一个调用仍是很重要的,由于不一样于进程,线程是没法利用时钟中断强制让线程让出 CPU 的。因此设法让线程的行为 高大上一些仍是比较重要的,而且随着时间的推移让线程让出 CPU,以便让其余线程得到运行的机会。线程会带来不少的问题,必需要在设计时考虑全面。

POSIX 线程

为了使编写可移植线程程序成为可能,IEEE 在 IEEE 标准 1003.1c 中定义了线程标准。线程包被定义为 Pthreads。大部分的 UNIX 系统支持它。这个标准定义了 60 多种功能调用,一一列举不太现实,下面为你列举了一些经常使用的系统调用。

POSIX线程(一般称为pthreads)是一种独立于语言而存在的执行模型,以及并行执行模型。它容许程序控制时间上重叠的多个不一样的工做流程。每一个工做流程都称为一个线程,能够经过调用POSIX Threads API来实现对这些流程的建立和控制。能够把它理解为线程的标准。

POSIX Threads 的实如今许多相似且符合POSIX的操做系统上可用,例如 FreeBSD、NetBSD、OpenBSD、Linux、macOS、Android、Solaris,它在现有 Windows API 之上实现了pthread

IEEE 是世界上最大的技术专业组织,致力于为人类的利益而发展技术。

线程调用 描述
pthread_create 建立一个新线程
pthread_exit 结束调用的线程
pthread_join 等待一个特定的线程退出
pthread_yield 释放 CPU 来运行另一个线程
pthread_attr_init 建立并初始化一个线程的属性结构
pthread_attr_destory 删除一个线程的属性结构

全部的 Pthreads 都有特定的属性,每个都含有标识符、一组寄存器(包括程序计数器)和一组存储在结构中的属性。这个属性包括堆栈大小、调度参数以及其余线程须要的项目。

新的线程会经过 pthread_create 建立,新建立的线程的标识符会做为函数值返回。这个调用很是像是 UNIX 中的 fork 系统调用(除了参数以外),其中线程标识符起着 PID 的做用,这么作的目的是为了和其余线程进行区分。

当线程完成指派给他的工做后,会经过 pthread_exit 来终止。这个调用会中止线程并释放堆栈。

通常一个线程在继续运行前须要等待另外一个线程完成它的工做并退出。能够经过 pthread_join 线程调用来等待别的特定线程的终止。而要等待线程的线程标识符做为一个参数给出。

有时会出现这种状况:一个线程逻辑上没有阻塞,但感受上它已经运行了足够长的时间而且但愿给另一个线程机会去运行。这时候能够经过 pthread_yield 来完成。

下面两个线程调用是处理属性的。pthread_attr_init 创建关联一个线程的属性结构并初始化成默认值,这些值(例如优先级)能够经过修改属性结构的值来改变。

最后,pthread_attr_destroy 删除一个线程的结构,释放它占用的内存。它不会影响调用它的线程,这些线程会一直存在。

为了更好的理解 pthread 是如何工做的,考虑下面这个例子

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

#define NUMBER_OF_THREADS 10

void *print_hello_world(vvoid *tid){
  /* 输出线程的标识符,而后退出 */
  printf("Hello World. Greetings from thread %d\n",tid);
  pthread_exit(NULL);
}

int main(int argc,char *argv[]){
  /* 主程序建立 10 个线程,而后退出 */
  pthread_t threads[NUMBER_OF_THREADS];
  int status,i;
    
  for(int i = 0;i < NUMBER_OF_THREADS;i++){
    printf("Main here. Creating thread %d\n",i);
    status = pthread_create(&threads[i], NULL, print_hello_world, (void *)i);
    
    if(status != 0){
      printf("Oops. pthread_create returned error code %d\n",status);
      exit(-1);
    }
  }
  exit(NULL);
}

主线程在宣布它的指责以后,循环 NUMBER_OF_THREADS 次,每次建立一个新的线程。若是线程建立失败,会打印出一条信息后退出。在建立完成全部的工做后,主程序退出。

线程实现

主要有三种实现,一种是在用户空间中实现线程,一种是在内核空间中实现线程,一种是在用户和内核空间中混合实现线程。下面咱们分开讨论一下

在用户空间中实现线程

第一种方法是把整个线程包放在用户空间中,内核对线程一无所知,它不知道线程的存在。全部的这类实现都有一样的通用结构

线程在运行时系统之上运行,运行时系统是管理线程过程的集合,包括前面提到的四个过程: pthread_create, pthread_exit, pthread_join 和 pthread_yield。

运行时系统(Runtime System) 也叫作运行时环境,该运行时系统提供了程序在其中运行的环境。此环境可能会解决许多问题,包括应用程序内存的布局,程序如何访问变量,在过程之间传递参数的机制,与操做系统的接口等等。编译器根据特定的运行时系统进行假设以生成正确的代码。一般,运行时系统将负责设置和管理堆栈,而且会包含诸如垃圾收集,线程或语言内置的其余动态的功能。

在用户空间管理线程时,每一个进程须要有其专用的线程表(thread table),用来跟踪该进程中的线程。这些表和内核中的进程表相似,不过它仅仅记录各个线程的属性,如每一个线程的程序计数器、堆栈指针、寄存器和状态。该线程标由运行时系通通一管理。当一个线程转换到就绪状态或阻塞状态时,在该线程表中存放从新启动该线程的全部信息,与内核在进程表中存放的信息彻底同样。

在用户空间实现线程的优点

在用户空间中实现线程要比在内核空间中实现线程具备这些方面的优点:考虑若是在线程完成时或者是在调用 pthread_yield 时,必要时会进程线程切换,而后线程的信息会被保存在运行时环境所提供的线程表中,进而,线程调度程序来选择另一个须要运行的线程。保存线程的状态和调度程序都是本地过程,因此启动他们比进行内核调用效率更高。于是不须要陷入内核,也就不须要上下文切换,也不须要对内存高速缓存进行刷新,由于线程调度很是便捷,所以效率比较高。

在用户空间实现线程还有一个优点就是它容许每一个进程有本身定制的调度算法。例如在某些应用程序中,那些具备垃圾收集线程的应用程序(知道是谁了吧)就不用担忧本身线程会不会在不合适的时候中止,这是一个优点。用户线程还具备较好的可扩展性,由于内核空间中的内核线程须要一些表空间和堆栈空间,若是内核线程数量比较大,容易形成问题。

在用户空间实现线程的劣势

尽管在用户空间实现线程会具备必定的性能优点,可是劣势仍是很明显的,你如何实现阻塞系统调用呢?假设在尚未任何键盘输入以前,一个线程读取键盘,让线程进行系统调用是不可能的,由于这会中止全部的线程。因此,使用线程的一个目标是可以让线程进行阻塞调用,而且要避免被阻塞的线程影响其余线程

与阻塞调用相似的问题是缺页中断问题,实际上,计算机并不会把全部的程序都一次性的放入内存中,若是某个程序发生函数调用或者跳转指令到了一条不在内存的指令上,就会发生页面故障,而操做系统将到磁盘上取回这个丢失的指令,这就称为缺页故障。而在对所需的指令进行读入和执行时,相关的进程就会被阻塞。若是只有一个线程引发页面故障,内核因为甚至不知道有线程存在,一般会吧整个进程阻塞直到磁盘 I/O 完成为止,尽管其余的线程是能够运行的。

在用户空间实现线程的另一个问题是,若是一个线程开始运行,该线程所在进程中的其余线程都不能运行,除非第一个线程自愿的放弃 CPU,在一个单进程内部,没有时钟中断,因此不可能使用轮转调度的方式调度线程。除非其余线程可以以本身的意愿进入运行时环境,不然调度程序没有能够调度线程的机会。

在内核中实现线程

如今咱们考虑使用内核来实现线程的状况,此时再也不须要运行时环境了。另外,每一个进程中也没有线程表。相反,在内核中会有用来记录系统中全部线程的线程表。当某个线程但愿建立一个新线程或撤销一个已有线程时,它会进行一个系统调用,这个系统调用经过对线程表的更新来完成线程建立或销毁工做。

内核中的线程表持有每一个线程的寄存器、状态和其余信息。这些信息和用户空间中的线程信息相同,可是位置却被放在了内核中而不是用户空间中。另外,内核还维护了一张进程表用来跟踪系统状态。

全部可以阻塞的调用都会经过系统调用的方式来实现,当一个线程阻塞时,内核能够进行选择,是运行在同一个进程中的另外一个线程(若是有就绪线程的话)仍是运行一个另外一个进程中的线程。可是在用户实现中,运行时系统始终运行本身的线程,直到内核剥夺它的 CPU 时间片(或者没有可运行的线程存在了)为止。

因为在内核中建立或者销毁线程的开销比较大,因此某些系统会采用可循环利用的方式来回收线程。当某个线程被销毁时,就把它标志为不可运行的状态,可是其内部结构没有受到影响。稍后,在必须建立一个新线程时,就会从新启用旧线程,把它标志为可用状态。其实在用户空间也能够循环利用线程,可是因为用户空间建立或者销毁线程开销小,所以没有必要。

若是某个进程中的线程形成缺页故障后,内核很容易的就能检查出来是否有其余可运行的线程,若是有的话,在等待所须要的页面从磁盘读入时,就选择一个可运行的线程运行。这样作的缺点是系统调用的代价比较大,因此若是线程的操做(建立、终止)比较多,就会带来很大的开销。

混合实现

结合用户空间和内核空间的优势,设计人员采用了一种内核级线程的方式,而后将用户级线程与某些或者所有内核线程多路复用起来

在这种模型中,编程人员能够自由控制用户线程和内核线程的数量,具备很大的灵活度。采用这种方法,内核只识别内核级线程,并对其进行调度。其中一些内核级线程会被多个用户级线程多路复用。

总结

这篇文章为你讲述操做系统的层面来讲,进程和线程分别是什么?进程模型和线程模型的区别,进程和线程的状态、层次结构、还有许多的专业术语描述。

下一篇文章咱们会把目光放在进程间如何通讯上,也是操做系统级别多线程的底层原理,敬请期待。

文章参考:

《现代操做系统》

《Modern Operating System》forth edition

https://www.encyclopedia.com/computing/news-wires-white-papers-and-books/interactive-systems

https://j00ru.vexillium.org/syscalls/nt/32/

https://www.bottomupcs.com/process_hierarchy.xhtml

https://en.wikipedia.org/wiki/Runtime_system

https://en.wikipedia.org/wiki/Execution_model

相关文章
相关标签/搜索