程序与进程CPU执行的奥秘

11059 字
55 分钟
程序与进程CPU执行的奥秘

本文基于教程:https://www.youtube.com/playlist?list=PL9vTTBa7QaQPdvEuMTqS9McY-ieaweU8M 如有疑惑,请移步观看课程视频。

**一、程序和进程#

首先我们要区别程序(program)和进程(process)的区别:程序(program)是静态的文本代码,需要通过编译或解释才能执行。执行时创建动态实体,即进程(process)。在现代计算机中,操作系统通过调度算法(如时间片轮询)管理多个进程的并发运行。同一个程序可以启动多个进程实例,每个实例有独立的内存空间;内存占用(如大小)不同,是由于运行时数据(如用户输入或文件内容)的差异,而非程序代码本身。例如,多个记事本进程打开不同文件时,内存分配因文件数据而异。 同时,我们要理清第二个概念:程序执行的到底是什么?关于这一点,我的理解是程序执行本质上是CPU执行机器码的过程。对于编译型语言(如C/C++/Rust),代码经过预处理、编译、汇编和链接后生成可执行文件。该文件包含机器码(存储在内存的代码段)和初始数据(存储在数据段),机器码直接指导CPU的活动与调度。例如,使用g++编译命令g++ xxx.cpp -o xxx.exe生成的.exe文件,其机器码部分在运行时加载到代码段。而python/java类的解释性语言则完全不同,解释型语言(如Python/Java)并非完全不需要编译:Java源代码先编译为字节码,Python源代码编译为字节码,然后在虚拟机或解释器上执行。与编译型语言的关键区别在于,字节码需要运行时解释或JIT编译为机器码,这牺牲了性能以换取跨平台性和开发效率。性能开销大的原因包括解释执行延迟、动态类型检查以及内存管理(如垃圾回收),而非‘次次编译’或‘文本存储’。代码本身存储在内存的代码区,而非堆区;堆区主要用于对象分配。并且,除了TEXT段,其他段都是动态响应的。

**二、CPU执行中的中断恢复机制#

在CPU时间片轮询的过程中,我们常常会有疑惑:在有限的轮询时间里进程数据是否会有安全性泄露和运行计算错误的随机性风险。下面我将结合我的理解来聊聊这个机制。 首先为了防止安全性泄露和错漏问题我们在中断前引入了类似“快照”的PCB存储机制。具体实现为:在内存中划定一篇区域用结构体去存上一个进程中断前的所有寄存器数据及相关存储元件数据,等到下次轮询的时候再恢复状态【中断发生时,CPU 会先将当前进程的寄存器上下文(程序计数器、栈指针、通用寄存器等)保存到该进程的内核栈上;随后操作系统把这些信息整理到进程控制块(PCB)中,相当于为进程拍了一张“快照”。当该进程被再次调度时,系统就从 PCB 中恢复这些寄存器值,从而继续执行】。**(此时我们将每个进程看作一个容器,进程只有在自己容器里运行,这也是多进程场景为人诟病开销大的根本原因。同时,进程不好抽象放入队列时间片轮询,故其实在队列中的一直是PCB结构体。)这被称作“上下文切换”。根据系统架构不同,PCB也不相同。同时操作系统OS也在不停的给不同进程划分合适的内存空间保证安全性。(PS:多核可以同时维护多个PCB队列)

三、操作系统OS与硬件组成HardWare的联动#

首先,我们要更正一种观点:OS也是一种广义上的软件,只不过它运行在内核层,是一种特殊的交互软件,拥有特权指令的使用权(可以理解为super管理员权限),它负责用户空间和底层硬件的交互。 然后我们再来刨析OS是怎样运行且保证安全性的:首先我们要认识到一点——为了保证安全性,我们通常划分为内核模式(可调用特权指令)和用户模式。而他们的切换由一个寄存器来控制: 我们在运行程序的时候通常使用的是用户空间,这意味着我们不能使用特权指令,那么我们在需要使用调用系统层面的api函数的时候就会触发中断,将进程托管给内核运行。【当用户程序需要请求操作系统服务(如读写文件、网络通信等)时,会执行一条特殊的系统调用指令(例如 syscallint 0x80),主动触发一个软件中断。此时 CPU 从用户模式切换到内核模式,并把控制权转交给操作系统内核中对应的系统调用处理程序,相当于把当前进程的“托管权”交给了内核。内核完成服务后,再通过中断返回指令让进程继续在用户空间执行**】。 现代系统常用将内核常驻特权模式以获得通过特权指令保持对计算机的完全掌控。图中棕色内存区域存储着中断所需的服务例程(上一个图片所述)。也就是说,我们可以梳理出一个进程处理范式:cpu轮询处理队列中的pcb模块,然而当一个新进程启动的时候,假设它遇到了中断,就将此内容抛入pcb队列,等待cpu执行。OS在次过程中控制:IO,内存和中断。同时我们要认识到:中断不仅发生在硬件,还发生在软件层面:如果你有兴趣钻研linux源码,可以发现系统api(系统调用)函数内部我们也能找到出发中断的特权指令,让程序计数器跳转至操作系统地址空间中的特定例程。同时,系统调用函数也是对于驱动层以下的抽象化接口,让我们更加易的调用硬件抽象。同时你不用担心c/cpp这类中级语言会烧坏电路,因为硬件层由操作系统调配,我们只是做逻辑上的抽象。

| 中断类型 | 触发源 | 处理机制 |
|----------------|---------------|----------------------|
| 硬件中断 | 外设(键盘/磁盘) | 中断控制器→中断服务例程 |
| 软件中断(int) | 系统调用 | 陷入内核→执行特权指令 |

综上,操作系统让调用变得易用且安全。多系统的架构适配也让它具有跨平台功能。下面我们将要探讨非抢占式或协作式操作系统与抢占式操作系统。我们之前所说的用户空间与内核空间的协作是传统的非抢占式或协作式操作系统,现代基本已经弃用。因为假如在一个程序里面卡住了死循环,进程不能进入下一步,那么下一次系统调用永远进入不了pcb队列,操作系统也就会因此卡住。 现代的做法则是使用抢占式操作系统。它的结构很简单,就是在原来的基础上增添了计时器来在某些时刻强制中断一段例程进入下一步。增加了操作系统运行的鲁棒性。 Tips:现在我们聊一下知识拓展:不仅仅OS存在于内核模式,我们常见的显卡驱动和作弊检测等进程也运行在此模式中。 首先关于显卡,它的出现是晚于操作系统的,故操作系统并没有对于此显卡的适配,也就是说操作系统识别不出显卡是个什么东西。所以经常是厂家编写显卡的驱动程序来告诉操作系统如何运行使用它。由于是从硬件自底向上,所以它在OS所在层运行。有趣的是这类驱动常常和os写在一块地址上,所以编写十分困难且谨慎,否则容易让整个操作系统崩溃。作弊检测软件也在OS所在层进行,因为它的职责是扫描硬件是否存在作弊行为。如果是在用户层运行,那么经过操作系统,我们不知道是否是来自键盘输入还是来自手柄输入。最后我想聊聊安全检查的运行,它也在OS所在的内核模式层运行,通过监测内存空间的以异动和是否向可疑终端发送信息来判定是否受到攻击。

四、IPC通信机制#

在进程的运行中,我们常常将其分为协作进程和独立进程。在具有并行性能的计算机通常使用拆解为模块化的例程同时并行执行,减少运行时间。同时,我们在上一节课里已经了解到:特权指令保证了进程之间不会越界,如果越界,操作系统将立即中断并终止该进程,通过强制隔离策略保证数据的安全性。 但是在协作进程里我们该怎么做呢?主流的方法有共享内存和消息传递:首先我们来介绍共享内存—— 共享内存是操作系统在内存中为多个进程通信而制作的内存块。操作系统只负责创建而不负责后续的分配。故而其效率非常高。多个进程将内存映射到这一块共享内存中,并且通过系统调用进行信息的读写。但是由于缺乏操作系统的规范和维护,我们通常使用信号量这一机制协助操作来维持通信的正确性和稳定性。一个经典的例子是谷歌浏览器:我们在打开使用谷歌浏览器的时候通常会在任务管理器里发现我们开启了多个进程,这其实是谷歌对Javastrcip所带有的语言缺陷而制作的防御机制。当我们运行时,它被分为多个进程分管插件,检索,播放器等的进程。这保证了一个进程崩溃而其他组件(进程)仍继续运行。他们之间使用共享内存和信号量来通信,实现数据互通。有趣的是,共享内存+信号量其实更常用于量化交易这类高性能场所,因为它复杂且难,不过这也是为什么谷歌被称为最棒的浏览器的原因之一了。

下面,我要介绍消息传递机制(也称消息队列)了,常用的消息传递机制包括管道、套接字和远程过程调用。操作系统内核会在自身地址空间内创建队列作为“邮箱”,而进程会在这些邮箱中来取信息和发信息,你可以把它看作一个拷贝中间件。当我们需要限制消息队列的时候,常用异步代替同步机制,或使用带缓存的队列。为了实现全双工的功能,我们使用双队列来执行“邮箱”功能,使两个进程能同时互传消息且互不干扰: 当然,由于消息队列处于操作系统的内存内,进程不能够对它进行直接的调用,所以操作系统为它提供了两种交互方式:send()和receive(),他们作为系统调用的中间件来运输信息(虽然会有拷贝来减低性能)。 客户端和服务端常使用套接字建立连接,他们是在机器上运行的进程而且并非必须需要网络才能运行,比如本地环回就能避免网络传输。无论如何,当客户端向服务器发送请求时,IP地址标识的是承载服务器进程的机器,提供HTTP、FTP或SSH等特定服务的服务器都依赖于套接字接口, 虽然消息队列实现有很多优点,但是每一次执行都有复数级的系统调用导致了开销巨大: 而共享内存由于直接操作内存片段,故而没有这个开销,但是代码极其复杂。

五、关于线程及其机制(单核)#

首先,我们要介绍并发这一技术,它通常与CPU调度一起协作使用。并发是指多个进程或线程在同一时间段内交替执行,通过时间片轮询的方式共享CPU资源。这种技术使得多个任务看似同时运行,实际上是通过快速切换实现的。

  • 时间片轮询**:每个进程被分配一个固定的时间片,时间片用完后,CPU切换到下一个进程。
  • 上下文切换:调度器保存当前进程的状态,加载下一个进程的状态,确保切换高效。

在传统的多进程中,每个进程只有一个程序计数器这也导致了它不能使用异步协程,故只能顺序执行。此时下图灰框的时间CPU没有运作,而因此它处于阻塞状态,这很浪费。 为了解决这个棘手的问题,我们构造了一个监听进程专门来处理消息,然后把他们分派给其他进程执行,大大减少执行时间,提高了执行效率,尽管造成了资源浪费,内存被大量开销: 为了解决程序在进程里线性顺序运行而多进程开销巨大的问题(程序计数器一次只能处理一个任务的中断),我们引入了线程,其精髓是将程序计数器分发给进程内每个需要并发执行的内部可执行实体: 这样既解决了阻塞问题又无需创建新进程。假如某个线程要进行IO操作,不会中断影响其它线程的执行: 同时,线程为了保证在“中断”后下次仍继续运行,除了程序 计数器之外,还同时配备专属的寄存器组、状态标志位、累加器等,本质上构成其独有的CPU环境。同时具有独有的栈空间,用来防范数据冲突。 线程之间消息传递通常使用堆内存,因为它不易错,更安全。虽然我们默认多线程共享一个进程空间,但是他们仍要通过IPC进程通信来共享数据。 现代操作系统常用的是动态线程操纵,尽管会增加操作系统复杂度,但是由于编译阶段无法预知运行时实际需要的线程数量,它仍是现阶段场景下的主流选择。同时当主线程结束,其它线程也会跟着结束,他们很脆弱(^_^) 在服务器中,线程通过程序计数器指向进程的TEXT区域(文本段和数据段),这意味着多个线程可以指向完全相同的可执行代码。而且多进程执行相同的一段代码是绝对安全的 很多时候大家会困惑C/CPP里面为什么线程函数为什么要带指针?答曰:实际上我们传递的是线程将要执行的代码起始内存地址。

六、线程与系统控制(多核)#

多核处理器让并发并行运行或全部并行,提高了效率,以下是对比: 多核建立线程并不能通过建立n个线程同时进行n个任务,他们遵循两个准则:(1)核心数量是固定的:当有N个核心时,最多可有N个线程并行执行。(2)线程竞争资源:操作系统确保在所有线程之间公平分配CPU资源。 并行也分为两种:(1)数据并行性:将相同数据的子集分布在多个计算核心上,并在每个核心上执行相同的操作。(2)任务并行性:将任务(或线程)而非数据分发到多个计算核心。每个线程执行一个独特操作。不同线程可能正在对同一数据进行操作。不同的线程可能在不同数据上运行。

七:不同系统间的文件#

在生活中我们有时发现奇怪的现象:同样的程序在win上能运行但是在mac上却不能?很多人会回答是因为CPU架构不同,一个是x86,一个是arm,但是2019之前,两家都用的x86却不能够互通,这说明原因不仅仅在与处理器芯片架构,还和系统调用(systemcalls)有着密切的关系。 usermode(用户模式)不能直接控制kernelcore(内核模式)但是可以请求系统调用来通过操作系统完成读出和写入的步骤。同时,因为系统调用的不同,在编译机器码的时候,不同平台会生成不同的指令来实现相同结果,但那些额外指令在另一个操作系统中将失去意义,最终导致未定义行为而系统崩溃。除此之外,系统调用的编号不同和寄存器编号不同都会影响程序的编译,导致崩溃。但是即使这些都相同也不行,因为系统调用传递参数的方式也不同(^_^),他们传递放置的位置也不同。最后,可执行文件的文件格式也是原因之一,因为他们格式导致了存储和调用在内存里分配的位置不同。所以说,不同系统里面的文件是不能互通的。 在用户空间执行系统调用之前,会实现一次中断并切换进入内核空间。我们常用的X86-64架构在执行这项任务的时候常用systemcall,但是如何进行区分这些systemcall呢?这时候我们采用了随机分配编号的方法,为每个调用分配唯一的编号。当使用时,将对应编号写入寄存器,进行相应的行为(查询系统调用表并进行相应的系统调用)

八、基于中断的切换机制#

正如我们之前所讨论的,中断的过程是:中断触发(系统调用) → 硬件保存部分上下文 & 切换内核模式 → 软件保存完整上下文到PCB → 执行中断服务程序 (定时器监督)→ 返回(恢复上下文) / 可选上下文切换**。当然这是我们前面讲述的软件实现方式,它很少用于现实,因为安全性和实用性都很差(软件开销大且效率慢),下面我将拓展探讨关于中断的一原理。 中断令人难以处理的部分是——在执行中断前,寄存器程序计数器已经存放的是中断代码所在的内存地址,这代表着我们在内核模式通过特权指令难以通过被禁用的特权指令完成相应任务后由于中断信号被覆盖,难以再通过程序计数器返回上一状态。 故我们常常先通过操作系统软件的栈指针来记录CPU寄存器里的值,并且在存入开始和结束的时间,修改栈指针前将其值存入内存单元,记录下栈顶指针的值,之后直接从该内存区域推送数值。

下面,我们来介绍硬件的解决方法:我们为关键寄存器(例如栈指针和程序计数器)配备独立副本,同时将寄存器划分为内核和用户两部分,这样就不用每次繁琐的进行切换了。 用户寄存器处理用户级别的普通的数据,当涉及系统调用的时候,我们将使用内核寄存器来控制使用那些被禁用的特权指令达到目的。同时在这个过程中上一个模式自然的记录了重要的寄存器数据,完成执行后直接去读就好了,避免了传统栈方法的繁琐和软件层面的耗时。当时首先你得认识到硬件永远比软件要快且好,但是基于其不可变性与批量生产制造成本高昂,所以即使是最激进的操作系统内核开发者也只会将它用在自动化存储数据这种通用内容之上。

九、系统理论:操作系统的核心组件——CPU调度器#

CPU调度是操作系统采用的核心技术,它根据特定规则决定在何时将CPU资源分配给哪个任务或进程执行。这一节我们将要介绍CPU在调度中的核心机制。 CPU 调度是操作系统中最核心的组件之一,它决定了哪一个进程能够在给定的时间使用处理器。正是因为通用操作系统在设计调度策略时优先保障响应时间,我们才能在 CPU 满载运行基准测试或者渲染视频的时候,仍然流畅地拖动鼠标、播放音乐,仿佛系统毫不费力。要理解这背后的机理,需要从最基本的概念开始,逐步深入到实际系统使用的复杂调度算法。 当一个程序被加载执行时,它成为一个进程,操作系统在内存中为其分配一个文本段,用来存放可执行代码。CPU 内部有一个程序计数器,也就是地址寄存器,它始终指向内存中下一条要执行的指令。所谓把 CPU 分配给某个进程,本质上就是让程序计数器指向该进程的文本段。当 CPU 被重新分配给其他进程时,操作系统会修改程序计数器的值,使它指向新进程的代码。这个切换过程叫做上下文切换,它需要保存当前进程的全部执行现场,并恢复下一个进程的现场,CPU 才能继续执行。操作系统并不直接把进程作为调度对象,而是使用进程控制块来代表一个进程。进程控制块不是进程本身,而是一个数据结构,里面记录了进程的状态、程序计数器、寄存器内容、内存管理信息等几乎所有必要的元数据。每创建一个进程,系统就生成对应的进程控制块,后续的调度、切换和回收都是围绕这些控制块展开。 进程在执行过程中并不会永远占据 CPU,它的生命周期由几个状态组成。刚被创建时处于新建状态,程序文件加载完毕后进入就绪状态,等待使用 CPU。一旦被调度器选中并开始执行,就进入运行状态。程序在执行过程中经常会因为输入输出操作或者等待用户输入等事件而主动放弃 CPU,这时它会进入等待状态,直到事件完成后重新回到就绪状态。最终进程执行结束,进入终止状态。对于绝大多数进程,都是在就绪、运行和等待这三个状态中反复循环,它们交替经历 CPU 突发和 I/O 突发。CPU 突发是指进程连续使用处理器进行计算的时间段,而 I/O 突发则是进程在发起 I/O 请求之后,等待磁盘、网络或者键盘等设备响应的时间。大量的观察数据表明,绝大部分进程的 CPU 突发都非常短,只有极少数进程存在很长的 CPU 突发。这个规律对调度算法的设计有着根本性的影响。 为了有效管理众多进程,调度本身由两个紧密配合的部分组成。调度器负责按照既定策略从就绪队列里挑选下一个获得 CPU 的进程;分派程序则负责执行实际的上下文切换,把选中的进程真正放到 CPU 上去运行。上下文切换的时间,也被称为调度延迟,在很长一段时间里,工程师们热衷于减少调度延迟的时间优化系统响应速度,因为调度延迟纯粹是系统开销因此分派程序必须做到尽可能快。 评价一个调度算法好坏的标准有很多:CPU 利用率衡量了处理器真正用于执行用户工作的时间比例;吞吐量关注单位时间内完成的进程数量;周转时间是从进程创建到它彻底完成所经历的总时间,既包括在就绪队列里的等待,也包括 CPU 执行和 I/O 等待;等待时间则只计算进程在就绪队列中等待 CPU 的总时长;响应时间则衡量从提交请求到系统第一次给出响应之间的时间长短,它对交互式体验至关重要。 最简单的调度算法是先来先服务。它使用一个先进先出的队列,进程的进程控制块按照到达顺序加入队尾,当 CPU 空闲时,就从队头取出控制块分配处理器。这个策略的最大优点是实现简单,几乎没有额外的调度开销。但它的缺陷也很明显:排在队列头部的是一个 CPU 突发很长的进程时,所有后续的短进程都只能眼巴巴地等着,形成所谓的护航效应。此时 I/O 设备处于闲置状态,因为短进程无法及时执行并发起新的 I/O 操作,而 CPU 却被长进程独占,整个系统的设备利用率和吞吐量都被拉低。更糟糕的是,在非抢占系统里,如果某个进程没有主动执行系统调用,却在一个无限循环中空转,它就会永远霸占 CPU,其他所有进程都将冻结。 针对长进程阻塞短进程的问题,最短作业优先调度算法进行了改进。它不再简单地按照到达顺序分配 CPU,而是去选择下一次 CPU 突发预测长度最短的那个进程。如果两个进程的预测长度相同,则退回到先来先服务的规则。从理论上说,最短作业优先能够在给定进程集合上获得最小的平均等待时间。原因在于,让短进程先运行可以大幅缩减它们的等待时间,而长进程尽管被延后了,但它只增加了一个相对微不足道的等待时间,两者的平均下降了。然而,这个算法面临一个几乎无法克服的困难:我们根本不可能提前知道某一个进程的下一次 CPU 突发到底有多长。解决的办法是去做预测,利用进程过去的历史来估计未来。通常采用指数平均法来推算预测值:公式的主体是把上一次的实际 CPU 突发长度和上一次对它的预测值按一定比例组合起来。具体来说,有一个系数 a,取值在 0 和 1 之间。当 a 等于 1 时,预测值完全取决于最近一次实际突发;当 a 等于 0 时,则完全抛弃近期行为,只信赖最初设定的基准值。展开这个公式会发现,历史上的每一次实际突发都对当前预测有贡献,但时间距离现在越远,其权重越小,呈指数衰减。这样,预测值既能跟踪进程行为的变化,又不会因为一两次异常值而剧烈浮动。对于新创建的进程,因为没有历史数据,操作系统一般会用一个人为设定的默认值或系统平均值作为初始预测。尽管这种预测永远不可能绝对准确,但已经能为最短作业优先算法提供可用的近似值。 从先来先服务和最短作业优先可以看出,非抢占式的调度在通用系统中存在天然缺陷。如果运行中的进程不主动让出 CPU,调度器就毫无办法。为了解决这个问题,现代操作系统几乎都采用抢占式调度。也就是说,即使某个进程还在运行,只要条件满足,调度器就能强制打断它,把 CPU 夺走分配给另一个进程。这种方式靠的是硬件定时器支持,当分配给进程的时间片用完时,定时器会产生一个中断,操作系统顺势执行上下文切换。正是依靠抢占,系统才能实现并发,让多个进程在微观上轮流执行,宏观上给人所有程序同时运行的错觉。不过,抢占也会带来另一个麻烦:如果调度算法总是优先某些进程,其他进程就可能长时间分不到 CPU,产生饥饿。比如在最短作业优先里,只要不断有短进程进来,长进程的预测值永远竞争不过,可能永远得不到执行。 轮转调度是一种专门为分时系统设计的抢占式算法,追求的是公平。它依然使用先进先出队列,但增加了一个关键机制——时间片。每个进程被分派到 CPU 时,最多只能连续运行一个时间片的时间。如果进程在这个时间片内因为 I/O 等原因主动释放 CPU,调度器自然切换到下一个进程。如果时间片耗尽了它还没有完成当前的 CPU 突发,定时器会强行中断它,把它挂到就绪队列的末尾,然后调度队头的下一个进程。这样的轮转保证了每一个就绪进程都能定期获得一小段 CPU 时间,不存在某一个进程一直占着处理器的情况。从响应时间的角度看,如果有 n 个进程,每个进程最多等待 n-1 个时间片就能再次运行,对用户来说响应是均匀且可预测的。但轮转调度的表现严重依赖于时间片的大小。一旦时间片设置得太大,轮转调度就退化成了先来先服务,长进程能连续运行很久,其他进程长时间无响应。反过来,如果盲目地把时间片设置得极小,上下文切换就会频频发生,处理器花费在保存和恢复现场上的时间比例急剧上升,真正用来干活的 CPU 时间变少,系统的吞吐量显著下降,周转时间被拉长。需知上下文切换绝非只是简单地保存几个寄存器的值,它往往需要陷入内核态,将当前进程完整的寄存器组、程序计数器、栈指针、内存映射信息等全部保存到进程控制块中,再从另一个进程的进程控制块中恢复整套运行现场,其中还可能涉及页表切换带来的 TLB 刷新以及 CPU 高速缓存的逐渐失效。这些操作本身就要消耗数十微秒甚至上百微秒的时间。如果时间片长度与上下文切换开销处于同一数量级,甚至更小,那么 CPU 每完成短短几微秒的用户指令就会被中断一次,转而花费大量周期去执行内核中的切换代码,整个系统将陷入“切换—执行一丝—再切换”的恶性循环,有效工作比例急剧下降。因此在实践中,时间片的大小必须远大于一次上下文切换的耗时,又要让绝大多数 CPU 突发能在一个时间片内自然完成,在响应速度和整体效率之间达到平衡。 轮转调度虽然公平,但它对所有进程一视同仁,这在真实系统中并不是最优解。用户正在打字或者播放视频的交互式进程需要被快速响应,而后台压缩文件或者检查更新的进程晚半秒钟根本无人在意。于是,优先级调度应运而生。系统给每个进程赋予一个代表优先级的整数值,通常数值越小表示优先级越高,但也可能反过来,这完全取决于具体实现。调度时总是选择当前就绪进程中优先级最高的那一个来运行。相同优先级的进程之间则可以结合轮转调度来保证内部的公平性。优先级调度带来的最大好处是能够按照实际需求区分对待进程,把重要的、交互性强的任务推到前面,从而在感官上大幅提升系统的流畅度。但它的风险在于饥饿:如果高优先级进程源源不断地到来,低优先级的进程就可能永远没法被选中。一种经典的应对手段叫做老化,就是让进程在就绪队列里等待的时间越长,它的优先级就慢慢往上升。哪怕初始优先级最低,只要等得足够久,它终究会变成最高优先级而得到执行。这样既保留了优先级调度的灵活性,又避免了永久阻塞。 如果只用一个队列来管理不同优先级的进程,调度器在高负载下可能需要遍历整个队列才能找出优先级最高的项,效率会降低。多级队列调度进一步把进程分门别类,放入各自独立的就绪队列当中,每一个队列可以有自己的调度算法。比如,前台交互进程进入一个队列用轮转调度,后台批处理进程进入另一个队列用先来先服务。然后在这些队列之间再建立一个层级调度,常见的方式是让高优先级队列具有绝对优先权,只有当它为空时,才去调度低优先级队列中的进程。也可以按比例在不同队列间分配 CPU 时间,避免低优先级队列完全饿死。但这种多级队列有一个明显的不足:进程一创建就被分配到了某一级别的队列,并且终身不变。然而一个进程的行为是会变化的,比如邮件客户端绝大多数时间在后台空闲,偶尔被用户激活到前台时,立即就需要极高的响应速度,固定的队列分配不能满足这种动态需求。 为了应对进程行为的动态变化,多级反馈队列调度把多级队列的思路往前推进了一大步。它同样设置了多个优先级的就绪队列,但进程的队列归属不是一成不变的,而是根据它在运行中实际表现出来的 CPU 使用模式动态升降。一般来说,最高优先级的队列分配的时间片最短,往下每降低一级,时间片就增长一些。所有新进程都从最高优先级队列开始。如果进程在获得 CPU 后,在时间片用完之前就主动发起 I/O 进入等待状态,这表明它很有可能是交互式的短突发进程,于是它被放回原来的高优先级队列。如果进程耗尽了分配给它的时间片还没有结束这一次 CPU 突发,系统就会抢断它,并把它降级到下一层优先级更低的队列,在那里它将获得更长时间片,以便在后台一次性完成更多的计算。这种设计把短 CPU 突发、需要快速响应的进程自然而然地留在了高层,而把那些真正需要大量计算的 CPU 密集型进程慢慢沉到底层的大时间片队列里。同时,为了防止底层进程被无限期推迟,还可以结合老化机制,在一定条件或时间后将其优先级重新提升。这样一来,多级反馈队列不需要人工去标记哪个进程更“重要”,而是让每个进程用自己的实际行为投票,自适应地实现了交互进程快速响应与后台密集计算批量执行之间的平衡。这也是为什么我们在高负载时依然感觉系统流畅的根本原因:调度器自动把绝大多数 CPU 时间让给了那些在高层排队、突发极短的交互式进程,而占用 CPU 多的渲染或编译任务默默地在底层用剩下的时间片运行,不会打断我们的操作。 关于现代 CPU 调度,还需要补充几个关键事实。今天操作系统调度的实体已经不是传统意义上的整个进程,而是线程。同一个进程内部可以有一个线程专门负责用户界面,另一个线程负责后台计算,它们各自拥有独立调度资格,可以分配不同的优先级,从而让一个重度计算的应用程序也能保持界面的流畅。此外,进程在发起 I/O 后进入的等待队列也并不是一个简单的先进先出队列,实际的 I/O 子系统会有自己的 I/O 调度器,会根据设备特性优化寻道、合并请求等,这与 CPU 调度是分离的。在多核处理器环境中,调度器还要考虑进程在各个核心之间的分配与迁移,牵涉到负载均衡、缓存亲和性以及功耗管理,这些问题需要更加复杂的调度策略去应对。

十、竞态条件#

首先我们需要理解到:源代码中的大多数操作需要通过多个 CPU 指令周期才能完成预期效果。多进程则是通过系统调用实现的“可并发执行”的例程,其执行无需等待前一个进程完成即可启动(也可设置为阻塞模式,但这属于核心功能的扩展内容,暂不深入讨论)。当机器具备多核处理器且进程数量不超过核心数时,系统执行的是真正的并行并发;而在单核处理器上,则是通过操作系统在进程间进行时间片轮转调度来实现并发执行。 那么我们在这里需要讲述一个例子:在实际运用中,HTTP是我们常用的报文方法,而在这种BS的端侧架构中,我们常用并发来处理客户端信息,这样可以保证消息的实时性。朴素的 解决方式是定义一个全局变量并制作一个客户端存取这个全局变量并专门处理消息的逻辑。 不过这会导致一个极大的错误:在8500w次的首发测试中,我们会发现一些不和谐的地方而且这些不和谐的插曲是不可预测的: 那么,这是为什么呢?答案说:对于不同的计算机架构,操作系统传递消息是一块一块传递的,而并非C语言的原子化单字符传递,这样效率太低,因此,当时间片运行完之前被中断,那么剩余部分的信息块与剩余未操作完的汇编指令就会错位到另一个进程中使用,同时可能引起连锁反应,所以盲目使用竞态是危险的。有人想出用特定的寄存器存取状态来避免这种情况的产生,但是很遗憾,寄存器在被时间片中断的时候也可能会产生竞态:比如说我们在写一个计数器操作时要自增五次,但是第一次在中途中断了,程序计数器停在了11,那么后面的进程没有受影响一直加到了14,并返回时,再次遇见11,这时14变成11,4个存储次数消失了。

十一、内存理论#

在讲述这块理论之前,我们要梳理一下观点:内存是CPU直接操纵的工作台,当我们想要运行程序的时候(程序存在磁盘里),我们会先从磁盘加载程序并创建进程运行,此时cpu才能操作并调用。 这里涉及到一个安全问题:当我们想线程间共享数据的时候,很有可能会导致内存信息泄露,比如说支付程序,假如可以无限制访问那么黑客可以通过另外一条进程执行加载/存储指令的代码,就能进程的地址空间读取支付数据将其复制到自身地址空间,并通过网络发送给恶意攻击者或者篡改支付缓冲区,改动支付对象谋取私利。所以,默认情况下,进程必须被隔离,并且不允许 访问任何其他进程的地址空间。 通常,我们使用基址寄存器(开始地址)和界限寄存器(大小)来限制访问区域,如果本进程有范围外地址被访问,则判定为恶意入侵。 我们通过二进制比较器来确定范围: 通过图中Read Eable和Write Enable可以很简单实现阻止非法访问的操作

最后加上审查电路,我们可以讲信息反馈给CPU,进而终止进程来抵抗这种违规行为。

下图是现代封装架构: 总结一下,操作系统不仅要保存被中断进程的状态,并恢复新进程的状态,还需更新内存边界——即调整基址与界限寄存器,使其与新进程的地址空间相匹配。当所有设置但在该模式下,CPU无法修改特权寄存器。准备就绪后,操作系统会将CPU切换至用户模式,因此,进程只能使用操作系统明确分配给它的内存空间。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
程序与进程CPU执行的奥秘
https://firefly.cuteleaf.cn/posts/knowledge/程序与进程cpu执行的奥秘/
作者
Firefly
发布于
2026-06-21
许可协议
CC BY-NC-SA 4.0
相关文章 智能推荐
1
多进程编程总结
Linux编程 本章记录笔者在多进程编程中的实验心得与感受。 1、多进程的相关概念: 1进程是程序一次执行的过程,有一定的生命周期,分为:创建态,就绪态,执行态,挂起态和死亡态。 2进程是计算机资源分配的基本单位,系统会给每个进程分配04G的虚拟内存,其中03G是用户空 间,34G是内核空间 3其中多个进程...
2
链表的实现与介绍
数据结构 ——————————————本文旨在交流计算机知识,欢迎指正————————————— 首先,我们回顾一下上一期讲的顺序表: 顺序表是一种物理结构上连续的,一整块连续空间的逻辑表,但是,试想一下,如果每次我们都是申请在一块整块空间里面的顺序表,但是我们并不知道堆空间里面我们malloc一块空间之后我...
3
数据结构深度搜索与广度搜索的概念
数据结构 ————————————本文旨在交流计算机知识,欢迎指正!———————————— 上一章节我们系统介绍了树的存储结构与基本概念,这一章,我们将详细介绍一下——树的基本应用。首先,笔者会介绍树的基本遍历模式,其次,我会详细讲解深度优先搜索和广度优先搜索的概念。 首先,我们来介绍一下树的几种遍历形式:...
4
顺序表的实现与介绍及部分工程思想的思考
数据结构 ——————————本文旨在讨论和探究计算机知识,欢迎指正———————————— 首先,我们来聊聊什么是线性表: 线性表,顾名思义,是一种具有线性结构的数据排布形式 ,是一个类似数组的东西,在逻辑上一个接一个有序的整块链接,区别于链表的无序分布链接。 如图,我们可以得出,顺序表是一条线一样的结构...
5
数据结构之树及树的存储
数据结构 ——————————————本栏目旨在交流计算机知识————————————————— 上一章节,我们介绍了线性的数据结构:表,栈,队列。接下来,我们将进入下一章节——树结构的学习。 首先,我们先介绍一下树的基本概念: 结点:使⽤树结构存储的每一个数据元素都被称为“结点”。例如图中的A就是一个结点。...
随机文章 随机推荐
Profile Image of the Author
Firefly
Hello, I'm Firefly.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
33
分类
7
标签
25
总字数
56,127
运行时长
0
最后活动
0 天前
站点信息
构建平台
Vercel
博客版本
Firefly v6.12.3
文章许可
CC BY-NC-SA 4.0

文章目录