说明:文中内容大部分都是大部分都是《操作系统-精髓与设计原理 第八版》的原文,自己做了一些删改,使其更易于理解。
本章讲述一些与进程管理相关的高级概念,这些概念在很多现代操作系统中都可以找到。实际上,它包含了两个独立的概念:一个与资源所有权有关,一个与执行相关。这一区别使得许多操作系统中出现和发展了称为线程(thread)的结构。
一 进程和线程
在迄今为止的讨论中,进程具有如下两个特点:
这两个特点是独立的,因此操作系统应能分别处理它们。很多操作系统,特别是近期开发的操作系统已在这样做。为区分这两个特点,我们通常将分派的单位称为线程或轻量级进程(Light Weight Process,LWP),而将拥有资源所有权的单位称为进程(process)或任务(task)。
1.1 多线程
多线程是指操作系统在单个进程内支持多个并发执行路径的能力。每个进程中仅执行单个线程的传统方法(此时还未提出线程的概念)称为单线程方法(single-threaded approach)。
在多线程环境中,进程定义为资源分配单元和一个保护单元。与进程相关联的有:
• ·容纳进程映像的虚拟地址空间。
• ·对处理器、其他进程(用于进程间通信)、文件和I/O资源(设备和通道)的受保护访问。
一个进程中可能有一个或多个线程,每个线程都有:
• 一个线程执行状态(运行、就绪等)。
• 未运行时保存的线程上下文;线程可视为在进程内运行的一个独立程序计数器。
• 一个执行栈。
• 每个线程用于局部变量的一些静态存储空间。
• 与进程内其他线程共享的内存和资源的访问。
— 单线程多线程进程模型
图4.2从进程管理的角度说明了线程和进程的区别。
在单线程进程模型中(无明确的线程概念),进程的表示包括其进程控制块和用户地址空间,以及在进程执行中管理调用/返回行为的用户栈和内核栈。进程正运行时,处理器寄存器由该进程控制;进程未运行时,将保存这些处理器寄存器中的内容。
在多线程环境中 进程仍然只有一个与之关联的进程控制块和用户地址空间,但每个线程现在会有许多单独的栈和一个单独的控制块,控制块中包含寄存器值、优先级和其他与线程相关的状态信息。
因此,进程中的所有线程共享该进程的状态和资源,所有线程都驻留在同一块地址空间中,并可访问相同的数据。当某个线程改变了内存中的一个数据项时,其他线程在访问这一数据项时会看到这一变化。若一个线程以读权限打开一个文件,那么同一进程中的其他线程也能从这个文件中读取数据。
— 比较性能后会发现线程的如下优点:
1.在已有进程中创建一个新线程的时间,远少于创建一个全新进程的时间。Mach开发人员的研究表明,线程创建要比在UNIX中创建进程快10倍。
2.终止线程要比终止进程所花的时间少。
3.同一进程内线程间切换的时间,要少于进程间切换的时间。
4.线程提高了不同执行程序间通信的效率。在多数操作系统中,独立进程间的通信需要内核介入,以提供保护和通信所需的机制。但是,由于同一进程中的多个线程共享内存和文件,因此它们无须调用内核就可互相通信。
总结起来就是创建、终止、切换时间和通信效率的提高。
因此,若将一个应用程序或函数实现为一组相关联的执行单元,则用一组线程要比用一组分离的进程更有效。此外,在单处理器上使用线程结构,但其可简化逻辑上从事几种不同工作的程序的结构。
— 这里给出在单用户多处理系统中使用线程的4个例子:
- 前台后台工作
- 异步执行
- 执行速度:一个线程在读取数据时被I/O操作阻塞,另一个线程仍然可以继续运行。
- 模块化程序结构
在支持线程的操作系统中,调度和分派是在线程基础上完成的,因此大多数与执行相关的信息可以保存在线程级的数据结构中。但是,有些活动会影响进程中的所有线程,因此操作系统必须在进程级对它们进行管理。例如,挂起操作会把一个进程的地址空间换出内存,以便为其他进程的地址空间腾出位置。因为一个进程中的所有线程共享同一个地址空间,因此它们会同时被挂起。类似地,进程终止时会使得进程中的所有线程都终止。
1.2 线程功能
类似于进程,线程也具有执行状态,且可彼此同步。下面依次介绍线程的这两种功能。
— 线程状态
和进程一样,线程的主要状态有运行态、就绪态和阻塞态。一般来说,挂起态对线程没有意义,因此这类状态仅适用于进程。特别地,如果一个进程被换出,由于所有线程都共享该进程的地址空间,因此所有线程都须被换出。
有4种与线程状态改变相关的基本操作:
- 派生:典型情况下,在派生一个新进程时,同时也会为该进程派生一个线程。随后,进程中的线程可在同一进程中派生另一个线程,并为新线程提供指令指针和参数;新线程拥有自己的寄存器上下文和栈空间,并放在就绪队列中。
- 阻塞:线程需要等待一个事件时会被阻塞(保存线程的用户寄存器、程序计数器和栈指针),处理器转而执行另一个就绪线程。
- 解除阻塞:发生阻塞一个线程的事件时,会将该线程转移到就绪队列中。
- 结束:一个线程完成后,会释放其寄存器上下文和栈。
— 线程同步
一个进程中的所有线程共享同一个地址空间和诸如打开的文件之类的其他资源。
一个线程对资源的任何修改都会影响同一进程中其他线程的环境,因此需要同步各种线程的活动,以便它们互不干扰且不破坏数据结构。例如,两个线程都试图同时往一个双向链表中增加一个元素时,可能会丢失一个元素或破坏链表结构。
二 线程分类
2.1 用户级和内核级线程
线程分为两大类,即用户级线程(User-Level Thread,ULT)和内核级线程(Kernel-Level Thread,KLT),后者又称内核支持的线程或轻量级进程。
用户级线程
在纯ULT软件中,管理线程的所有工作都由应用程序完成,内核意识不到线程的存在。图4.5(a)说明了纯ULT方法。任何应用程序都可使用线程库设计成多线程程序。线程库是管理用户级线程的一个例程包,它含有创建和销毁线程的代码、在线程间传递消息和数据的代码、调度线程执行的代码,以及保存和恢复线程上下文的代码。
默认情况下,应用程序从单个线程开始,并在该线程中开始运行。这个应用程序及其线程将分配给一个由内核管理的进程。应用程序在运行(进程处于运行态)的任何时刻,都可派生一个在相同进程中运行的新线程。线程派生是通过调用线程库中的派生例程实现的。通过过程调用,控制权传递给派生例程。线程库为新线程创建一个数据结构,然后使用某种调度算法,把控制权传递给该进程中处于就绪态的一个线程。当控制权传递给线程库时,需要保存当前线程的上下文,然后在控制权从线程库中传递给一个线程时,恢复那个线程的上下文。上下文实际上包括用户寄存器的内容、程序计数器和栈指针。
上段描述的所有活动发生在用户空间中和一个进程内,内核并不知道这些活动。内核继续以进程为单位进行调度,并为进程指定一个执行状态(就绪态、运行态、阻塞态等)。下面的例子将阐述线程调度和进程调度的关系。假设进程B在其线程2中执行,进程和作为进程一部分的两个用户级线程的状态如图4.6(a)所示。此时可能会发生如下情况之一:
- 在线程2中执行的应用程序代码进行一个阻塞进程B的系统调用。例如,执行一次I/O调用。这会把控制权转交给内核,随后内核启动I/O操作,把进程B置于阻塞态,并切换到另一个进程。在此期间,根据线程库维护的数据结构,进程B的线程2仍处于运行状态。注意,从在处理器上执行的角度来看,线程2实际上并不处于运行态,只是在线程库看来它处于运行态。
- 时钟中断把控制权传递给内核,内核确定当前正运行的进程B已用完其时间片。内核把进程B置于就绪态并切换到另一个进程。同时,根据线程库维护的数据结构,进程B的线程2仍处于运行态。
- 线程2运行到需要进程B的线程1执行某些动作的一个点。此时,线程2进入阻塞态,而线程1从就绪态转换到运行态。进程自身保留在运行态。
在线程的调度过程中,可以认为进程一直是在运行之中的。因为在进程不在运行的时候,可以认为进程内部一切都停止了。当进程再次运行,进程内部才继续进行。
— 使用ULT而非KLT的优点如下:
- 所有线程管理数据结构都在一个进程的用户地址空间中,线程切换不需要内核模式特权,因此进程不需要为了管理线程而切换到内核模式,进而节省了两次状态转换(从用户模式到内核模式,以及从内核模式返回用户模式)的开销。
- 调度因应用程序的不同而不同。一个应用程序可能更适合简单的轮转调度算法,而另一个应用程序可能更适合基于优先级的调度算法。为了不要乱底层的操作系统调度程序,可以做到为应用程序量身定做调度算法。
- ULT可在任何操作系统中运行,不需要对底层内核进行修改以支持ULT。线程库是供所有应用程序共享的一组应用级函数。
总结:减少模式切换开销、自定义调度算法、操作系统平台无关
— 使用ULT而非KLT的缺点如下:
- 在典型的操作系统中,许多系统调用都会引起阻塞。因此,在ULT执行一个系统调用时,不仅会阻塞这个线程,也会阻塞进程中的所有线程。这在之前的运行情况之中的第一点有说明。一个线程启动可以阻塞的操作,如I/O操作,将把执行权归还内核,内核会直接阻塞这个进程,也就阻塞了所有线程。
- 在纯ULT策略中,多线程应用程序不能利用多处理技术。内核一次只把一个进程分配给一个处理器,因此一个进程中只有一个线程可以执行,这相当于在一个进程内实现了应用级多道程序设计。虽然多道程序设计可明显提高应用程序的速度,但同时执行部分代码更会使某些应用程序受益。
总结:一个线程阻塞,则同一个进程中所有线程阻塞;无法使用多处理器技术
— 现在已有解决这两个问题的方法
解决问题1
使用一种称为“套管”(jacketing)的技术。“套管”的目标是把一个产生阻塞的系统调用转化为一个非阻塞的系统调用。例如,替代直接调用一个系统I/0例程,让线程调用一个应用级的I/O套管例程,这个套管例程中的代码用于检查并确定I/O设备是否忙。如果忙,该线程进入阻塞态并把控制权传送给另一个线程。这个线程重新获得控制权后,套管例程会再次检查I/O设备。
这就有点像线程提示操作系统产生一个进程,这个新进程去执行I/O操作,执行完成后提示这个线程,在完成之前,这个线程都是阻塞,但是原来的进程就可以执行别的线程而不被阻塞。
解决问题2
把应用程序写成一个多进程程序而非多线程程序。但是,这种方法消除了线程的主要优点:每次切换都变成进程间的切换而非线程间的切换,导致开销过大。
内核级线程
在纯KLT软件中,管理线程的所有工作均由内核完成。应用级没有线程管理代码,只有一个到内核线程设施的应用编程接口(API)。Windows是这种方法的一个例子。
图4.5(b)显示了纯KLT方法。内核为进程及进程内的每个线程维护上下文信息。调度由内核基于线程完成。
这种方法克服了ULT方法的两个缺点: 首先,内核可以同时把同一个进程中的多个线程调度到多个处理器中;其次,进程中的一个线程被阻塞时,内核可以调度同一个进程中的另一个线程。KLT方法的另一个优点是,内核例程自身也可是多线程的。
与ULT方法相比,KLT方法的主要缺点是: 在把控制权从一个线程传送到同一个进程内的另一个线程时,需要切换到内核模式。
为说明两种方法的区别,表4.1给出了在单处理器VAX机上运行类UNIX操作系统的测量结果。表中进行了两个基准测试,即Null Fork和Signal-Wait,前者测量创建、调度、执行和完成一个调用空过程的进程/线程的时间(即派生一个进程/线程的开销),后者测量进程/线程给正在等待的进程/线程发信号,然后在某个条件上等待所需要的时间(即两个进程/线程的同步时间)。可以看出,ULT和KLT之间、KLT和进程之间都有一个数量级以上的性能差距。
从表中可以看出,虽然使用KLT多线程技术与使用单线程的进程相比,速度明显提升,但使用ULT与使用KLT相比,速度再次得以提升。速度的再次提升是否能实现,取决于应用程序的性质。应用程序中的大多数线程切换都需要内核模式访问时,基于ULT的方案不见得会比基于KLT的方案好。
混合方法
有些操作系统提供混合ULT和KLT的方法【见图4.5(c)】。在混合系统中,线程创建完全在用户空间中完成,线程的调度和同步也在应用程序中进行。一个应用程序中的多个用户级线程会被映射到一些(小于等于用户级线程数)内核级线程上。为使整体性能最佳,程序员可为特定应用程序和处理器调节KLT的数量。
在混合方法中,同一个应用程序中的多个线程可在多个处理器上并行地运行,某个会引起阻塞的系统调用不会阻塞整个进程。设计正确时,这种方法可结合纯ULT方法和纯KLT方法的优点,并克服它们的缺点。
Solaris操作系统是使用混合方法的很好例子。最新版的Solaris将ULT/KLT关系限制为1:1。
三 多核和多线程
使用多核系统支持单个多线程应用程序的情况可能会出现在工作站、游戏机或正运行处理器密集型应用的个人计算机上。这时会带来性能和应用程序设计上的一些问题。本节首先介绍在多核系统上运行的多线程应用程序性能,然后介绍采用多核系统性能设计应用程序的一个具体示例。
3.1多核系统上的软件性能
多核组织结构带来的性能提升,取决于应用程序有效利用并行资源的能力。我们首先介绍运行在多核系统上的单个应用程序。Amdahl定律声称
该定律假设程序执行时间的(1-f)分之一所涉及的代码本质上是串行的,其余f分之一所涉及的代码是无限并行的,并且没有调度开销。
该定律看上去使得多核组织结构的前景很迷人。然而,如图4.7(a)所示,即使是一小部分串行代码也会显著影响性能。假设只有10%的代码本质上是串行的(即f=0.9),那么在一个8处理器的多核系统上运行该程序也仅有4.7倍的性能提升。另外,多处理器任务调度和通信以及高速缓存一致性维护都会给软件带来额外的开销。这就使得性能曲线达到峰值后便开始下降。图4.7(b)是一个有代表性的例子。
尽管如此,软件工程师们一直在努力解决这个问题,而且发现大量应用程序可以有效利用多核系统。研究了一系列数据库应用程序,这些程序采取了很多措施来降低硬件组织结构、操作系统、中间件和数据库应用软件本身的串行部分比例。 从图4.8中可以看出,数据库管理系统和数据库应用程序能有效地使用多核系统。还有许多不同类型的服务器程序能够有效使用并行化的多核组织结构,因为服务器程序通常会并行地处理许多相对独立的事务。
除通用服务器软件外,其他类型的应用程序也可从多核系统中直接获益,因为它们的吞吐量能随着处理器核心的数量伸缩。【MCDO06】给出了如下示例:
-
原生多线程应用程序:多线程应用程序的特征是具有少数几个高度线程化的进程。线程化应用程序的例子包括Lotus Domino和SiebelCRM(客户关系管理)。
-
多进程应用程序: 多进程应用程序的特征是具有多个单线程的进程。多进程应用程序的例子包括Oracle数据库、SAP和PeopleSoft。
-
Java 应用程序: Java从根本上支持线程的概念。不仅Java语言本身能够很方便地支持多线程应用程序开发,Java虚拟机也是一个多线程进程,它为Java应用程序提供调度机制和内存管理。能够直接从多核系统资源中获益的Java应用程序包括Sun公司的Java应用服务器、BEA公司的WebLogic、IBM公司的WebSphere和开源的Tomcat应用服务器。基于J2EE开发的所有应用程序也可直接从多核技术中获益。
-
多实例应用程序: 即使个别应用程序未利用大量的线程来达到伸缩性,仍然可以通过并行运行多个应用程序的实例来从多核组织结构中获益。多个应用程序实例需要一定程度上的隔离性时,可使用虚拟化技术(虚拟出支撑操作系统的硬件)为每个实例提供独立、安全的环境。
3.2应用示例:
Valve 娱乐科技公司开发了众多游戏和Source 游戏引擎。Source是玩家数量最多的游戏引擎之一。Valve公司在自己的游戏中使用Source引擎,并通过颁发许可证的方式来允许其他游戏开发者使用Source引擎。
近年来,Valve使用多线程技术重新编写了Source引擎,以充分利用Intel和AMD多核处理器芯片的性能。改进后的Source引擎代码为Valve公司的游戏如《半条命2》提供了更为强大的支持。
从Valve的角度来看,线程根据其粒度定义为如下几种:
• 粗粒度线程:分配到各个处理器上的各个模块(称为系统)。在Source引擎中,一个处理器负责渲染,一个处理器负责AI(人工智能),一个处理器负责物理计算,以此类推。这种策略非常简单。从本质上讲,每个主要模块都是单线程的,由一个时间轴线程来协调所有其他线程的同步。
• 细粒度线程:分布在多个处理器上的许多相似或相同的任务。例如,在数据数组上迭代的一个循环可分割为一些小循环,而小循环则在各个线程上并行执行。
• 混合线程:这涉及为某些系统选择性使用细粒度线程,或为其他系统使用单个线程。
Valve公司发现,使用粗粒度线程策略时,在双处理器上运行程序的性能是在单处理器上运行程序的性能的两倍,但这种性能提升仅在某些专门设计的情形下才能达到。在真实的游戏环境中,性能约能提升为原来的1.2倍。Valve公司还发现有效地利用细粒度线程非常困难,因为每个工作单元耗费的时间是变化的,而且管理输出和结果的时间轴所涉及的编程相当复杂。
Valve公司还发现,随着8颗核心甚至16颗核心的多核系统的出现,混合线程方法最具应用前景,因此它可实现最佳的加速比。Valve识别了那些只在单处理器上运行时才非常有效率的系统。例如,混音系统几乎不与用户交互,它只处理自身的数据集,且不受窗口的帧配置限制。其他模块(如场景渲染)可组织为许多线程,这类模块既可以在单个处理器上运行,也可以在多个处理器上运行,因此能获得更好的性能。
根据系统特性来选择使用的单个线程或多个线程,可以有效的提高加速比。如果在可以使用多个线程的系统中使用了单个线程,性能自然会有所损失。而在应该使用单个线程的地方使用了多个线程来执行反而会使性能下降,也同时增加了编程的复杂性。
四 Linux的进程和线程管理
4.1 Linux进程
Linux中的进程或任务由一个task struct数据结构表示。task struct数据结构包含以下各类信息:
- 状态:进程的执行状态(执行态、就绪态、挂起态、停止态、僵死态)。这些状态将在下面进一步讲述。
- 调度信息:Linux调度进程所需要的信息。一个进程可能是普通的或实时的,并具有优先级。
- 实时进程在普通进程前调度,且在每类中使用相关的优先级。一个计数器会记录允许进程执行的时间量。
- 标识符:每个进程都有唯一的一个进程标识符,以及用户标识符和组标识符。组标识符用于给一组进程指定资源访问特权。
- 进程间通信:Linux支持UNIXSVR4中的IPC机制,详见第6章。
- 链接:每个进程都有一个到其父进程的链接及到其兄弟进程(与它有相同的父进程)的链接,以及到所有子进程的链接。
- 时间和计时器:包括进程创建的时刻和进程所消耗的处理器时间总量。一个进程可能还有一个或多个间隔计时器,进程通过系统调用来定义间隔计时器,计时器期满时,会给进程发送一个信号。计时器可以只用一次或周期性地使用。
- 文件系统:包括指向被该进程打开的任何文件的指针和指向该进程当前目录与根目录的指针。
- 地址空间:定义分配给该进程的虚拟地址空间。
- 处理器专用上下文:构成该进程上下文的寄存器和栈信息。
图4.15给出了Linux中一个进程的执行状态,如下所示:
- 运行:这个状态值对应于两个状态。一个运行进程要么正在执行,要么准备执行。
- 可中断:这是一个阻塞态,此时进程正在等待一个事件(如一个I/O操作)的结束、一个可用的资源或另一个进程的信号。
- 不可中断:这是另一个阻塞态。它与可中断状态的区别是,在不可中断状态下,进程正在等待一个硬件条件,因此不会接收任何信号。
- 停止:进程被中止,并且只能由来自另一个进程的主动动作恢复。例如,一个正在被调试的进程可被置于停止态。
- 僵死:进程已被终止,但由于某些原因,在进程表中仍然有其任务结构。
4.2 Linux线程
传统UNIX系统支持在每个进程中只执行一个线程,但现代UNIX系统通常支持在一个进程中含有多个内核级线程。如同传统UNIX系统那样,老版本的Linux内核不支持多线程。多线程应用程序需要用一组用户级程序库来编写,以便将所有线程映射到一个单独的内核级进程中,最著名的是pthread(POSIX thread)库。现代UNIX提供内核级线程。Linux提供一种不区分进程和线程的解决方案,这种解决方案使用一种类似于Solaris轻量级进程的方法,将用户级线程映射到内核级进程。组成一个用户级进程的多个用户级线程则映射到共享同一个组ID的多个Linux内核级进程上。因此,这些进程可以共享文件和内存等资源,使得同一个组中的进程调度切换时不需要切换上下文。
在Linux中通过复制当前进程的属性可创建一个新进程。新进程创建后,可以共享资源,如文件、信号处理程序和虚存。两个进程共享相同的虚存时,可将它们视为一个进程中的线程。 然而,没有给线程单独定义数据结构,因此Linux中的进程和线程没有区别。Linux用clone()命令代替通常的fork()命令来创建进程。该命令包含了下面所示的一组标识作为参数。传统的fork()系统调用在Linux上是用所有克隆标志清零的clone()系统调用实现的。
克隆标识包括以下实例:
- CLONE_NEWPID:创造新的进程ID命名空间。
- CLONE PARENT:调用者及其创建的任务共享同一个父进程。
- CLONE_SYSVSEM:共享System V SEM_UNDO语义。
- CLONE_THREAD:将该进程插入父进程的同一个线程组。该标志为真时,它隐含设置了
CLONE_PARENT。
- CLONE_VM:共享地址空间(内存描述符和所有页表)。
当Linux内核执行从一个进程到另一个进程的切换时,会检查当前进程的页目录地址是否与将被调度的进程的相同。若相同,则它们共享同一个地址空间,所以此时上下文切换仅是从代码的一处跳转到代码的另一处。
虽然属于同一个进程组的克隆进程共享同一内存空间,但不能共享同一个用户栈。所以clone()调用会为每个进程创建独立的栈空间。
五 小结
某些操作系统区分进程和线程的概念,前者涉及资源的所有权,后者涉及程序的执行。这种方法可提高性能,方便编码。在多线程系统中,可在一个进程内定义多个并发线程,实现方法是使用用户级线程或内核级线程。用户级线程对操作系统是未知的,它们由一个在进程的用户空间中运行的线程库创建并管理。用户级线程非常高效,因为从一个线程切换到另一个线程不需要进行状态切换,但一个进程中一次只有一个用户级线程可以执行,如果一个线程发生阻塞,整个进程都会被阻塞。进程内包含的内核级线程是由内核维护的。由于内核认识它们,因而同一个进程中的多个线程可在多个处理器上并行执行,一个线程的阻塞不会阻塞整个进程,但从一个线程切换到另一个线程时需要进行模式转换。