操作系统原理

操作系统原理

01- 程序并非进程

image-20261018102808024

image-20261018102827448

image-20261018102927333

image-20261018102952184

image-20261018103018942

image-20261018103113066

image-20261018103143246

image-20261018103159789

image-20261018103217098

image-20261018103242179

image-20261018103348927

image-20261018103427739

image-20261018103532239

image-20261018103656000

image-20261018103923134

同样的代码(program) 运行两份,使用的 内存 和 数据不同,程序 不等于进程

程序是被动的实体,而进程则是主动的实体

即使两个进程关联着相同的程序,它们仍被视为两个独立的执行序列

image-20261018104723468

image-20261018104816208

image-20261018104935783

image-20261018105004099

02- 计算机科学中最成功的理念

image-20261018105405651

image-20261018105445343

image-20261018105507570

image-20261018105558690

image-20261018105643979

image-20261018105722441

cpu 内部还包含 堆栈指针, 状态标志等组件

image-20261018105942426

cpu 在不同进程间进行切换的时候, 面临两个问题 ,数据安全性,执行正确性

image-20261018110249542

image-20261018110359456

image-20261018110430375

单独使用 CPU 和内存,进程在切换过程中,无法保存和恢复 切换前后的数据, 进程会得到错误的计算结果。

image-20261018110537908

使用操作系统,来存储进程的状态 信息, 复制一份 CPU 状态副本,将寄存器的状态 全部置为零。

当进程重新获得 CPU 控制权的时候,将操作系统中存储的 CPU 状态副本恢复,进程会发现 寄存器的状态和中断发生时完全一致,

这一机制成功解决了 数据安全性,执行正确性 的 问题。

这一执行 过程 被称为 上下文切换

image-20261018111607912

image-20261018113240834

image-20261018113439742

image-20261018113501793

image-20261018113519641

image-20261018113540283

image-20261018113610998

image-20261018113653662

image-20261018113711456

image-20261018113737284

image-20261018113803473

操作系统需要 分清每个进程 使用的 内存的地址空间,防止其他进程 修改和使用

image-20261018114023618

image-20261018114200127

image-20261018114228249

image-20261018114308495

03- 处理器中一个比特如何守护操作系统完整性

操作系统扮演着硬件与用户程序之间的中介角色。

image-20261018175804347

image-20261018175939986

image-20261018180019440

image-20261018180149697

image-20261018180234783

image-20261018180330896

image-20261018180419262

image-20261018180457055

image-20261018180527192

image-20261018180559009

image-20261018180827971

image-20261018180841262

image-20261018180935278

image-20261018181012178

image-20261018181154563

image-20261018181230809

image-20261018181401564

image-20261018181416534

image-20261018181457954

kernel mode 可以做的事情, user mode 不能做的

image-20261018181626861

image-20261018181738690

image-20261018181823755

image-20261018182039350

image-20261018182215034

下面这些函数被称为系统调用,是用户程序 和操作系统交互的关键桥梁。

image-20261018182254485

image-20261018182454143

image-20261018182510770

系统调用函数中存在 中断, 中断不仅可以由硬件触发,也能通过软件方式产生。

image-20261018182633425

通过触发中断,使程序计数器跳转到操作系统地址空间中的特定例程。当中断发生时,CPU 会切换至内核模式, 使中断程序能够执行所有必须的硬件操作特权指令, 从而完成系统调用。

image-20261018182847269

image-20261018183654377

小总结

image-20261018183752034

image-20261018183810850

image-20261018183832793

image-20261018183846615

image-20261018183858354

image-20261018183911871

image-20261018184018741

通过定时器,避免程序在循环中,未触发中断, 一直占用内存

image-20261018184252090

image-20261018184355529

image-20261018184459883

image-20261018184606429

除了操作系统,是否还有其他直接和硬件交互的软件, 驱动软件,因为操作系统 并没有兼容所有的硬件 ,防作弊软件

驱动软件 -- 专门用于控制特定硬件的软件。

image-20261018184652964

image-20261018184943178

image-20261018185107484

image-20261018185156091

操作系统几乎不可能为所有硬件设备编写代码

image-20261018185337779

image-20261018185351336

试想一个游戏,为了维护公平性,禁止使用手柄操作以防止其相对于鼠标玩家产生优势,通过第三方程序,将 手柄映射为键盘输入来作弊, 由于用户在用户模式下,它无法辨别输入信号是否真正来自键盘, 然而, 运行在内核模式的反作弊软件能够直接监控硬件活动。

从而检测输入是否真实来源于控制器。

image-20261018185410313

image-20261018185450260

image-20261018190111364

04-IPC 共享内存 还是 消息传递?

image-20261024100043550

image-20261024100145422

image-20261024100208404

image-20261024100259660

image-20261024100340172

默认相互隔离的进程 如何通过信息交换实现 与其他进程的交互。

image-20261024100645279

image-20261024100700125

image-20261024100726586

image-20261024100813850

image-20261024101138763

因此, 进程并不仅仅是程序本身,它是程序运行所需的完整环境,然而这种隔离性也带来了显著缺陷。因为实际应用中经常需要多个进程协同工作。若进程完全隔离,它们将无法实现协作, 因此,我们可以根据进程是否与其他进程协作来进行分类。

image-20261024102236095

进程可分为独立进程与协作进程两类。

两个共享数据的进程显然属于协作进程,

但实现进程协作还存在其他考量因素, 例如, 为了提升计算速度,在支持并行处理的系统中,可将复杂任务拆分为多个能同时执行的子任务,从而缩短完成整体任务所需的时间,或出于模块化设计的考量,采用模块化架构, 可将系统功能拆分为多个独立进程,但需注意,无论采用哪种方式,进程间都需要特定机制协调各自行为才能确保系统正常运行,协作进程需要通过进程间通信机制实现数据交换,使彼此能够相互发送和接收信息

image-20261024102439033

image-20261024102554151

image-20261024103024961

image-20261024103727096

image-20261024103811043

进程间通信存在两种基本模型:共享内存 和消息传递

image-20261024103857376

image-20261024104004372

image-20261024104021039

image-20261024104037211

共享内存:顾名思义,该机制允许多个进程直接共享同一块内存空间,需要注意的是,当多个进程在系统中同时运行时,操作系统会为每个进程分配独立的内存区域,这块独立内存区域就称为进程的地址空间。通过名为特权指令的机制,操作系统实现了内存隔离,确保进程无法相互访问其他进程的内存空间,若有 进程试图读写其他进程的地址空间,操作系统将立即中断并终止该进程, 通过强制隔离策略确保数据安全。因此, 若要让两个或多个进程共享内存区域,操作系统要求它们必须共同解除这项限制, 通常共享内存区域通过系统调用创建,并驻留在创建它的进程地址空间中。任何希望通过该区域进行通信的进程,同样需要通过系统调用将其映射到自身的地址空间中。

当共享内存区域建立后,进程间可通过对此共享区域的读写操作实现通信, 关键细节: 操作系统在授予共享内存区域后,便不再管理进程对该区域的具体操作 (数据的组织结构以及共享区域内数据写入的具体位置, 完全由进程自行决定,而非操作系统规定)

以生产者-消费者模型为例: 生产者进程负责生成数据, 消费者 进程负责读取这些数据, 假设共享内存区域中存在一个数组,生产者进程将向其中写入8 位 有符号整数, 若消费者将这些数据作为无符号8位整数读取,虽然读取的是完全相同的比特序列,但会对其赋予不同的数值含义。同样地, 若消费者预期接收的是 其他数据类型(例如 :32 位无符号整数) 或从错误的内存地址读取数据,都会导致数据解读错误, 由此可见, 这个过程中存在诸多可能导致错误的环节。消费者必须准确掌握数据结构及其写入位置,若未遵循这些规范,可能导致单个或全部进程运行失败,更严重的是,可能引发无法预知的异常行为, 各进程还需确保 不会同时对同一位置进行写入操作。否则,可能引发竞态条件问题。

image-20261024104242834

image-20261024104535005

image-20261024104903364

image-20261024105035028

image-20261024105052411

image-20261024105218268

image-20261024110216053

image-20261024110623027

image-20261024112336037

image-20261024112633603

image-20261024112852698

image-20261024112917021

image-20261024112928930

image-20261024112945985

image-20261024112958863

image-20261024113012399

image-20261024113026515

image-20261024113102273

image-20261024113125606

image-20261024113146497

image-20261024113201735

image-20261024113225212

image-20261024113246755

image-20261024113327502

image-20261024113348402

image-20261024113405201

image-20261024113419796

image-20261024113454073

image-20261024113515152

image-20261024113534853

image-20261024113547482

image-20261024113604163

image-20261024113627073

image-20261024113642243

image-20261024113736230

image-20261024113755382

image-20261024113927437

image-20261024114050828

image-20261024114117792

若进程A 需向进程B发送消息,必须通过系统调用请求, 操作系统建立与进程B的通信链路,该链路构成消息传递的逻辑通路, (强调是逻辑路径)操作系统内核会在自身地址空间内创建队列作为 “邮箱” , 进程A 向此队列发送消息,进程B 则从中接收消息。该队列的行为会根据通信需求动态调整

例如: 当需要限制邮箱容量时,我们可能采用异步通信替代同步通信, 或使用带缓冲的队列。为了实现全双工通信, 可通过双队列结构实现邮箱功能,使两个进程能同时互传消息且互不干扰, 由于邮箱位于操作系统地址空间内,进程本不应直接访问该区域,进程无法直接对邮箱进行读写操作,因此操作系统必须提供至少两种系统调用:send() and receive(), 当进程A需要向进程B发送消息时,会通过系统调用通知操作系统执行传递操作, (请将这条消息存入与进程B共享的邮箱, 由于我无法直接访问该邮箱,烦请代为处理)由于 邮箱位于内核地址空间 , 操作系统能够将消息复制到该邮箱中。同样地, 当进程B需要检查消息时, 会通过对应的系统调用向操作系统发起查询请求。(我无法直接读取与 进程A 共享的邮箱,您能帮我查一下是否有给我的留言吗?)若有消息,操作系统可将其作为函数返回指返回

这便是消息传递通信的基本原理。

image-20261024114322767,

image-20261024114748616

image-20261024114907006

image-20261024120118837

image-20261024120240380

image-20261024121451950

image-20261024121519458

image-20261024121535676

image-20261024121604106

image-20261024121630390

image-20261024121701509

image-20261024121733312

image-20261024121809763

image-20261024121832646

image-20261024121918766

image-20261024121943208

image-20261024122014575

image-20261024122100611

image-20261024122132315

image-20261024122201538

image-20261024122231487

image-20261024122326891

image-20261024122412257

image-20261024122429079

image-20261024122456143

image-20261024122533884

image-20261024122553744

image-20261024122610181

image-20261024122630040

image-20261024122643061

image-20261024122726123

image-20261024122802621

image-20261024122815319

image-20261024122844692

image-20261024122859832

image-20261024122920410

image-20261024122937243

image-20261024122958098

image-20261024123019264

image-20261024123041327

image-20261024123100684

image-20261024123642734

05-单核处理器为何需要多线程

线程

image-20261024174852166

image-20261024174913982

image-20261024174952474

image-20261024175017126

image-20261024175113469

image-20261024175205536

image-20261024175259252

在多进程系统中,若存在单个CPU 和多个运行中的进程,操作系统会通过轮转调度机制让这些进程共享 CPU资源, 这种切换在瞬间完成。切换速度之快,足以让用户产生所有进程都在同步运行的错觉,这种技术被称为并发处理(concurrency), 它与 CPU调度技术共同作用,造就了我们日常使用的流畅多任务操作体验, 但多任务处理只是并发与 cpu 调度最广为人知的作用, 另一个不太显见却同样关键的作用 --- 最大化利用计算机资源。

image-20261024175744686

image-20261024180032643

image-20261024180049596

image-20261024180517164

虽然进程常被定义为 “正在执行的程序” ,但这并不意味着进程会持续占用CPU,即使cpu 已被分配给该进程,

例如: 某个进程可能正在等待I/O 资源,这种情况下, 将CPU 分配给该进程会造成资源浪费,因为在请求的 I / O资源就绪前,进程根本无法执行指令,因此,更合理的做法是将 CPU分配给其他已就绪、能够执行指令的进程。这正是并发机制至关重要的第二个原因。这不仅仅 是同时运行多个程序那么简单,更重要的是, 当一个进程无法使用CPU 时, 系统能立即将这些空闲资源分配给其他可运行的进程。

image-20261024181543964

image-20261024181614108

在传统进程模式下,同一程序内的任务, 例如两个函数,无法实现异步执行,请记住,每个进程只有一个程序计数器,当CPU 被分配给其他进程时,它会指向该进程被中断的指令位置,但我们无法在属于同一进程的两个函数之间切换CPU, 因为单一计数器无法同时记录两者的中断位置,其中一个函数必须等待另一个函数执行完毕

但我们究竟 为什么需要进程内部的并发呢?

如果让 两段代码并发运行并相互配合,我们完全可以把 它们放在不同的进程里,再通过进程间通信来协调行动啊?

共享内存 或者 消息传递

但是进程间通信用起来并不总是那么直观, 有时候,为实现同一核心目标而设计的代码模块关联性极强,硬要把它们拆分到不同进程里反而显得不合逻辑。

在众多场景中,单个进程内的并发处理能发挥巨大作用

假设有个服务器在 3000 端口接收请求,其唯一的功能就是返回每个客户端对应的头像图片,当客户端发送请求时,服务器接收请求,通过磁盘搜索并读取图片进行处理,待图片加载到内存后,将图片发送回客户端完成响应。由于图片以文件形式存储在磁盘上,需要执行高成本的 I/O操作来查找图片并将其加载到内存中,因此,处理请求,所需的时间可能远超接收请求或发送响应的时间。这些步骤按顺序执行,虽然只需几毫秒,但对单个客户端而言完全适用, 但当客户端数量增多时,问题便随之产生。若 5 个请求几乎同时到达,服务器虽能快速接收首个请求,却需要耗费大量时间进行处理。第二个请求必须等待第一个处理完毕才能开始,后续请求亦按此顺序排队等待。这种情况会形成瓶颈,导致后续请求需要长时间排队等待, 目前五个请求尚可应对, 但若激增至上千请求,瓶颈问题将急剧凸显, 由此我们得以窥见问题的核心所在,

image-20261024181735146

image-20261024182511181

共享内存

image-20261024182612427

消息传递

image-20261024182649842

image-20261024183036227

image-20261024183158561

image-20261024184047477

image-20261024184837421

image-20261024185116054

注意上图时间轴的灰色间隙,这些间隙代表着 CPU 处于 闲置状态,未执行任何运算,这造成了计算资源的浪费,这些时间成本可用于接收并开始处理其他客户端的请求,当一个任务因其他任务正在运行而无法执行时,即使两者互不依赖,这种现象便称为阻塞效应,长久以来,这个问题的解决方案如下,存在一个监听请求的主进程,我们称之为监听进程,每当客户端发送请求时,监听进程不会直接处理,而是创建一个全新的进程来专门服务该客户端。这样一来, 当其他客户端发送请求时,无需等待前一个请求完全处理完毕,因为监听接收请求的进程与服务前一个客户端的进程是并发运行的,这种方式能充分利用并发机制,最大限度提升 CPU 利用率。 虽然这种方法很巧妙,但并非完美无缺,每个进程都像是独立运行的封闭环境,拥有包括地址空间在内的专属属性,为每个客户端创建完整进程的方案,在数百客户端时或许可行, 但当规模扩大至数千客户端时,内存使用效率将显著降低。此外, 创建新进程本身也是一项开销可观的操作,这会消耗处理器时间。因此即便初衷是利用CPU空闲间隙,实际上一部分时间仍会消耗在创建新进程的过程中。此外,若服务器需要维护全局状态,所有子进程都必须保持同步以追踪状态变化。这就需要通过进程间通信来实现,从而增加了实现的复杂度。

image-20261025182450654

image-20261025182858661

image-20261025183541005

虽然进程是计算机科学中最核心的概念之一,但它并非完美无缺,新的解决方案是,既然进程这一概念非常实用,我们无法直接摒弃它,

但可以稍作调整,使单个进程的可执行代码能够支持并发操作。

总体而言,进程通常包含进程ID,程序计算器、寄存器组、地址空间,以及打开的文件 和 I/O 设备等其他资源。

单一程序计数器无法让进程控制块同时追踪多个任务,解决方案是取消程序计数器与进程的直接关联,转而将程序计数器分配给进程内每个需要并发执行的内部可执行实体, 这些内部可执行实体就是我们所说的线程。通过打破每个进程只能拥有单一程序计数器的限制,现在当同一进程内的代码需要并发执行时,我们就能创建新线程,这样既解决了阻塞问题,又无需创建新进程。当某个线程需要 I/O资源时,(例如将文件内容加载到数组),其他线程仍可继续执行而不受阻塞。虽然线程共享所属进程的完整地址空间,由于线程会被中断以便将cpu 分配给其他线程,我们需要记录每个线程的状态,以便在 CPU 重新分配时,能够恢复其执行状态。因此,若每个线程都拥有独立的程序计数器,则必须同时分配专属的寄存器组、状态标志位、累加器等,本质上构成其独有的CPU 状态。需要注意的是,这其中还包含独立的栈指针。栈是一种在内存中组织与访问局部变量的高效方式, 若同一个进程中的两个函数并发运行且共享同一栈空间,极易发生数据互相覆盖的问题。因此,每个线程都必须拥有独立的栈空间。每个线程拥有独立栈空间,并不意味着它们无法相互读写对方的数据。当我们把地址空间视为进程(而非线程)的属性时,由于所有栈都位于共享地址空间内,线程之间完全可以相互访问彼此的栈空间。

是否应该这样做完全是另一回事,但通常,除非有充分把握,否则最好避免直接访问其他线程的栈空间。若同一进程内的线程需要通过共享内存进行通信,堆内存是更合适的选择--因为堆中存储的数据,本身就不具有可预测的组织结构。但即使使用堆内存,仍需保持谨慎。

当一个线程正在读取某块内存时,若另一个线程同时进行写入操作,可能导致灾难性后果。同步并发任务至关重要,因此硬件层面直接内置了相应的同步机制。此外, 由于我们并未完全摒弃进程的概念,不同进程的地址空间仍然保持隔离,因此,虽然同源线程默认共享内存,但不同进程的线程需要共享数据,则必须通过进程间通信实现。

image-20261025184249723

image-20261031100602291

image-20261031102942608

image-20261031103601690

采用多线程方案代替传统的多进程方案时,需要重新设计进程控制块的实现。

最直接的解决方案是引入第二种结构体来表示线程。虽然具体实现因平台而异,但最普遍的做法是采用linux 内核的方案:

用名位 task 的同一结构体来表示 进程和线程。它巧妙淡化了进程与线程的界限。因此,与其将并发描述为进程、线程 或二则交替获取CPU资源,不如直接将其表述为任务间的交替执行。若要对线程和进程使用统一调度器。每个进程都必须至少包含一个主线程。

操作系统在创建新进程时,会自动生成该线程。因此,即使进程不依赖多线程机制,操作系统仍可通过调度其主线程来为其分配CPU资源。进程初始线程数量是否固定,以及能否动态创建线程,取决于操作系统的具体实现方式。当前主流操作系统普遍采用动态线程管理机制:进程初始均为单线程模式,运行时按需创建额外线程。这自然,增加了操作系统内部实现的复杂度,因为系统必须提供动态创建线程的调用接口。但动态线程创建仍时首选方案,因为在多数场景下,编译阶段无法预知运行时实际需要的线程数量。

因为在大多数实现中,主线程决定了其所属进程的身份标识。若主线程执行结束,即使其他派生线程仍在运行,整个进程也会立即终止。

针对这一问题存在多种解决方案。

image-20261031103822569

image-20261031103935317

image-20261031104458311

image-20261031104715880

image-20261031105125341

采用多线程方案后,每当新客户端接入时,我们只需创建一个对应线程。这样既能实现于多进程方案相当的效果,又能显著降低内存占用。

因为创建线程所需的系统调用通常比创建进程快得多。

image-20261031110024833

image-20261031110230328

线程并非函数,线程执行的所有代码, 都位于进程内存布局的文本段中,线程本身并不包含代码。它通过程序计数器指向代码,这意味着多个线程可以指向完全相同的可执行代码,这正是服务器示例中的实际情况。由于所有请求都需要以相同方式处理,所有处理线程并包含独立函数,而是指向内存中的同一个函数。

image-20261031110304167

image-20261031110342534

image-20261031110911126

多个线程同时访问该内存区域是否危险----其实无需顾虑,因为可执行代码和常量都存储在文本段和数据段,这两个区域永远不会被写入。所有在运行生成的线程特定信息,都存储在栈或堆内存中。因此,多个线程并发访问同一端可执行代码是完全安全的。

image-20261031111203368

为何在C 这类底层语言中,创建线程需要以函数指针作为参数,实际上我们传递的是 线程将要执行的代码起始内存地址

image-20261031111435400

image-20261031111559569

image-20261031111722897

线程并非异步函数,可以通过两种方式来定义线程,

从操作系统的视角来看,线程就如同进程一样,是最基本的执行单元。

从开发者的角度来看,线程是一种机制,用于向操作系统表明程序中特定代码可以并发执行。

线程可被视为轻量级进程,创建起来更简单快捷。

image-20261031112357902

本节 内容,即使对于单核处理器系统也同样适用。

06-多核系统中的线程

并发

当多个进程需要共享单个CPU时,操作系统会通过极速切换CPU使用权,制造出所有进程同时运行的假象。

线程 在 程序编写中主要具有两大优势:当同一程序中某个任务耗时较长,而另一个任务仅需数毫秒时,并发机制能确保任务无需长时间等待即可启动运行。若某个任务正在等待文件读取或网络响应等I/O 资源,在此期间该任务将无法占用CPU,避免CPU资源闲置 ,操作系统会将这段空闲时间分配给其他待运行任务。因此,线程本质上是一种向操作系统表明,同一进程内多个任务能够并发运行的机制。它们使得应用程序能够同时执行多项任务。

​ 当并发任务数量过多时,系统在某个临界点,会开始出现卡顿,即使CPU 以极快速度在任务间切换,当任务数量超过某个临界点时,

​ 每个任务重新获得CPU 访问权的时间间隔会变得过长。针对这以问题, 存在三种可行的解决方案。

最直接的解决方案是提升CPU 运行速度。若CPU 能在相同时间内处理更多任务,各任务就能更快重新获得处理器资源,但这种方案存在局限:若持续增加任务数量,最终仍会陷入任务过载导致延时的困境。此外, 近十年 来 提升 CPU 性能已变得愈发困难。

第二种解决方案是采用 更智能的 CPU 调度策略。

第三种解决方案 若单个处理器无法承载过多任务,无论其运行速度多快,只需增加处理器数量即可。

通过在主板增加更多 CPU 插槽,实现多物理CPU 的配置。

另一种方式是在单个封装或芯片中集成多个处理单元。多核处理器。

image-20261031112728810

image-20261031115229373

image-20261031115357595

image-20261031115510373

image-20261031115526108

image-20261031115623216

image-20261031115646293

image-20261031115704960

image-20261031115753090

image-20261031115816613

image-20261031115835197

假设某个应用程序包含8个线程,在单核计算系统中,并发意味着多个线程随时间交替执行,因为单个处理器 核心在同一时刻只能运行一个线程。但在多核系统中,并发的含义发生了根本性改变。此时,多个线程能真正同步运行,因为系统可以将不同线程分配到独立的核心上执行,换言之,在多核架构下, 我们实现的不仅是并发,更是真正的并行处理。

并发系统通过让所有任务交替推进的方式,同时支持多个任务的执行。而并行系统则真正同时执行多个任务。因此,存在不依赖并行的并发处理方式。

并行系统的主要优势在于:多任务运行的流畅性不再依赖快速切换营造的假象。随着多核处理器成为标准配置,同一进程内的线程得以充分利用并行处理能力。

image-20261031120604103

image-20261031120843045

image-20261031121218579

核心数量是固定的, 因此创建上千个线程并不意味着能并行执行上千个任务,实际上,若系统拥有n 个核心,

则最多只能同时并行执行 n 个线程。因为线程会竞争资源。即使我们创建的线程数量与可用核心数完全一致,其他进程中的线程同样需要占用CPU时间。由于操作系统的主要目标之一是,确保所有线程公平分配CPU 资源,因此限制了我们的线程能够并行运行的数量。

image-20261031121908232

程序需要并行处理的另一个重要原因,性能,

通常并行处理分为两种类型: 数据并行 与任务并行。

数据并行的核心在于将同一数据集的不同子集分配给多个计算核心,并在每个核心上执行相同的操作。

任务并行是指将不同的计算任务或线程(而非数据)换言之,每个线程都在执行独特的运算操作。

但实际应用中存在多种不同场景。不同线程可能同时对相同数据进行操作。或者它们可能分别处理不同的数据。

image-20261031122004494

image-20261031122812322

判断一个数组中哪些数是质数 任务之间相互独立 数据集拆分,多核并行

image-20261031123053153

07-为何应用程序与操作系统绑定

如果你将可执行文件从windows 电脑复制到 Mac, 立即就会发现这个程序无法在 Mac 上运行。Mac 采用 arm架构处理器,而多数windows 设备使用 intel x86 处理器,这个答案虽在理,却未触及全貌。因为 回溯到2019 年,当时几乎所有Mac 仍采用 Intel 处理器时,同样会遇到这个问题。

实际上,同一台硬件设备可以运行多个操作系统。比如 同时运行 Linux 和 Windows,但这并不意味着我能在 Linux 系统上运行 Windows 程序,即使两者使用的是相同的 CPU。

这是为什么?

编译后的代码不是应该直接装换成硬件能执行的机器指令吗?

如果两个操作系统运行在同一个CPU上, 程序难道不应该都能运行吗?

image-20261031143426522

事实上,应用程序不仅与硬件架构相关,还与操作系统紧密绑定。

image-20261031144423576

通用操作系统的设计目标是同时运行多个进程,这意味着它们必须共享硬件资源,其中最重要的就是 CPU。CPU 作为计算机的核心部件,几乎控制着所有的操作。这就带来了潜在的安全隐患:理论上任何进程都可以利用CPU。绕过操作系统,通过恶意操作夺取系统控制权。为了防止这种情况,CPU 设置了两种基本运行模式:用户模式与内核模式。

image-20261031144549103

image-20261031144743272

在内核模式(亦称特权模式)下, CPU能够执行指令集中的所有指令,从而获得 对系统的完全控制权。这包括直接操作输入输出设备的能力。诸如CPU定时器,内存管理单元登硬件组件和设备。

在用户模式下, CPU 仅能执行受限的指令子集。该指令子集支持数据移动与复制、数学运算、条件判断及循环操作等任务。虽然这些指令仍非常实用,但在该模式下CPU无法执行直接控制硬件所需的核心指令。用户程序运行在用户模式,而操作系统则运行在内核模式。

操作系统通过核心指令确保用户程序无法越权进入内核模式。这种隔离机制对安全至关重要,但也带来了新的挑战。无论是读取文件这类简单操作,还是通过网络发送消息等复制任务,用户程序都需要通过特定方式访问硬件资源。

解决方案?

既然用户程序无法直接操作硬件,它可以请求操作系统代为执行。例如,当程序需要打开文件并写入数据时,它会调用相应的系统函数,

请求操作系统代为执行,在磁盘上定位文件并进行写入等操作。这正是系统调用的核心作用。系统调用为操作系统提供的服务建立起标准化接口。系统调用通常以c 或 c++ 编写的函数形式提供,可大致分为六大类别。每个操作系统都提供各自独有的系统调用集合,这正是兼容性问题的根源所在。实际上,绝大多数程序都深度依赖系统调用。看视频所用的程序,也在持续调用操作系统,每分钟发出数千次服务请求。

image-20261031145119405

image-20261031150357979

系统调用函数

image-20261031150936715

查看系统调用的方法:

linux 系统: cat /proc/3826/status 将进程ID 换成自己的。

其中的 voluntary_ctxt_swithes 参数表示进程通过系统调用向操作系统请求服务的总次数,用户程序对系统调用的依赖度极高,若其他操作系统未以完全相同的方式实现这些调用,程序将完全无法运行。这不仅仅是命名差异的问题。即使不同操作系统存在功能相似的系统调用,其具体实现也可能存在显著差异。例如 在Windows 系统中, 可以通过CreateProcess 系统调用来创建新进程,该调用只需接收程序可执行文件的路径,既可实现此功能。如果不熟悉系统编程,可能会认为Unix系统中的fork 系统调用同样用于创建新进程。它确实能创建进程,但并非任意进程。实际上,它会创建调用进程的副本,唯一的区别在于新进程拥有独立的进程标识符。因此在 Unix 系统中,要运行特定程序还需执行额外步骤。在使用fork 后, 子进程必须立即调用另一个系统调用(例如 exec), 将其代码段替换为需要运行的可执行文件内容。这类设计决策导致了操作系统之间的显著差异。

image-20261031151211270

image-20261031151410205

image-20261031152131755

同一任务在这个平台可能只需一次系统调用, 在另一个平台,却可能需要多次调用。因此当编译为机器码时,不同平台会生成不同的指令来实现效果。尽管两个程序都针对相同CPU架构编译,但那些额外指令在另一个操作系统中将失去意义,最终引发未定义行为导致系统崩溃。这还不是全部,即使两个操作系统定义了完全相同的系统调用集合,其他底层实现细节仍可能引发兼容性问题。

当程序需要向操作系统发起请求时,会触发一个中断信号,该中断会触发CPU切换至内核模式,并将控制权移交操作系统, 在 x86-64这类架构中,通常会通过名为 syscall 的特殊指令实现这一操作。但操作系统一旦接管控制权,它如何知道具体请求的是哪个系统调用呢?

如果系统调用需要参数,这些参数是如何从用户程序传递给操作系统的?识别系统调用的通用方法是为 每个调用分配唯一编号。

程序在硬件层面执行系统调用前,会将对应编号写入寄存器。当操作系统获得控制权时,会读取该寄存器中的值,并查询系统调用表以确定请求的系统调用。通过查询Linux 内核源码中的架构相关文件,可以查看到每个系统调用的对应编号。这也揭示了linux 实际支持的系统调用数量。

同样地, 若两个操作系统提供的系统调用不同,即使使用完全相同的调用表,相同的机器代码在不同系统上也会请求到截然不同的服务。假设两个操作系统采用完全相同的系统调用表。这能保证兼容性码?并不能, 如果某个操作系统使用不同的寄存器来指定系统调用编号,那么程序在该系统上运行时就会出错,因为程序会读取错误的寄存器,从而识别出错误的系统调用,最终导致未定义行为。

但如果两个系统都读取同一个寄存器呢? 问题解决了吗 ? 还是不行

许多系统调用还需要传递参数。这些参数的传递方式取决于具体的操作系统。有些系统要求将参数存入寄存器,有些则需写入特定内存区域,或是两种方式结合使用。

例如 在 linux 系统中, 若系统调用参数少于6 个,则必须通过寄存器传递。当参数超过6 个时,超出部分需存入内存空间。这些看似微末的细节恰恰至关重要。这再次印证了, 为某操作系统设计的程序,即使能在同架构的其他系统上运行,也可能出现难以预料的异常行为。例如, 操作系统可能要求参数存放于内存,而程序却将其存储在寄存器中,反之亦然。若参数需存储于内存,其存放方式将直接影响程序行为。程序是否使用了栈?或者是否存在专门用于此目的的特定内存位置?若参数包含指针,地址位宽便成为关键因素。这些只是需要保持一致的底层细节中的几个例子。这套约定规范称为应用程序二进制接口(ABI),二进制代码各组件在特定操作系统 和架构中的交互方式。而这还仅仅时系统调用层面的要求。

可执行文件的格式 也会有影响, 可执行文件是编译器翻译源代码后生成的产物,但这个文件并非只包含指令,它同时包含数据和元数据,使操作系统能够加载并执行程序。在 0 和 1 组成的海洋中, 程序的每个部分究竟位于何处?每个操作系统对此都有特定的规定。

这种结构确保操作系统能够正确加载,并运行可执行文件,同时支持链接,调试和动态库使用等多种功能。

image-20261031153006726

下图为机器码

image-20261031153108449

image-20261031155000867

image-20261031155240126

image-20261031155347997

image-20261031155443108

image-20261031155605069

image-20261031160017709

image-20261031161614795

有些编程语言并不直接编译为机器码。它们通过虚拟机或解释器运行,这意味着只要安装了虚拟机,同一台套代码就能跨平台运行。

具有讽刺意义的是,虚拟机和解释器的设计初衷是为了规避诸多兼容性问题。然而现代应用程序通常采用模块化方式构建。如果所有模块都编译为机器码,自然最为理想。但只要有一个模块依赖运行时环境,该环境未在机器上安装,整个应用程序就可能无法运行。

image-20261031162430105

image-20261031162701399

image-20261031162940894

08-多任务处理中硬件如何辅助软件

当进程运行时,它会持续占用CPU,按顺序逐条执行指令,在此过程中,指令执行的结果取决于其所在的完整上下文环境,尤其受到寄存器状态的直接影响。同一条指令会因寄存器中存储值的不同而产生相异的结果。

image-20261031191043443

image-20261031191248772

谈到并发时,因为不能直接将CPU 控制权移交给下一个进程,若某个进程被中断,此时寄存器中的数据仍属于中断进程。若新进程未妥善处理就恢复执行,将在错误上下文中运行并处理异常数据,因此,每当进程被中断以便将CPU分配给其他进程时,必须将寄存器状态捕获并存储到内存中,同时加载新进程的状态,确保其能够正常恢复执行。这被称为上下文切换,其命名源于我们正在改变CPU运行时所处的上下文环境。

操作系统需要执行特定代码才能实现这一机制。这一过程如何在不改变被中断进程状态的前提下实现?

image-20261031191649977

image-20261031192619847

基于中断的切换机制

在非常简单的CPU架构中-- 由于没有中断处理机制,上下文切换完全依赖软件实现。在这类系统中,每个程序在让出CPU给其他程序之前,都需要自行保存其运行状态。这类CPU 相当罕见,因为它们既缺乏安全性,实用性也十分有限。

image-20261031192712893

image-20261031192832125

中断(有时也称为陷阱)是计算机不可或缺的核心功能。

image-20261031193258678

image-20261031193351448

传奇的 MOS 6502 --- 这款问世近 50 年、史上最受欢迎的处理器之一,早已配备了中断功能。即便是为控制电子设备而设计的简易处理器--微控制器,也配备了中断功能。配置中断机制的计算机问世至今已逾七十载,可以说,没有中断机制,计算机将不复今日之貌。

image-20261031193525580

image-20261031193726278

image-20261031193811470

image-20261031193955644

如我们所知,进程可通过两种方式被中断。

进程可通过触发软件中断 或执行特殊指令,主动请求操作系统分配资源。

另一种情况是:当进程占用CPU时间过长,或遇到I/O操作时,硬件组件(如定时器) 会发送信号 强制中断进程。

无论哪种情况,中断产生的影响都相同。CPU会先完成当前指令的执行以避免数据不一致,随后程序计数器立即被重置,使程序跳转到特定的内存地址。该内存地址存放着处理中断所需的可执行代码,其中包含用于保存被中断进程状态的必要程序。在获取这些指令之前,程序计数器已被中断信号覆盖,这意味着我们无法再定位进程被中断时的执行位置。

image-20261031194422367

image-20261031194511496

image-20261031195148903

需要先考虑如何编写代码来保存被中断进程的CPU状态。 首先需要保存通用寄存器的值,因此中断处理程序可以包含如下指令:

这需要操作专门预留的特定内存区域,过程较为繁琐。为了避免这种情况,更简便的做法是将数值压入栈中。标志寄存器的值同样重要,因此我们将其一并压入栈中,栈指针本身也需要如此处理。当前栈指针正指向用户程序的堆栈。这会导致严重问题,因为当前指令应由操作系统执行,而操作系统拥有独立的堆栈空间。(操作系统本身也是软件, 它拥有独立的堆栈空间)执行这些指令会修改被中断进程的堆栈。

如何处理中断? 先调整栈指针,让寄存器状态保存到内核栈中。为了避免保存栈指针之前覆盖寄存器状态,可以在修改栈指针前将其值存入内存单元,从内存区域推送数值。

程序计数器被中断本身覆盖的问题,使的这个难题,至少无法通过软件解决。因为在初始阶段,我们无法通过任何指令来保存这个数值。

当CPU 开始执行第一条指令时,这个数值已经丢失了。硬件需要反过来为软件提供支持,而非惯常的软件适配硬件。

image-20261031195459074

image-20261031195808517

image-20261031195836433

image-20261031200248807

所有支持中断的架构都必须提供一种机制,确保被中断进程的状态不会丢失。硬件至少需要提供基础支持,确保软件能够保存被中断进程的程序计数器。

处理中断主要有两种方案,每种架构都有其独特实现方式,下面是两种通用方案。

处理器无需依赖单一寄存器组,可提供多组寄存器配置。多组寄存器可分配给不同进程使用。这样当中断发生时,新进程可直接切换至另一组寄存器,无需修改原进程状态。但进程数量可能超过可用寄存器组,而寄存器属于硬件资源,无法在运行时动态增加。但问题在于:我们并需要无限多的寄存器组。两组寄存器已足够。若CPU 支持操作模式,一组寄存器可由多个程序共享,另一组仅供操作系统在内核模式下访问。

image-20261031201720086

image-20261031201851078

image-20261031202049618

image-20261031202538331

当CPU 处于内核模式时,它几乎拥有无所不能的权限。这意味着,操作系统能够自由操控和访问额外的寄存器组。为方便区分,将其称为通用寄存器组与特权寄存器组。

image-20261031202734830

image-20261031202825924

当中断发生时,CPU 会切换至内核模式,并停用通用寄存器并启用特权寄存器组,从而确保被中断进程的状态保持原封不动。

这样,中断就不会覆盖通用寄存器组中的程序计数器,而是会覆盖特权寄存器组中的程序计数器。通过特殊特权指令,操作系统能够访问被禁用的寄存器,并将其数值复制到当前启用的寄存器中。每执行完一条此类指令, 便会通过常规指令将刚从禁用寄存器提取的值存入内存位置。这可以是栈空间, 最终, 这些数据会被复制到中断进程的进程控制块中。当然,这需要额外的指令,来查找进程控制块在内存中的存储位置。接着读取另一个进程的PCB, 并执行逆向操作流程。常规指令将数值从内存复制到活动寄存器组,随后通过特权指令将这些数据传送到禁用寄存器中。

若采用抢占式调度,需通过特权指令设置定时器,随后执行 sysret 特权指令。在此类架构中,sysret指令执行双重操作:既切换活动寄存器组(将用户程序锁定于其中一组,又使系统返回用户模式。)由于指令开始在通用寄存器组上执行,CPU 将在下一个周期恢复执行选定进程。

image-20261031203451986

image-20261031203838445

image-20261031204140361

遗憾的是, 这种方式并不常见,尤其在现代架构中更为罕见。

推测主要原因在于:将完全相同的电路复制到CPU晶片上并不划算,尤其现代架构已拥有数百个寄存器,还有 SIMD 寄存器等更复杂的组件。其实并不需要完整复制所有寄存器,只需为关键寄存器(例如栈指针和程序计数器)配置独立副本就足够了,

image-20261031205138275

image-20261031205349616

若能通过硬件实现流程的部分自动化,所需软件数量能否大幅减少?不同架构的具体实现方式各异,通常通过两种途径实现。

硬件可通过自动化特定步骤来避免寄存器组的重复配置。这些步骤如同直接内置于CPU 的硬连线指令般工作。正如我们有时候会将数据库URL或私钥直接硬编码在源码中,而非从环境文件读取,指令和数据同样可以直接固化在CPU电路里。这使得它们能在中断生效前立即执行。

一种实现方式是,通过固化在CPU 中的内存地址或专用寄存器,确保操作系统栈指针始终可被CPU 访问,中断发生时,程序计数器和栈指针等关键寄存器,会在程序计算器被覆盖前,自动压入操作系统栈,跳转至中断处理程序。此后, 保持其余寄存器的职责便交由软件承担。必须确保在将寄存器内容存入内存前不对器进行写操作,被中断进程的程序计数器和栈指针已处于安全状态,得益于硬件支持。

image-20261031205616268

image-20261031210359585

image-20261031210516843

有些架构设计更为激进,它们几乎完全通过硬件来执行上下文切换。这通常通过一个指向操作系统管理内存区域的段寄存器来实现,该区域存放着特定的数据结构。在某些架构中,这种数据结构被称为任务状态段,对应的寄存器则被称为任务寄存器。

每当发生中断时,CPU 会自动将所有寄存器的完整内容复制到任务状态段中。从软件角度来看,这个过程确实可以自动完成,但奇怪的是,主流操作系统都可以避开了这项功能。

image-20261031211137390

image-20261031211332358

09-让电脑更流畅的智能算法

即使CPU 占用率达到100%,整个系统仍能保持基本运行。这主要得益于通用操作系统对响应时间的优化,即使在满负荷状态下,系统仍能通过调度机制保障交互应用的流畅运行。

操作系统的核心组件----CPU 调度器

image-20261031222136185

CPU 调度是操作系统采用的核心技术,它根据特定规则决定在何时,将CPU 资源分配给哪个任务或进程执行。响应时间是衡量调度算法的重要指标之一,不过根据系统类型的不同,其他指标同样至关重要,例如 CPU 利用率, 系统吞吐量、任务周转时间以及等待时长。

image-20261031222428995

image-20261031222753421

1-先来先服务算法

最基础的调度器模型,目前最基础的CPU 调度算法当属先来先服务算法。该机制会将CPU资源分配给最先提出请求的进程。首先需要明确的是, 将 CPU 分配给进程具体意味着什么。当程序开始执行时,它便成为一个进程,操作系统会为其分配专属的内存空间。在这块内存区域中,存在一个称为文本段的内存分区,其中存储着程序的执行代码。每个进程都拥有独立的文本段。

image-20261031222926455

image-20261031223825725

CPU 中设有专门地址寄存器,既程序计数器,该寄存器始终指向待执行的下一条指令。当CPU 被分配给某个进程时,意味着程序计算器正指向该进程的文本段,使得CPU 能够执行其中的指令。当CPU 被重新分配给其他进程时,操作系统会修改寄存器,使其指向新进程的可执行代码段。这涉及到更复杂的上下文切换操作。

image-20261031224046959

image-20261031224321205

image-20261031224422795

另一个重要概念是:进程并非传统计算机科学意义上的具体实现,例如对象或结构体实例。实际上,进程是计算机运行时所处的上下文环境。为管理这些进程,操作系统使用一种进程控制块的特殊结构。该结构并非进程本身,而是进程的抽象表示,其中包含进程状态、程序计数器、寄存器等所有必要信息及管理数据。每当创建新进程时,操作系统都会生成对应的进程控制块来追踪其运行状态。

image-20261031224820267

image-20261031225053021

image-20261031225207399

先来先服务策略可通过先进先出队列轻松实现。这完全符合其运行逻辑:最先进入队列的元素将优先被移出处理。但进程 作为抽象的执行上下文,无法直接作为离散元素存入队列数据结构。操作系统实际上将各进程的进程控制块置入队列进行管理。进入队列的并非进程本身,而是进程控制块。

image-20261031225340002

image-20261031225556949

image-20261031225705233

在先进先出调度中,当新进程创建时,其进程控制块会被添加到队列末端。当CPU空闲时,系统会从队列头部取出进程控制块,为其分配CPU 资源。

首先,我们默认进程必须在前一个进程执行完毕后,才能获得CPU 资源,这会导致诸多问题。首先,并非所有进程都会终止。许多应用程序(例如服务器或后台服务) 被设计为持续运行。 若将CPU 分配给永不终止的进程,后续所有进程将无期限地在队列中等待。其次,若假设进程必须在前一进程执行完毕后才能获得CPU,忽略了一个关键事实, 正在执行的程序并不会持续占用CPU。若任务耗时2分钟执行,并不意味着它在这两分钟内持续占用 CPU。程序执行过程中常会出现等待外部事件的间歇期。

image-20261031230030193

image-20261031230532521

image-20261031230807467

image-20261031231143922

通过一个实例程序来具体说明,本程序打开两个文本文件: 一个存有文本内容,另一个为空文件。该程序旨在将源文件内容复制到目标文件。为控制内存占用,采用分块复制策略,每次仅处理8 字节数据片段。

image-20261031231237149

image-20261031231408619

我们重点关注程序中执行文件复制的核心环节,若观察计算机执行的指令序列,其运行过程将呈现如下形态。读写文件需要程序调用操作系统,因为文件操作涉及与硬件(此处指磁盘)的交互。换言之,读写操作本质上属于I/O操作范畴。问题核心在于,I/O操作通常会产生显著的性能开销。这些操作往往需要较长时间才能完成。此处的指令无法在前一指令执行后立即运行,必须等待系统调用管理的I/O操作完成--- 这个过程完全取决于 I/O硬件的处理能力,与 CPU 性能无关。 因此,程序无法持续占用CPU 资源。

image-20261031233048051

如果我们将程序随时间变化的CPU使用情况绘制称图表,会观察到 CPU 处于 空闲状态的间隙,这些间隙整数 处理器 等待 I/O操作完成才能继续执行后续指令的时间段。这些间隙并非例外情况, 而是常态现象。至少在基于 冯 . 诺依曼架构的系统中,这一架构覆盖了当今99% 以上的计算机设备 --- 情况正是如此。程序占用 CPU 的时间段成为i CPU 突发(CPU bursts),而程序等待 I/O操作完成的时间段,则称为 I/ O 突发(I-O bursts)

image-20261031233914955

image-20261031234530991

每个进程的执行都始于CPU bursts 随后进入 I /O Bursts, 继而再次转入CPU 突发,如此循环往复。最终, 最后一个 CPU 突发会以系统终止执行的请求告终。这种运行模式已被研究数十年,因为CPU调度的成效很多程度上取决于此。CPU Bursts 的持续时间经过大量实测,虽然不同进程和系统间存在差异,但总体上遵循可预测的分布规律,其分布形态如图所示。

图表显示,大多数进程包含大量短时CPU 突发,仅存在少量 长时 CPU 突发。 这一发现对设计 CPU 调度算法至关重要,因为它揭示了进程经常需要花费大量时间等待I/O操作。 若调度算法未能考量这一特性, 将会导致大量CPU 时间被浪费。因此当进程进入 I/O突发时, 理想情况下,应将CPU 分配给另一个未处于 I/O等待状态的进程。CPU 调度的核心目标之一,就是通过进程间共享CPU 的方式, 在确保进程高速运行的同时,尽可能维持CPU处于忙碌状态。进程的CPU 突发从来不会在前一个突发结束后立即开始,中间始终存在一个微小的附加进程,调度器不应将 CPU 分配给进程直至其完全终止, 而应仅分配至当前 CPU Bursts 结束为至。当然,实现这种机制,会使调度器的设计稍显复杂,需要考虑进程可能处于不同状态的情况。

image-20261031235148192

​ CPU- Burst Duration

image-20261031235647471

image-20261101000235264

所有进程都始于新建状态,新建进程是指已启动 ,但可执行文件仍在载入内存的程序。当程序完全载入且所有必要资源分配完毕后,进程便进入就绪状态,这意味着它已准备就绪,正在等待CPU 分配执行时间。当CPU被分配给某个进程时,该进程便进入运行状态。当进程执行系统调用时,例如 发出 I/O请求,会主动将 CPU控制权交还给操作系统。需要注意的是, I/O 调用不仅用于请求资源(如操作文件或分配内存),也可能仅用于等待特定事件发,例如用户按键操作或网络请求到达。无论哪种情况, 进程都会进入等待状态,直到事件发生或 I/O 操作完成。当请求完成后,进程将重新进入就绪状态,等待调度器分配CPU 时间。需要注意的是, 即使 I/O操作已完成,

进程也不会立即恢复执行,这是因为在进程等待期间,CPU 很可能已被分配给其他进程。因此,即使进程已准备就绪可以继续运行,仍需等待重新获得 CPU 调度。

在进程的整个生命周期中,它们会在 就绪、运行和等待这三种状态之间循环切换,某些进程(例如 web 服务器)

被设计为永不终止,会持续保持这种状态循环。但对大多数用户程序而言, 进程最终会进入终止状态。这些命令具有任意性,且在不同操作系统中存在差异。然而, 这些状态所代表的含义在所有系统中都是相通的。某些操作系统还会对进程状态进行更精细的划分,例如, 一个已终止的进程可能并未真正终止,当进程执行退出系统调用后,操作系统必须释放所有已分配的资源,包括内存空间。

image-20261101081534367

image-20261101081647487

image-20261101082143648

某些操作系统会明确区分已完全终止的进程与仍在终止过程中的进程。这种状态模型解释了,为何 队列是 CPU 调度中使用的基础数据结构。尽管存在特定的等待状态,但除运行状态外,其他状态下的进程实际上也处于 等待中。考虑到可能有数百个进程同时处于等待状态,采用队列来管理它们的执行顺序既符合逻辑又高效。必须明确的是,在任何给定时刻,单个处理器核心上只能运行一个进程。这意味着队列适用于等待进程,却不适用于正在运行的进程,

image-20261101082718661

image-20261101083119349

image-20261101083202717

研究CPU 调度算法时,需要了解操作系统另一个关键组件 --- 调度器。

当运行中的进程进入 I/O等待状态且 CPU 空闲时,调度器便会介入,其目标非常明确,将CPU 分配给就绪队列首端的进程。换言之,执行上下文切换,尽管运行中的进程不存储在任何队列中,但其进程控制块的引用始终被保留,这使得调度器能够在中断发生时,将CPU 状态保持至进程控制块中。保存的状态使得进程能够在后续恢复执行。随后, 调度器将该进程控制块置入等待队列,随后, 在处理完被中断的进程后,调度器就从就绪队列首部获取下一进程的进程控制块, 将其标记为新的运行进程,恢复其原有的 CPU 状态(包括寄存器和程序计数器), 并重新分配CPU 资源,使该进程得以从中断点继续执行。若处理器支持,调度器还需在将控制权移交进程前,切换至用户模式,换言之,调度器通过特定调度策略管理就绪队列 ,决定下一个使用 CPU 的进程,而调度程序则负责执行调度器作出的决策。这些由调度程序引发的额外CPU 突发不可避免,因为执行上下文切换本身就需要占用 CPU 资源,这种延迟被称为调度延迟(dispatch latency) ,即调度程序停止一个进程并启动另一个进程所需的时间。由于每次上下文切换都会调用调度程序,其运行速度必须尽可能快,正因如此,调度程序成为操作系统优化程度最高的组件之一。

image-20261101083526227

image-20261101083729428

image-20261101083912422

image-20261101090027657

由于这种延迟具有隐含性,进程间的调度延迟通常不会被明确标示,

image-20261101090644765

image-20261101090749925

在先进先出调度策略中,系统通过采用FIFO队列作为就绪队列来实现这一机制。一旦某个进程发出 I/O 请求, CPU会立即分配给队列首位的进程,“先进先服务” 这一名称可能存在歧义, 因为调度器实际管理的并非整个进程, 而是它们下一次的CPU 执行时段,更准确的说法应是 “ 首个 CPU 执行时段先到先服务”,

image-20261101090935165

image-20261101091405738

虽然最常见的进程类型是那些频繁出现短时CPU 突发的进程,这类进程被称为 I/O密集型进程。这类进程高度依赖I/O 操作,这意味着 I/O 子系统运行速度更快,它们的性能将得到显著提升,但也存在另一类进程, 它们会频繁出现长时间的 CPU 突发,与 I/O 密集型进程不同,这类进程无法通过提升 I/O 子系统速度获得显著收益,其性能 主要取决于 CPU 的运算速度,因此这类进程被称为 CPU 密集型进程。

image-20261101091500779

image-20261101091708700

image-20261101092048352

CPU 密集型进程与 先来先服务调度算法结合时,可能对 CPU 利用率产生负面影响,

image-20261101092140479

假设存在一个 CPU密集型进程和若干 I/O 密集型进程。为方便区分,将 为 所有 I/O密集型进程使用相同颜色标识,CPU 密集型进程将长时间占用并持有CPU 资源,在此期间,其他进程会陆续完成 I/O操作并进入 就绪队列,等待 CPU 调度,当进程在就绪队列中等待时, I/O设备将处于闲置状态,最终, CPU 密集型进程完成 其CPU 执行周期,转入 I/O 设备操作,所有 I/O 密集型进程由于CPU 执行周期较短,都能快速完成处理并重新返回 I/O 队列,此时系统无进程可调度, CPU 将处于 空闲状态,随后, CPU 密集性进程将返回就绪队列并重新获得 CPU 分配,所有 I/O进程将再次滞留于就绪队列,直至CPU 密集型进程执行完毕,

这出现了车队效应 ------ 所有其他进程都得等着那个大块头进程 离开 CPU, 这种效应会导致 CPU 和设备利用率下降,若允许较短进程优先执行,本可获得更高利用率。确实存在 更短作业优先调度算法,

image-20261101092321678

image-20261101092437372

image-20261101093311603

2-最短作业优先算法

会将CPU 分配给下一个 CPU burst时间最短的进程。若两个进程的突发时间相同,则采用先来先服务规则 作为平局决胜机制,更准确的名称应为 “最短下次 CPU突发优先调度“,因为该算法优先考虑的是 进程的下次CPU突发时长,而非进程总长度,

image-20261101093627579

image-20261101094025657

该算法可通过优先级队列实现,其优先级由等待进程种下一个CPU 突发的时长决定,这使得短 CPU 突发进程能够超越 高CPU 突发进程 占用CPU资源,从而缓解护航效应,该算法已被证明 具有最优性,能为 给定进程集提供最小的平均等待时间,

image-20261101094154975

假设由四个几乎同时到达的进程,首个进程的下个 CPU 突发需要两分钟完成,其余进程则各仅数毫秒,在,“先来先服务”调度算法中,

若这些短 CPU 突发恰好在长CPU 突发之后到达,就会因必须等待长进程执行完毕 ,而产生严重延迟,

image-20261101094558568

image-20261101094731308

image-20261101101750792

反之, 若允许短 CPU 突发进程优先执行,其等待时间将大幅缩短,而长CPU 突发进程几乎不受影响,它仅仅会延迟几毫秒启动,这对它来说几乎毫无影响,将短进程移至长进程之前执行,短进程等待时间的减少量,远大于长进程等待时间的增加量。因此,平均等待时间得以缩减。最小化系统平均等待时间至关重要,因为这直接反映了进程就绪后,因等待 CPU 使用权而无法执行的时间损耗。

最短作业优先算法存在一个显著缺陷,完美实现这一算法存在 根本性困难,因为我们无法预知未来,所有不可能准确获知 进程下一次 CPU 突发的确切时长,你可能会想到通过程序计数器来定位, 下一个系统调用的位置,从而预测 进程在 下次 I/O突发前 需要占用 CPU的时长,然而, 这种方法并不可靠,在当前指令与下一个系统调用之间,可能存在着循环或函数调用等会改变执行时长的代码结构,程序的执行路径可能受外部输入(例如用户操作)影响,这使得预测变得更加困难,例如, 一个函数接收用户输入,将其转换为大写后输出,循环次数取决于用户输入内容的长度,由于无法预知确切的下一段CPU 突发时间,只能根据历史 CPU 突发记录进程估算,并假设下一段 CPU 突发时长很可能与之前的记录相近,为估算该数值,我们采用历史cpu 突发时长的指数平均值进行计算。

image-20261101102030112

image-20261101103058049

首先需要明确:每个进程的 CPU 突发时长预测仅取决于其自身历史记录,其他进程不会影响该进程的预测结果,每个进程的 CPU 突发时长会随着时间形成一系列观测值序列, n 代表最近一次CPU 突发时长, n - 1 是前一次突发时长, n - 2则代表更早一次的突发时长,

n + 1 代表接下来要预测的CPU 突发时长,在公式中 最后一项表示最近一次迭代中该进程的预测突发时长, 而t_n 则是实际观测到的突发时长,因此 ,公式中第一项代表下一次迭代的预估CPU 突发时长,

image-20261101104040267

image-20261101104237463

image-20261101104534813

image-20261101104636838

image-20261101104659686

image-20261101104741081

image-20261101104830798

我们只需保留最新的预测值即可,

若 下图中的 “阿尔法”等于0,则完全忽略最近一次 CPU 突发,预测结果仅取决于历史数据。

若 “阿尔法” 等于1, 则预测结果完全取决于最近一次 CPU 突发,所有历史数据均被忽略,由此可见 “阿尔法” 参数决定了近期数据与历史记录在预测中的相等权重。通常选择将“阿尔法” 设置为 1/ 2, 这样近期数据和历史数据就具有同等权重,

image-20261101105122841

image-20261101105323082

image-20261101105440181

image-20261101105648832

image-20261101105727189

image-20261101105739023

这符合逻辑, 因为较早的执行模式在预测未来CPU 突发时参数价值较低,

此外,当进程初次创建时, 并不存在历史执行数据,这种情况下, 初始预测值通常设为操作系统定义的常量,或采用系统全局平均值,例如,若初始值设为100 毫秒,最终测得下次CPU 突发时长约为 72 毫秒,

image-20261101110255254

虽然不够完美,但这种方法,能提供相当不错的近似效果,

假设进程A 带着特定的下一个CPU 执行时间进入 就绪队列,并最终获得运行权限,紧接着,进程B进入就绪队列,而此时进程 A仍在运行中, 问题是,如果 进程 A 的 剩余 CPU 执行时间比进程 B 的下一个CPU 执行更长,是否应该中断 进程A 来优先执行进程 B ?

这取决于调度器的策略,若调度器允许中断正在运行的进程,我们面对的是抢占式调度算法,操作系统能够中断当前正在执行的进程,而非抢占式调度器则永远不会中断正在运行的进程,即使就绪 队列中出现 CPU 执行时间更短的进程,也必须等待当前运行进程完成其 CPU执行并释放处理器资源。这个决策并非无足轻重,因为进程的执行顺序会因最短作业优先算法采用抢占式或非抢占式模式而发生改变。

image-20261101110759428

image-20261101111327958

抢占机制,

为什么它成为现代系统的默认选择,假设在非抢占式系统中, 某个具有极长CPU 执行时间的进程抵达就绪队列首部并被选中,由于系统采用非抢占式调度,该进程将一直占用 CPU 直至主动释放控制权,但如果它永远不发生呢?无限循环在计算机科学中并不罕见,若循环内部未使用系统调用,进程将永远不会将CPU 控制权交给操作系统,这对以并发为核心特性的通用系统而言,会造成严重问题,并发是一种让 CPU 在进程间快速切换执行的技术,从而营造出所有进程同时运行的假象,然而, 在非抢占式系统中,若某个CPU 密集型进程无期限占用CPU,即使用户看起来所有其他进程都会陷入停滞,这是个严重问题,因为非抢占式系统依赖进程规范运行,并需要在合理时间交还控制权。恶意软件可能通过刻意规避系统调用的方式持续占用CPU时间,当某个调度策略导致进程在就绪队列中无期限等待时,这种现象被称为 饥饿,因为该进程始终无法获得CPU时间,即使是 CPU 密集型进程也可能遭遇饥饿问题。

例如在 最短作业优先调度中,预计需要长CPU 突发的进程可能被无期限滞留在队列中,若持续由较短CPU 突发的进程加入队列,该进程获得CPU时间的唯一机会,就是就绪队列中没有其他进程存在,在实际系统中, 这种情况几乎不可能发生,因为通常有数百个进程在竞争CPU 时间。

从并发角度看,情况大致如下所示,

因此饥饿现象不仅与进程执行时长相关,更是调度测试本身导致的必然结果,虽然最短作业优先在 CPU 利用率和等待时间方面效率很高,但通用操作系统还需要考虑其他调度标准,轮转算法就是抢占式调度的经典范例。

image-20261101113241384

3- 轮转算法

轮转调度算法与先来先服务调度算法有相似之处,系统采用先进先出队列作为就绪队列,但通过引入抢占机制实现了进程间的切换,系统设定了一个称为时间片的小时间单位,新进程会被添加到就绪队列的末尾,CPU 调度程序从就绪队列中选取首个进程,设定定时器在单个时间片结束后发出中断信号,随后将该进程投入运行,

随后会出现以下两种情况之一,

进程的CPU 执行时间可能短于单个时间片,此时,进程将主动释放CPU控制权,随后, 调度程序将继续处理就绪队列中的下一个进程。

若当前运行进程的CPU 执行时间超过单个时间片,计时器将触发中断,操作系统,随即执行上下文切换,并将该进程移至就绪队列末尾,

随后,调度程序将从就绪队列中选取下一个待执行进程,该调度机制会持续应用与每个被调度的进程,确保没有单个进程能长时间或无期限占用CPU,从而避免其他进程因无法获得CPU 时间而陷入饥饿状态。 需要特别说明的是,计时器是物理设备,通常 嵌入在 CPU电路中,这意味着只有当硬件提供支持时,才能实现抢占式调度,

image-20261101114512686

image-20261101115604059

请注意: 当进程被抢占时,由于并未请求 I/O操作,它并不会进入等待状态,它会直接进入就绪状态,等待下一个时间片的分配,若需要公平分配CPU时间的调度策略,轮转调度算法堪称理想选择,

image-20261101115943046

image-20261101120127121

image-20261101120205360

这样可以确保,进程进入就绪队列后, 最多 只需等待(N- 1) * Q个时间单位,即可获得下一次执行机会,

image-20261101120338455

image-20261101120354101

若时间片设置过大, 导致轮转调度退化为先来先服务模式,乍看之下,最简单的解决方案似乎是直接缩短时间片,但若时间片设置过小,进程将被频繁中断,导致上下文切换次数激增。需要特别注意的是, 上下文切换时间属于纯粹的系统开销,--- 切换过程中系统并未执行任何有效任务,因此,由于频繁中断,进程完成所需的时间将会延长,CPU 在进程间切换所耗费的时间将超过实际执行任务的时间。

image-20261101120836580

image-20261101120959012

4- 调度指标--周转时间

它指的是从进程创建到执行完毕所经历的总时长,从数学角度看,周转时间是进程在就绪队列中的等待时间、CPU 执行时间以及 I/O操作时间的总和 ,另一个重要调度指标是吞吐量,它定义为每单位时间内完成的进程数量,同样地,若时间片小于平均上下文切换耗时,虽然CPU始终处于忙碌状态,但实际是在频繁切换进程而非执行有效计算,若CPU 未执行有效工作,追求利用率最大化便毫无意义。设计优良的调度算法需在 CPU 分配与系统开销之间取得平衡,从而同时优化周转时间和吞吐量。

进程组的平均周转时间,时间片的延长并不总能改善, 如图所示,通常来说,若多数进程能在单个时间片内完成下一次CPU 执行, 确保上下文切换仅在必要时发生, 平均周转时间便能得到改善,然而, 周转时间并非衡量调度效能的最佳标准,通常,进程在执行完成前即可开始输出结果,正如之前提到的, 某些进程甚至可能永不终止,因此,响应时间称为另一项重要指标。

image-20261101122307811

5-调度指标-- 响应时间

它定义为从请求到产生首次响应之间的时间间隔,这并非指进程完成执行所需的总时长,而是衡量其开始响应速度的指标,响应时间至关重要,因为它直接影响用户在交互系统中期待的流畅体验, 尤其在多任务场景下,

轮转调度是实现并发的最简单方式之一,每个进程按轮转方式获得固定的时间片,当一个进程的时间片用尽后,系统会立即切换到下一个进程继续执行,若这种轮转切换足够迅速,系统便呈现出所有进程同步运行的假象,唯一的问题在于,所有计算机处理器都存在速度上限, 随着并发进程数量的增加,轮转调度机制将难以维持系统的响应能力。当进程数量达到某个临界点时,每个进程都需要等待更长时间才能获得CPU执行时间 这种并行执行的假象也将随之瓦解,随着平均响应时间的延长,减小时间片看似是个解决方案,毕竟,这会让进程看起来更频繁地重新获得CPU时间,过度减小时间片会导致频繁的上下文切换,这同样会带来问题,针对这个问题,存在三种可能的解决方案,

提升处理器速度使上下文切换更快速,这样就能采用更小时间片的同时避免对周期时间产生负面影响,

或者采用多处理取架构,使进程能够并行运行并分担工作负载。然而 这两种方案都需要硬件升级,但硬件升级并发总是可行的方案

第三种方案,仅需通过更智能的 CPU 调度器就能实现,无需硬件改动。想象一下计算机上正在运行的进程,游戏,文件资源管理器及其后台应该都在同步运行,运行程序过多导致轮转调度使游戏出现轻微卡顿,问题是, 这些进程真的都需要 相同的CPU 时间吗?很可能不是,如果我们为合适的进程分配更高优先级,就能缩短它们的响应时间,让用户感觉系统重新变得流畅灵敏,确实, 后台进程 获得CPU的机会会减少,但请思考一下: 邮件通知延迟半秒真的重要吗?不过,并非所有后台进程都无关紧要。有些进程(例如操作系统服务) 至关重要,关键在于,若能准确识别需要更高优先级的进程,就能在不升级CPU 或增加核心的情况下,显著提升系统整体响应能力,

image-20261101154922668

6- 优先级调度

优先级调度通过优先级队列实现,每个进程被分配一个代表其优先级的整数值,某些系统可能采用0到255的数值范围,其他系统则可能采用0到32000的范围,特别在需要精细优先级划分的场景下。数值大小与优先级高低的关系同样取决于具体系统。混合使用多种调度策略也是常见做法,例如, 可将优先级调度于轮转调度结合,确保优先级进程间的公平性。

优先级调度存在一个固有问题:饥饿现象,在负载较重的系统中,持续涌入的高优先级进程,可能导致低优先级进程始终无法获得CPU资源,最短作业优先算法本质上是一种优先级调度,其优先级与预测的下一CPU执行时长成反比。

image-20261101160116513

image-20261101160233282

image-20261101160422679

image-20261101160555896

IBM 7094 大型机 作为 1962 年问世的超级计算机,它同时承担着美国空军的科研与军事任务,据传闻,当1973年这台设备退役时,人们发现了一个自1967年提交后,始终未被执行的低优先级进程,针对低优先级,进程可能被无限期阻塞的问题,“老化”机制提供了一种解决方案,老化机制通过逐步提升长时间等待进程优先级来实现这一目标。

例如, 若优先级范围从127 (最低) 到 0 (最高), 系统可定期(比如每秒)将等待进程的优先级提升1级,最终,即使时初始优先级为127的进程,也能获得系统最高优先级并得以执行,在优先级调度与轮转调度结合得场景中,所有进程可被置于单一队列进行管理,但根据队列管理方式得不同,可能需要通过O(n) 次搜索才能确定最高优先级进程,实践中, 为不同优先级分别设立独立队列通常更为便捷,这种方法被称为多级队列调度。此时,需要在各队列之间进行调度决策,每个队列对低优先级队列拥有绝对优先级。调度程序会直接选取最高优先级非空队列种得进程执行,但为避免低优先级队列陷入饥饿状态,我们还可以采用另一种队列调度方式。我们可以采用轮转方式调度就绪队列,但会根据队列优先级连续分配不同数量得进程,在队列间进行时间片轮转,多级队列调度通常根据进程类型将其划分至多多个独立队列,从而更有效调度具有不同响应时间要求得进程, 这种设计甚至允许我们为每个队列采用不同得调度算法。例如:前台队列可采用轮转调度算法,而后台队列则使用先来先服务算法,优先处理交互进程,同时避免低优先级进程陷入饥饿状态。

image-20261101161330364

image-20261101161655638

image-20261101162412107

image-20261101162614017

image-20261101163120076

image-20261101163230905

在典型的多级队列调度算法中,进程进入系统时会被永久分配到某个队列,这种设计的优势在于开销较小,但缺乏灵活性,因为进程在其生命周期内的行为模式并非一成不变。以电子邮件客户端为例,它通常在后台静默运行,偶尔检查新邮件,但当你切换到前台时,必然期望它能快速响应。菜单,按钮, 表单 都必须立即响应。

进程的CPU 使用模式及其响应时间要求会动态变化,多级反馈队列算法,正是为适应这类行为动态变化而设计的。

image-20261101163822837

7 - 多级反馈队列算法

所有进程初始都置于最高优先级队列中运行,若进程在单个时间片内完成其CPU执行周期,则可在 I/O操作结束后返回原优先级队列,若进程所需CPU 时间操过当前时间片限额,则将其移至较低优先级队列,较低优先级队列拒用更长的时间片,若进程能在比高优先级队列时间片更短的周期内完成 CPU执行, 则这类表现更接近I/O密集型任务的进程,可被移回高优先级队列,随着时间推移,具有较短CPU 执行周期的进程将倾向于驻留在较高优先级队列中。相比之下,CPU密集型进程将逐渐下沉至较低优先级队列。

image-20261101163929841

image-20261101164528878

image-20261101164637575

系统正是通过这种方式, 根据进程行为自动调整其优先级。需要注意的是, 这仅是多重反馈队列调度的一种实现方式,

它存在多种变体,形态各异,各具特色。

不仅每个队列可采用不同的调度算法, 进程在队列间升降的判定标准也各不相同,

image-20261101164918259

image-20261101165058609

系统可以不依赖最近一次的CPU 突发记录,来调整进程优先级,而是采用基于历史数据的预测方法,这种做法, 可避免偶发性突发(无论是极短还是异常冗长)导致不必要的队列切换,

一旦某个进程被判定为 CPU 密集型,最终仍会被降级处理,这是怎么回事?

虽然受限于I/O的进程于交互式进程并非同一概念,但二者都具有占用时间极短的特点,其次, 现代 处理器的运行速度极为迅速,若打开任务管理器,会看到数百个进程正在同时运行,若打开资源监视器,会发现这些进程总共 占用的CPU 时间还不到 熊总量的10%,

这两点表面: 大多数时候, 高优先级队列实际上处于空闲状态,因此当某个进程进入最低优先级队列时,实际情况并不像听起来那么糟糕,实际上,未经老化处理就直接被调度的概率,可能比我们想象的要高。

image-20261101165443730

image-20261101165853491

例如当系统处于高负载时,比如视频渲染或跑分测试期间,即使CPU 占用率达到100% ,桌面操作依然流畅,音乐播放持续不断,浏览器也能保存正常响应。这是因为系统调度器会优先处理日常交互任务,而那些不常运行的CPU密集型进程,则通过利用高优先级任务剩余的CPU 周期来获得执行机会,

如果把多级队列和多级反馈队列放在一起比较,听起来哪一种更通用?

过去许多操作系统都采用过多级反馈队列的变体,但现代系统面临着新的挑战,

image-20261101170755885

下面这些因素增加了调度复杂度,因此催生了更先进的调度策略来应对,

image-20261101171028689

现代操作系统调度的是线程,而非进程,这使得单个应用程序内部能够实现并发执行,例如, 一个线程 负责处理用户界面,另一个线程则执行后台计算任务,通过多级反馈队列机制,即使某个进程大量消耗CPU 资源,器用户界面仍能保持流畅,这是因为管理用户界面的线程可以独立于执行繁重计算任务的线程,始终维持较高的优先级,

image-20261101171330817

image-20261101171432703

为了便于理解, I/ O waiting 队列 简化为先进先出队列, 实际上输出子系统的运行机制要复杂得多,进入I/O 队列得进程, 其完成顺序可能和到达顺序不一致,正如先到先服务模式在 CPU 调度中存在 效率瓶颈,这种调度方式对 I/O 设备同样不是最优选择,例如,需要使用显卡得进程,不必等待正在使用磁盘得进程完成操作,不过, 这些具体细节都属于操作系统另一组件 ---- I/O 调度器得职责范畴,

image-20261101172313338

image-20261101172745872

image-20261101172906460

10-编程中最诡异的漏洞 ---- 竞态条件

并发技术结合CPU 调度,将计算资源分配给多个可执行实体,系统在多个任务间快速切换CPU,营造出所有任务同时运行的假象,当这些进程独立运行时,一切往往顺畅无阻。但并发实体需要交互时, 情况会变得复杂,若处理不当,后果可能相当严重,

竞态条件 -- 这是系统编程中最诡异难缠的错误之一。

image-20261101182010065

初学编程时, 我们常被告知代码只是指令序列,--- 通过逐条定义步骤来指引计算机达成目标,但很少有人告诉 初学者:每行代码未必对应单个 CPU 指令,实际上, 有些代码行可能只需编译为一条指令,而另一些则需要多条指令,以两数相加为例, 就需要多条指令才能完成,需要先用指令将两个操作数从内存载入CPU 寄存器, 再通过指令让 CPU 对寄存器中的值执行加法运算,最后用指令将结果存回内存,以供后续调用,源代码中的大多数操作都需要通过多个CPU步骤才能实现预期功能,这正是理解竞态条件需要掌握的首要关键概念,

image-20261101182323598

大多数操作并非原子操作,

线程通常通过系统调用创建,例如在 C 语言中,创建线程最常用的方式时通过 符合POSIX 标准的系统提供的pthread_create函数;该函数接收指向另一个函数的指针,当调用 pthread_create 时, 系统底层会执行一系列复杂操作。系统内部通过系统调用通知操作系统,作为参数传递 的函数可以开始作为独立的可执行实体运行,并与发起调用的线程并发执行,同时要记住, 并发并不等于并行,在多核系统中,并发执行的函数可以实现并行运行,但在单核系统中,所有任务共享同一个 CPU 核心,线程切换的速度极快,看起来就像在同时运行,线程与并发技术在许多应用场景中发挥着不可替代的重要作用,以现代 HTTP 服务器为例,它们正是通过 多线程技术来同时处理大量客户端请求的。若未采用并发技术,主程序必须完成前一个客户端请求的处理,才能开始接收下一个请求,这种处理方式的低效程度可想而知,通过并发技术,主线程持续监听请求,一旦收到新请求,立即创建线程进行处理,由于新线程独立运行,主线程得以立即返回继续监听新请求,然而尽管并发技术极具价值, 使用时仍需格外谨慎,假设服务器需要返回客户端在请求体中发送的用户名,这种情况下,每个线程仅处理客户端的请求,彼此互不干扰,由于存在共享数据,基本不会出现异常情况,

image-20261101182827070

image-20261101183217691

image-20261101183602086

image-20261101184413710

但如果多个线程需要共享并修改同一份数据呢?

假设服务器不再返回客户端名称,而是返回一个由所有线程共享的字符串,该字符串会由 一个线程定期从语句列表中更新,在这种情况下,线程之间需要某种通信机制, 许多人 会定义要给全局变量来存储当前字符串,创建一个定期更新该变量的函数,并启动一个线程来运行这个函数,写入线程会根据需要修改这个变量,而处理线程在响应客户端请求时则会读取该变量,

image-20261101184955742

image-20261101185107755

结果发现,在某些响应中,服务器发送的时不同句子的组合内容,那么, 这到底时怎么回事?

由于我们忽略了底层系统的运行机制, 要理解为何出现如此异常的行为,需要探究字符串的工作机制,

总而言 之,在 C 语言中,字符串在内存中以字符序列的形式表示,并以特殊的空字符作为结束标志,这种空字符的设计非常实用,它无需想某些面向对象语言那样同时存储指针和字符串长度,当前对我们最重要的是, 大多数字符串操作都不是原子操作,例如在打印字符串时,函数会逐个遍历字符,并将器输出到控制台,值到遇到空字符为止。同样的,复制字符串也是一个多步骤的操作过程,若字符串长度较大,计算机无法通过单步操作直接将整个字符数组从一个内存位置复制到另一个位置,实际上,它需要逐个字符遍历,直到遇到空终止符才会停止复制,为了提升效率,某些架构会采用分块复制的方式处理数据,但即便如此,这些数据块也存在最大尺寸限制,根据字符串长度的不同,复制操作仍可能需要多个步骤才能完成。为了简化说明,我们仍以逐字复制为例进行讨论。

image-20261101185707533

当一个线程正在读取共享字符串,而另一个线程同时进行写入时,并发机制可能导致操作系统在写入线程复制过程中突然中断,将CPU 资源分配给其他进程,此时共享字符串便处于无效状态,--- 它的当前值完全超出了我们的预期范围,若调度程序在写入线程恢复并完成更新前,将CPU 分配给读取该共享数据的线程,就会导致该线程复制到不一致的数据,这正是实例中出现的情况,写入线程在修改字符串过程中被中断,导致处理客户端请求的线程复制到了不完整的字符串数据,

image-20261101190509022

image-20261101191240361

竞态条件是一种软件缺陷,当程序执行结果取决于多个线程或进程访问修改共享资源的不可预测顺序时,就会发生这种错误,竞态条件通常时抢占式操作系统的副作用,这正是此类缺陷难以预测的关键所在,抢占式操作系统会强制中断正在运行的进程,以便其他进程能够获得CPU 实用权,通过这种方式,系统得以在所有活动程序间公平分配计算资源,在讨论CPU 调度时,我们提到了时间片的概念--- 这是一个固定时长,决定了进程或线程在被中断前能持续实用 CPU 的时间,通过系统调用,进程和线程主动让出CPU控制权,使其他程序得以运行,但若线程或进程在时间片耗尽前未能完成当前任务,硬件计时器会触发中断并抢占该进程,--- 这意味着操作系统会暂停器执行,并将CPU 重新分配给其他任务,因此,若数据修改操作耗时超过单个时间片,数据必将进入无效状态, 因为至少会遭遇一次中断,但这并不意味着耗时较短的操作就是安全的。即使数据修改能在单个时间片内完成,竞态条件仍可能出现,首先,如果操作恰好在时间片即将结束时启动,那么它极有可能被中断,但除了时间问题外,CPU 还可能被许多与调度器无关的事件中断,其他进程设置的定时器,用户在界面中点击按钮、接收网络数据包,这类事件都会触发中断,而无论中断源是什么,都可能抢占当前运行线程,--- 即使其时间片未用完,遗憾的是,我们无法阻止这类事件发生,若强行阻止,反而会削弱系统,对这些中断事件本身的响应能力,但我们至少能试着防止他们篡改数据吧?

如果我们引入一个控制变量 ---- 比如一个标志位,用来表示共享数据当前是否正在被修改呢?例如, 用1 表示正在写入, 0 表示未写入,通过这种设置,想要读取共享字符串的线程可以先检查标志位,以判断写入线程当前是否正在修改数据,虽然这个方案听起来不错,但我们需要格外谨慎---- 因为当这个控制变量被多个线程共享时,它本身就成了共享数据,同样会受到竞态条件的影响,设置或检查变量值的操作并不总是原子性的。在某些架构中,对变量进行写入可能时原子性的,但读取并比较的过程几乎从来都不是原子操作,这个过程通常包括: 将变量值从内存加载到寄存器, 将寄存器中的值与某个常量或预期值进行比较,然后根据比较结果执行相应操作,

设置变量其实并没有起到太大作用。因此,除非确有把握,否则应避免使用常规变量作为控制共享数据访问的标志位,

某些经典的软件解决方案(例如名为 Peterson 的算法)需要依赖诸多微妙的前提条件,仅在特定场景下才能正确工作,这些细节涵盖软件行为、编译器优化,乃至硬件架构等多个层面。

image-20261101191442300

image-20261101192645345

image-20261101193019250

image-20261101193243998

目前场景为:一个线程向共享数据写入,而其他线程从中读取数据,

但还存在另一种情况,多个线程需要同时向共享数据执行写入操作,在这种情况下,竞态条件同样会带来严重问题,

假设某个程序接收一个数字,需要计算从0 到该数字范围内存在多少个质数,为了提升计算效率,程序会采用并行处理方式,让每个线程在独立的CPU核心上运行,为此,各线程会共享一个计数器,当某个线程出现质数时,这个整数计数器就会执行自增操作,问题在于,许多系统架构中的数值自增操作,并非原子操作,假设两个线程几乎同时找到质数,我们期望计数器能正确累增到 459,但由于并行执行,两个线程可能同时读取原始数值,各自完成增量计算后回写结果,最终导致即使发现两个质数,计数器却仅增加1,这种情况甚至无需中断介入,竞态条件的产生源于对共享变量的并行访问,但这并不意味着不适用并行处理就能改善情况,

设想这样一个简单场景,假设程序已找到10 个质数,突然有5 个线程几乎同时找到了质数,此时质数总数应为15 个,但实际可能出现这样的情况,假设第一个线程读取计数值并完成递增,却在将结果写回内存前被中断, 随后四个线程连续无中断运行,各自成功完成计数递增,使计数器达到14,此时首个线程从中断处恢复,恰好在将递增后的数值存回内存的指令前继续执行。但由于该数值是在其他四个线程递增计数器之前计算的,它会覆盖已更新的结果,导致数值从14 回到11. 就这样,四个已被正确识别的质数就此丢失。

程序的行为,取决于不可控事件的发生时机于执行顺序,这些事件的发生时机完全无法预测,这正是竞态条件如此危险的原因。

image-20261101195402247

image-20261101195615861

image-20261101195727165

之所以称为竞态条件,是因为多个操作竞相访问或修改共享数据。最终结果取决于哪个操作先完成,竞态条件的危险之处,恰恰在于它们极少发生,因为如此,它们才难已复现和调试。因此, 当你的项目涉及并非编程时,请务必谨记,稍有不慎,每个创建的线程都可能成为一枚定时炸弹的倒计时开关。最理想情况下, 程序会直接崩溃,最糟糕的是, 你永远无法预料它最终会引发怎样的后果。

幸运的是,计算机提供了多种工具来帮助我们避免竞态 条件。

image-20261101201429841

11-为什么程序不能互相访问内存?

若问及计算机中仅次于中央处理器的重要部件,答案多半是内存,

image-20261101202157674

image-20261101202220264

能够存储二进制数值的电路,无论是通过晶体管,还是电容器,都能构成可存储1 或0的存储单元, 从物理结构来看,内存本质上是由这些微型存储单元构成的巨型网格,每个单元都可通过称为地址的二进制数值进行访问。

image-20261101202526190

image-20261101202539994

image-20261101202555798

image-20261101202710928

由于每个存储单元在矩阵中都有唯一位置,通过二进制地址与辅助电路结合,就能精确定位需要读写的目标单元,通过单比特信息,我们可以表示真与假,仅能表示两种状态数据,要表示更复杂的数据,就需要更多比特位,

image-20261101203017864

image-20261101203038561

image-20261101203123579

image-20261101203142104

一个字节通常包含8 个比特,这样的设计实用得多,因为8 个比特能表示256 种不同状态,仅需一个内存地址即可访问,在我们看来,内存就像一个巨大的字节数组,每个字节都有唯一的地址,内存是现代计算机系统的 运行核心,因为主存与每个CPU 核心内置的寄存器

是CPU 能够直接访问的通用存储介质,存在 LOAD 和 STORE 这类机器指令, 它们以内存地址为参数,使 CPU 能够从特定内存位置读取数据。或向特定内存位置写入数据。但不存在任何以磁盘地址为参数的指令,因此,正在执行的指令及其操作的所有数据,都必须存放在这些直接存取存储设备中。若数据尚未存入内存,则必须将数据移至内存中,CPU方能调用,

image-20261101203439217

image-20261101203616820

在同一个系统上同时运行多个进程会带来诸多挑战,当多个程序同时运行时,它们需要共享CPU 资源,因此系统会采用调度算法来确保每个程序都能公平获得计算时间,但并非仅仅影响CPU时间,还会对内存造成影响,程序通常存储在磁盘中,没有任何机器指令可以直接操作磁盘地址,因此当我们启动程序时,必须将其加载到内存中,这样CPU才能获取并执行其中的指令和数据,此时,程序就成为了一个进程,每个进程还需要内存空间来存储变量、临时数据及其他各类数据,分配给进程的完整区域被称为地址空间, 这是我们希望确立的规则,默认情况下, 进程绝不应具备访问其他进程地址空间的能力。但 这是为什么?

假设两个进程正在同时运行,第一个进程显示付款表单,允许用户向他人转账,当用户填写表单时,输入内容会暂存到该进程地址空间的某个缓冲区, 若未实施内存访问限制,第二个进程(任意进程)仅需执行一小段包含加载/存储指令的代码,就能从首个进程的地址空间读取支付数据,将其复制到自身地址空间,并通过网络发送给恶意攻击者,这会造成严重的安全隐患,尤其考虑到我们常常会随意安装来源不明的应用程序,由于操作系统本身也依赖内存来管理系统,未受保护的内存访问是现代操作系统绝不能忽视的关键问题,更危险的是,问题不仅限数据读取,若某个进程能访问其他进程的地址空间,就意味着它同样具备写入权限,恶意进程可能篡改支付缓存区,在用户毫无察觉的情况下修改转账金额或收款方信息。更严重的是, 若 无内存保护机制,进程不仅能篡改其他进程的代码,甚至能修改操作系统核心代码,通过注入恶意指令,几乎可以实现任意操作。

进程的地址空间具有不可侵犯性,进程之间必须实现内存隔离,禁止互相读写对方的内存空间,实现这一规则存在多种技术路径,但完全依赖软件方案并不可行,如前所述, 进程在运行期间会持续执行内存操作,若操作系统内需对每个使用CPU 的进程进行非法内存访问检查,就必须在用户进程的每次内存操作时,执行额外代码,这绝非易事,由于用户进程执行的每条指令,都需从内存中获取,操作系统可能还需运行更多指令来验证内存访问,这会带来巨大性能开销。

在程序运行时,内存 单元只能看到,通过地址总线这组并行线路从CPU传来的一连串内存地址,主内存并不清楚这些地址时如何生成的,也不知道它们的具体用途,因此直接在内存中处理这个过程并非最佳方案,

image-20261101211734649

我们将使用两个特殊寄存器来实施内存边界保护,基址寄存器指向进程可访问的最小物理内存地址,界限寄存器定义了该进程可寻址范围的大小,借助这两个寄存器,我们可以验证,进程试图访问的地址是否在其自身地址空间范围内,从代码实现来看,我们需要验证目标地址是否同时满足:大于等于基址 且小于基址与界限值之和。虽然操作系统在软件层面进行此类验证时遵循相同逻辑,但此刻,我们正将其转化为硬件实现,请注意,基址与界限值之和定义了地址空间的上界,但该上界属于开区间边界,这正是我们采用“小于” 而非 “小于等于”进行边界校验的原因,好消息是,通过二进制加法器计算这个上级非常简单,--- 只需将基址寄存器与界限寄存器的值相加即可, 我们需要对每次内存操作传递的地址进行验证。由于所有地址都通过地址总线传输,正好可以在此处对地址进行实时拦截校验, 要验证地址是否越界,最直接的方法是使用二进制比较器,进行边界比对,

image-20261101212010129

image-20261101213322289

image-20261101213341070

image-20261101213413547

但单单一根导线如何阻止非法内存访问?

仅通过地址总线发送地址本身并不会触发内存操作,要实际执行内存操作,我们必须明确指定是要读取还是写入该地址,这需要通过激活读取使能或写入使能信号来实现。当读取使能信号激活时,内存会 将指定地址的内容置入数据总线,当写入使能信号激活时,内存会使用数据总线上的信息,覆盖指定地址原有的内容,阻止非法内存访问最直接的方法,就是利用地址验证电路的输出来控制读写信号的传输,如此一来,若有指令试图访问进程地址空间之外的地址,硬件将阻止读写信号传递至内存模块,换言之,只有当访问地址位于当前进程地址空间范围内时,内存才会接收到读写使能信号,

另一个可行的方案是增设逻辑门电路,用于侦测,进程尝试访问非法地址的行为。这样,电路不仅能阻断内存操作,还会向中央处理器发送硬件信号,立即中断当前进程并调用操作系统。后者通常会通过终止进程来处理此类违规行为,在 类 Unix 系统中,当操作系统捕获此信号时, 会终止进程并显示“段错误“提示信息,表明该进程试图越界访问内存。该操作未被允许。操作系统无需执行额外指令,即可触发硬件保护机制。由于硬件线路的特殊设计,该电路能自动拦截所有内存操作 --- 无论其目的为何,并即时完成权限验证,

操作系统的唯一职责是将CPU 调度给用户进程前,正确设置基址寄存器和界限寄存器的数值。

image-20261101213731969

image-20261101214033855

image-20261101214507068

image-20261101214657235

image-20261101215408557

需要牢记的是: 要实现这些功能,CPU 必须支持操作模式,这些内存管理寄存器(例如基址寄存器和界限寄存器) 必须实现为特权寄存器。仅允许操作系统进行修改,这便成为操作系统在上下文切换中的又一项职责。当CPU 从一个进程切换至另一个进程时,操作系统不仅要保持被中断进程的状态,并恢复新进程的状态,还需要更新内存边界,当所有设置准备就绪后,操作系统将会将CPU切换至用户模式。从而允许用户进程开始执行。但在该模式下,CPU无法修改特权寄存器。因此,进程只能使用操作系统明确分配给它的内存空间。

这就是操作系统在硬件支持下,防止用户进程相互访问内存的基本原理。

image-20261101215923736

image-20261101220209670

posted @ 2026-11-01 22:19  Ref-brief  阅读(2)  评论(0)    收藏  举报