Amiya · 2023年08月02日 · 北京市

剖析虚幻渲染体系(18)- 操作系统(1)(下)

18.4 进程

本章将阐述各种操作系统下的进程的概念、特点和技术内幕。

18.4.1 进程模型

在这种模型中,计算机上所有可运行的软件,有时包括操作系统,都被组织成若干顺序进程,或者简称为进程。进程只是执行程序的一个实例,包括程序计数器、寄存器和变量的当前值。从概念上讲,每个进程都有自己的虚拟CPU。当然,在现实中,真正的CPU在进程之间来回切换,但为了理解系统,考虑以(伪)并行方式运行的进程集合要比跟踪CPU如何在程序之间切换容易得多。这种快速的来回切换称为多道程序设计(multiprogramming)

在下图(a)中,我们看到一台计算机在内存中多道编程四个程序。在下图(b)中,我们看到四个进程,每个进程都有自己的控制流(即自己的逻辑程序计数器),每个进程独立于其他进程运行。当然,只有一个物理程序计数器,因此当每个进程运行时,其逻辑程序计数器被加载到实际程序计数器中。当它完成时(暂时),物理程序计数器保存在内存中进程的存储逻辑程序计数器中。在下图(c)中,我们可以看到,从足够长的时间间隔来看,所有进程都取得了进展,但在任何给定的时刻,只有一个进程实际在运行。

image.png
(a) 多道程序设计四个程序。(b) 四个独立、连续进程的概念模型。(c) 一次只有一个程序处于活动状态。

进程或任务是正在执行的程序的实例,进程的执行必须按编程顺序。任何时候最多执行一条指令,包括由程序计数器的值和处理器寄存器的内容表示的当前活动,还包括包含临时数据(如方法参数返回地址和局部变量)的进程堆栈和包含全局变量的数据段。进程还可能包括堆,堆是在进程运行时动态分配的内存。

image.png
image.png
常规的进程实现和内存结构。

进程和程序之间的差异:程序本身不是一个进程,正在执行的程序称为进程。程序是一个被动实体,例如存储在磁盘上的文件的内容,而进程是一个活动实体,有一个程序计数器指定要执行的下一条指令,一组相关的资源可以在多个进程之间共享,使用一些调度算法来确定何时停止一个进程并为另一个进程服务。

当进程执行时,它会改变状态,其状态由该进程的正确活动定义。每个进程可能处于以下状态之一:

  • 新建(New):正在创建进程。
  • 就绪(Ready):进程正在等待分配给处理器。
  • 正在运行(Running Man):正在执行指令。
  • 等待(Waiting):进程正在等待某些事件发生。
  • 已终止(Terminated:):进程已完成执行。

许多进程可能同时处于就绪和等待状态,但在任何一个处理器上,任何时候都只能运行一个进程。下图是进程不同状态之间的切换:

image.png

RTOS+的进程状态切换如下:

image.png

Linux进程状态切换如下:

image.png

两状态的进程模型:

image.png

五状态的进程模型:

image.png

进程队列模型样例图:

image.png

带一个或两个暂停状态的进程状态转换图:

image.png

Unix进程状态转换表:

image.png

18.4.2 进程控制块

每个进程在操作系统中由进程控制块(Process Control Block,PCB)表示,也由进程控制块控制。进程控制块也称为任务控制块,包含与特定过程相关联的许多信息,包括以下信息:

image.png
image.png

  • 进程状态:状态可以是新状态、就绪状态、运行状态、等待状态或终止状态。
  • 程序计数器:它指示为此目的执行的下一条指令的地址。
  • CPU寄存器:寄存器的数量和类型因计算机架构而异。它包括累加器、索引寄存器、堆栈指针和通用寄存器,加上在发生中断时必须保存的任何条件代码信息,以便在之后正确地继续处理。
  • CPU调度信息:此信息包括调度队列的进程优先级指针和任何其他调度参数。
  • 内存管理信息:根据操作系统使用的内存系统,该信息可能包括诸如条形和限制寄存器、页面表或段表的值等信息。
  • 账号信息:此信息包括CPU数量和实时使用时间、时间限制、帐号、作业或进程编号等。
  • I/O状态信息:此信息包括分配给此进程的I/O设备列表、打开的文件列表等,PCB只是用作存储可能因进程而异的任何信息的存储库。

image.png
CPU利用PCB来切换进程的执行。

18.4.3 上下文切换

当CPU切换到另一个进程时,系统必须保存旧进程的状态,并加载新进程的保存状态,这个行为称为上下文切换。上下文切换时间开销大,系统在切换时没有做任何有用的工作。切换速度因机器而异,具体取决于内存速度、必须复制的寄存器数量以及特殊指令的存在,典型的速度是几毫秒。上下文切换时间高度依赖于硬件支持。

18.4.4 进程组成

进程是一个包含和管理对象,表示程序的运行实例。以往经常使用的“进程运行”是不准确的,进程实际上不运行——而是进程管理。线程才是执行代码并在技术上运行的载体。从高层次的角度来看,一个进程具有以下特点:

  • 一个可执行程序,其中包含用于在进程中执行代码的初始代码和数据。
  • 一个私有虚拟地址空间,用于为进程内的代码需要的任何目的分配内存。
  • 访问令牌(有时称为主令牌),是存储进程默认安全上下文的对象,由进程内执行代码的线程使用(除非线程通过模拟使用不同的令牌)。
  • 执行(内核)对象的私有句柄表,如事件、信号量和文件。
  • 一个或多个执行线程。使用一个线程(执行进程的主入口点)创建普通用户模式进程,没有线程的用户模式进程通常是无用的,通常情况下会被内核销毁。

image.png
一个进程的重要组成部分。

image.png
进程的寻址需求。

image.png
操作系统控制表的常规结构。

image.png
Windows进程和它的资源构成。

进程由其进程ID唯一标识,只要内核进程对象存在,进程ID就保持唯一。一旦它被销毁,相同的ID就可以重新用于新的进程。可执行文件本身不是进程的唯一标识符,例如,Windows的记事本(notepad.exe)可能有5个实例的exe同时运行,每个进程都有自己的地址空间、线程、句柄表、进程ID等。这5个进程都使用相同的镜像文件(notepad.exe)作为其初始代码和数据,但每个实例都有自己的属性。

动态链接库(DLL)是可执行文件,可以包含代码、数据和资源(至少其中之一)。DLL在进程初始化时(称为静态链接)或在显式请求时(动态链接)动态加载到进程中。DLL由于不包含可执行文件等标准主函数,因此无法直接运行。DLL允许在使用同一DLL的多个进程之间共享物理内存中的代码,下图显示使用映射到相同物理(和虚拟)地址的共享DLL的两个进程。

image.png

尽管自Windows NT第一次发布以来,进程的基本结构和属性没有改变,但新的进程类型已经引入到具有特殊行为或结构的系统中。以下是当前支持的所有进程类型的快速概述:

  • 受保护进程。是在Windows Vista中引入的。创建它们是为了通过防止对呈现受数字版权管理(DRM)保护内容的进程的侵入性访问来支持数字版权管理。例如,没有其他进程(即使以管理员权限运行)可以读取具有受保护进程地址空间的内存,因此DRM保护的数据不能直接被窃取。
  • UWP进程。从Windows 8开始可用,承载Windows运行时,通常发布到Microsoft应用商店。UWP进程在AppContainer中执行——是一个沙盒,限制了该进程可以执行的操作。
  • 受保护的轻量进程(PPL)。扩展了Vista的保护机制,增加了多个级别的保护,甚至允许第三方服务作为PPL运行,保护它们免受入侵访问和终止,即使是管理级进程。
  • 最小化进程(Minimal Process)。是一种真正新的进程形式,其地址空间不包含正常进程所包含的常用图像和数据结构。例如,没有映射到进程地址空间的可执行文件,也没有DLL,进程地址空间实际上是空的。
  • 微进程(Pico Process)。这些进程是最小的进程,只有一个附加:微提供程序,它是一个内核驱动程序,可以拦截Linux系统调用并将其转换为等效的Windows系统调用。这些进程用于Windows Subsystem for Linux(WSL),可从Windows 10版本1607获得。

18.4.5 进程调度
几乎所有进程都会交替使用(磁盘或网络)I/O请求进行计算,如下图所示。通常,CPU会运行一段时间而不停止,然后进行系统调用以读取文件或写入文件。当系统调用完成时,CPU会再次计算,直到需要更多数据或必须写入更多数据,依此类推。请注意,一些I/O活动算作计算。例如,当CPU将位复制到视频RAM以更新屏幕时,它是在计算,而不是执行I/O,因为CPU正在使用中。在这种意义上,I/O是指进程进入阻塞状态,等待外部设备完成其工作。

image.png
CPU使用的突发与等待I/O的时间交替发生。(a) CPU受限的进程。(b) I/O受限的进程。

关于上图,需要注意的重要一点是,一些进程,如(a)中的进程,花费了大部分时间进行计算,而其他进程,如(b)所示,花费了大量时间等待I/O。

前者称为计算受限或CPU受限;后者称为I/O受限。计算受限的进程通常有较长的CPU突发,因此很少有I/O等待,而I/O受限进程有较短的CPU突发时间,因此频繁的I/O等待。注意,关键因素是CPU突发的长度,而不是I/O突发的长度。I/O受限进程是I/O受限的,因为它们不会在I/O请求之间进行大量计算,而不是因为它们有特别长的I/O请求。发出读取磁盘块的硬件请求需要同样的时间,无论数据到达后处理数据需要多少时间。

值得注意的是,随着CPU的速度越来越快,进程往往会获得更多的I/O受限。出现这种效果是因为CPU的改进速度比磁盘快得多。因此,I/O受限进程的调度在未来可能会成为一个更重要的主题。这里的基本思想是,如果一个I/O受限的进程想要运行,它应该能够很快获得机会,以便发出磁盘请求并保持磁盘繁忙。当进程受到I/O限制时,需要相当多的进程来保持CPU的完全占用。

与进程调度相关的一个关键问题是何时做出进程调度策略。事实证明,在各种情况下都需要调度。首先,创建新进程时,需要决定是运行父进程还是子进程。由于这两个进程都处于就绪状态,这是一个正常的调度决策,可以选择任何一种方式,也就是说,调度程序可以合法地选择下一个运行父进程或子进程。

其次,当进程退出时,必须做出调度决策。该进程无法再运行(因为它不再存在),因此必须从就绪进程集中选择其他进程。如果没有进程就绪,则系统提供的空闲进程通常会运行。

第三,当一个进程在I/O、信号量或其他原因上阻塞时,必须选择另一个进程来运行。有时,阻塞的原因可能会影响选择。例如,如果A是一个重要的进程,它正在等待B退出其关键区域,那么让B接下来运行将允许它退出其关键区,从而让A继续。然而,问题是调度程序通常没有必要的信息来考虑这种依赖关系。

第四,当发生I/O中断时,可以做出调度决策。如果中断来自现已完成其工作的I/O设备,则等待I/O的某些进程可能已准备好运行。由调度程序决定是运行新准备好的进程、中断时正在运行的进程还是第三个进程。

如果硬件时钟以50或60 Hz或其他频率提供周期性中断,则可以在每个时钟中断或每个第k个时钟中断时做出调度决策。调度算法可以根据如何处理时钟中断分为两类。非临时调度算法选择要运行的进程,然后让它运行,直到它阻塞(在I/O上或等待另一个进程)或自动释放CPU。即使它运行了许多小时,也不会被强制暂停。实际上,在时钟中断期间不会做出调度决策。时钟中断处理完成后,中断前运行的进程将恢复,除非高优先级进程正在等待现已满足的超时。

相比之下,抢占式调度算法选择一个进程,并让它最多运行一段固定时间。如果它在时间间隔结束时仍在运行,那么它将被挂起,并且调度程序会选择另一个要运行的进程(如果有的话)。执行抢占式调度需要在时间间隔结束时发生时钟中断,以便将CPU控制权交还给调度程序。如果没有可用的时钟,则非临时调度是唯一的选择。

在不同的环境中,需要不同的调度算法。调度程序应该优化的内容在所有系统中并不相同,值得区别的三种环境是:批次、互动、实时。

为了设计调度算法,有必要了解好的算法应该做什么。有些目标取决于环境(批处理、交互式或实时),但有些目标在所有情况下都是可取的。下面列出了一些目标:

  • 所有系统:
  • 公平性:给每个进程公平的CPU份额。
  • 策略执行:确保所述策略得到执行。
  • 平衡:使系统的所有部分保持忙碌。
  • 批处理系统:
  • 吞吐量:最大化每小时作业数。
  • 周转时间:最小化提交和终止之间的时间。
  • CPU利用率:使CPU始终处于繁忙状态。
  • 交互式系统:
  • 响应时间:快速响应请求。
  • 比例:满足用户的期望。
  • 实时系统:
  • 满足最后期限:避免丢失数据。
  • 可预测性:避免多媒体系统的质量下降。

在任何情况下,公平都很重要。可比进程应获得可比服务,给一个进程比同等进程多很多CPU时间是不公平的。当然,不同类别的进程可能会有不同的处理方式。与公平相关的是执行系统的策略,如果本地策略是安全控制进程可以在任何时候运行,即使意味着工资单延迟30秒,调度程序也必须确保执行此策略。

另一个总体目标是尽可能使系统的所有部分保持繁忙。如果CPU和所有I/O设备都可以一直运行,那么与某些组件处于空闲状态相比,每秒完成的工作量会更多。例如,在批处理系统中,调度程序可以控制哪些作业进入内存以运行。

在内存中同时使用一些CPU受限进程和一些I/O受限进程比首先加载和运行所有CPU受限作业,然后在它们完成时加载和运行全部I/O受限作业要好。如果使用后一种策略,当CPU受限的进程正在运行时,它们将争夺CPU,磁盘将处于空闲状态。稍后,当I/O受限作业进入时,它们将争夺磁盘,CPU将处于空闲状态。最好通过仔细混合进程来保持整个系统同时运行。

系统在不同调度级别下的状态转换图如下:

image.png

调度队列图如下:

image.png

18.4.5.1 调度基础

进程调度是操作系统的基本功能。当一台计算机进行多程序设计时,它有多个进程同时竞争CPU。如果只有一个CPU可用,那么必须选择下一个执行哪个进程。这一决策过程称为调度(scheduling),做出此选择的操作系统部分称为调度程序(scheduler),用于进行此选择的算法称为调度算法(scheduling algorithm)

调度队列(Scheduling queue)是进程进入系统时被放入的作业队列,此队列由系统中的所有进程组成,驻留在主存中并已准备好等待执行或保存在名为就绪队列的列表中的进程。

image.png

此队列通常存储为链接列表,就绪队列标题包含指向列表中第一个和最后一个PCB的指针,PCB包含一个指向就绪队列中下一个PCB的指针字段。等待特定I/O设备的进程列表保存在名为设备队列的列表中,每个设备都有自己的设备队列。新进程最初被放入就绪队列。它在就绪队列中等待,直到它被选中执行并被赋予CPU。

image.png

18.4.5.2 调度程序

调度程序的描述如下:

  • 进程在其生命周期内在各种调度队列之间迁移。操作系统必须以某种方式从这些队列中选择调度进程。此选择过程由适当的调度程序执行。
  • 在批处理系统中,会提交更多进程,然后立即执行。因此,这些进程被假脱机到一个大容量存储设备(如磁盘)中,并保存在那里供以后执行。

调度程序的类型有以下几种:

长期调度程序(Long term scheduler)

长期调度程序从磁盘中选择进程并将其加载到内存中以便执行。它控制多重编程的程度,即内存中进程的数量,执行频率低于其他调度程序。如果多道程序设计的程度是稳定的,那么进程创建的平均速度等于进程离开系统的平均离开速度。因此,仅当进程离开系统时才需要调用长期调度程序。由于执行之间的间隔较长,它可以花费更多的时间来决定应该选择哪个进程来执行。

CPU中的大多数进程要么是I/O密集的,要么是CPU密集的。I/O密集的进程(交互式“C”程序)是一个将大部分时间花在I/O操作上的进程,而不是花在执行I/O操作上,CPU密集的进程在计算上花费的时间比I/O操作(复杂的排序程序)要多。长期调度程序应选择I/O绑定和CPU绑定进程的良好组合,这一点很重要。

短期调度程序(Short term scheduler)

短期调度程序在准备执行的进程中进行选择,并将CPU分配给其中一个进程,这两个调度程序之间的主要区别是它们的执行频率。短期调度程序必须经常为CPU选择新进程,它必须在100毫秒内至少执行一次。由于两次执行之间的时间间隔很短,因此必须非常快。

中期调度程序(Medium term scheduler)

一些操作系统引入了一种称为中期调度程序的额外中间级别的调度,这个调度器背后的主要思想是,有时从内存中删除进程是有利的,从而降低多道程序的程度。然后,该进程可以重新引入内存,并且可以从中断的地方继续执行,称为交换。进程稍后由中期调度程序调出和调入。交换对于改善进程未命中是必要的,或者由于内存需求的某些变化,超出了可用内存限制,这需要释放一些内存。

image.png

18.4.5.3 调度目标

调度的目标有:

  • CPU利用率:使CPU尽可能繁忙。
  • 吞吐量:每个时间单位完成其执行的进程数。
  • 周转时间:执行特定流程的时间量。
  • 等待时间:进程在就绪队列中等待的时间。
  • 响应时间:从提交请求到生成第一个响应(而不是输出)所用的时间(对于分时环境)。

调度的总体目标:

  • 公平。在任何情况下,公平都很重要。调度程序确保每个进程都能获得其CPU的公平份额,并且没有进程会无限期延迟。 请注意,给予同等或同等的时间是不公平的。想想核电站的安全控制和工资单。
  • 政策执行。调度程序必须确保系统的策略得到执行。例如,如果局部策略是安全的,那么安全控制处理必须能够在任何时候运行,即使意味着工资单处理延迟。
  • 效率。如果可能的话,调度程序应该使系统(特别是CPU)在百分之几的时间内保持繁忙。如果CPU和所有输入/输出设备可以一直运行,那么与某些组件处于空闲状态时相比,每秒完成的工作更多。
  • 响应时间。调度程序应尽量减少交互式用户的响应时间。
  • 轮回。调度程序应最小化批处理用户必须等待输出的时间。
  • 吞吐量。调度程序应最大化单位时间内处理的作业数。
  • 稍加思考就会发现其中一些目标是相互矛盾的。可以看出,任何支持某类作业的调度算法都会损害另一类作业。毕竟,可用的CPU时间是有限的。

就如何处理时钟中断而言,调度算法可以分为两类:

  • 非抢占式调度。如果一个进程一旦被赋予CPU,CPU就不能从该进程中取出,那么调度程序是非抢占性的。以下是非抢占式调度的一些特征:
  • 在非抢占式系统中,短作业由长作业等待,但所有进程的总体处理是公平的。
  • 在非抢占式系统中,响应时间更容易预测,因为传入的高优先级作业不能取代等待的作业。
  • 在非抢占式调度中,调度程序在两种情况下执行作业:1、当进程从运行状态切换到等待状态时;2、当进程终止时。
  • 抢占式调度。如果一个进程一旦被给予,CPU就可以被拿走,那么调度规程是优先的。允许逻辑上可运行的进程暂时挂起的策略称为抢占式调度,它与“运行到完成”方法相反。

18.4.5.4 CPU调度算法

CPU调度处理决定就绪队列中的哪些进程将分配给CPU的问题。下面是我们将要研究的一些调度算法。

先到先服务(FCFS)

FCFS是最简单的CPU调度算法。首先请求CPU的进程,即首先分配给CPU的进程。可以通过FIFO队列轻松管理,当进程进入就绪队列时,其PCB链接到队列的后部。但是,FCFS的平均等待时间很长。考虑以下情况:

image.png

如果顺序为P1、P2、P3、P4,则使用FCFS算法计算平均等待时间和平均周转时间。解决方案:如果进程以P1、P2、P3、P4的顺序到达,则根据FCFS,甘特图将为:

image.png

不同的时间描述如下:

  • 进程等待时间:P1 = 0,P2 = 3,P3 = 8,P4 = 10。
  • 进程周转(Turnaround)时间:P1 = 0 + 3 = 3,P2 = 3 + 5 = 8,P3 = 8 + 2 = 10,P4 = 10 + 4 =14。
  • 平均等待时间:(0 + 3 + 8 + 10) / 4 = 21 / 4 = 5.25。
  • 平均周转时间:(3 + 8 + 10 + 14)/4 = 35 / 4 = 8.75。

FCFS算法是非抢占式的,即一旦CPU分配给进程,该进程就会通过终止或请求I/O来保持CPU,直到释放CPU为止。

最短作业优先(SJF)

此算法的另一个名称是下一个最短进程(SPN),如果CPU可用,此算法将与每个进程关联。这种调度也称为最短的下一次CPU迸发(burst),因为调度是通过检查进程的下一个CPU迸发的长度而不是其总长度来完成的。考虑以下情况:

image.png
解决方案:根据SJF,甘特图将是:

image.png

不同的时间描述如下:

  • 进程等待时间:P1 = 0,P2 = 2,P3 = 5,P4 = 9。
  • 进程周转时间:P3 = 0 + 2 = 2,P1 = 2 + 3 = 5,P4 = 5 + 4 = 9,P2 = 9 + 5 =14。
  • 平均等待时间:(0 + 2 + 5 + 9) / 4 = 16 / 4 = 4。
  • 平均周转时间:(2 + 5 + 9 + 14)/4 = 30 / 4 = 7.5。

SJF算法可以是抢占式或非抢占式算法,抢占式的SJF也称为最短剩余时间优先。考虑以下示例:

image.png

此情况的甘特图如下:

image.png

进程等待时间:P1 = 10 - 1 = 9,P2 = 1 – 1 = 0,P3 = 17 – 2 = 15,P4 = 5 – 3 = 2。
平均等待时间:(9 + 0 + 15 + 2) / 4 = 26 / 4 = 6.5。

优先级调度

在这个调度中,每个进程都有一个优先级编号(整数),CPU被分配给优先级最高的进程(最小的整数,最高的优先级),可分为抢占式和非抢占式。同等优先级的流程以FCFS方式安排。SJF是一种优先级调度,其中优先级是预测的下一个CPU突发时间。

存在饥饿问题——低优先级进程可能永远不会执行,解决方案是老化——随着时间的推移,进程的优先级增加。

优先级可以在内部或外部定义。内部优先事项的例子有:时间限制、内存要求、文件要求(如打开文件的数量)、CPU与I/O要求。外部定义的优先级由操作系统外部的标准设置,例如进程的重要性、为使用计算机而支付的资金类型或金额、赞助工作的部门、政策。

image.png
优先级队列。

考虑以下示例:

image.png

根据优先级调度,甘特图将为:

image.png

进程等待时间:P1 = 6,P2 = 0,P3 = 16,P4 = 18,P5 = 1。
平均等待时间:(0 + 1 + 6 + 16 + 18) / 5 = 41 / 5 = 8.2。

轮询(Round Robin)

这种算法仅用于分时系统设计,类似于具有抢占条件的FCFS调度,可以在进程之间切换。一个称为量程时间或时间片的小时间单位用于在进程之间切换,轮询制下的平均等待时间很长。考虑以下示例:

image.png

时间片 = 1ms,则甘特图为:

image.png

进程等待时间:

  • P1 = 0 + (4 – 1) + (8 – 5) = 0 + 3 + 3 = 6
  • P2 = 1 + (5 – 2) + (9 – 6) + (11 – 10) + (13 – 12) = 1 + 3 + 3 + 1 + 1 = 9
  • P3 = 2 + (6 – 3) = 2 + 3 = 5
  • P4 = 3 + (7 – 4) + (10 – 8) + (12 – 11) = 3 + 3 + 2 + 1 = 9

平均等待时间:(6 + 9 + 5 + 9) / 4 = 7.2

最短剩余时间(SRT)

SRT是SJF的抢占式对等物,在分时环境中很有用。在SRT调度中,下一步运行估计运行时间最短的进程,包括新到达的进程。在SJF方案中,一旦作业开始执行,它就会一直运行到完成,一个正在运行的进程可以被一个估计运行时间最短的新到达进程抢占。SRT的开销高于对应的SJF,必须跟踪运行进程的运行时间,并且必须处理偶尔的抢占。在这个方案中,小进程的到达几乎会立即运行,然而,更长的工作意味着更长的等待时间。

最短进程优先(Shortest Process Next)

因为最短的作业首先总是为批处理系统产生最小的平均响应时间,所以如果它也可以用于交互式流程,那就更好了。在一定程度上是可以的。交互进程通常遵循等待命令、执行命令、等待命令、运行命令等模式。如果我们将每个命令的执行视为单独的“作业”,那么我们可以通过先运行最短的一个来最小化总体响应时间,前提是找出当前可运行的进程中最短的进程。

保证调度(Guaranteed Scheduling)

调度的一种完全不同的方法是向用户作出关于性能的真正承诺,然后兑现这些承诺。一个切实可行且易于实现的承诺是:如果在你工作时有n个用户登录,你将获得大约1/n的CPU电量。类似地,在一个运行n个进程的单用户系统上,在所有条件都相同的情况下,每个进程应该获得1/n的CPU周期,这似乎很公平。

为了兑现这一承诺,系统必须跟踪每个进程自创建以来有多少CPU。然后,它计算每个进程有权使用的CPU数量,即自创建以来的时间除以n。由于每个进程实际拥有的CPU时间量也是已知的,因此计算实际消耗的CPU时间与有权使用CPU时间的比率非常简单。比率为0.5意味着一个进程只拥有它应该拥有的一半,比率为2.0意味着进程拥有的是它应有的两倍。然后,算法以最低比率运行该进程,直到其比率超过其最接近的竞争对手的比率。然后选择下一个运行。

彩票调度

虽然向用户作出承诺,然后兑现承诺是一个好主意,但很难实现。然而,可以使用另一种算法以更简单的实现给出类似的可预测结果。这被称为彩票调度(Lottery Scheduling)

基本思想是为各种系统资源(如CPU时间)提供进程彩票。每当必须做出调度决策时,都会随机选择彩票,持有该彩票的进程将获得资源。当应用于CPU调度时,系统可能每秒举行50次抽奖,每个优胜者都会获得20毫秒的CPU时间作为奖品。

公平调度(Fair-Share Scheduling)

到目前为止,我们假设每个进程都是自己调度的,而不管其所有者是谁。因此,如果用户1启动九个进程,而用户2启动一个进程,并且具有循环或同等优先级,则用户1将获得90%的CPU,用户2仅获得10%的CPU。

为了防止这种情况,一些系统在调度进程之前会考虑哪个用户拥有进程。在这个模型中,每个用户都被分配了CPU的一部分,调度程序以强制执行的方式选择进程。因此,如果向两个用户承诺每人50%的CPU,那么无论他们有多少进程,他们都会得到50%的CPU。

举个例子,考虑一个有两个用户的系统,每个用户承诺占用50%的CPU。用户1有四个进程(A、B、C和D),用户2只有一个进程(E)。如果使用循环调度,则满足所有约束的可能调度序列如下:

A E B E C E D E A E B E C E E D E ...

另一方面,如果用户1有权获得用户2两倍的CPU时间,我们可能会得到:

A B E C D E A B E C D E ...

当然,还有许多其他的可能性,可以利用,取决于公平的概念是什么。

多队列

多级队列调度算法将就绪队列划分为几个单独的队列,例如,在多级队列调度中,进程被永久分配给一个队列。根据进程的某些属性,如内存大小、进程优先级、进程类型,这些进程被永久分配给另一个进程。算法从具有最高优先级的已占用队列中选择进程,然后运行该进程。

image.png

多级反馈队列

多级反馈队列调度算法允许进程在队列之间移动,使用许多就绪队列,并将不同的优先级与每个队列相关联。算法从占用的队列中选择优先级最高的进程,并以抢占或非抢占方式运行该进程,如果进程使用了太多CPU时间,它将移动到低优先级队列。类似地,在较低优先级队列中等待时间过长的进程可能会被移动到较高优先级队列,也可能被移动到最高优先级队列。请注意,这种形式的老化可以防止饥饿。例子:

  • 进入就绪队列的进程被放置在队列0中。
  • 如果未在8毫秒内完成,则会将其移动到队列1的尾部。
  • 如果它没有完成,它将被抢占并放入队列2中。
  • 队列2中的进程仅在队列0和队列1为空时,在FCFS基础上运行,只有当队列2在FCFS基本队列上运行。

三个队列:Q0–RR,时间量为8毫秒;Q1–RR时间量16毫秒;Q2–FCFS。

image.png
image.png

通常,多级反馈队列调度程序定义的参数有:队列数,每个队列的调度算法,用于确定何时将进程升级到更高优先级队列的方法,用于确定何时将进程降级到较低优先级队列的方法,用于确定进程需要服务时将进入哪个队列的方法。

18.4.5.5 调度总结

进程调度在实际运行环境中,需要考量CPU、核心数量、IO等因素的影响,然后通过不同的调度算法来统计其数据,从而得出相对客观且有参考价值的调度数据。评估示意图如下:

image.png

各种调度策略的特点:

image.png

调度策略的时序对比图:

image.png

调度策略的综合对比:
image.png

除了上述出现的调度算法,实际上还有很多其它调度策略,如适用于实时操作系统的时限调度(Deadline Scheduling)比率单调调度(Rate Monotonic Scheduling)等。

另外,还存在优先级反转(Priority Inversion),它是一种可能发生在任何基于优先级的抢占式调度方案中的现象,但在实时调度环境中尤其相关。优先级反转的最著名例子涉及火星探路者(Pathfinder)任务,这个漫游机器人于1997年7月4日登陆火星,开始收集大量数据并将其传送回地球。但在任务开始几天之后,着陆器软件开始经历整个系统重置,每次都会导致数据丢失。在建造“探路者”号的喷气推进实验室团队进行了大量努力之后,问题被追溯到优先级反转。

在任何优先级调度方案中,系统应始终以最高优先级执行任务。当系统内的情况迫使较高优先级的任务等待较低优先级的任务时,会发生优先级反转。如果较低优先级的任务锁定了资源(如设备或二进制信号量),而较高优先级的任务试图锁定同一资源,则会发生优先级反转的简单示例。在资源可用之前,优先级较高的任务将处于阻塞状态。如果较低优先级的任务很快完成并释放资源,则较高优先级的任务可能会很快恢复,并且可能不会违反实时约束。

一种更严重的情况称为无限优先级反转(Unbound Priority Inversion),其中优先级反转的持续时间不仅取决于处理共享资源所需的时间,还取决于其他不相关任务的不可预测的操作。Pathfinder软件中经历的优先级反转是无限的,是个很好的例子。

18.4.6 进程属性

Windows进程的常见属性在任务管理器中可以查看,它们的详情如下所述:

  • 名字。通常是进程所基于的可执行文件名,但不是进程的唯一标识符。有些进程似乎根本没有可执行名称,例如包括系统、安全系统、注册表、内存压缩、系统空闲进程和系统中断。
  • 系统中断(System Interrupt)实际上不是一个进程,只是用来衡量内核服务硬件中断和延迟过程调用所花费的时间。
  • 系统空闲进程也不是真正的进程,它的进程ID(PID)始终为零,只是描述了Windows空闲时间——CPU无事可做时的占比。
  • 系统进程是一个真正的过程,在技术上也是一个最小化进程,总是有一个4的PID。它代表内核空间中发生的一切——内核和内核驱动程序使用的内存、开放句柄、线程等。
  • 安全系统进程仅在Windows 10和Server 2016(及更高版本)系统上可用,这些系统启动时启用了基于虚拟化的安全性。它代表了安全内核中发生的一切。
  • 注册表进程是Windows 10版本1803(RS4)中可用的最小化进程,用作管理注册表的“工作区”,而不是像以前版本那样使用分页池。
  • 内存压缩进程是Windows 10版本1607上可用的最小化进程,并在其地址空间中保存压缩内存。内存压缩是Windows 10中添加的一项功能,用于保存物理内存(RAM),特别适用于资源有限的设备,如手机和物联网设备。令人困惑的是,任务管理器没有显示此进程,但可被Process Explorer正确显示。
  • PID。进程的唯一ID,是4的倍数,其中最低有效PID值为4(属于系统进程)。一旦进程终止,进程ID将被重用,因此可以看到一个新进程。如果进程需要唯一标识符,则PID和流程启动时间的组合在特定系统上确实是唯一的。
  • 状态(Status)。状态可以有三个值之一:运行(Running)、挂起(Suspended)和不响应(Not Responding),根据进程类型总结了它们的含义。

image.png

常见的状态转换如下图所示:

image.png

  • 用户名。用户名指示进程正在哪个用户下运行。令牌对象附加到进程(称为主令牌),该进程基于用户保存进程的安全上下文。该安全上下文包含用户所属的组、权限等信息。进程可以在特定的内置用户下运行,例如本地系统(在任务管理器中显示为系统)、网络服务和本地服务。这些用户帐户通常用于运行服务。
  • 会话ID。进程在其下执行会话的会话号,会话0用于系统进程和服务,会话1及以上用于交互式登录。
  • CPU。显示该进程的CPU消耗百分比,注意它仅显示整数。要获得更好的精度,请使用Process Explorer。
  • 内存。与内存相关的列有些棘手,任务管理器显示的默认列是内存(活动专用工作集)或内存(专用工作集,早期版本)。术语工作集是指RAM(物理存储器),私有工作集是进程使用的RAM,不与其他进程共享。共享内存最常见的例子是DLL代码。活动专用工作集与专用工作集相同,但对于当前挂起的UWP进程设置为零。以上两个计数器是否能很好地指示进程使用的内存量?不幸的是,不是。这些指示使用的是专用RAM,但是当前被调出的内存呢?还有另一列——提交大小(Commit Size),用于了解进程内存使用情况的最佳列。任务管理器默认情况下不显示此列。
  • 基本优先级。基本优先级列(正式称为优先级类)显示了六个值中的一个,为该进程中执行的线程提供基本调度优先级。与优先级相关联的可能值如下:
  • 低(Idle)= 4
  • 低于正常 = 6
  • 正常(Normal) = 8。最常见(默认)的优先级类别是正常(8)。
  • 高于正常 = 10
  • 高(High) = 13
  • 实时(Real-Time) = 24
  • 句柄。显示在特定进程中打开的内核对象的句柄数量。
  • 线程。“线程”列显示每个进程中的线程数量。通常至少应该是一个,因为没有线程的进程是无用的。但是,一些进程显示为没有线程。具体来说,安全系统显示为没有线程,因为安全内核实际上使用普通内核进行调度。系统中断伪进程根本不是进程,因此不能有任何线程。最后,系统空闲进程也不拥有线程。此进程显示的线程数是系统上的逻辑处理器数。

进程更详细的属性表如下:

image.png

在虚拟内存的用户进程:

image.png

18.4.7 进程操作

进程创建中涉及的主要部分如下图所示。

image.png

1、Open Image File

内核打开镜像(可执行文件)文件,并验证其是否为可移植可执行文件(PE)的正确格式。文件扩展名并不重要,实际内容才重要。假设各种头文件有效,内核将创建一个新的进程内核对象和一个线程内核对象,因为一个正常进程是由一个线程创建的,最终应该执行主入口点。

2、Create & Initialize Kernel Process Object

此时,内核将映像映射到新进程的地址空间以及NtDll.Dll。NtDll映射到每个进程(最小和微进程除外),因为它在进程创建的最后阶段具有非常重要的职责,并且是调用系统调用的最终阶段。创建进程仍在执行的最后一个主要步骤是通知Windows子系统进程(Csrss.exe)已创建新进程和线程。(Csrss可以被认为是内核管理Windows子系统进程某些方面的助手)。

3、Create & Initialize Kernel Thread Object

此时,从内核的角度来看,进程已经成功创建,因此调用方调用的进程创建函数(通常是CreateProcess)返回成功。然而,新进程尚未准备好执行其初始代码。进程初始化的第二部分必须在新进程的上下文中由新创建的线程执行。

一些开发人员认为,在新流程中运行的第一件事是可执行文件的主要功能。然而,在实际的主函数开始运行之前,还有很多事情要做,最明显的是NtDll,因为目前进程中没有其他操作系统级代码。此时,NtDll有几个职责:

  • 首先,它为进程创建用户模式管理对象,称为进程环境块(PEB),并为第一个线程创建用户模式控制对象,称之为线程环境块(TEB)。这些结构部分记录(在<winternl.h>中),正式不应由开发人员直接使用。也就是说,在某些情况下,此结构是有用的,特别是在试图实现难以实现的事情时。
  • 然后执行其他一些初始化,包括创建默认进程堆、创建和初始化默认进程线程池等。
  • 入口点开始执行之前的最后一个主要部分是加载所需的DLL,通常称为加载器。加载程序查看可执行文件的导入部分,其中包括可执行文件所依赖的所有库。这些通常包括Windows子系统dll,如kernel32.dll、user32.dll,gdi32.dll和advapi32.dll。

实际上,开发人员可以编写四个主要函数,每个函数都有相应的C/C++运行时函数。下表总结了这些名称及其使用时间。

image.png

大多数进程将在系统关闭之前的某个时间点终止,有几种方法可以退出或终止进程。需要记住的一点是,无论进程如何终止,内核都会确保进程没有私有的内容:释放所有私有(非共享)内存,并关闭进程句柄表中的所有句柄。如果满足以下任一条件,则过程终止:

1、进程中的所有线程退出或终止。

2、进程中的任何线程调用了ExitProcess。

3、使用TerminateProcess终止进程(通常在外部,但可能是由于未处理的异常)。

编写Windows应用程序的开发者通常会在某个时刻发现执行主函数的线程是“特殊的”,通常称为主线程。可以观察到,无论何时主函数返回,进程都会退出——似乎是上述流程退出原因中未列出的场景。然而,它确实如此,实际是上述的情形2。C/C++运行时库调用main/WinMain,然后执行所需的清理,如调用全局C++析构函数、C运行时清理等,最后调用ExitProcess,导致进程退出。

从内核的角度来看,进程中的所有线程都是相等的,并且没有主线程。当内核中的所有线程退出/终止时,内核会销毁进程,因为没有线程的进程几乎是无用的。实际上,这种情况只能在原生进程(仅依赖于NtDll.dll且没有C/C++运行时的可执行文件)中实现。换句话说,在正常的Windows编程中不太可能发生。

18.4.8 进程补述

18.4.8.1 多程序设计建模

当使用多道程序设计时,CPU利用率可以提高。粗略地说,如果平均进程只计算了它在内存中的20%的时间,那么当五个进程同时在内存中时,CPU应该一直处于繁忙状态。然而,这个模型是不切实际的乐观,因为它默认所有五个进程永远不会同时等待I/O。

image.png
CPU利用率是内存中进程数的函数。

从图中可以明显看出,如果进程花费80%的时间等待I/O,那么必须同时在内存中至少有10个进程才能使CPU浪费低于10%。当你意识到等待用户在终端键入内容(或单击图标)的交互式进程处于I/O等待状态时,应该很清楚,80%以上的I/O等待时间并不罕见。但即使在服务器上,执行大量磁盘I/O的进程通常也会有这个百分比或更多。

为了准确起见,应该指出,刚才描述的概率模型只是一个近似值。它隐式假设所有n个进程都是独立的,意味着内存中有5个进程的系统可以有三个运行,两个等待。但是,对于单个CPU,我们不能同时运行三个进程,因此在CPU繁忙时准备就绪的进程将不得不等待,因此,这些进程不是独立的。使用排队论可以构建更精确的模型,但我们所做的多道程序设计让进程在CPU空闲时使用它,当然,即使上图中的真实曲线与图中所示的曲线略有不同,它仍然有效。

尽管上图中的模型思想简单,但它仍然可以用于对CPU性能进行具体的、尽管是近似的预测。例如,假设一台计算机有8GB内存,操作系统及其表占2GB,每个用户程序也占2GB。这些大小允许三个用户程序同时在内存中,平均I/O等待时间为80%时,CPU利用率(忽略操作系统开销)为$1− 0.8^3$或约49%。再增加8GB内存,系统就可以从三路多道程序设计过渡到七路多道编程,从而将CPU利用率提高到79%。换句话说,额外的8GB将提高30%的吞吐量。

再增加8GB只会将CPU利用率从79%提高到91%,因此吞吐量只会再提高12%。使用这个模型,计算机的所有者可能会认为第一次增加内存是一项不错的投资,但第二次不是。

18.4.8.2 加载和链接

创建活动进程的第一步是将程序加载到主内存中并创建进程映像(下图),下下图描述了大多数系统的典型场景。应用程序由多个编译或组装的目标代码形式的模块组成,这些模块被链接以解析模块之间的任何引用,同时,解析对库例程的引用。库例程本身可以合并到程序中或作为共享代码引用,这些代码必须在运行时由操作系统提供。

image.png
加载函数。

image.png
一个加载和链接的场景。

  • 加载

在上图中,加载器从位置开始将加载模块放置在主存储器中。在加载程序时,必须满足寻址要求。一般来说,可以采取三种方法:

1、绝对加载。

绝对加载绝对加载程序要求将给定的加载模块始终加载到主内存中的同一位置。因此,在呈现给加载器的加载模块中,所有地址引用都必须指向特定的或绝对的主内存地址。例如,如果上图中的x是位置1024,那么加载模块中指定给该内存区域的第一个字具有地址1024。

将特定地址值分配给程序内的内存引用可以由程序员完成,也可以在编译或汇编时完成。前一种方法有几个缺点,首先,每个程序员都必须知道将模块放入主存的预期分配策略,其次,如果对程序进行了任何修改,涉及到模块主体中的插入或删除,那么所有地址都必须更改。因此,最好允许程序中的内存引用以符号方式表示,然后在编译或汇编时解析这些符号引用,如下图所示,对指令或数据项的每个引用最初都用符号表示,在准备模块输入到绝对加载器时,汇编程序或编译器将把所有这些引用转换为特定地址(在本例中,对于从1024位置开始加载的模块),如下下图b所示。

image.png

2、可重定位加载。

可重定位加载在加载之前将内存引用绑定到特定地址的缺点是,生成的加载模块只能放在主内存的一个区域中。然而,当许多程序共享主内存时,可能不希望提前决定将特定模块加载到内存的哪个区域。最好在加载时做出该决定,因此,我们需要一个可以位于主内存中任何位置的加载模块。

为了满足这个新的要求,汇编程序或编译器生成的不是实际的主存储器地址(绝对地址),而是与某个已知点(例如程序的开始)相关的地址。这种技术如下图c所示。加载模块的开头被分配了相对地址0,并且模块内的所有其他内存引用都是相对于模块的开始来表示的。

由于所有内存引用都以相对格式表示,所以加载器将模块放置在所需的位置就成为一项简单的任务。如果要从位置开始加载模块,则加载程序必须在将模块加载到内存中时简单地添加到每个内存引用中。为了协助该任务,加载模块必须包含告诉加载器地址引用在哪里以及如何解释地址引用的信息(通常相对于程序源,但也可能相对于程序中的其他点,例如当前位置)。这组信息由编译器或汇编程序准备,通常称为重定位字典(relocation dictionary)

image.png

3、动态运行时加载。

重定位加载比较常见,相对于绝对加载,它具有明显的优势。然而,在多道程序设计环境中,即使是不依赖虚拟内存的环境,可重定位的加载方案依然不够。需要在主内存中交换进程映像,以最大限度地提高处理器的利用率。为了最大限度地提高主内存利用率,我们希望能够在不同的时间将进程映像交换回不同的位置。因此,一个程序一旦加载,就可以交换到磁盘上,然后在不同的位置重新交换。如果在初始加载时将内存引用绑定到绝对地址,是不可能的。

另一种方法是推迟绝对地址的计算,直到在运行时实际需要它。为此,加载模块被加载到主内存中,所有内存引用都以相对形式(上图c)。直到实际执行了一条指令,才计算出绝对地址。为了确保此功能不会降低性能,必须通过特殊的程序或硬件而不是软件来完成。

动态地址计算提供了完全的灵活性。程序可以加载到主存储器的任何区域,随后,程序的执行可以被中断,程序可以从主存储器中调出,稍后再调回另一个位置。

  • 链接

链接器的功能是将一组目标模块作为输入,并生成一个加载模块,该加载模块由一组集成的程序和数据模块组成,并传递给加载器。在每个对象模块中,可能存在对其他模块中位置的地址引用。每个这样的引用只能在未链接的对象模块中用符号表示。链接器创建一个加载模块,它是所有对象模块的连续连接。每个模块内引用必须从符号地址更改为对整个加载模块内某个位置的引用。例如,图7中的模块A包含模块B的过程调用。当这些模块在加载模块中组合时,对模块B的符号引用将更改为对加载模块中B入口点位置的特定引用。

链接编辑器此地址链接的性质将取决于要创建的加载模块的类型以及链接发生的时间(上表b)。通常情况下,如果需要可重新定位的负载模块,则通常按以下方式进行连接。每个编译或组装的对象模块都是用相对于对象模块开头的引用创建的,所有这些模块都被放在一个单独的可重定位加载模块中,其中包含相对于加载模块原点的所有引用,该模块可以用作可重定位加载或动态运行时加载的输入。

产生可重定位加载模块的链接器通常被称为链接编辑器。下图显示了链接编辑器功能。

image.png

与加载一样,可以延迟某些链接功能。术语动态链接用于指将某些外部模块的链接延迟到加载模块创建之后的做法,因此加载模块包含对其他程序的未解析引用,这些引用可以在加载时或运行时解析。

对于加载时动态链接,需要执行以下步骤。将要加载的加载模块(应用程序模块)读入存储器,对外部模块(目标模块)的任何引用都会导致加载器找到目标模块,加载它,并从应用程序模块的开头将引用更改为内存中的相对地址。与所谓的静态链接相比,动态链接有几个优点:

1、合并目标模块的更改或升级版本变得更容易,目标模块可以是操作系统实用程序或其他通用例程。对于静态链接,对这种支持模块的更改将需要重新链接整个应用程序模块,这不仅效率低下,而且在某些情况下可能是不可能的。例如,在个人计算机领域,大多数商业软件都是以加载模块的形式发布的:源码和对象版本不发布。

2、在动态链接文件中包含目标代码为自动代码共享铺平了道路。操作系统可以识别多个应用程序正在使用相同的目标代码,因为它加载并链接了该代码。它可以使用该信息加载目标代码的单个副本并将其链接到两个应用程序,而不必为每个应用程序加载一个副本。

3、独立软件开发人员更容易扩展广泛使用的操作系统(如Linux)的功能。开发人员可以提出一个对各种应用程序有用的新函数,并将其打包为动态链接模块。

对于运行时动态链接,一些链接被推迟到执行时。目标模块的外部引用保留在加载的程序中。当调用不存在的模块时,操作系统定位该模块,加载该模块,并将其链接到调用模块。这些模块通常是可共享的,在Windows环境中,这些称为动态链接库(DLL)。因此,如果一个进程已经在使用动态链接的共享模块,则该模块位于主内存中,新进程可以简单地链接到已加载的模块。

如果两个或多个进程共享一个DLL模块,但期望该模块的不同版本,则使用DLL可能会导致通常称为DLL地狱(DLL hell)的问题。例如,可能会重新安装应用程序或系统功能,并将较旧版本的DLL文件带入其中。

我们已经看到,动态加载允许整个加载模块到处移动,但是,模块的结构是静态的,在整个进程的执行过程中以及从一个执行到下一个执行都保持不变。但是,在某些情况下,无法在执行之前确定需要哪些对象模块,例如事务处理应用程序(如航空公司预订系统或银行应用程序),事务的性质决定了需要哪些程序模块,它们被适当地加载并与主程序链接。使用这种动态链接器的优点是,除非引用了程序单元,否则不必为程序单元分配内存。此功能用于支持分段系统。

一个额外的细化是可行的:应用程序不需要知道可能被调用的所有模块或入口点的名称。例如,可以编写绘图程序以与各种绘图仪配合使用,每个绘图仪都由不同的驱动程序包驱动。应用程序可以从另一个进程或在配置文件中查找当前安装在系统上的绘图仪的名称,这允许应用程序的用户安装在编写应用程序时不存在的新绘图仪。

18.5 线程

线程是通过进程代码的执行流,有自己的程序计数器、系统寄存器和堆栈,是通过并行提高应用程序性能的一种流行方法。线程有时称为轻量级进程,代表了一种通过减少超线程来提高操作系统性能的软件方法,相当于一个经典的进程。每个线程只属于一个进程,进程外不能存在任何线程,每个线程代表一个单独的控制流。

image.png

一些操作系统提供了用户级线程和内核级线程的组合功能,Solaris就是这种组合方法的一个很好的例子。在组合系统中,同一应用程序中的多个线程可以在多个处理器上并行运行,阻塞系统调用不需要阻塞整个进程。

18.5.1 线程和进程

在下图(a)中,我们看到了三个传统进程,每个进程都有自己的地址空间和单个控制线程。相反,在下图(b)中,我们看到一个具有三个控制线程的单个进程。尽管在这两种情况下,我们都有三个线程,但在下图(a)中,每个线程都在不同的地址空间中运行,而在下图(b)中,三个线程共享相同的地址空间。当多线程进程在单个CPU系统上运行时,线程轮流运行。通过在多个进程之间来回切换,系统提供了并行运行的独立顺序进程的错觉。多线程的工作方式相同,CPU在线程之间快速来回切换,提供了线程并行运行的假象,尽管在比实际CPU慢的CPU上运行。在一个进程中有三个计算绑定线程,这些线程看起来是并行运行的,每个线程在一个CPU上的速度是实际CPU的三分之一。

image.png
(a) 三个进程,每个进程有一个线程。(b) 一个进程有三个线程。

进程中的不同线程不像不同进程那样独立。所有线程都有完全相同的地址空间,意味着它们也共享相同的全局变量。由于每个线程都可以访问进程地址空间内的每个内存地址,因此一个线程可以读取、写入甚至清除另一个线程的堆栈。线程之间没有保护,原因有二:其一,这是不可能的;其二,不应该有保护。不同的进程可能来自不同的用户,并且可能相互竞争,不同的是,一个进程总是由一个用户拥有,该用户可能创建了多个线程,以便他们能够合作,而不是竞争。除了共享地址空间外,所有线程还可以共享同一组打开的文件、子进程、警报和信号等,如下表所示。因此,当三个进程基本无关时,将使用上图(a)的组织,然而,当三个线程实际上是同一工作的一部分并且相互积极密切合作时,上图(b)是合适的。

image.png

第一列中的项目是进程属性,而不是线程属性。例如,如果一个线程打开了一个文件,则该文件对进程中的其他线程可见,并且它们可以读取和写入该文件。这是合乎逻辑的,因为进程是资源管理的单元,而不是线程。如果每个线程都有自己的地址空间、打开的文件、挂起的警报等,那么它将是一个单独的进程。我们试图通过线程概念实现的是多个执行线程共享一组资源的能力,以便它们能够紧密协作来执行某些任务。

image.png
Windows进程和线程对比图。

与传统进程(即只有一个线程的进程)一样,线程可以处于以下几种状态之一:运行、阻塞、就绪或终止。正在运行的线程当前具有CPU并且处于活动状态。相反,阻塞的线程正在等待某个事件解除阻塞。例如,当线程执行从键盘读取的系统调用时,它会被阻塞,直到输入被键入为止。线程可以阻止等待某个外部事件发生或其他线程解除阻止。就绪线程计划运行,并在轮到它时立即运行,线程状态之间的转换与进程状态之间的过渡相同。

重要的是要认识到每个线程都有自己的堆栈,如下图所示。每个线程的堆栈包含一个帧,用于每个调用但尚未返回的过程。此帧(frame)包含过程的局部变量和过程调用完成时要使用的返回地址。例如,如果过程X调用过程Y,而Y调用过程Z,那么在执行Z时,X、Y和Z的帧都将位于堆栈上。每个线程通常会调用不同的过程,因此具有不同的执行历史。这就是为什么每个线程都需要自己的堆栈。

image.png
每个线程都有自己的堆栈。

当存在多线程时,进程通常以单个线程开始。此线程能够通过调用库过程(如线程创建)来创建新线程。线程创建的参数指定要运行新线程的过程的名称。没有必要(甚至不可能)指定任何关于新线程地址空间的内容,因为它会自动在创建线程的地址空间中运行。有时线程是分层的,具有父子关系,但通常不存在这种关系,所有线程都是相等的。无论是否具有层次关系,创建线程通常都会返回一个线程标识符,用于命名新线程。

当一个线程完成它的工作时,它可以通过调用库过程来退出,比如线程退出。然后它会消失,不再可调度。在某些线程系统中,一个线程可以通过调用过程等待(特定)线程退出,例如线程联接。此过程将阻塞调用线程,直到(特定)线程退出。在这方面,线程创建和终止与进程创建和终止非常相似,也有大致相同的选项。

另一个常见的线程调用是线程放弃(thread yield),它允许一个线程自愿放弃CPU时间片,让另一个线程运行。这种机制很重要,因为没有时钟中断来实际执行多道程序,就像进程一样。因此,线程要有礼貌,并不时主动放弃CPU,以给其他线程一个运行的机会。其他调用允许一个线程等待另一个线程完成一些工作,等待一个线程宣布它已经完成了一些工作,依此类推。

虽然线程通常很有用,但它们也给编程模型带来了许多复杂性。首先,考虑UNIX fork系统调用的效果。如果父进程有多个线程,那么子进程是否也应该有它们?如果没有,进程可能无法正常运行,因为所有这些可能都是必需的。

但是,如果子进程获得的线程数与父进程相同,那么如果父进程中的线程在读调用(例如从键盘进行的读调用)中被阻塞,会发生什么情况?现在键盘上有两个线程被阻塞了吗?一个在父线程,一个在子线程?当一行被键入时,两个线程都会得到它的副本吗?只有父母?只有孩子?打开的网络连接也存在同样的问题。

另一类问题与线程共享许多数据结构有关。如果一个线程关闭一个文件,而另一个线程仍在读取该文件,会发生什么情况?假设一个线程注意到内存太少,并开始分配更多内存。中途,线程切换发生,新线程还注意到内存太少,并开始分配更多内存,结果内存可能会分配两次。这些问题可以通过一些努力来解决,但要使多线程程序正确工作,需要仔细考虑和设计。

18.5.2 多线程模型

多线程模型有三种类型:

  • 多对多关系。在这个模型中,许多用户级线程多路复用到数量较小或相等的内核线程,内核线程的数量可能特定于特定的应用程序或特定的计算机。在这个模型中,开发人员可以根据需要创建任意多个用户线程,相应的内核线程可以在多处理器上并行运行。

image.png

  • 多对一关系。多对一模型将多个用户级线程映射到一个内核级线程,线程管理是在用户空间中完成的。当线程进行阻塞系统调用时,整个进程将被阻塞。一次只有一个线程可以访问内核,因此多个线程无法在多处理器上并行运行。如果用户级线程库是在操作系统中实现的,则该系统不支持内核线程使用多对一关系模式。

image.png

  • 一对一关系。用户级线程与内核级线程之间存在一对一的关系,此模型比多对一模型提供更多并发性。当一个线程进行阻塞的系统调用时,它允许另一个线程运行,支持在微处理器上并行执行多个线程。

image.png

此模型的缺点是创建用户线程需要相应的内核线程。OS/2、Windows NT和Windows 2000使用一对一关系模型。

18.5.3 多线程优点

多线程编程的好处可以分为四大类:

  • 响应性。多线程交互应用程序可以允许程序继续运行,即使部分程序被阻止或正在执行较长的操作,从而提高对用户的响应能力。这种质量在设计用户界面时特别有用。例如,考虑一下当用户单击一个导致执行耗时操作的按钮时会发生什么。在操作完成之前,单线程应用程序将对用户无响应。相反,如果耗时的操作是在单独的线程中执行的,那么应用程序将保持对用户的响应。
  • 资源共享。进程只能通过共享内存和消息传递等技术共享资源,这些技术必须由程序员明确调度,但默认情况下,线程共享其所属进程的内存和资源。共享代码和数据的好处是,它允许应用程序在同一地址空间内具有多个不同的活动线程。
  • 经济。为进程创建分配内存和资源成本高昂。因为线程共享它们所属进程的资源,所以创建和上下文切换线程更经济。从经验上衡量开销的差异可能很困难,但一般来说,创建和管理进程比线程要花费更多的时间。例如,在Solaris中,创建进程比创建线程慢大约三十倍,上下文切换大约慢五倍。
  • 可扩展性。在多处理器体系结构中,多线程的好处甚至更大,其中线程可以在不同的处理核心上并行运行。无论有多少可用处理器,单线程进程只能在一个处理器上运行。

总之,线程的优点/优点是最小化上下文切换时间,提供了进程内的并发性,高效沟通,创建和上下文切换线程更经济。利用多处理器体系结构–多线程的好处可以在多处理器体系架构中大大增加。

18.5.4 用户和内核线程

线程可分为用户级线程内核级线程

在用户线程中,线程管理的所有工作都由应用程序完成,内核不知道线程的存在。线程库包含用于创建和销毁线程、在线程之间传递消息和数据、调度线程执行以及保存和恢复线程上下文的代码。应用程序从单个线程开始,并开始在该线程中运行,用户级线程的创建和管理通常很快。

用户级线程相对于内核级线程的优势:线程切换不需要内核模式特权,用户级线程可以在任何操作系统上运行,计划可以是特定于应用程序的,用户级线程的创建和管理速度很快。

用户级线程的缺点:在典型的操作系统中,大多数系统调用都是阻塞的,多线程应用程序无法利用多处理。

在内核级线程中,由内核完成的线程管理。应用程序区域中没有线程管理代码,操作系统直接支持内核线程。任何应用程序都可以编程为多线程,单个进程支持应用程序中的所有线程。

内核维护整个进程以及进程中各个线程的上下文信息,内核的调度是在线程的基础上完成的,内核在内核空间中执行线程创建、调度和管理,内核线程的创建和管理速度通常比用户线程慢。

内核级线程的优点:内核可以在多个进程上同时调度来自同一进程的多个线程,如果进程中的一个线程被阻塞,内核可以调度同一进程的另一个线程。内核例程本身可以多线程。

内核级线程的缺点:内核线程的创建和管理速度通常比用户线程慢,在同一进程中将控制权从一个线程转移到另一个线程需要将模式切换到内核。

image.png
用户和内核线程对比图。

线程涉及了复杂的状态转换,以下是操作系统常见的转换图:

image.png
用户级线程状态和进程状态之间的关系示例。

image.png
Windows线程状态转换图。

image.png
Linux进程、线程模型。

image.png
Solaris用户线程和LWP状态。

用户级线程和内核级线程之间的差异如下表:

image.png

进程和线程的区别如下表:

image.png

实现线程有两个主要位置:用户空间和内核空间,以及它们的混合实现。下面将描述这些方法及其优缺点。

  • 用户空间线程

这种方法将线程包完全放在用户空间中,内核对它们一无所知。就内核而言,它管理的是普通的单线程进程。第一个也是最明显的优点是,用户级线程包可以在不支持线程的操作系统上实现。过去所有的操作系统都属于这一类,甚至现在仍有一些。使用这种方法,线程由库实现。所有这些实现都具有相同的总体结构,如下图所示。线程运行在运行时系统之上,运行时系统是管理线程的进程的集合。

image.png
左:用户级线程包。右:由内核管理的线程包。

当在用户空间中管理线程时,每个进程都需要自己的私有线程表来跟踪该进程中的线程。这个表类似于内核的进程表,只是它只跟踪每个线程的属性,例如每个线程的程序计数器、堆栈指针、寄存器、状态等等。线程表由运行时系统管理,当线程移动到就绪状态或阻塞状态时,重新启动线程所需的信息存储在线程表中,与内核在进程表中存储进程信息的方式完全相同。

当一个线程做了一些可能导致其在本地被阻塞的事情时,例如,等待其进程中的另一个线程完成某些工作,它将调用一个运行时系统过程。此过程检查线程是否必须置于阻塞状态。如果是这样,它将线程的寄存器(即它自己的)存储在线程表中,在表中查找准备运行的线程,并用新线程保存的值重新加载机器寄存器。一旦堆栈指针和程序计数器被切换,新线程就会自动重新启动。如果机器恰巧有一条指令存储所有寄存器,另一条指令加载所有寄存器,那么整个线程切换只需几个指令即可完成。这样做线程切换至少比捕获到内核快一个数量级,这是支持用户级线程包的有力论据。

然而,与进程有一个关键区别。当线程暂时完成运行时,例如,当它调用线程放弃时,线程放弃代码可以将线程的信息保存在线程表本身中。此外,它还可以调用线程调度程序来选择要运行的另一个线程。保存线程状态和调度程序的过程只是局部过程,因此调用它们比进行内核调用要高效得多。在其他问题中,不需要陷阱、不需要上下文切换、不需要刷新内存缓存等等。这些特点使得线程调度非常快速。

用户级线程还有其他优点。它们允许每个进程都有自己的定制调度算法。对于某些应用程序,例如,那些具有垃圾收集器线程的应用程序,不必担心线程在不方便的时候被停止。它们的伸缩性也更好,因为内核线程总是需要内核中的一些表空间和堆栈空间,如果有大量线程,可能是一个问题。

尽管它们的性能更好,但用户级线程包仍存在一些主要问题。首先是如何实现阻塞系统调用的问题,假设一个线程在按下任何键之前读取键盘,让线程实际执行系统调用是不可接受的,因为会停止所有线程。首先拥有线程的主要目标之一是允许每个线程使用阻塞调用,但要防止一个阻塞的线程影响其他线程。由于有阻塞系统调用,难以轻松实现这个目标。

系统调用可以全部更改为非阻塞(例如,如果没有缓冲字符,键盘上的读取只会返回0字节),但要求更改操作系统是不可取的。此外,用户级线程的一个论点是,它们可以在现有操作系统上运行。此外,更改read的语义将需要更改许多用户程序。

如果可以提前告知呼叫是否会阻塞,则可以使用另一种方法。在UNIX的大多数版本中,存在一个系统调用select,它允许调用者告诉预期的读取是否会阻塞。当存在此调用时,库过程read可以替换为一个新的过程,该过程首先执行select调用,然后仅在安全时执行read调用(即不会阻塞)。如果读取调用将阻塞,则不进行调用,而是运行另一个线程。下次运行时系统获得控制时,它可以再次检查读取是否现在是安全的。这种方法需要重写系统调用库的部分内容,效率低且不雅观,但别无选择。放置在系统调用周围进行检查的代码称为封套(jacket)包装器(wrapper)

与阻塞系统调用的问题类似的是页面错误问题。如果程序调用或跳转到不在内存中的指令,则会发生页错误,操作系统将从磁盘获取丢失的指令(及其邻居),称为页面错误(page fault)。当找到并读入必要的指令时,进程被阻塞。如果线程导致页面错误,内核甚至不知道线程的存在,自然会阻塞整个进程,直到磁盘I/O完成,即使其他线程可能可以运行。

用户级线程包的另一个问题是,如果一个线程开始运行,那么该进程中的其他线程将永远不会运行,除非第一个线程自愿放弃CPU。在单个进程中,没有时钟中断,因此无法以循环方式(轮流)调度进程。除非线程自愿进入运行时系统,否则调度程序永远不会有机会。

线程永远运行的问题的一个可能的解决方案是让运行时系统每秒请求一次时钟信号(中断)来给它控制权,但对程序来说也是粗糙和混乱的。频率较高的周期性时钟中断并不总是可行,即使是这样,总开销也可能很大。此外,线程还可能需要时钟中断,从而干扰运行时系统对时钟的使用。

另一个,也是最具破坏性的,反对用户级线程的论点是,程序员通常希望线程恰好位于线程经常阻塞的应用程序中,例如,在多线程Web服务器中,这些线程不断地进行系统调用。一旦内核出现执行系统调用的陷阱,如果旧的线程被阻塞,内核就几乎不需要再做任何工作来切换线程,并且让内核这样做可以消除不断进行选择系统调用以检查读取系统调用是否安全的需要。对于本质上完全受CPU限制且很少阻塞的应用程序,使用线程有什么意义?没有人会认真建议计算前n个质数或使用线程下棋,因为这样做毫无益处。

  • 内核空间线程

现在让我们考虑让内核了解并管理线程。如上图右所示,每个系统都不需要运行时系统。此外,每个进程中都没有线程表。相反,内核有一个线程表来跟踪系统中的所有线程。当线程想要创建新线程或销毁现有线程时,它会进行内核调用,然后通过更新内核线程表来创建或销毁线程。

内核的线程表保存每个线程的寄存器、状态和其他信息。这些信息与用户级线程的信息相同,但现在保存在内核中,而不是用户空间中(在运行时系统中),这些信息是传统内核维护的关于其单线程进程的信息的子集,即进程状态。此外,内核还维护传统的进程表以跟踪进程。

所有可能阻塞线程的调用都被实现为系统调用,其成本远远高于对运行时系统过程的调用。当一个线程阻塞时,内核可以选择运行同一进程中的另一个线程(如果一个线程已就绪)或不同进程中的一个线程。使用用户级线程,运行时系统会从自己的进程中保持线程的运行,直到内核占用CPU(或者没有准备好的线程可以运行)。

由于在内核中创建和销毁线程的成本相对较高,一些系统采用了一种环境正确的方法并回收其线程。当线程被销毁时,它被标记为不可运行,但其内核数据结构不会受到其他方面的影响。稍后,当必须创建新线程时,将重新激活旧线程,从而节省一些开销。用户级线程也可以进行线程回收,但由于线程管理开销要小得多,因此这样做的动机较小。

内核线程不需要任何新的非阻塞系统调用。此外,如果进程中的一个线程导致页面错误,内核可以轻松检查进程是否有其他可运行的线程,如果有,则在等待从磁盘引入所需页面的同时运行其中一个线程。它们的主要缺点是系统调用的成本很高,因此如果线程操作(创建、终止等)很常见,则会产生更多的开销。

虽然内核线程可以解决一些问题,但它们并不能解决所有问题。例如,当多线程进程分叉时会发生什么?新进程是否与旧进程具有相同数量的线程,还是只有一个线程?在许多情况下,最佳选择取决于进程下一步计划做什么。如果它要调用exec来启动一个新程序,可能选择一个线程是正确的,但如果它继续执行,则最好重新生成所有线程。

另一个问题是信号。请记住,至少在经典模型中,信号被发送到进程,而不是线程。当信号传入时,哪个线程应该处理它?线程可能会注册他们对某些信号的兴趣,因此当信号传入时,它会被发送给表示想要它的线程。但是,如果两个或多个线程注册同一信号,会发生什么情况?这些只是线程引入的两个问题,还有更多。

  • 混合实现

为了将用户级线程与内核级线程的优点结合起来,已经研究了多种方法。一种方法是使用内核级线程,然后将用户级线程复用到部分或全部线程上,如下图所示。当使用这种方法时,开发人员可以确定要使用多少内核线程,以及每个线程要复用多少用户级线程。该模型提供了最大的灵活性。

image.png
将用户级线程多路传输到内核级线程。

使用这种方法,内核只知道内核级别的线程并对其进行调度。其中一些线程可能有多个用户级线程在其上进行多路复用,这些用户级线程的创建、销毁和调度就像在没有多线程功能的操作系统上运行的进程中的用户级线程一样。在这个模型中,每个内核级线程都有一些用户级线程,这些线程轮流使用它。

18.5.5 单线程代码多线程化

许多现有程序都是为单线程进程编写的,将这些转换为多线程比最初看起来要复杂得多。下面,我们将研究几个陷阱。

首先,线程的代码通常由多个过程组成,就像一个进程一样,这些变量可能有局部变量、全局变量和参数。局部变量和参数不会引起任何问题,但对于线程是全局的但对于整个程序不是全局的变量是一个问题。这些变量是全局变量,因为线程中的许多过程都使用它们(因为它们可能使用任何全局变量),但其他线程在逻辑上应该不使用它们。

例如,考虑UNIX维护的errno变量。当进程(或线程)进行失败的系统调用时,错误代码被放入errno。在下图中,线程1执行系统调用访问,以确定它是否有权访问某个文件。操作系统在全局变量errno中返回答案,控制权返回线程1后,但在有机会读取errno之前,调度程序决定线程1目前有足够的CPU时间,并决定切换到线程2。线程2执行一个失败的打开调用,会导致errno被覆盖,线程1的访问代码永远丢失。线程1稍后启动后,将读取错误的值,并且行为不正确。

image.png
线程之间因使用全局变量而发生冲突。

这个问题有多种解决方案。一是完全禁止全局变量,无论这个理想多么值得,它都与许多现有的软件相冲突。另一种方法是为每个线程分配自己的私有全局变量,如下图所示。这样,每个线程都有自己的errno和其他全局变量的私有副本,从而避免了冲突。实际上,这个决定创建了一个新的作用域级别,变量对线程的所有过程都可见(但对其他线程不可见),此外,变量的现有作用域级别只对一个过程可见,变量在程序中到处可见。

image.png
线程可以拥有私有的全局变量。

然而,访问私有全局变量有点棘手,因为大多数编程语言都有表示局部变量和全局变量的方法,但没有中间形式。可以为全局变量分配一块内存,并将其作为额外参数传递给线程中的每个过程。虽然不是一个优雅的方法,但确实有效。或者,可以引入新的库过程来创建、设置和读取这些线程范围的全局变量。第一个调用可能如下所示:

create_global("bufptr");

它在堆上或为调用线程保留的特殊存储区域中为名为bufptr的指针分配存储。无论存储分配到哪里,只有调用线程可以访问全局变量。如果另一个线程创建了一个同名的全局变量,它将获得一个与现有存储位置不冲突的不同存储位置。访问全局变量需要两个调用:一个用于写入,另一个用于读取。写法类似以下代码:

set_global("bufptr", &buf);

它将指针的值存储在之前由创建全局调用创建的存储位置。要读取全局变量,调用可能如下所示:

bufptr = read_global("bufptr");

它返回存储在全局变量中的地址,因此可以访问其数据。

将单线程程序转换为多线程程序的下一个问题是,许多库过程都是不可重入的。也就是说,在前一个调用尚未完成的情况下,它们不会对任何给定过程进行第二次调用。例如,通过网络发送消息很可能被编程为在库内的固定缓冲区中组装消息,然后陷阱到内核发送消息。如果一个线程在缓冲区中组装了它的消息,然后时钟中断强制切换到第二个线程,该线程立即用自己的消息覆盖缓冲区,会发生什么情况?

类似地,内存分配过程(如UNIX中的malloc)维护有关内存使用的关键表,例如可用内存块的链接列表。当malloc忙于更新这些列表时,它们可能暂时处于不一致的状态,指针没有指向任何地方。如果在表不一致时发生线程切换,并且来自不同线程的新调用,则可能会使用无效指针,从而导致程序崩溃。要有效地解决所有这些问题意味着要重写整个库,是一项非常重要的工作,很可能会引入细微的错误。

另一种解决方案是为每个过程提供一个封套(jacket),该封套设置一个位,将库标记为正在使用。当上一个调用尚未完成时,另一个线程使用库过程的任何尝试都将被阻止。虽然这种方法可以工作,但它大大消除了潜在的并行性。

接下来,考虑信号。有些信号在逻辑上是特定于线程的,而其他信号则不是。例如,如果一个线程调用警报,那么产生的信号应该发送给进行调用的线程。然而,当线程完全在用户空间中实现时,内核甚至不知道线程,很难将信号指向正确的线程。如果一个进程一次只能有一个警报挂起,并且多个线程独立调用警报,则会出现额外的复杂性。

其他信号,如键盘中断,不是特定于线程的。谁应该抓住他们?一个指定线程?所有线程?新创建的弹出线程?此外,如果一个线程在不通知其他线程的情况下更改信号处理程序,会发生什么情况?如果一个线程想要捕捉一个特定的信号(例如,用户点击CTRL-C),而另一个线程希望这个信号终止进程,会发生什么?如果一个或多个线程运行标准库过程,而其他线程是用户编写的,则可能会出现这种情况。显然,这些期望是不相容的。通常,信号很难在单线程环境中管理,进入多线程环境并不会使它们更容易处理。

线程引入的最后一个问题是堆栈管理。在许多系统中,当进程的堆栈溢出时,内核只会自动为该进程提供更多堆栈。当一个进程有多个线程时,它也必须有多个堆栈。内核如果不知道所有这些堆栈,就无法在出现堆栈错误时自动增长它们,事实上,它甚至可能没有意识到内存故障与某些线程堆栈的增长有关。

这些问题当然不是不可克服的,但它们确实表明,仅仅将线程引入现有系统而不进行相当实质性的系统重新设计是行不通的。至少,系统调用的语义可能需要重新定义,库需要重写。所有这些事情都必须以这样一种方式进行,即在进程只有一个线程的情况下,保持与现有程序的向后兼容。

18.5.6 Windows线程

进程是管理对象,不直接执行代码。要在Windows上完成任何操作,必须创建线程。用户模式进程是由一个线程创建的,该线程最终执行可执行文件的主入口点。在许多情况下,应用程序可能不需要更多线程。然而,一些应用程序可能会受益于使用进程内执行的多个线程。每个线程都是独立的执行路径,因此可以使用不同的处理器,从而实现真正的并发。

线程是执行代码的实际载体,包含在进程中,使用进程公开的资源进行工作(如虚拟内存和内核对象的句柄)。线程拥有的最重要属性如下:

  • 当前访问模式,用户或内核。
  • 执行上下文,包括处理器寄存器。
  • 堆栈,用于本地变量分配和调用管理。
  • 线程本地存储(TLS)数组,提供了一种以统一访问语义存储线程私有数据的方法。
  • 基本优先级和当前(动态)优先级。
  • 处理器关联,指示允许线程在哪些处理器上运行。

线程最常见的状态是:

  • Running(正在运行)。当前正在(逻辑)处理器上执行代码。
  • Ready(就绪)。由于所有相关处理器忙或不可用,等待调度执行。
  • Waiting(等待)。在继续之前等待某些事件发生,事件发生后,线程将移动到就绪状态。

线程抽象了一个独立的执行路径,从执行的角度来看,它与可能同时处于活动状态的其他线程无关。一旦线程开始执行,它可以执行以下任何操作,直到退出:

  • CPU密集操作——依赖CPU操作进行进度的函数计算或调用。
  • I/O密集操作——针对I/O设备(如磁盘或网络)执行的操作。在等待I/O操作完成时,线程处于等待状态,不消耗CPU周期。
  • 可能导致线程进入等待状态的其他操作,如等待同步原语(互斥体等)。

在进一步研究线程之前,我们必须认识到线程是处理器的抽象。但处理器的定义究竟是什么?在多核构成一个典型CPU的时代,这些术语可能会变得混乱。下图显示了典型CPU的逻辑组成。

image.png

在上图中,有一个插槽(Socket),它是卡在计算机主板上的物理芯片。笔记本电脑和家用电脑通常只有一种,大型服务器计算机可能包含多个插槽。每个插槽都有多个内核,它们是独立的处理器(上图是4个)。

在英特尔处理器上,每个核心可能被分成两个逻辑处理器,由于一种称为超线程(Hyper Threading)的技术,也称为硬件线程。从Windows的角度来看,处理器的数量是逻辑处理器的数量。下图显示博主的笔记本有16个逻辑处理器,意味着在任何给定时刻,最多有16个线程正在运行。任务管理器还显示了插槽、内核和逻辑处理器的数量。

image.png

AMD也有类似的技术,称为并发多线程(Simultaneous Multi Threading,SMT)
可以在BIOS设置中禁用超线程。超线程的潜在缺点是,共享一个核心的每两个逻辑处理器也共享二级缓存,因此可能会相互“干扰”。

18.5.6.1 Fork-Join

下面的示例演示了多线程的复杂用法。PrimeSconter应用程序使用指定数量的线程对一系列数字中的质数进行计数,想法是将工作分成几个线程,每个线程都计算其数字范围内的素数。然后,主线程等待所有工作线程退出,允许它简单地对所有线程的计数求和。如下图所示。

image.png

这种创建多个执行某些工作的线程,并等待它们在聚合结果之前退出的想法有时被称为分叉-合并(Fork-Join),因为线程从某个初始线程“分叉”,然后在完成后“连接回”初始线程。

这种模式的另一个名称是结构化并行(Structured Parallelism)

此应用程序中使用的线程数是算法的参数之一——有趣的问题是,最快完成计算的最佳线程数是多少?

下面代码是上图所示的应用程序PrimeSconter的实现代码:

struct PrimesData 
{
    int From, To;
    int Count;
};

bool IsPrime(int n) 
{
    if (n < 2)
        return false;
    if(n == 2)
        return true;

    int limit = (int)::sqrt(n);
    for (int i = 2; i <= limit; i++)
        if (n % i == 0)
            return false;

    return true;
}

DWORD WINAPI CalcPrimes(PVOID param) 
{
    auto data = static_cast<PrimesData*>(param);
    int from = data->From, to = data->To;
    int count = 0;
    for (int i = from; i <= to; i++)
        if (IsPrime(i))
            count++;
        data->Count = count;

    return count;
}

int CalcAllPrimes(int from, int to, int threads, DWORD& elapsed) 
{
    auto start = ::GetTickCount64();
    // allocate data for each thread
    auto data = std::make_unique<PrimesData[]>(threads);
    // allocate an array of handles
    auto handles = std::make_unique<HANDLE[]>(threads);
    int chunk = (to - from + 1) / threads;
    for (int i = 0; i < threads; i++) 
    {
        auto& d = data[i];
        d.From = i * chunk;
        d.To = i == threads - 1 ? to : (i + 1) * chunk - 1;
        DWORD tid;
        handles[i] = ::CreateThread(nullptr, 0, CalcPrimes, &d, 0, &tid);
        assert(handles[i]);
        printf("Thread %d created. TID=%u\n", i + 1, tid);
    }

    elapsed = static_cast<DWORD>(::GetTickCount64() - start);
    FILETIME dummy, kernel, user;

    int total = 0;
    for (int i = 0; i < threads; i++) 
    {
        ::GetThreadTimes(handles[i], &dummy, &dummy, &kernel, &user);
        int count = data[i].Count;
        printf("Thread %2d Count: %7d. Execution time: %4u msec\n", i + 1, count, (user.dwLowDateTime + kernel.dwLowDateTime) / 10000);
        total += count;
        ::CloseHandle(handles[i]);
    }

    return total;
}

int main(int argc, const char* argv[]) 
{
    if (argc < 4) 
    {
        printf("Usage: PrimesCounter <from> <to> <threads>\n");
        return 0;
    }

    int from = atoi(argv[1]);
    int to = atoi(argv[2]);
    int threads = atoi(argv[3]);
    if (from < 1 || to < 1 || threads < 1 || threads > 64) 
    {
        printf("Invalid input.\n");
        return 1;
    }

    DWORD elapsed;
    int count = CalcAllPrimes(from, to, threads, elapsed);
    printf("Total primes: %d. Elapsed: %d msec\n", count, elapsed);

    return 1;
}

以下是相同值范围的一些运行,从使用一个线程的基线开始:

C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 1
Thread 1 created (3 to 20000000). TID=29760
Thread 1 Count: 1270606. Execution time: 9218 msec
Total primes: 1270606. Elapsed: 9218 msec

C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 2
Thread 1 created (3 to 10000001). TID=22824
Thread 2 created (10000002 to 20000000). TID=41816
Thread 1 Count: 664578. Execution time: 3625 msec
Thread 2 Count: 606028. Execution time: 5968 msec
Total primes: 1270606. Elapsed: 5984 msec

C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 4
Thread 1 created (3 to 5000001). TID=52384
Thread 2 created (5000002 to 10000000). TID=47756
Thread 3 created (10000001 to 14999999). TID=42296
Thread 4 created (15000000 to 20000000). TID=34972
Thread 1 Count: 348512. Execution time: 1312 msec
Thread 2 Count: 316066. Execution time: 2218 msec
Thread 3 Count: 306125. Execution time: 2734 msec
Thread 4 Count: 299903. Execution time: 3140 msec
Total primes: 1270606. Elapsed: 3141 msec

C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 8
Thread 1 created (3 to 2500001). TID=25200
Thread 2 created (2500002 to 5000000). TID=48588
Thread 3 created (5000001 to 7499999). TID=52904
Thread 4 created (7500000 to 9999998). TID=18040
Thread 5 created (9999999 to 12499997). TID=50340
Thread 6 created (12499998 to 14999996). TID=43408
Thread 7 created (14999997 to 17499995). TID=53376
Thread 8 created (17499996 to 20000000). TID=33848
Thread 1 Count: 183071. Execution time: 578 msec
Thread 2 Count: 165441. Execution time: 921 msec
Thread 3 Count: 159748. Execution time: 1171 msec
Thread 4 Count: 156318. Execution time: 1343 msec
Thread 5 Count: 154123. Execution time: 1531 msec
Thread 6 Count: 152002. Execution time: 1531 msec
Thread 7 Count: 150684. Execution time: 1718 msec
Thread 8 Count: 149219. Execution time: 1765 msec
Total primes: 1270606. Elapsed: 1766 msec

C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 16
Thread 1 created (3 to 1250001). TID=50844
Thread 2 created (1250002 to 2500000). TID=9792
Thread 3 created (2500001 to 3749999). TID=12600
Thread 4 created (3750000 to 4999998). TID=52804
Thread 5 created (4999999 to 6249997). TID=5408
Thread 6 created (6249998 to 7499996). TID=42488
Thread 7 created (7499997 to 8749995). TID=49336
Thread 8 created (8749996 to 9999994). TID=13384
Thread 9 created (9999995 to 11249993). TID=41508
Thread 10 created (11249994 to 12499992). TID=12900
Thread 11 created (12499993 to 13749991). TID=39512
Thread 12 created (13749992 to 14999990). TID=3084
Thread 13 created (14999991 to 16249989). TID=52760
Thread 14 created (16249990 to 17499988). TID=17496
Thread 15 created (17499989 to 18749987). TID=39956
Thread 16 created (18749988 to 20000000). TID=31672
Thread 1 Count: 96468. Execution time: 281 msec
Thread 2 Count: 86603. Execution time: 484 msec
Thread 3 Count: 83645. Execution time: 562 msec
Thread 4 Count: 81795. Execution time: 671 msec
Thread 5 Count: 80304. Execution time: 781 msec
Thread 6 Count: 79445. Execution time: 812 msec
Thread 7 Count: 78589. Execution time: 859 msec
Thread 8 Count: 77729. Execution time: 828 msec
Thread 9 Count: 77362. Execution time: 906 msec
Thread 10 Count: 76761. Execution time: 1000 msec
Thread 11 Count: 76174. Execution time: 984 msec
Thread 12 Count: 75828. Execution time: 1046 msec
Thread 13 Count: 75448. Execution time: 1078 msec
Thread 14 Count: 75235. Execution time: 1062 msec
Thread 15 Count: 74745. Execution time: 1062 msec
Thread 16 Count: 74475. Execution time: 1109 msec
Total primes: 1270606. Elapsed: 1188 msec

C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 20
Thread 1 created (3 to 1000001). TID=30496
Thread 2 created (1000002 to 2000000). TID=7300
Thread 3 created (2000001 to 2999999). TID=50580
Thread 4 created (3000000 to 3999998). TID=21536
Thread 5 created (3999999 to 4999997). TID=24664
Thread 6 created (4999998 to 5999996). TID=34464
Thread 7 created (5999997 to 6999995). TID=51124
Thread 8 created (6999996 to 7999994). TID=29972
Thread 9 created (7999995 to 8999993). TID=50092
Thread 10 created (8999994 to 9999992). TID=49396
Thread 11 created (9999993 to 10999991). TID=18264
Thread 12 created (10999992 to 11999990). TID=33496
Thread 13 created (11999991 to 12999989). TID=16924
Thread 14 created (12999990 to 13999988). TID=44692
Thread 15 created (13999989 to 14999987). TID=53132
Thread 16 created (14999988 to 15999986). TID=53692
Thread 17 created (15999987 to 16999985). TID=5848
Thread 18 created (16999986 to 17999984). TID=12760
Thread 19 created (17999985 to 18999983). TID=13180
Thread 20 created (18999984 to 20000000). TID=49980
Thread 1 Count: 78497. Execution time: 218 msec
Thread 2 Count: 70435. Execution time: 343 msec
Thread 3 Count: 67883. Execution time: 421 msec
Thread 4 Count: 66330. Execution time: 484 msec
Thread 5 Count: 65366. Execution time: 578 msec
Thread 6 Count: 64337. Execution time: 640 msec
Thread 7 Count: 63798. Execution time: 640 msec
Thread 8 Count: 63130. Execution time: 703 msec
Thread 9 Count: 62712. Execution time: 718 msec
Thread 10 Count: 62090. Execution time: 703 msec
Thread 11 Count: 61937. Execution time: 781 msec
Thread 12 Count: 61544. Execution time: 812 msec
Thread 13 Count: 61191. Execution time: 796 msec
Thread 14 Count: 60826. Execution time: 843 msec
Thread 15 Count: 60627. Execution time: 875 msec
Thread 16 Count: 60425. Execution time: 875 msec
Thread 17 Count: 60184. Execution time: 875 msec
Thread 18 Count: 60053. Execution time: 890 msec
Thread 19 Count: 59681. Execution time: 875 msec
Thread 20 Count: 59560. Execution time: 906 msec
Total primes: 1270606. Elapsed: 1109 msec

执行这些运行的系统具有16个逻辑处理器。以下是来自上述输出的几个有趣的观察结果:

  • 随着线程数量的增加,执行时间的改善不是线性的(甚至不接近)。
  • 使用比逻辑处理器数量更多的线程减少了执行时间。

为什么我们会得到这些结果?Fork-Join算法中的最佳线程数是多少?答案似乎应该是“逻辑处理器的数量”,因为越来越多的线程会导致上下文切换,因为不是所有线程都可以同时执行,而使用更少的线程肯定会使一些处理器无法使用。

然而,实际并不是那么简单。得到这两个观察结果的唯一原因是:线程之间的工作分配不均等(就执行时间而言)。仅仅是因为所使用的算法:数字越大,需要做的工作越多,因为sqrt函数是单调函数,其输出与其输入成正比。通常是Fork-Join算法的挑战:工作的公平分割。下图演示了四个线程的示例情况。

image.png

请注意,在上面的输出中,后面的线程运行时间更长,只是因为它们有更多的工作要做。很明显,即便系统只有16个逻辑处理器,我们也可以使用20个线程获得更好的运行时间。完成的早期线程使处理器空闲,允许那些“额外”线程(16之后)获得处理器,从而推动工作向前。有限制吗?当然,在某种程度上,上下文切换开销,加上线程堆栈内存分配过多可能导致的页面错误,将使情况变得更糟。显然,确定应用程序的最佳处理器数量不是一个容易的问题。更难以回答的是,如果线程需要频繁地进行I/O,这个问题就变得更加困难。

18.5.6.2 线程终止

每个好的(或坏的)线程都必须在某个时刻结束。Windows线程终止有三种方式:

1、线程函数返回(最佳选项)。

2、线程调用ExitThread(最好避免)。

3、线程以TerminateThread终止(通常是个坏主意)。

18.5.6.3 线程堆栈

函数的局部变量和返回地址驻留在线程堆栈上。线程堆栈的大小可以通过CreateThread的第二个参数指定,但实际上有两个值影响线程堆栈:作为堆栈最大大小的保留内存大小和准备使用的初始提交内存大小。保留内存只是将一个连续地址空间范围标记为用于某些目的,因此进程地址空间中的新分配不会从该范围中进行。对于堆栈,此举是必要的,因为堆栈始终是连续的。提交内存意味着实际分配的内存,因此可以使用。

可以立即分配最大堆栈大小,预先提交整个堆栈,但这种做法是一种浪费,因为线程可能不需要整个范围来执行与堆栈相关的工作。内存管理器有一个优化方案:提交较小的内存量,如果堆栈增长超过该量,则触发堆栈扩展,直至达到保留限制。触发是由一个带有特殊标志PAGE_GUARD的页面完成的,如果读写该页面,将导致异常。内存管理器捕获此异常,然后提交一个附加页,将PAGE_GUARD页向下移动一页(请记住,堆栈会增长到较低的地址)。下图显示了这种布置。

image.png

保护页的实际最小值为12KB,即3页,保证了堆栈扩展将允许至少12KB的提交内存可用于堆栈。

Visual Studio允许使用链接器/系统节点下的项目属性更改默认堆栈大小(下图),只是在PE头中设置请求的值。

image.png

18.5.7 Windows线程调度

18.5.7.1 优先级

每个线程都有一个相关的优先级,在线程数量比处理器多的情况下十分重要。

线程优先级从0到31,其中31是最高的。线程0是为称为零页线程的特殊线程保留的,该线程是内核内存管理器的一部分,是唯一允许优先级为零的线程。在用户模式下,优先级不能设置为任意值。相反,线程的优先级是进程优先级类(在任务管理器中称为基本优先级)和该基本优先级周围的偏移量的组合。

Windows线程可以使用以下API设置线程优先级:

BOOL SetThreadPriority(_In_ HANDLE hThread, _In_ int nPriority);

nPriority并非绝对优先级,而是相对优先级。其说明如下表:

image.png

在下图中,每个矩形表示基于SetPriorityClass / SetThreadPriority的可能线程优先级值。

image.png
下表是按优先级分类的线程优先级:

image.png
Windows优先级关系示例:

image.png

18.5.7.2 单CPU调度

调度通常非常复杂,考虑到几个因素,其中一些因素相互冲突:多处理器、电源管理(一方面希望节省电源,另一方面利用所有处理器)、NUMA(非统一内存架构)、超线程、缓存等。确切的调度算法没有文档记录是有原因的:微软可以在后续的Windows版本和更新中进行修改和调整,而开发人员不需要依赖确切的算法。话虽如此,通过实验可以体验许多调度算法。我们将从最简单的调度开始——当系统上只有一个处理器时,因为它是调度工作的基础。稍后将研究这些算法在多处理系统上的一些变化方式。

调度程序维护一个就绪队列,其中管理要执行(处于就绪状态)的线程。此时不想执行的所有其他线程(处于等待状态)都不会被查看,因为它们不想执行。下图显示了一个示例系统,其中七个线程处于就绪状态,它们根据它们所处的优先级排列在多个队列中。

image.png

一个系统上可能有数千个线程,但大多数都处于等待状态,因此调度程序不会考虑这些等待状态的线程。

单CPU的调度算法描述如下。

最高优先级线程首先运行。上图种的线程1和线程2具有最高(且相同)优先级(31),因此优先级为31的队列中的第一个线程运行;假设它是线程1(下图)。

image.png

线程1运行一段时间,称为量程(Quantum)。假设线程1有很多事情要做,当它的时间量到期时,调度程序抢占线程1,将其状态保存在内核堆栈中,然后返回到就绪状态(因为它仍然有事情要做)。线程2现在成为正在运行的线程,因为它具有相同的优先级(下图)。

image.png

因此,优先级是决定因素。只要线程1和线程2需要执行,它们就会在CPU上循环运行,每个线程运行一段时间。幸运的是,线程通常不会永远运行。相反,它们在某个点进入等待状态。以下是导致线程进入等待状态的几个示例:

  • 执行同步I/O操作。
  • 等待当前未发出信号的内核对象。
  • 当没有UI消息时,等待UI消息。
  • 进入自发的睡眠。

一旦线程进入等待状态,它将从调度程序的就绪队列中删除。假设线程1和线程2进入等待状态。现在最高优先级的线程是线程3,它成为运行线程(下图)。

image.png

线程3运行一个量程。如果它还有工作要做,它会得到另一个量程,因为它是其优先级中唯一的量程。然而,如果线程1接收到它正在等待的任何内容,它将进入就绪状态并抢占线程3(因为线程1具有更高的优先级),并成为正在运行的线程。线程3返回到就绪状态(下图)。此开关不在线程3的量程末尾,而是在更改时(线程1完成等待)。如果线程3的优先级高于15,则会补充线程3的量程。

image.png

考虑到该算法,如果在就绪状态中没有更高优先级的线程,线程4、5和6将各自具有自己的量运行。

以上是调度的基础。事实上,在实际的单CPU场景中,正是所使用的算法。然而,即使在这种情况下,Windows也试图在某种程度上“公平”。例如,如果较高优先级的线程处于就绪状态,则上述几图中的线程7(优先级为4)可能不会运行,因此它会受到CPU不足的影响。在这样一个系统中,这条线程注定会失败吗?当然不是;系统会将此线程的优先级提高到大约每4秒15次,使其有更好的机会向前推进。此提升持续线程实际执行的一个时间段,然后优先级下降到其初始值。

18.5.7.3 量程

量程(Quantum)在上面被提到了几次,但量程有多长?调度程序以两种正交的方式工作:第一种是使用计时器,默认情况下每15.625毫秒触发一次,可以通过调用GetSystemTimeAdjustment并查看第二个参数来获得。另一种方法是使用SysInternals中的clockres工具:

C:\Users\pavel>clockres

客户端机器(家庭、专业、企业、XBOX等)的默认时间量为2个时钟tick,服务器机器为12个时钟滴答(tick)。换句话说,客户端的时间量为31.25毫秒,服务器为187.5毫秒。

服务器版本获得更长时间量的原因是增加了在单个时间量中完全处理客户端请求的机会。这在客户端机器上不太重要,因为它可能有许多进程,每个进程所做的工作相对较少,其中一些进程的用户界面应该是响应性的,因此短的数量更适合。

调度类值(介于0和9之间)以以下方式设置作为作业一部分的进程中线程的量程:

Quantum = 2 * (TimerInterval) * (SchedulingClass + 1);

18.5.7.4 处理器组

最初的Windows NT设计最多支持32个处理器,其中一个机器字(32位)用于表示系统上的实际处理器,每个位代表一个处理器。当64位窗口出现时,处理器的最大数量自然扩展到64。

从Windows 7(仅限64位系统)开始,微软希望支持64个以上的处理器,因此有一个额外的参数出现:处理器组(Processor Group)。例如,Windows 7和Server 2008 R2最多支持256个处理器,意味着在具有256个处理器的系统上有4个处理器组。

线程可以是一个处理器组的成员,意味着线程可以在属于其当前组的(最多)64个处理器中的一个上调度。创建进程时,会以循环方式为其分配一个处理器组,这样进程就可以跨组“负载平衡”。进程中的线程被分配给进程组,父进程可以通过以下方式之一影响子进程的初始处理器组:

1、父进程可以使用INHERIT_PARENT_AFFINITY标志作为创建进程的标志之一,以指示子进程应该继承其父处理器组,而不是根据系统管理的循环来获取它。如果父进程的线程使用多个关联组,则任意选择其中一个组作为用于子进程组的组。

2、父进程可以使用PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY进程属性来指定期望的默认处理器组。

18.5.7.5 多CPU调度

多处理器调度增加了调度算法的复杂性。Windows唯一能保证的是,要执行的最高优先级线程(如果有多个线程,至少一个)当前正在运行。

通常,可以在任何处理器上调度线程。然而,线程的亲缘性(Affinity),即允许在其上运行的处理器,可以通过多种方式进行控制。

理想的处理器是线程的属性,有时也称为软亲缘性(Soft Affinity)。理想的处理器作为调度程序的提示,在所有其他条件相同的情况下,它是执行该线程代码的首选处理器。默认的理想处理器以循环方式选择,从创建进程时生成的随机数开始。在超线程系统上,下一个理想处理器是从下一个核心中选择的,而不是从下一逻辑处理器中选择的。理想的处理器可以使用Process Explorer工具查看,作为线程选项卡中显示的属性之一(下图)。

image.png

虽然理想的处理器可以作为提示和建议线程应该在哪个处理器上执行,但硬亲缘性(Hard Affinity),有时就称为亲缘性,允许为特定线程或进程指定允许执行的处理器。硬亲缘性在两个级别上工作:进程和线程,其中基本规则是线程不能避开其进程设置的亲缘性。

一般来说,设置硬亲缘性约束通常是一个坏主意,限制了调度程序分配处理器的自由度,并可能导致线程获得的CPU时间比没有硬亲缘性约束时少。不过,在一些罕见的情况下,可能会很有用,因为在同一组处理器上运行的线程更有可能获得更好的CPU缓存利用率。对于运行特定已知进程的系统很有用,而不是运行任何东西的随机机器。硬亲缘性的另一个用途是压力测试,例如在某些执行中使用较少的处理器,以查看有限数量处理器的系统在运行相同进程时的行为。

线程的关联不能“逃逸”其进程亲缘性,然而,在某些情况下,让一个线程(或多个线程)使用进程中其他线程禁止使用的处理器是有益的。Windows 10和Server 2016添加了此功能,称为CPU集(CPU Set)

CPU集表示处理器的抽象视图,其中每个CPU集可能映射到一个或多个逻辑处理器。然而,目前,每个CPU集都精确地映射到单个逻辑处理器。系统有自己的CPU集,默认情况下包括系统上的所有处理器。

CPU集和硬亲缘性可能相互冲突。在这种情况下,硬亲缘性总是胜出,即忽略CPU集。

多处理器(MP)调度很复杂,涉及硬亲缘性、理想处理器、CPU集、电源考虑、游戏模式和其他方面。在MP系统上,每个处理器都有自己的就绪队列。此外,在Windows 8及更高版本上,处理器组有共享就绪队列(目前每组最多4个),允许调度程序在需要为连接到共享就绪队列的就绪线程定位处理器时拥有更多选项(每个CPU就绪队列仍用于具有硬亲缘性的线程)。下图给出了一种改进的、简化的MP调度算法。它假设没有亲和性或CPU集约束,没有功率或其他特殊考虑。

image.png

如上图所示,理想的处理器是首选处理器,其次是它运行的最后一个处理器(处理器的缓存可能仍然包含该线程使用的数据)。如果所有处理器都忙,调度器不抢占运行低优先级线程的第一个处理器;这是低效的,因为可能需要搜索许多处理器。相反,线程被放入其理想处理器的(共享)就绪队列中。

在Windows系统,可以使用Windows Performance Recorder (wprui.exe)和Windows Performance Analyzer (WPA)来分析、追踪和监视每个线程的调度情况(下图)。

image.png

18.5.7.6 后台模式

有些进程自然比其他进程更重要。例如,如果用户使用Microsoft Word,可能希望自己的交互和Word的使用非常好。另一方面,诸如备份应用程序、反病毒扫描程序、搜索索引器等进程并不重要,不应干扰用户的主要应用程序。

这些后台应用程序限制其影响的一种方法是降低其CPU优先级。此举虽然可行,但CPU只是进程使用的一种资源,其他资源还包括内存和I/O,意味着降低线程的CPU优先级或进程的优先级等级可能不足以降低此类进程的影响。

Windows提供了后台模式(Background Mode)的概念,其中线程的CPU优先级下降到4,内存优先级和I/O优先级也下降。例如,在线程视图的进程资源管理器中查看Windows资源管理器,显示内存和I/O优先级以及CPU优先级(下图)。I/O优先级的默认值为“正常”,内存优先级的默认数值为5(可能值为0到7)。

image.png

18.5.7.7 优先级提升

优先级是调度的决定因素。然而,Windows对优先级进行了一些调整,称为优先级提升(priority boosts)。这些优先级的临时增加是为了在某种意义上使调度更加“公平”,或者为用户提供更好的体验。

当线程发出同步I/O操作时,它将进入等待状态,直到操作完成。一旦完成,负责I/O操作的设备驱动程序就有机会提高请求线程的优先级,从而提高其运行速度,因为操作最终完成。优先级提升(如果应用)将线程的优先级增加一个由驱动程序决定的量,并且线程管理运行的每个时间段的优先级都会下降一个级别,直到优先级下降到其基本级别。下图显示了该过程的概念视图。

image.png

此外,前台进程、GUI线程唤醒、饥饿避免法则等情况也会触发优先级提升。但是,应尽量避免使用优先级提升,因为未来可能被微软抛弃。进程或线程还可以被挂起、继续或睡眠等操作,以执行不同粒度的调度行为。

作者:向往
文章来源:https://zhuanlan.zhihu.com/p/579029158
推荐阅读
关注数
1670
文章数
216
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息