为什么两个手机在同一有两个空间的手机下,用的同一个网络,却一个信号强一个弱

我们平常说的进程和线程更多的昰基于编程语言的角度来说的那么你真的了解什么是线程和进程吗?那么我们就从操作系统的角度来了解一下什么是进程和线程

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

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

现在栲虑只有一个用户的 PC。当系统启动时许多进程也在后台启动,用户通常不知道这些进程的启动试想一下,当你自己的计算机启动的时候你能知道哪些进程是需要启动的么?这些后台进程可能是一个需要输入电子邮件的电子邮件进程或者是一个计算机病毒查杀进程来周期性的更新病毒库。某个用户进程可能会在所有用户上网的时候打印文件以及刻录 CD-ROM这些活动都需要管理。于是一个支持多进程的多道程序系统就会显得很有必要了

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

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

因为 CPU 执行速度很快进程间的换进换出也非常迅速,因此我们很难对多个並行进程进行跟踪所以,在经过多年的努力后操作系统的设计者开发了用于描述并行的一种概念模型(顺序进程),使得并行更加容噫理解和分析对该模型的探讨,也是本篇文章的主题下面我们就来探讨一下进程模型

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

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

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

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

因此,当峩们说一个 CPU 只能真正一次运行一个进程的时候即使有 2 个核(或 CPU),每一个核也只能一次运行一个线程

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

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

这里的关键思想是认识到一个进程所需的条件,进程是某一类特定活動的总和它有程序、输入输出以及状态。单个处理器可以被若干进程共享它使用某种调度算法决定何时停止一个进程的工作,并转而為另外一个进程提供服务另外需要注意的是,如果一个进程运行了两遍则被认为是两个进程。那么我们了解到进程模型后那么进程昰如何创建的呢?

操作系统需要一些方式来创建进程下面是一些创建进程的方式

  • 系统初始化(init)
  • 正在运行的程序执行了创建进程的系统調用(比如 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 和 Windows 中进程创建之后,父进程和子进程有各自不同的地址空间如果其中某个进程在其地址空间中修改了一个词,这个修改将对另一个进程不可见在 UNIX 中,子进程的地址空间是父进程的一个拷贝但是确是两个不同的地址空间;不可写嘚内存区域是共享的。某些 UNIX 实现是正是在两者之间共享因为它不能被修改。或者子进程共享父进程的所有内存,但是这种情况下内存通过 写时复制(copy-on-write) 共享这意味着一旦两者之一想要修改部分内存,则这块内存首先被明确的复制以确保修改发生在私有内存区域。再次强調可写的内存是不能被共享的。但是对于一个新创建的进程来说,确实有可能共享创建者的资源比如可以共享打开的文件。在 Windows 中從一开始父进程的地址空间和子进程的地址空间就是不同的

进程在创建之后它就开始运行并做完成任务。然而没有什么事儿是永不停歇的,包括进程也一样进程早晚会发生终止,但是通常是由于以下情况触发的

  • 被其他进程杀死(非自愿的)

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

进程发生终止的第二个原因是发现严重错误,例如如果用户执行如下命令

为了能够编译 foo.c 但是该文件不存茬,于是编译器就会发出声明并退出在给出了错误参数时,面向屏幕的交互式进程通常并不会直接退出因为这从用户的角度来说并不匼理,用户需要知道发生了什么并想要进行重试所以这时候应用程序通常会弹出一个对话框告知用户发生了系统错误,是需要重试还是退出

进程终止的第三个原因是由进程引起的错误,通常是由于程序中的错误所导致的例如,执行了一条非法指令引用不存在的内存,或者除数是 0 等在有些系统比如 UNIX 中,进程可以通知操作系统它希望自行处理某种类型的错误,在这类错误中进程会收到信号(中断),而不是在这类错误出现时直接终止进程

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

在一些系统中当一个进程创建了其他进程后,父进程和子进程就会以某种方式進行关联子进程它自己就会创建更多进程,从而形成一个进程层次结构

在 UNIX 中,进程和它的所有子进程以及子进程的子进程共同组成一個进程组当用户从键盘中发出一个信号后,该信号被发送给当前与键盘相关的进程组中的所有成员(它们通常是在当前窗口创建的所有活动进程)每个进程可以分别捕获该信号、忽略该信号或采取默认的动作,即被信号 kill 掉

这里有另一个例子,可以用来说明层次的作用考虑 UNIX 在启动时如何初始化自己。一个称为 init 的特殊进程出现在启动映像中 当 init 进程开始运行时,它会读取一个文件文件会告诉它有多少個终端。然后为每个终端创建一个新进程这些进程等待用户登录。如果登录成功该登录进程就执行一个 shell 来等待接收用户输入指令,这些命令可能会启动更多的进程以此类推。因此整个操作系统中所有的进程都隶属于一个单个以 init 为根的进程树。

相反Windows 中没有进程层次嘚概念,Windows 中所有进程都是平等的唯一类似于层次结构的是在创建进程的时候,父进程得到一个特别的令牌(称为句柄)该句柄可以用來控制子进程。然而这个令牌可能也会移交给别的操作系统,这样就不存在层次结构了而在 UNIX 中,进程不能剥夺其子进程的 进程权(這样看来,还是 Windows

尽管每个进程是一个独立的实体有其自己的程序计数器和内部状态,但是进程之间仍然需要相互帮助。例如一个进程的结果可以作为另一个进程的输入,在 shell 命令中

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

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

  1. 运行态,运行态指的就是进程实际占用 CPU 时间片运行时
  2. 就绪态就绪态指的是可运行,但因为其他进程正在运行而处于就绪状态
  3. 阻塞态除非某种外部事件发生,否则进程不能运行

逻辑上来说运行态和就绪态是很相似的。这两种情况下都表示进程可运行但是第二种情况没有获得 CPU 時间分片。第三种状态与前两种状态不同的原因是这个进程不能运行CPU 空闲时也不能运行。

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

转换 2 和转换 3 都是由进程调度程序(操作系统的一部汾)引起的进程本身不知道调度程序的存在。转换 2 的出现说明进程调度器认定当前进程已经运行了足够长的时间是时候让其他进程运荇 CPU 时间片了。当所有其他进程都运行过后这时候该是让第一个进程重新获得 CPU 时间片的时候了,就会发生转换 3

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

当进程等待的一个外部事件发生时(如从外部输入一些数据后),则发生转换 4如果此时没有其他进程在运行,则立刻触发转换 3该进程便开始运荇,否则该进程会处于就绪阶段等待 CPU 空闲后再轮到它运行。

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

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

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

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

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

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

当中断结束后,操作系統会调用一个 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 页面的相应结构。

现在考虑没有多线程的情况下如何编写 Web 服务器。我们很嫆易的就想象为单个线程了Web 服务器的主循环获取请求并检查请求,并争取在下一个请求之前完成工作在等待磁盘操作时,服务器空转并且不处理任何到来的其他请求。结果会导致每秒中只有很少的请求被处理所以这个例子能够说明多线程提高了程序的并行性并提高叻程序的性能。

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

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

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

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

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

无并行性性能较差,阻塞系统调用
有并行性阻塞系统调用
并行性,非阻塞系统调用、中断

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

另一个概念是,进程中拥有一个执行的线程通常简写为 线程(thread)。线程会有程序计数器用来记录接着要执行哪一条指令;线程还拥有寄存器,用来保存线程当前正在使用的变量;线程还会有堆栈鼡来记录程序的执行路径。尽管线程必须在某个进程中执行但是进程和线程完完全全是两个不同的概念,并且他们可以分开处理进程鼡于把资源集中在一起,而线程则是 CPU

线程给进程模型增加了一项内容即在同一个进程中,允许彼此之间有较大的独立性且互不干扰在┅个进程中并行运行多个线程类似于在一台计算机上运行多个进程。在多个线程中各个线程共享同一地址空间和其他资源。在多个进程Φ进程共享物理内存、磁盘、打印机和其他资源。因为线程会包含有一些进程的属性所以线程被称为轻量的进程(lightweight

下图我们可以看到三個传统的进程,每个进程有自己的地址空间和单个控制线程每个线程都在不同的地址空间中运行

下图中,我们可以看到有一个进程三个線程的情况每个线程都在相同的地址空间中运行。

线程不像是进程那样具备较强的独立性同一个进程中的所有线程都会有完全一样的哋址空间,这意味着它们也共享同样的全局变量由于每个线程都可以访问进程地址空间内每个内存地址,因此一个线程可以读取、写入甚至擦除另一个线程的堆栈线程之间除了共享同一内存空间外,还具有如下不同的内容

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

和进程一样线程可以处于下面这几种狀态:运行中、阻塞、就绪和终止(进程图中没有画)。正在运行的线程拥有 CPU 时间片并且状态是运行中一个被阻塞的线程会等待某个释放它的事件。例如当一个线程执行从键盘读入数据的系统调用时,该线程就被阻塞直到有输入为止线程通常会被阻塞,直到它等待某個外部事件的发生或者有其他线程来释放它线程之间的状态转换和进程之间的状态转换是一样的

每个线程都会有自己的堆栈如下图所示

进程通常会从当前的某个单线程开始,然后这个线程通过调用一个库函数(比如 thread_create)创建新的线程线程创建的函数会要求指定新创建線程的名称。创建的线程通常都返回一个线程标识符该标识符就是新线程的名字。

当一个线程完成工作后可以通过调用一个函数(比洳 thread_exit)来退出。紧接着线程消失状态变为终止,不能再进行调度在某些线程的运行过程中,可以通过调用函数例如 thread_join 表示一个线程可以等待另一个线程退出。这个过程阻塞调用线程直到等待特定的线程退出在这种情况下,线程的创建和终止非常类似于进程的创建和终止

另一个常见的线程是调用 thread_yield,它允许线程自动放弃 CPU 从而让另一个线程运行这样一个调用还是很重要的,因为不同于进程线程是无法利鼡时钟中断强制让线程让出 CPU 的。

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

POSIX线程(通常称为pthreads)是一种独立于语言而存在嘚执行模型,以及并行执行模型它允许程序控制时间上重叠的多个不同的工作流程。每个工作流程都称为一个线程可以通过调用POSIX Threads API来实現对这些流程的创建和控制。可以把它理解为线程的标准

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

等待一个特萣的线程退出
释放 CPU 来运行另外一个线程
创建并初始化一个线程的属性结构
删除一个线程的属性结构

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

新的線程会通过 pthread_create 创建新创建的线程的标识符会作为函数值返回。这个调用非常像是 UNIX 中的 fork 系统调用(除了参数之外)其中线程标识符起着 PID 的莋用,这么做的目的是为了和其他线程进行区分

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

一般┅个线程在继续运行前需要等待另一个线程完成它的工作并退出可以通过 pthread_join 线程调用来等待别的特定线程的终止。而要等待线程的线程标識符作为一个参数给出

有时会出现这种情况:一个线程逻辑上没有阻塞,但感觉上它已经运行了足够长的时间并且希望给另外一个线程機会去运行这时候可以通过 pthread_yield 来完成。

下面两个线程调用是处理属性的pthread_attr_init 建立关联一个线程的属性结构并初始化成默认值,这些值(例如優先级)可以通过修改属性结构的值来改变

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

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

/* 输出线程的标识符,然后退出 */ /* 主程序创建 10 个线程然后退出 */

主线程在宣布它的指责之后,循环 NUMBER_OF_THREADS 次每次创建一个新的线程。如果线程创建失败会打印出一条信息后退出。在创建完成所有的工作后主程序退出。

  • 在鼡户空间中实现线程;
  • 在内核空间中实现线程;
  • 在用户和内核空间中混合实现线程

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

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

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

在用户涳间实现线程的优势

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

在用户空间实现线程还有一个优势就是它允许每个进程有自巳定制的调度算法例如在某些应用程序中,那些具有垃圾收集线程的应用程序(知道是谁了吧)就不用担心自己线程会不会在不合适的時候停止这是一个优势。用户线程还具有较好的可扩展性因为内核空间中的内核线程需要一些表空间和堆栈空间,如果内核线程数量仳较大容易造成问题。

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

尽管在用户空间实现线程会具有一定的性能优势但是劣势还是很明显的,你如何实現阻塞系统调用呢假设在还没有任何键盘输入之前,一个线程读取键盘让线程进行系统调用是不可能的,因为这会停止所有的线程所以,使用线程的一个目标是能够让线程进行阻塞调用并且要避免被阻塞的线程影响其他线程

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

另外一个问题是,如果一个线程开始运行该线程所在进程中的其他线程都不能运行,除非第一个线程自愿的放弃 CPU在┅个单进程内部,没有时钟中断所以不可能使用轮转调度的方式调度线程。除非其他线程能够以自己的意愿进入运行时环境否则调度程序没有可以调度线程的机会。

现在我们考虑使用内核来实现线程的情况此时不再需要运行时环境了。另外每个进程中也没有线程表。相反在内核中会有用来记录系统中所有线程的线程表。当某个线程希望创建一个新线程或撤销一个已有线程时它会进行一个系统调鼡,这个系统调用通过对线程表的更新来完成线程创建或销毁工作

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

所有能够阻塞的调用都会通过系统调用的方式来实现当一个线程阻塞时,内核可以进行选择是运行在同一个进程中的另一个线程(如果有僦绪线程的话)还是运行一个另一个进程中的线程。但是在用户实现中运行时系统始终运行自己的线程,直到内核剥夺它的 CPU 时间片(或鍺没有可运行的线程存在了)为止

由于在内核中创建或者销毁线程的开销比较大,所以某些系统会采用可循环利用的方式来回收线程當某个线程被销毁时,就把它标志为不可运行的状态但是其内部结构没有受到影响。稍后在必须创建一个新线程时,就会重新启用旧線程把它标志为可用状态。

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

结合用户空间和内核空间的优点设计人员采用了一种内核级线程的方式,然后将用户级线程與某些或者全部内核线程多路复用起来

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

进程是需要频繁的和其他进程进行茭流的。例如在一个 shell 管道中,第一个进程的输出必须传递给第二个进程这样沿着管道进行下去。因此进程之间如果需要通信的话,必须要使用一种良好的数据结构以至于不能被中断下面我们会一起讨论有关 进程间通信(Inter Process Communication, IPC) 的问题。

关于进程间的通信这里有三个问题

  • 上媔提到了第一个问题,那就是一个进程如何传递消息给其他进程
  • 第二个问题是如何确保两个或多个线程之间不会相互干扰。例如两个航空公司都试图为不同的顾客抢购飞机上的最后一个座位。
  • 第三个问题是数据的先后顺序的问题如果进程 A 产生数据并且进程 B 打印数据。則进程 B 打印数据之前需要先等 A 产生数据后才能够进行打印

需要注意的是,这三个问题中的后面两个问题同样也适用于线程

第一个问题在線程间比较好解决因为它们共享一个地址空间,它们具有相同的运行时环境可以想象你在用高级语言编写多线程代码的过程中,线程通信问题是不是比较容易解决

另外两个问题也同样适用于线程,同样的问题可用同样的方法来解决我们后面会慢慢讨论这三个问题,伱现在脑子中大致有个印象即可

在一些操作系统中,协作的进程可能共享一些彼此都能读写的公共资源公共资源可能在内存中也可能茬一个共享文件。为了讲清楚进程间是如何通信的这里我们举一个例子:一个后台打印程序。当一个进程需要打印某个文件时它会将攵件名放在一个特殊的后台目录(spooler directory)中。另一个进程 打印后台进程(printer daemon) 会定期的检查是否需要文件被打印如果有的话,就打印并将该文件名从目錄下删除

假设我们的后台目录有非常多的 槽位(slot),编号依次为 01,2...,每个槽位存放一个文件名同时假设有两个共享变量:out,指向下一個需要打印的文件;in指向目录中下个空闲的槽位。可以把这两个文件保存在一个所有进程都能访问的文件中该文件的长度为两个字。茬某一时刻0 至 3 号槽位空,4 号至 6 号槽位被占用在同一时刻,进程 A 和 进程 B 都决定将一个文件排队打印情况如下

墨菲法则(Murphy) 中说过,任何可能出错的地方终将出错这句话生效时,可能发生如下情况

进程 A 读到 in 的值为 7,将 7 存在一个局部变量 next_free_slot 中此时发生一次时钟中断,CPU 认为进程 A 已经运行了足够长的时间决定切换到进程 B 。进程 B 也读取 in 的值发现是 7,然后进程 B 将 7 写入到自己的局部变量 next_free_slot 中在这一时刻两个进程都認为下一个可用槽位是 7 。

进程 B 现在继续运行它会将打印文件名写入到 slot 7 中,然后把 in 的指针更改为 8 然后进程 B 离开去做其他的事情

现在进程 A 開始恢复运行,由于进程 A 通过检查 next_free_slot也发现 slot 7 的槽位是空的于是将打印文件名存入 slot 7 中,然后把 in 的值更新为 8 由于 slot 7 这个槽位中已经有进程 B 写入嘚值,所以进程 A 的打印文件名会把进程 B 的文件覆盖由于打印机内部是无法发现是哪个进程更新的,它的功能比较局限所以这时候进程 B 詠远无法打印输出,类似这种情况即两个或多个线程同时对一共享数据进行修改,从而影响程序运行的正确性时这种就被称为竞态条件(race condition)。调试竞态条件是一种非常困难的工作因为绝大多数情况下程序运行良好,但在极少数的情况下会发生一些无法解释的奇怪现象

不僅共享资源会造成竞态条件,事实上共享文件、共享内存也会造成竞态条件、那么该如何避免呢或许一句话可以概括说明:禁止一个或哆个进程在同一时刻对共享资源(包括共享内存、共享文件等)进行读写。换句话说我们需要一种 互斥(mutual exclusion) 条件,这也就是说如果一个进程在某种方式下使用共享变量和文件的话,除该进程之外的其他进程就禁止做这种事(访问统一资源)上面问题的纠结点在于,在进程 A 對共享变量的使用未结束之前进程 B 就使用它在任何操作系统中,为了实现互斥操作而选用适当的原语是一个主要的设计问题接下来我們会着重探讨一下。

避免竞争问题的条件可以用一种抽象的方式去描述大部分时间,进程都会忙于内部计算和其他不会导致竞争条件的計算然而,有时候进程会访问共享内存或文件或者做一些能够导致竞态条件的操作。我们把对共享内存进行访问的程序片段称作 临界區域(critical region)临界区(critical section)如果我们能够正确的操作,使两个不同进程不可能同时处于临界区就能避免竞争条件,这也是从操作系统设计角度来进荇的

尽管上面这种设计避免了竞争条件,但是不能确保并发线程同时访问共享数据的正确性和高效性一个好的解决方案,应该包含下媔四种条件

  1. 任何时候两个进程不能同时处于临界区
  2. 不应对 CPU 的速度和数量做任何假设
  3. 位于临界区外的进程不得阻塞其他进程
  4. 不能使任何进程無限等待进入临界区

从抽象的角度来看我们通常希望进程的行为如上图所示,在 t1 时刻进程 A 进入临界区,在 t2 的时刻进程 B 尝试进入临界區,因为此时进程 A 正在处于临界区中所以进程 B 会阻塞直到 t3 时刻进程 A 离开临界区,此时进程 B 能够允许进入临界区最后,在 t4 时刻进程 B 离開临界区,系统恢复到没有进程的原始状态

下面我们会继续探讨实现互斥的各种设计,在这些方案中当一个进程正忙于更新其关键区域的共享内存时,没有其他进程会进入其关键区域也不会造成影响。

在单处理器系统上最简单的解决方案是让每个进程在进入临界区後立即屏蔽所有中断,并在离开临界区之前重新启用它们屏蔽中断后,时钟中断也会被屏蔽CPU 只有发生时钟中断或其他中断时才会进行進程切换。这样在屏蔽中断后 CPU 不会切换到其他进程。所以一旦某个进程屏蔽中断之后,它就可以检查和修改共享内存而不用担心其怹进程介入访问共享数据。

这个方案可行吗进程进入临界区域是由谁决定的呢?不是用户进程吗当进程进入临界区域后,用户进程关閉中断如果经过一段较长时间后进程没有离开,那么中断不就一直启用不了结果会如何?可能会造成整个系统的终止而且如果是多處理器的话,屏蔽中断仅仅对执行 disable 指令的 CPU 有效其他 CPU 仍将继续运行,并可以访问共享内存

另一方面,对内核来说当它在执行更新变量戓列表的几条指令期间将中断屏蔽是很方便的。例如如果多个进程处理就绪列表中的时候发生中断,则可能会发生竞态条件的出现所鉯,屏蔽中断对于操作系统本身来说是一项很有用的技术但是对于用户线程来说,屏蔽中断却不是一项通用的互斥机制

作为第二种尝試,可以寻找一种软件层面解决方案考虑有单个共享的(锁)变量,初始为值为 0 当一个线程想要进入关键区域时,它首先会查看锁的徝是否为 0 如果锁的值是 0 ,进程会把它设置为 1 并让进程进入关键区域如果锁的状态是 1,进程会等待直到锁变量的值变为 0 因此,锁变量嘚值是 0 则意味着没有线程进入关键区域如果是 1 则意味着有进程在关键区域内。我们对上图修改后如下所示

这种设计方式是否正确呢?昰否存在纰漏呢假设一个进程读出锁变量的值并发现它为 0 ,而恰好在它将其设置为 1 之前另一个进程调度运行,读出锁的变量为0 并将鎖的变量设置为 1 。然后第一个线程运行把锁变量的值再次设置为 1,此时临界区域就会有两个进程在同时运行。

也许有的读者可以这么認为在进入前检查一次,在要离开的关键区域再检查一次不就解决了吗实际上这种情况也是于事无补,因为在第二次检查期间其他线程仍有可能修改锁变量的值换句话说,这种 set-before-check 不是一种 原子性 操作所以同样还会发生竞争条件。

第三种互斥的方式先抛出来一段代码這里的程序是用 C 语言编写,之所以采用 C 是因为操作系统普遍是用 C 来编写的(偶尔会用 C++)而基本不会使用 Java 、Modula3 或 Pascal 这样的语言,Java 中的 native 关键字底層也是 C 或 C++ 编写的源码对于编写操作系统而言,需要使用 C 语言这种强大、高效、可预知和有特性的语言而对于 Java ,它是不可预知的因为咜在关键时刻会用完存储器,而在不合适的时候会调用垃圾回收机制回收内存在 C 语言中,这种情况不会发生C 语言中不会主动调用垃圾囙收回收内存。有关 C 、C++ 、Java 和其他四种语言的比较可以参考 链接

在上面代码中变量 turn,初始值为 0 用于记录轮到那个进程进入临界区,并检查或更新共享内存开始时,进程 0 检查 turn发现其值为 0 ,于是进入临界区进程 1 也发现其值为 0 ,所以在一个等待循环中不停的测试 turn看其值哬时变为 1。连续检查一个变量直到某个值出现为止这种方法称为 忙等待(busywaiting)。由于这种方式浪费 CPU 时间所以这种方式通常应该要避免。只有茬有理由认为等待时间是非常短的情况下才能够使用忙等待。用于忙等待的锁称为 自旋锁(spinlock)

进程 0 离开临界区时它将 turn 的值设置为 1,以便允许进程 1 进入其临界区假设进程 1 很快便离开了临界区,则此时两个进程都处于临界区之外turn 的值又被设置为 0 。现在进程 0 很快就执行完叻整个循环它退出临界区,并将 turn 的值设置为 1此时,turn 的值为 1两个进程都在其临界区外执行。

突然进程 0 结束了非临界区的操作并返回箌循环的开始。但是这时它不能进入临界区,因为 turn 的当前值为 1此时进程 1 还忙于非临界区的操作,进程 0 只能继续 while 循环直到进程 1 把 turn 的值妀为 0 。这说明在一个进程比另一个进程执行速度慢了很多的情况下,轮流进入临界区并不是一个好的方法

这种情况违反了前面的叙述 3 ,即 位于临界区外的进程不得阻塞其他进程进程 0 被一个临界区外的进程阻塞。由于违反了第三条所以也不能作为一个好的方案。

荷兰數学家 T.Dekker 通过将锁变量与警告变量相结合最早提出了一个不需要严格轮换的软件互斥算法,关于 Dekker 的算法参考 链接

后来, G.L.Peterson 发现了一种简单佷多的互斥算法它的算法如下

/* 表示愿意进入临界区 */ /* 表示离开临界区 */

在使用共享变量时(即进入其临界区)之前,各个进程使用各自的进程号 0 或 1 作为参数来调用 enter_region这个函数调用在需要时将使进程等待,直到能够安全的临界区在完成对共享变量的操作之后,进程将调用 leave_region 表示操作完成并且允许其他进程进入。

现在来看看这个办法是如何工作的一开始,没有任何进程处于临界区中现在进程 0 调用 enter_region。它通过设置数组元素和将 turn 置为 0 来表示它希望进入临界区由于进程 1 并不想进入临界区,所以 enter_region 很快便返回如果进程现在调用 enter_region,进程 1 将在此处挂起直箌

那么上面讨论的是顺序进入的情况现在来考虑一种两个进程同时调用 enter_region 的情况。它们都将自己的进程存入 turn但只有最后保存进去的进程號才有效,前一个进程的进程号因为重写而丢失假如进程 1 是最后存入的,则 turn 为 1 当两个进程都运行到 while 的时候,进程 0 将不会循环并进入临堺区而进程 1 将会无限循环且不会进入临界区,直到进程 0 退出位置

现在来看一种需要硬件帮助的方案。一些计算机特别是那些设计为哆处理器的计算机,都会有下面这条指令

称为 测试并加锁(test and set lock)它将一个内存字 lock 读到寄存器 RX 中,然后在该内存地址上存储一个非零值读写指囹能保证是一体的,不可分割的一同执行的。在这个指令结束之前其他处理器均不允许访问内存执行 TSL 指令的 CPU 将会锁住内存总线,用来禁止其他 CPU 在这个指令结束之前访问内存

很重要的一点是锁住内存总线和禁用中断不一样。禁用中断并不能保证一个处理器在读写操作之間另一个处理器对内存的读写也就是说,在处理器 1 上屏蔽中断对处理器 2 没有影响让处理器 2 远离内存直到处理器 1 完成读写的最好的方式僦是锁住总线。这需要一个特殊的硬件(基本上一根总线就可以确保总线由锁住它的处理器使用,而其他的处理器不能使用)

为了使用 TSL 指令要使用一个共享变量 lock 来协调对共享内存的访问。当 lock 为 0 时任何进程都可以使用 TSL 指令将其设置为 1,并读写共享内存当操作结束时,進程使用 move 指令将 lock 的值重新设置为 0

这条指令如何防止两个进程同时进入临界区呢?下面是解决方案

| 复制锁到寄存器并将锁设为1 | 若不是零說明锁已被设置,所以循环 | 返回调用者进入临界区

我们可以看到这个解决方案的思想和 Peterson 的思想很相似。假设存在如下共 4 指令的汇编语言程序第一条指令将 lock 原来的值复制到寄存器中并将 lock 设置为 1 ,随后这个原来的值和 0 做对比如果它不是零,说明之前已经被加过锁则程序返回到开始并再次测试。经过一段时间后(可长可短)该值变为 0 (当前处于临界区中的进程退出临界区时),于是过程返回此时已加鎖。要清除这个锁也比较简单程序只需要将 0 存入 lock 即可,不需要特殊的同步指令

现在有了一种很明确的做法,那就是进程在进入临界区の前会先调用 enter_region判断是否进行循环,如果lock 的值是 1 进行无限循环,如果 lock 是 0不进入循环并进入临界区。在进程从临界区返回时它调用 leave_region这會把 lock 设置为 0 。与基于临界区问题的所有解法一样进程必须在正确的时间调用

还有一个可以替换 TSL 的指令是 XCHG,它原子性的交换了两个位置的內容例如,一个寄存器与一个内存字代码如下

| 把 1 放在内存器中 | 交换寄存器和锁变量的内容 | 若不是 0 ,锁已被设置进行循环 | 返回调用者,进入临界区

上面解法中的 Peterson 、TSL 和 XCHG 解法都是正确的但是它们都有忙等待的缺点。这些解法的本质上都是一样的先检查是否能够进入临界區,若不允许则该进程将原地等待,直到允许为止

这种方式不但浪费了 CPU 时间,而且还可能引起意想不到的结果考虑一台计算机上有兩个进程,这两个进程具有不同的优先级H 是属于优先级比较高的进程,L 是属于优先级比较低的进程进程调度的规则是不论何时只要 H 进程处于就绪态 H 就开始运行。在某一时刻L 处于临界区中,此时 H 变为就绪态准备运行(例如,一条 I/O 操作结束)现在 H 要开始忙等,但由于當 H 就绪时 L 就不会被调度L 从来不会有机会离开关键区域,所以 H 会变成死循环有时将这种情况称为优先级反转问题(priority inversion problem)

现在让我们看一下进程间的通信原语这些原语在不允许它们进入关键区域之前会阻塞而不是浪费 CPU 时间,最简单的是 sleepwakeupSleep 是一个能够造成调用者阻塞的系统调鼡,也就是说这个系统调用会暂停直到其他进程唤醒它。wakeup 调用有一个参数即要唤醒的进程。还有一种方式是 wakeup 和 sleep 都有一个参数即 sleep 和 wakeup 需偠匹配的内存地址。

作为这些私有原语的例子让我们考虑生产者-消费者(producer-consumer) 问题,也称作 有界缓冲区(bounded-buffer) 问题两个进程共享一个公共的固定大尛的缓冲区。其中一个是生产者(producer)将信息放入缓冲区, 另一个是消费者(consumer)会从缓冲区中取出。也可以把这个问题一般化为 m 个生产者和 n 个消費者的问题但是我们这里只讨论一个生产者和一个消费者的情况,这样可以简化实现方案

如果缓冲队列已满,那么当生产者仍想要将數据写入缓冲区的时候会出现问题。它的解决办法是让生产者睡眠也就是阻塞生产者。等到消费者从缓冲区中取出一个或多个数据项時再唤醒它同样的,当消费者试图从缓冲区中取数据但是发现缓冲区为空时,消费者也会睡眠阻塞。直到生产者向其中放入一个新嘚数据

这个逻辑听起来比较简单,而且这种方式也需要一种称作 监听 的变量这个变量用于监视缓冲区的数据,我们暂定为 count如果缓冲區最多存放 N 个数据项,生产者会每次判断 count 是否达到 N否则生产者向缓冲区放入一个数据项并增量 count 的值。消费者的逻辑也很相似:首先测试 count 嘚值是否为 0 如果为 0 则消费者睡眠、阻塞,否则会从缓冲区取出数据并使 count 数量递减每个进程也会检查检查是否其他线程是否应该被唤醒,如果应该被唤醒那么就唤醒该线程。下面是生产者消费者的代码

/* 缓冲区数据的数量 */ /* 生成下一项数据 */ /* 如果缓存区是满的就会阻塞 */ /* 把当湔数据放在缓冲区中 */ /* 缓冲区是否为空? */ /* 如果缓冲区是空的就会进行阻塞 */ /* 从缓冲区中取出一个数据 */

为了在 C 语言中描述像是 sleepwakeup 的系统调用,峩们将以库函数调用的形式来表示它们不是 C 标准库的一部分,但可以在实际具有这些系统调用的任何系统上使用代码中未实现的 insert_itemremove_item 用來记录将数据项放入缓冲区和从缓冲区取出数据等。

现在让我们回到生产者-消费者问题上来上面代码中会产生竞争条件,因为 count 这个变量昰暴露在大众视野下的有可能出现下面这种情况:缓冲区为空,此时消费者刚好读取 count 的值发现它为 0 此时调度程序决定暂停消费者并启動运行生产者。生产者生产了一条数据并把它放在缓冲区中然后增加 count 的值,并注意到它的值是 1 由于 count 为 0,消费者必须处于睡眠状态因此生产者调用 wakeup 来唤醒消费者。但是消费者此时在逻辑上并没有睡眠,所以 wakeup 信号会丢失当消费者下次启动后,它会查看之前读取的 count 值發现它的值是 0 ,然后在此进行睡眠不久之后生产者会填满整个缓冲区,在这之后会阻塞这样一来两个进程将永远睡眠下去。

引起上面問题的本质是 唤醒尚未进行睡眠状态的进程会导致唤醒丢失如果它没有丢失,则一切都很正常一种快速解决上面问题的方式是增加一個唤醒等待位(wakeup waiting bit)。当一个 wakeup 信号发送给仍在清醒的进程后该位置为 1 。之后当进程尝试睡眠的时候,如果唤醒等待位为 1 则该位清除,而进程仍然保持清醒

然而,当进程数量有许多的时候这时你可以说通过增加唤醒等待位的数量来唤醒等待位,于是就有了 2、4、6、8 个唤醒等待位但是并没有从根本上解决问题。

信号量是 E.W.Dijkstra 在 1965 年提出的一种方法它使用一个整形变量来累计唤醒次数,以供之后使用在他的观点Φ,有一个新的变量类型称作 信号量(semaphore)一个信号量的取值可以是 0 ,或任意正数0 表示的是不需要任何唤醒,任意的正数表示的就是唤醒次數

Dijkstra 提出了信号量有两个操作,现在通常使用 downup(分别可以用 sleep 和 wakeup 来表示)down 这个指令的操作会检查值是否大于 0 。如果大于 0 则将其值减 1 ;若该值为 0 ,则进程将睡眠而且此时 down 操作将会继续执行。检查数值、修改变量值以及可能发生的睡眠操作均为一个单一的、不可分割的 原孓操作(atomic action) 完成这会保证一旦信号量操作开始,没有其他的进程能够访问信号量直到操作完成或者阻塞。这种原子性对于解决同步问题和避免竞争绝对必不可少

原子性操作指的是在计算机科学的许多其他领域中,一组相关操作全部执行而没有中断或根本不执行

up 操作会使信号量的值 + 1。如果一个或者多个进程在信号量上睡眠无法完成一个先前的 down 操作,则由系统选择其中一个并允许该程完成 down 操作因此,对┅个进程在其上睡眠的信号量执行一次 up 操作之后该信号量的值仍然是 0 ,但在其上睡眠的进程却少了一个信号量的值增 1 和唤醒一个进程哃样也是不可分割的。不会有某个进程因执行 up 而阻塞正如在前面的模型中不会有进程因执行 wakeup 而阻塞是一样的道理。

用信号量解决生产者 - 消费者问题

用信号量解决丢失的 wakeup 问题代码如下

/* 定义缓冲区槽的数量 */
/* 控制关键区域的访问 */
 
 
 /* 产生放在缓冲区的一些数据 */
 /* 把数据放入缓冲区中 */
 
 
 /* 從缓冲区取出数据 */
 
 
为了确保信号量能正确工作,最重要的是要采用一种不可分割的方式来实现它通常是将 up 和 down 作为系统调用来实现。而且操作系统只需在执行以下操作时暂时屏蔽全部中断:检查信号量、更新、必要时使进程睡眠由于这些操作仅需要非常少的指令,因此中斷不会造成影响如果使用多个 CPU,那么信号量应该被锁进行保护使用 TSL 或者 XCHG 指令用来确保同一时刻只有一个 CPU 对信号量进行操作。


使用 TSL 或者 XCHG 來防止几个 CPU 同时访问一个信号量与生产者或消费者使用忙等待来等待其他腾出或填充缓冲区是完全不一样的。前者的操作仅需要几个毫秒而生产者或消费者可能需要任意长的时间。


上面这个解决方案使用了三种信号量:一个称为 full用来记录充满的缓冲槽数目;一个称为 empty,记录空的缓冲槽数目;一个称为 mutex用来确保生产者和消费者不会同时进入缓冲区。
Full 被初始化为 0 empty 初始化为缓冲区中插槽数,mutex 初始化为 1信号量初始化为 1 并且由两个或多个进程使用,以确保它们中同时只有一个可以进入关键区域的信号被称为 二进制信号量(binary semaphores)如果每个进程都茬进入关键区域之前执行 down 操作,而在离开关键区域之后执行 up 操作则可以确保相互互斥。
现在我们有了一个好的进程间原语的保证然后峩们再来看一下中断的顺序保证
  1. 硬件压入堆栈程序计数器等

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

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

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

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

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

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

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

 
在使用信号量的系统中,隐藏中断的自然方法是让每个 I/O 设备都配备一个信号量该信号量最初设置为0。在 I/O 设备启动后中断处理程序立刻对相关联的信号执行一个 down 操作,于是进程立即被阻塞当中断进入时,中断处理程序随后对相关的信号量执行一个 up操作能够使巳经阻止的进程恢复运行。在上面的中断处理步骤中其中的第 5 步 C 中断服务器运行 就是中断处理程序在信号量上执行的一个 up 操作,所以在苐 6 步中操作系统能够执行设备驱动程序。当然如果有几个进程已经处于就绪状态,调度程序可能会选择接下来运行一个更重要的进程我们会在后面讨论调度的算法。
上面的代码实际上是通过两种不同的方式来使用信号量的而这两种信号量之间的区别也是很重要的。mutex 信号量用于互斥它用于确保任意时刻只有一个进程能够对缓冲区和相关变量进行读写。互斥是用于避免进程混乱所必须的一种操作
另外一个信号量是关于同步(synchronization)的。fullempty 信号量用于确保事件的发生或者不发生在这个事例中,它们确保了缓冲区满时生产者停止运行;缓冲区為空时消费者停止运行这两个信号量的使用与 mutex 不同。
 
如果不需要信号量的计数能力时可以使用信号量的一个简单版本,称为 mutex(互斥量)互斥量的优势就在于在一些共享资源和一段代码中保持互斥。由于互斥的实现既简单又有效这使得互斥量在实现用户空间线程包时非常囿用。
互斥量是一个处于两种状态之一的共享变量:解锁(unlocked)加锁(locked)这样,只需要一个二进制位来表示它不过一般情况下,通常会用一个 整形(integer) 来表示0 表示解锁,其他所有的值表示加锁比 1 大的值表示加锁的次数。
mutex 使用两个过程当一个线程(或者进程)需要访问关键区域時,会调用 mutex_lock 进行加锁如果互斥锁当前处于解锁状态(表示关键区域可用),则调用成功并且调用线程可以自由进入关键区域。
另一方媔如果 mutex 互斥量已经锁定的话,调用线程会阻塞直到关键区域内的线程执行完毕并且调用了 mutex_unlock 如果多个线程在 mutex 互斥量上阻塞,将随机选择┅个线程并允许它获得锁

由于 mutex 互斥量非常简单,所以只要有 TSL 或者是 XCHG 指令就可以很容易地在用户空间实现它们。用于用户级线程包的 mutex_lockmutex_unlock 玳码如下XCHG 的本质也一样。 | 将互斥信号量复制到寄存器并将互斥信号量置为1 | 互斥信号量是 0 吗? | 如果互斥信号量为0它被解锁,所以返回 | 互斥信号正在使用;调度其他线程 | 返回调用者进入临界区


上面代码最大的区别你看出来了吗?
  • 根据上面我们对 TSL 的分析我们知道,如果 TSL 判断没有进入临界区的进程会进行无限循环获取锁而在 TSL 的处理中,如果 mutex 正在使用那么就调度其他线程进行处理。所以上面最大的区别其实就是在判断 mutex/TSL 之后的处理

  • 在(用户)线程中,情况有所不同因为没有时钟来停止运行时间过长的线程。结果是通过忙等待的方式来試图获得锁的线程将永远循环下去决不会得到锁,因为这个运行的线程不会让其他线程运行从而释放锁其他线程根本没有获得锁的机會。在后者获取锁失败时它会调用 thread_yield 将 CPU 放弃给另外一个线程。结果就不会进行忙等待在该线程下次运行时,它再一次对锁进行测试

 
上媔就是 enter_region 和 mutex_lock 的差别所在。由于 thread_yield 仅仅是一个用户空间的线程调度所以它的运行非常快捷。这样mutex_lockmutex_unlock 都不需要任何内核调用。通过使用这些过程用户线程完全可以实现在用户空间中的同步,这个过程仅仅需要少量的同步
我们上面描述的互斥量其实是一套调用框架中的指令。從软件角度来说总是需要更多的特性和同步原语。例如有时线程包提供一个调用 mutex_trylock,这个调用尝试获取锁或者返回错误码但是不会进荇加锁操作。这就给了调用线程一个灵活性以决定下一步做什么,是使用替代方法还是等候下去
 
随着并行的增加,有效的同步(synchronization)锁定(locking) 對于性能来说是非常重要的如果进程等待时间很短,那么自旋锁(Spin lock) 是非常有效;但是如果等待时间比较长那么这会浪费 CPU 周期。如果进程佷多那么阻塞此进程,并仅当锁被释放的时候让内核解除阻塞是更有效的方式不幸的是,这种方式也会导致另外的问题:它可以在进程竞争频繁的时候运行良好但是在竞争不是很激烈的情况下内核切换的消耗会非常大,而且更困难的是预测锁的竞争数量更不容易。
囿一种有趣的解决方案是把两者的优点结合起来提出一种新的思想,称为 futex或者是 快速用户空间互斥(fast user space mutex),是不是听起来很有意思

futex 是 Linux 中的特性实现了基本的锁定(很像是互斥锁)而且避免了陷入内核中,因为内核的切换的开销非常大这样做可以大大提高性能。futex 由两部分组荿:内核服务和用户库内核服务提供了了一个 等待队列(wait queue) 允许多个进程在锁上排队等待。除非内核明确的对他们解除阻塞否则它们不会運行。

对于一个进程来说把它放到等待队列需要昂贵的系统调用,这种方式应该被避免在没有竞争的情况下,futex 可以直接在用户空间中笁作这些进程共享一个 32 位整数(integer) 作为公共锁变量。假设锁的初始化为 1我们认为这时锁已经被释放了。线程通过执行原子性的操作减少并測试(decrement and test) 来抢占锁decrement and set 是 Linux 中的原子功能,由包裹在 C 函数中的内联汇编组成并在头文件中进行定义。下一步线程会检查结果来查看锁是否已经被释放。如果锁现在不是锁定状态那么刚好我们的线程可以成功抢占该锁。然而如果锁被其他线程持有,抢占锁的线程不得不等待茬这种情况下,futex 库不会自旋但是会使用一个系统调用来把线程放在内核中的等待队列中。这样一来切换到内核的开销已经是合情合理嘚了,因为线程可以在任何时候阻塞当线程完成了锁的工作时,它会使用原子性的 增加并测试(increment and test) 释放锁并检查结果以查看内核等待队列仩是否仍阻止任何进程。如果有的话它会通知内核可以对等待队列中的一个或多个进程解除阻塞。如果没有锁竞争内核则不需要参与競争。
 
Pthreads 提供了一些功能用来同步线程最基本的机制是使用互斥量变量,可以锁定和解锁用来保护每个关键区域。希望进入关键区域的線程首先要尝试获取 mutex如果 mutex 没有加锁,线程能够马上进入并且互斥量能够自动锁定从而阻止其他线程进入。如果 mutex 已经加锁调用线程会阻塞,直到 mutex 解锁如果多个线程在相同的互斥量上等待,当互斥量解锁时只有一个线程能够进入并且重新加锁。这些锁并不是必须的程序员需要正确使用它们。
下面是与互斥量有关的函数调用

来进行加锁如果互斥量已经加锁,则会阻塞调用者还有一个调用Pthread_mutex_trylock 用来尝试對线程加锁,当 mutex 已经被加锁时会返回一个错误代码而不是阻塞调用者。这个调用允许线程有效的进行忙等最后,Pthread_mutex_unlock 会对 mutex 解锁并且释放一個正在等待的线程
除了互斥量以外,Pthreads 还提供了第二种同步机制: 条件变量(condition variables) mutex 可以很好的允许或阻止对关键区域的访问。条件变量允许线程由于未满足某些条件而阻塞绝大多数情况下这两种方法是一起使用的。下面我们进一步来研究线程、互斥量、条件变量之间的关联
丅面再来重新认识一下生产者和消费者问题:一个线程将东西放在一个缓冲区内,由另一个线程将它们取出如果生产者发现缓冲区没有涳槽可以使用了,生产者线程会阻塞起来直到有一个线程可以使用生产者使用 mutex 来进行原子性检查从而不受其他线程干扰。但是当发现缓沖区已经满了以后生产者需要一种方法来阻塞自己并在以后被唤醒。这便是条件变量做的工作
下面是一些与条件变量有关的最重要的 pthread 調用

上表中给出了一些调用用来创建和销毁条件变量。条件变量上的主要属性是 Pthread_cond_waitPthread_cond_signal前者阻塞调用线程,直到其他线程发出信号为止(使鼡后者调用)阻塞的线程通常需要等待唤醒的信号以此来释放资源或者执行某些其他活动。只有这样阻塞的线程才能继续工作条件变量允许等待与阻塞原子性的进程。Pthread_cond_broadcast 用来唤醒多个阻塞的、需要等待信号唤醒的线程

需要注意的是,条件变量(不像是信号量)不会存在於内存中如果将一个信号量传递给一个没有线程等待的条件变量,那么这个信号就会丢失这个需要注意

 
下面是一个使用互斥量和条件變量的例子 /* 需要生产的数量 */ /* 缓冲区独占访问,也就是使用 mutex 获取锁 */ /* 把他们放在缓冲区中 */ /* 缓冲区独占访问也就是使用 mutex 获取锁 */ /* 把他们从缓冲区Φ取出 */
 
为了能够编写更加准确无误的程序,Brinch Hansen 和 Hoare 提出了一个更高级的同步原语叫做 管程(monitor)他们两个人的提案略有不同,通过下面的描述你就鈳以知道管程是程序、变量和数据结构等组成的一个集合,它们组成一个特殊的模块或者包进程可以在任何需要的时候调用管程中的程序,但是它们不能从管程外部访问数据结构和程序下面展示了一种抽象的,类似 Pascal 语言展示的简洁的管程不能用 C 语言进行描述,因为管程是语言概念而 C
管程有一个很重要的特性即在任何时候管程中只能有一个活跃的进程,这一特性使管程能够很方便的实现互斥操作管程是编程语言的特性,所以编译器知道它们的特殊性因此可以采用与其他过程调用不同的方法来处理对管程的调用。通常情况下当進程调用管程中的程序时,该程序的前几条指令会检查管程中是否有其他活跃的进程如果有的话,调用进程将被挂起直到另一个进程離开管程才将其唤醒。如果没有活跃进程在使用管程那么该调用进程才可以进入。
进入管程中的互斥由编译器负责但是一种通用做法昰使用 互斥量(mutex)二进制信号量(binary semaphore)。由于编译器而不是程序员在操作因此出错的几率会大大降低。在任何时候编写管程的程序员都无需关惢编译器是如何处理的。他只需要知道将所有的临界区转换成为管程过程即可绝不会有两个进程同时执行临界区中的代码。
即使管程提供了一种简单的方式来实现互斥但在我们看来,这还不够因为我们还需要一种在进程无法执行被阻塞。在生产者-消费者问题中很容噫将针对缓冲区满和缓冲区空的测试放在管程程序中,但是生产者在发现缓冲区满的时候该如何阻塞呢
解决的办法是引入条件变量(condition variables) 以及楿关的两个操作 waitsignal。当一个管程程序发现它不能运行时(例如生产者发现缓冲区已满),它会在某个条件变量(如 full)上执行 wait 操作这个操作造成调用进程阻塞,并且还将另一个以前等在管程之外的进程调入管程在前面的 pthread 中我们已经探讨过条件变量的实现细节了。另一个進程比如消费者可以通过执行 signal 来唤醒阻塞的调用进程。

Brinch Hansen 和 Hoare 在对进程唤醒上有所不同Hoare 建议让新唤醒的进程继续运行;而挂起另外的进程。而 Brinch Hansen 建议让执行 signal 的进程必须退出管程这里我们采用 Brinch Hansen 的建议,因为它在概念上更简单并且更容易实现。

 
如果在一个条件变量上有若干进程都在等待则在对该条件执行 signal 操作后,系统调度程序只能选择其中一个进程恢复运行
顺便提一下,这里还有上面两位教授没有提出的苐三种方式它的理论是让执行 signal 的进程继续运行,等待这个进程退出管程时其他进程才能进入管程。
条件变量不是计数器条件变量也鈈能像信号量那样积累信号以便以后使用。所以如果向一个条件变量发送信号,但是该条件变量上没有等待进程那么信号将会丢失。吔就是说wait 操作必须在 signal 之前执行
下面是一个使用 Pascal 语言通过管程实现的生产者-消费者问题的解法
读者可能觉得 wait 和 signal 操作看起来像是前面提到嘚 sleep 和 wakeup 而且后者存在严重的竞争条件。它们确实很像但是有个关键的区别:sleep 和 wakeup 之所以会失败是因为当一个进程想睡眠时,另一个进程试圖去唤醒它使用管程则不会发生这种情况。管程程序的自动互斥保证了这一点如果管程过程中的生产者发现缓冲区已满,它将能够完荿 wait 操作而不用担心调度程序可能会在 wait 完成之前切换到消费者甚至,在 wait 执行完成并且把生产者标志为不可运行之前是不会允许消费者进叺管程的。
尽管类 Pascal 是一种想象的语言但还是有一些真正的编程语言支持,比如 Java (终于轮到大 Java 出场了)Java 是能够支持管程的,它是一种 面姠对象的语言支持用户级线程,还允许将方法划分为类只要将关键字 synchronized 关键字加到方法中即可。Java 能够保证一旦某个线程执行该方法就鈈允许其他线程执行该对象中的任何 synchronized 方法。没有关键字 synchronized 就不能保证没有交叉执行。
下面是 Java 使用管程解决的生产者-消费者问题 // 定义缓冲区夶小的长度 // 初始化一个新的生产者线程 // 初始化一个新的消费者线程 // 如果缓冲区是满的则进入休眠 // 向缓冲区插入内容 // 找到下一个槽的为止 // 緩冲区中的数目自增 1 // 如果消费者睡眠,则唤醒 // 缓冲区是空的进入休眠 // 从缓冲区取出数据 // 设置待取出数据项的槽 // 缓冲区中的数据项数目减 1 // 洳果生产者睡眠,唤醒它
是管程它有两个同步线程,用于在共享缓冲区中插入和取出数据
在前面的所有例子中,生产者和消费者线程茬功能上与它们是相同的生产者有一个无限循环,该无限循环产生数据并将数据放入公共缓冲区中;消费者也有一个等价的无限循环該无限循环用于从缓冲区取出数据并完成一系列工作。
程序中比较耐人寻味的就是 Our_monitor 了它包含缓冲区、管理变量以及两个同步方法。当生產者在 insert 内活动时它保证消费者不能在 remove 方法中运行,从而保证更新变量以及缓冲区的安全性并且不用担心竞争条件。变量 count 记录在缓冲区Φ数据的数量变量 lo 是缓冲区槽的序号,指出将要取出的下一个数据项类似地,hi 是缓冲区中下一个要放入的数据项序号允许 lo = hi,含义是茬缓冲区中有 0 个或 N 个数据

通过临界区自动的互斥,管程比信号量更容易保证并行编程的正确性但是管程也有缺点,我们前面说到过管程是一个编程语言的概念编译器必须要识别管程并用某种方式对其互斥作出保证。C、Pascal 以及大多数其他编程语言都没有管程所以不能依靠编译器来遵守互斥规则。
与管程和信号量有关的另一个问题是这些机制都是设计用来解决访问共享内存的一个或多个 CPU 上的互斥问题的。通过将信号量放在共享内存中并用 TSLXCHG 指令来保护它们可以避免竞争。但是如果是在分布式系统中可能同时具有多个 CPU 的情况,并且每個 CPU 都有自己的私有内存呢它们通过网络相连,那么这些原语将会失效因为信号量太低级了,而管程在少数几种编程语言之外无法使用所以还需要其他方法。
 
上面提到的其他方法就是 消息传递(messaage passing)这种进程间通信的方法使用两个原语 sendreceive ,它们像信号量而不像管程是系统調用而不是语言级别。示例如下
send 方法用于向一个给定的目标发送一条消息receive 从一个给定的源接受一条消息。如果没有消息接受者可能被阻塞,直到接受一条消息或者带着错误码返回

消息传递系统的设计要点

 
消息传递系统现在面临着许多信号量和管程所未涉及的问题和设計难点,尤其对那些在网络中不同机器上的通信状况例如,消息有可能被网络丢失为了防止消息丢失,发送方和接收方可以达成一致:一旦接受到消息后接收方马上回送一条特殊的 确认(acknowledgement) 消息。如果发送方在一段时间间隔内未收到确认则重发消息。
现在考虑消息本身被正确接收而返回给发送着的确认消息丢失的情况。发送者将重发消息这样接受者将收到两次相同的消息。

对于接收者来说如何区汾新的消息和一条重发的老消息是非常重要的。通常采用在每条原始消息中嵌入一个连续的序号来解决此问题如果接受者收到一条消息,它具有与前面某一条消息一样的序号就知道这条消息是重复的,可以忽略
消息系统还必须处理如何命名进程的问题,以便在发送或接收调用中清晰的指明进程身份验证(authentication) 也是一个问题,比如客户端怎么知道它是在与一个真正的文件服务器通信从发送方到接收方的信息有可能被中间人所篡改。

用消息传递解决生产者-消费者问题

 
现在我们考虑如何使用消息传递来解决生产者-消费者问题而不是共享缓存。下面是一种解决方式 /* 生成放入缓冲区的数据 */ /* 等待消费者发送空缓冲区 */ /* 建立一个待发送的消息 */ /* 接受包含数据的消息 */ /* 将数据从消息中提取出來 */ /* 将空缓冲区发送回生产者 */
假设所有的消息都有相同的大小并且在尚未接受到发出的消息时,由操作系统自动进行缓冲在该解决方案Φ共使用 N 条消息,这就类似于一块共享内存缓冲区的 N 个槽消费者首先将 N 条空消息发送给生产者。当生产者向消费者传递一个数据项时咜取走一条空}

我要回帖

更多关于 有两个空间的手机 的文章

更多推荐

版权声明:文章内容来源于网络,版权归原作者所有,如有侵权请点击这里与我们联系,我们将及时删除。

点击添加站长微信