面试八股——操作系统
进程,线程,协程的区别是什么?
- 进程(Process)—— 独立的“资源城堡”
进程是操作系统分配资源(如内存、文件句柄、CPU 时间片)的最小单位。当你运行一个程序(比如 Chrome 浏览器或一个 Python 脚本)时,操作系统就会为它创建一个进程。
- 特点:每个进程都有自己独立的虚拟内存空间(代码段、数据段、堆、栈等)。
- 优缺点:
- 优点:安全性高。一个进程崩溃了,不会直接导致其他进程崩溃。
- 缺点:创建、销毁和切换的开销非常巨大,因为操作系统需要频繁地在内核态和用户态之间切换,并且要刷新内存映射表(TLB/页表)。
- 线程(Thread)—— 城堡里的“打工人”
线程是进程内部的一个执行路径,是 CPU 调度和执行的最小单位。一个进程可以包含多个线程,它们共享该进程的所有资源。
- 特点:同一个进程内的多个线程共享堆内存和全局变量,但每个线程有自己独立的栈(Stack)*和*程序计数器(PC)。
- 优缺点:
- 优点:通信极其方便,因为它们可以直接访问相同的内存数据;切换开销比进程小得多。
- 缺点:因为共享内存,多个线程同时读写同一块数据时容易产生并发冲突(数据竞争),需要引入锁机制(如
Mutex)。此外,一个线程崩溃(如段错误)可能会导致整个进程挂掉。
- 协程(Coroutine)—— 程序员掌控的“分身术”
协程是一种用户态的轻量级线程。它完全由程序(或编程语言的运行时,如 Go 的 goroutine,Python 的 asyncio)来控制,操作系统根本不知道协程的存在。
- 特点:
- 非抢占式(协作式):线程的切换是由操作系统强行插手的(抢占式调度);而协程的切换是自愿的。当一个协程遇到 I/O 阻塞时,它会主动“让出” CPU,让其他协程执行。
- 单线程内并发:多个协程可以在同一个线程内运行。
- 优缺点:
- 优点:
- 性能压倒性优势:切换不涉及内核态,纯粹是用户态的指针移动,极其轻量。
- 极高的并发量:单机轻松创建百万个协程(而如果是百万个线程,内存早就爆了,CPU 也会被上下文切换拖垮)。
- 缺点:无法直接利用多核
CPU(除非配合多线程/多进程模型)。如果一个协程内部执行了死循环或者同步的阻塞操作(如传统的
time.sleep),整个线程都会被卡死。
- 优点:
线程与并发
任务的类型:CPU 密集型 vs I/O 密集型
多线程利用多核,在不同任务类型下的效果是完全不同的:
- CPU 密集型任务(如:视频渲染、3D 游戏、科学计算、AI 模型推理):这类任务死磕 CPU 算力。此时,线程数通常设置为 CPU 核心数 + 1 最合适。让每个核心死磕一个线程,没有多余的切换开销,多核利用率最高。
- I/O 密集型任务(如:网络爬虫、文件下载、数据库查询):这类任务大部分时间 CPU 都在闲着,等待硬盘或网络返回数据。此时,哪怕你开了 100 个线程,CPU 核心的利用率可能也只有 5%,因为 CPU 根本不忙,忙的是网卡和硬盘。
1. 什么是并发(Concurrency)?
在计算机世界里,并发是指系统在同一时间段内处理多个任务的能力。
注意这里的措辞:是“同一时间段”,而不是“同一绝对时刻”。
- 没有并发的系统:就像一个极其死板的银行柜员,必须给 A 办完所有的存款、贷款、理财手续,才能叫 B 的号。如果 A 在等待审批,柜员也只能干坐着,B 只能在后面死等。
- 支持并发的系统:柜员给 A 提交了贷款审批(进入等待),立刻招呼 B 过来办存款;B 拿单据去填写的空档,柜员又转头帮 A 把剩下的手续办了。在宏观上看,A 和 B 的业务是同时在推进的。
2. 线程:并发的“最小执行单元”
前面我们提到过,进程是资源分配的单位,而线程(Thread)是 CPU 调度的最小单位。在并发模型中,线程就是那个真正去执行任务的“具体的柜员”或“执行流”。
现代软件为了实现并发,通常会采用多线程模型。 例如,当你打开一个高并发的 Web 服务器(如 Nginx 或 Tomcat):
- 主线程:坐在门口(监听端口),专门负责迎接新进来的用户网络请求。
- 工作线程 A:负责去数据库读取用户的小说文本。
- 工作线程 B:负责把用户上传的图片进行压缩解码。
- 工作线程 C:负责校验用户的登录密码。
通过把一个庞大的进程拆分成无数个各司其职的线程,程序就具备了同时处理成千上万用户请求的并发能力。
3. 并发(Concurrency) vs 并行(Parallelism)
这是学习并发最容易混淆的两个概念。它们的区别,完美体现了多线程是如何在不同硬件上运转的。
假并发(宏观并行,微观串行)—— 单核 CPU
如果你的 CPU 只有一个核心,但你同时打开了音乐播放器、浏览器和游戏,它们能同时运行吗?能。但这是 CPU 演的戏。
- CPU 会把时间切成极小的碎片(比如 5 毫秒一段,称为时间片)。
- 前 5 毫秒给音乐播放器线程,播放一段音频;后 5 毫秒切换给浏览器线程,渲染一部分网页;再后 5 毫秒给游戏。
- 因为 CPU 切换的速度高到每秒几亿次,人类的大脑根本察觉不到断点。这种利用时间片轮转、在单核上交替执行多个线程的方式,叫做并发(Concurrency)。
真并行(真正的同时发生)—— 多核 CPU
如果你的 CPU 有 4 个核心,操作系统就可以把音乐播放器丢给核心 1,浏览器丢给核心 2,游戏丢给核心 3。
- 在任何一个绝对的微观时刻,这三个核心都在同时通电、同时计算。
- 这种在同一时刻、物理上真正同时执行多个线程的方式,叫做并行(Parallelism)。
总结: 并发是架构设计上的概念(代码逻辑支持同时处理多件事);并行是硬件执行上的概念(硬件有能力同时开工)。多线程代码在单核上叫并发,在多核上叫并行。
python中的进程与线程
在标准的 CPython 解释器中,存在一个叫做 GIL (Global Interpreter Lock) 的机制。它的作用是:在任何一个时刻,只允许一个线程执行 Python 字节码。
- 这意味着什么? 即使你有 8 核 CPU,Python 的多线程在同一时刻也只能在 1 个核上运行。
- 为什么要有 GIL? 为了简化 CPython 的内存管理(特别是引用计数机制),避免多线程同时修改对象导致内存泄漏或崩溃。
- 结论:在 Python 中,多线程不能提升 CPU 密集型任务(如大量数学计算、图像处理)的速度,反而可能因为线程切换的开销变得更慢。
- 多线程 (
threading模块)
虽然受 GIL 限制,但多线程在 I/O 密集型任务(如网络请求、文件读写、数据库查询)中依然非常有用。因为当线程等待 I/O(如等待网页返回)时,它会主动释放 GIL,让其他线程运行。
- 多进程 (
multiprocessing模块)
为了绕过 GIL,真正利用多核 CPU 来处理 CPU 密集型任务,我们需要使用多进程。每个进程都有自己独立的 Python 解释器和内存空间,因此各自拥有独立的 GIL,互不干扰。
线程间的通信方式
共享内存 (Shared Memory)
原理:因为同一个进程内的线程共享堆内存和全局变量区,所以线程 A 直接把数据写入一个全局变量,线程 B 直接去读这个变量,这就完成了通信。
致命缺陷:如果两个线程同时读写这个变量,会导致数据竞争 (Data Race),数据就乱了。
结论:共享内存必须配合下面的“锁机制”才能安全使用。存必须配合下面的“锁机制”才能安全使用。
互斥锁 (Mutex Lock) —— 保护数据的“防盗门”
原理:用于保证互斥。当线程 A 要修改共享变量时,先“上锁”,其他线程想修改只能阻塞等待;A 修改完“解锁”,下一个线程才能进。
场景:比如多个线程同时给一个全局计数器
count++,必须用互斥锁把 count++
保护起来(变成原子操作)。
条件变量 (Condition Variable) —— 线程间的“对讲机”(🔥面试重灾区)
原理:互斥锁只解决了“抢资源”的问题,但解决不了“等待”的问题。条件变量用于线程间的同步,允许线程阻塞,直到某个特定条件成立。
经典搭配:条件变量 永远和 互斥锁 配合使用。
核心 API:wait() (等待),
signal() (唤醒一个), broadcast()
(唤醒所有)。
信号量 (Semaphore) —— 控制人数的“限流器”
原理:本质上是一个带锁的计数器。它允许指定数量的线程同时访问某个资源。
对比互斥锁:互斥锁其实就是值为 1 的信号量(二值信号量),只允许 1 个线程进。
场景:比如你的系统最多只能同时处理 3 个视频渲染任务。你可以初始化一个值为 3 的信号量。前 3 个线程拿到信号量直接执行,第 4 个线程来了只能阻塞,直到前 3 个里有一个执行完释放信号量。
上下文切换(Context Switch)
什么是上下文切换?(核心概念)
一句话总结: 上下文切换就是 CPU 从一个进程(或线程)切换到另一个进程(或线程)执行的过程。
为了让被切换掉的程序下次被调度时能接着跑,CPU 在切换前必须把当前的“运行现场”保存起来,并加载新程序的“运行现场”。这里的“现场”就是上下文(Context),主要包括:
- 硬件上下文: 通用寄存器、程序计数器(PC,指向下一条指令的位置)、堆栈指针(SP)等。
- 内核管理数据: 进程控制块(PCB)或线程控制块(TCB)中记录的运行状态信息。
上下文切换的触发时机
一、 自愿上下文切换(Voluntary Context Switch)
核心特征: 线程自己发现“日子过不下去了”或者“活干完了”,主动让出 CPU,把执行机会留给别人。
- 面临阻塞 I/O(最常见):
- 场景:
线程尝试读取一个大文件(
read())或者等待网络数据包(recv())。 - 底层: 硬件速度(磁盘、网卡)远慢于 CPU。线程此时必须等待硬件把数据拷贝到内核缓冲区。由于无事可做,内核调度器会把该线程的状态从“运行态(Running)”改为“睡眠/阻塞态(Blocked)”,并立即切换到另一个就绪线程。
- 场景:
线程尝试读取一个大文件(
- 等待同步锁或线程协同:
- 场景: 高并发下,线程去拿一个互斥锁(如 C++ 的
std::mutex,Java 的synchronized或ReentrantLock),结果发现锁被别的线程占了;或者调用了wait()、park()等待被唤醒。 - 底层: 线程无法进入临界区,继续空转会白白浪费 CPU,于是操作系统将其挂起,放入锁的等待队列中,触发上下文切换。
- 场景: 高并发下,线程去拿一个互斥锁(如 C++ 的
- 代码主动“摆烂”(挂起/休眠):
- 场景: 程序员在代码里写了
Thread.sleep(1000),或者调用了sched_yield()(主动放弃剩余时间片)。 - 底层: 内核定时器开始倒计时,在这个线程醒来之前,CPU 被切换给其他线程使用。
- 场景: 程序员在代码里写了
二、 非自愿上下文切换(Non-voluntary Context Switch)
核心特征: 线程自己还想拼命工作,但被操作系统无情地强行剥夺了 CPU 使用权(抢占式调度)。
- 时间片耗尽(Time Slice Expiration):
- 场景: 现代操作系统(如 Linux 的 CFS 调度器)是分时复用的。每个线程被分配了一小段可以运行的时间(比如几个毫秒)。
- 底层: 每一个时钟中断(Clock Interrupt)到来时,内核都会检查当前线程的时间片。一旦发现额度扣完,内核就会在中断返回前,强行把当前线程踢下来,换另一个线程上去。
- 高优先级线程抢占(Preemption):
- 场景: 此时正在运行一个低优先级的后台清理线程。突然,一个负责处理用户点击、或者刚从 I/O 阻塞中醒来的高优先级线程进入了就绪队列。
- 底层: 为了保证系统的实时响应,操作系统内核会立刻发出抢占信号,强行中断低优先级线程,把 CPU 让给高优先级线程。
PCB(Process Control Block,进程控制块)
PCB 是操作系统为了管理进程而专门维护的一种内核数据结构。它是进程存在的唯一凭证(进程消失,PCB 也会被销毁)。
| 核心功能大类 | 具体用处(解决什么问题?) | PCB 中对应的关键字段/数据 | 常见面试场景/考点 |
|---|---|---|---|
| 1. 身份与生命周期管理 | 作为进程存在的唯一标志;区分不同的进程;处理父子进程的同步。 | • PID(进程 ID) • 父子进程指针 • 退出码(Exit Code) | 僵尸进程/孤儿进程的产生原因及清理机制(wait()
系统调用)。 |
| 2. 状态与调度管理 | 告诉内核调度器当前进程能不能运行、应该什么时候运行。 | • 进程状态(就绪/运行/阻塞) • 优先级(Priority) • 调度策略与时间片额度 | 操作系统是如何挑选下一个执行进程的?(引出 CFS 调度算法和就绪队列)。 |
| 3. 上下文记忆存储 | 在多任务切换(被踢下 CPU)时,保存断点现场,确保下次能无缝接着跑。 | • 程序计数器(PC) • 堆栈指针(SP) • 所有通用硬件寄存器状态 | 上下文切换的直接开销是什么?寄存器里的数据保存在哪里? |
| 4. 内存与地址空间隔离 | 圈定进程的活动范围,防止进程越界访问别人的内存;实现资源分配。 | • 页表根地址指针(如 CR3 的值) • 内存段描述(代码段/数据段界限) | 为什么进程切换比线程切换慢?(引出修改页表和 TLB 失效)。 |
| 5. I/O 与外设资源管理 | 记录进程持有哪些系统资源,防止资源泄露;支持进程的网络和磁盘读写。 | • 文件描述符表(fd table) • 占用的网络 Socket • 打开的外设清单 | 高并发下“文件描述符耗尽”(Too many open files)报错的根本原因是什么? |
用户态与内核态
用户态(User Mode)*和*内核态(Kernel Mode)*是 CPU 的两种*工作状态(特权级别)。操作系统通过这种划分,把普通应用程序和系统核心资源隔离开来,防止普通程序犯错导致整个系统崩溃。
在 Linux/x86 架构中,CPU 的特权级别被划分为 4 个级别(Ring 0 到 Ring 3),但操作系统主要只使用了其中两个:
- 用户态(Ring 3):
- 定义: 普通应用程序(如你的浏览器、IDE、微信、游戏)运行的状态。
- 权限: 受限权限。只能访问受保护的内存空间,绝对不允许直接访问底层硬件设备(如硬盘、网卡、显卡)或执行特权指令(如关机、修改页表)。
- 内核态(Ring 0):
- 定义: 操作系统的核心(Kernel)运行的状态。
- 权限: 最高权限。可以执行 CPU 的所有特权指令,可以直接控制和访问任何硬件资源,管理所有内存空间。
用户态如何切换到内核态?
应用程序在运行过程中,不可避免地需要用到硬件资源(比如读取文件、发送网络数据)。由于它在用户态没有权限,就必须触发状态切换,请求内核帮忙。
切换的触发途径主要有以下三种:
- 系统调用(System Call,最主动):
这是普通程序主动请求内核服务的唯一方式。
- 例子: 你在代码里调用了
printf()(底层调用write往屏幕写数据)、open()读写文件、或者socket()发送网络包。
- 例子: 你在代码里调用了
- 异常(Exception,最被动): 当 CPU
在执行用户态指令时,发生了一些内部错误或特殊事件,CPU
会被迫切换到内核态,由内核的异常处理器来处理。
- 例子: 发生了除以 0 错误、空指针异常(缺页异常 Page Fault)。
- 外设中断(Hardware Interrupt,最随机):
当外设(如键盘、鼠标、网卡、定时器)完成某些任务或发生状态改变时,会向
CPU 发出硬件中断信号。CPU
收到信号后,会暂停当前的用户程序,切换到内核态去执行对应的中断处理程序(ISR)。
- 例子: 网卡收到了一个网络数据包、或者倒计时定时器到期了(触发时间片轮转)。
段页式存储管理
分页与分段
| 对比维度 | 分页管理 (Paging) | 分段管理 (Segmentation) |
|---|---|---|
| 划分目的 | 主要是为了提高内存利用率,减少碎片,是系统的物理管理需要。 | 主要是为了满足用户的逻辑需求(代码共享、保护、模块化)。 |
| 块的大小 | 固定大小(由操作系统和硬件决定,通常为 4KB)。 | 大小不固定(由程序员在编译时根据代码逻辑决定)。 |
| 地址维度 | 一维地址。知道了虚拟地址,除以页大小就能自动算出页号和偏移量。 | 二维地址。必须显式给出【段号】和【段内偏移量】。 |
| 碎片类型 | 无外部碎片,但会产生内部碎片。 | 无内部碎片,但会产生外部碎片。 |
| 共享与保护 | 不容易实现(因为一个页内可能混杂了不同逻辑属性的代码)。 | 极易实现(一个逻辑段就是一个天然的共享/保护单元)。 |
既然分页和分段各有优缺点(分页能绝育外部碎片,分段方便逻辑保护),那聪明的架构师一拍大腿:我全都要! 这就诞生了现代 CPU(如 x86 架构)普遍采用的 段页式内存管理。
- 做法:
- 先把程序按照逻辑分段(分成代码段、数据段等)。
- 在每一个段内部,再把它无情地切成固定大小的页(比如 4KB 一页)。
- 寻址流程: 虚拟地址 → 查段表(找到页表起始地址) → 查页表(找到物理页框地址) → 加上页内偏移量 → 物理地址。
- 代价: 算一次地址需要访问三次内存,速度变慢了。不过不用担心,硬件层面上我们有 TLB(快表) 来做缓存加速。
为什么说分段分页是针对进程?
我们可以从进程和线程在内核中的资源划分来理解:
- 进程拥有独立的“财产清单”(页表/段表):
当操作系统启动一个新进程时,会为它圈出一块完全独立的、甚至高达
4GB(32位系统)的虚拟内存空间。为了管理这块空间,系统会为该进程专门创建并维护一套页表(Page
Table)*或*段表(Segment Table)。
- 这个页表的根地址,就记录在进程的 PCB(进程控制块) 里面。
- 线程只是共享进程的财产:
同一个进程里的所有线程,就像是住在同一个屋檐下的亲兄弟。它们共享该进程的整个虚拟内存空间。
- 这意味着,线程 A 和线程 B 使用的是同一个页表。一个相同的虚拟地址,无论是线程 A 还是线程 B 去访问,通过页表翻译出来的物理物理内存地址完全是一样的。
- 所以,线程自己是没有独立的页表或段表的,它只是一个在进程划分好的“格子(页)”里跑代码的工具人。
缺页中断 (Page Fault)
1. CPU 发起寻址
- CPU 给出要访问的虚拟地址(逻辑地址),由硬件 MMU(内存管理单元)试图进行地址翻译。
2. MMU 硬件检查
- MMU 查询页表,发现该页表项的“驻留标识位 / 有效位(Valid Bit)”为 0,代表该页面目前只躺在硬盘里,不在物理内存中。
3. 触发硬件中断
- MMU 当场触发缺页中断(实质上是一种内核异常)。
- CPU 立即暂停当前用户进程,保存当前硬件现场,特权级从用户态陷入内核态,将控制权全权交给操作系统的缺页中断处理程序。
4. OS 核心处理(关键分水岭分支)
操作系统接管后,首先检查地址合法性。确认合法后,根据当前物理内存的拥挤程度,分流为以下两种情况:
🟩 情况 A:物理内存有空闲位(按需调页)
- 磁盘读取: 操作系统直接启动磁盘 I/O,从硬盘中找到对应的页面数据。
- 数据载入: 将页面数据读入物理内存的空闲页框(物理块)中。
- 更新页表: 修改该虚拟页对应的页表项,将块号(物理页框号)*填入,并将*驻留位置为 1(标记已在内存)。
🟥 情况 B:物理内存已满(触发页面置换)
- 挑选倒霉蛋: 操作系统执行页面置换算法(如 LRU),挑出一个物理页作为淘汰页。
- 脏页写回(面试必杀点 🌟):
- 检查该淘汰页的“修改位 / 脏位(Dirty Bit)”。
- 如果被修改过(脏页):必须先把它异步写回磁盘,防止数据丢失;
- 如果未被修改过(干净页):直接无情释放,省去一次磁盘写入开销。
- 鸠占鹊巢: 把淘汰后腾出来的空闲位给新页面使用,启动磁盘 I/O 读入新页。
- 双向更新:
- 将淘汰页的页表驻留位置为 0;
- 将新页的页表填入新块号,驻留位置为 1。
5. 现场恢复与指令重执
- 操作系统更新完页表、完成内存搬运后,将之前保存的进程现场恢复到 CPU 寄存器中。
- 指令重新执行(核心特征): CPU 重新执行刚才那条导致中断的旧指令。这一次 MMU 查表成功(有效位为 1),顺利拿到数据,进程继续流畅运行。
页面置换算法
| 算法名称 | 核心淘汰策略(挑谁当倒霉蛋?) | 核心优点 | 核心缺点 | 大厂面试超高频考点 / 连连看 |
|---|---|---|---|---|
| OPT (最佳置换算法) | 淘汰以后永不使用,或者在最长时间内不再被访问的页面。 | 缺页率最低,性能堪称完美。 | 无法实现。因为操作系统没有超能力,无法预知未来哪个页面会被访问。 | 仅作为衡量其他现实算法好坏的绝对参考标准。 |
| FIFO (先进先出算法) | 谁最先进入内存,就先淘汰谁(像排队一样,队列实现)。 | 实现极其简单,开发成本低。 | 性能很差。完全违背了局部性原理(最先来的可能是一直在用的热点代码)。 | ⚠️ 必考:Belady 异常(诡异现象:物理块增加,缺页次数反而上升)。 |
| LRU (最近最少使用) | 淘汰最近最长时间没有被访问的页面。依据是“过去的时间”。 | 性能极好,最符合时空局部性原理,实际缺页率很低。 | 需要硬件支持(计数器或栈),每次访问都要更新顺序,系统开销巨大。 | 👑 面试大厂手写代码必考题(LeetCode 146,用“哈希表 + 双向链表”实现)。 |
| CLOCK (时钟/NRU算法) | 页面排成环形链表,指针像时钟一样转动。利用“访问位(0/1)”,碰到 1 改为 0(给一次机会),碰到 0 之间淘汰。 | 工程落地首选。性能逼近 LRU,但实现极其轻量,不需要硬件频繁记录时间。 | 极端情况下,指针需要转好几圈才能找到淘汰页,有扫描开销。 | 现代 Linux 等操作系统的实际底层选型。改进型 CLOCK 会同时看“访问位”和“修改位(脏位)”。 |
| LFU (最不经常使用) | 淘汰在一段时间内访问次数(频率)最少的页面。依据是“访问次数”。 | 适合某些长期高频访问、周期性访问的特定业务场景。 | 没考虑时间维度。如果一个页面前期被疯狂访问(计数极高)但后期废弃了,它会一直赖在内存里占地方。 | 核心对比:LRU 看的是“多久没用过”(时间),LFU 看的是“用得有多频繁”(次数)。 |
虚拟内存(Virtual Memory)
为什么需要虚拟内存?
在早期没有虚拟内存的系统里,程序是直接运行在物理内存上的。也就是说,代码里的地址
0x0012 就是内存条上的第 0x0012
个格子。这带来了三个灾难性的后果:
- 毫无安全可言(没有隔离): 进程 A
如果写错了指针(比如野指针),不小心改了地址
0x0050的数据,而这个地址正好是进程 B 的核心数据,进程 B 就会莫名其妙地崩溃。恶意软件甚至可以直接读取你微信进程的物理内存来偷看聊天记录。 - 物理内存容易得“高血压”(碎片化): 物理内存必须连续分配。如果系统里零散地运行着几个小软件,哪怕剩余的总内存足够,但只要没有一块连续的大空间,大程序就根本无法启动。
- 程序大小被死死卡死(容量限制): 如果你的电脑只有 8GB 内存,那你绝对运行不了一个 15GB 的大型游戏,因为内存条根本装不下。
| 核心功能大类 | 底层实现机制(怎么做到的?) | 带来的实质好处(解决什么问题?) | 面试高频核心词 / 连连看考点 |
|---|---|---|---|
| 1. 内存隔离与安全保护 | 每个进程分配一套独立的虚拟地址空间。通过各自的页表进行地址翻译,如果试图读写未授权的地址,内核会直接拦截。 | 防止进程之间内存互相篡改。游戏脚本无法读取支付宝的数据,某个程序崩溃也不会导致整个系统蓝屏。 | 权限检查、段错误(Segmentation Fault)、内核态/用户态隔离。 |
| 2. 扩大地址空间(以小博大) | 采用按需分页(Demand Paging)。只把当前需要运行的代码载入物理内存,不常用的部分悄悄换出到硬盘(Swap分区)中。 | 突破物理内存条的容量限制,允许系统运行远超实际物理内存大小的程序(如 8GB 内存跑 15GB 游戏)。 | 缺页中断(Page Fault)、页面置换算法(LRU/FIFO)、Swap 分区。 |
| 3. 消除物理碎片(简化分配) | 为程序员提供连续的虚拟地址空间(数数组、走指针很方便),但在物理内存中允许完全离散、零散地存放。 | 彻底消除了外部碎片。只要物理内存条里还有空闲的方格,不管多零散,操作系统都能利用页表拼凑起来给程序用。 | 页(Page)、页框(Frame)、物理内存碎片化。 |
| 4. 内存共享与高效复制 | 多个不同的进程,其虚拟内存中的某一段可以同时映射到物理内存中的同一份公共数据(如标准 C 库)。 | 极大地节省了物理内存。同时在创建子进程时,利用写时复制(COW)技术,避免了盲目拷贝大量内存,让进程创建变得极快。 | 写时复制(Copy-on-Write)、fork()
优化、共享内存(IPC)。 |
逻辑地址 vs 物理地址
1. 什么是逻辑地址(Logical Address)?
逻辑地址又叫虚拟地址(Virtual Address)*或*相对地址。
- 谁产生的: 由编译器在编译代码时自动生成的,运行期间由 CPU 执行指令时使用。
- 本质:
是目标代码在各个程序块内部的相对位置。程序员在 C/C++
里打印出来的一个指针地址(如
0x7ffee3bf8),或者编译后产生的可执行文件(ELF/EXE)内部的机器指令地址,全部都是逻辑地址。 - 特点:
它给程序创造了一个完美的、连续的幻想空间(比如 32
位系统下每个进程都以为自己拥有从
0x00000000到0xFFFFFFFF的 4GB 连续大饼)。
2. 什么是物理地址(Physical Address)?
物理地址又叫绝对地址。
- 谁使用的: 由内存控制器、系统总线和物理内存条(RAM 芯片)使用的地址。
- 本质: 它是内存条上数以亿计的微型电容(存储单元)的真实物理编号。
- 特点: 当 CPU 最终想要往内存里写入一个字节时,必须把这个地址丢到物理地址总线上,内存条才能定位到具体的硅晶片电路。在物理内存中,数据往往是零散、不连续跳跃分布的。
TLB(Translation Lookaside Buffer,旁路转换缓冲,快表)。
页表与快表存储位置的差异
1. 页表(Page Table)存储在哪?
- 物理存储位置: 物理内存(RAM / 主存)。
- 硬件本质: 普通的内存块(DRAM)。
- 底层机制: 页表是由操作系统内核在物理内存中开辟空间并维护的。因为页表记录了整个进程虚拟地址到物理地址的映射,体积通常很大(尤其是进程多、空间大的时候),CPU 芯片里根本没有那么大的地方能放下它,所以它只能老老实实地躺在内存条里。
- CPU 怎么找到它: CPU 内部只保留了一个极其珍贵的寄存器,叫做页表基址寄存器(在 x86 架构中就是著名的 CR3 寄存器)。这个寄存器里只存一个东西——当前正在运行进程的页表在物理内存中的起始首地址。当发生进程切换时,操作系统只需要把新进程的页表首地址写进 CR3 寄存器,CPU 就能顺藤摸瓜去内存里查新页表了。
2. TLB(快表)存储在哪?
- 物理存储位置: CPU 芯片内部(具体集成在 MMU 内存管理单元中)。
- 硬件本质: 高速静态表面缓存(SRAM)。
- 底层机制: TLB 是纯硬件实现的缓存,它直接嵌在 CPU 核心内部的 MMU(Memory Management Unit,内存管理单元) 里面。因为使用的是比内存(DRAM)快上百倍、但也极度昂贵的 SRAM 材质,所以它的容量非常小(通常只能存几十到几百个核心条目)。它存在的唯一目的就是离 CPU 核心足够近,让 CPU 能在 1 个时钟周期内瞬间完成地址转换。
为什么必须要有 TLB?
要理解 TLB 的价值,必须先看看没有它时,CPU 的日子有多痛苦。
在虚拟内存机制下,CPU 只要想读写一个变量,就必须把虚拟地址翻译成物理地址。
- 第一次访问内存: CPU 跑到物理内存里,去查这个进程的页表,传回物理地址。
- 第二次访问内存: CPU 拿着刚查到的物理地址,再次跑到物理内存里,去读写真正的变量数据。
😱 性能灾难: 也就是说,原本只需要访问一次内存的操作,因为虚拟内存的存在,变成了一定要访问两次内存,CPU 的执行效率直接腰斩!
TLB 的工作流程(Hit vs Miss)
当 CPU 发出一个虚拟地址请求时:
- TLB 命中(TLB Hit): MMU 直接在极其快速的 TLB 里找到了对应的物理页框号。耗时:不到 1 纳秒(通常只需 1 个 CPU 周期)。直接去物理内存拿数据,完美避开查页表的开销。
- TLB 缺失(TLB Miss): TLB 里没有这条记录。
- CPU 只能老老实实启动硬件“页表遍历器”(Page Table Walker),去慢速的物理内存里一级一级查页表。
- 拿到物理地址后,顺手把这一条映射关系写进 TLB 里(小便签记下来),以便下次使用。
- 最后去读数据。
多级页表(Multi-Level Page Table)
痛点引入:单级页表的“内存大爆炸”
在单级页表下,每一个进程一启动,操作系统就必须无条件地在物理内存里划出一块 4MB 的连续空间 来存放它的页表。系统里如果有 100 个进程,光是存页表就要死死啃掉 400MB 的物理内存。
查询流程
1. 拆分虚地址: MMU 将 32
位虚拟地址切成三段:[一级页号 (10位) | 二级页号 (10位) | 页内偏移量 (12位)]。
2. 定位一级表: CPU 读取 CR3 寄存器,锁定“一级页目录表”在内存中的物理首地址。
3. 查一级页表: 用 一级页号 当数组下标,在一级表中查到对应的“二级页表”的物理首地址。
4. 查二级页表: 用 二级页号 当数组下标,在二级表中查到最终数据所在的 物理页框号。
5. 拼接真地址: 将 物理页框号(作为高位)和虚拟地址原本的 页内偏移量(作为低位)直接拼接,合体成最终的物理地址。
6. 读写物理内存: MMU 将物理地址送上总线,CPU 直接去内存条里抓取目标数据。
阻塞与非阻塞
| 对比维度 | 阻塞 I/O (Blocking) | 非阻塞 I/O (Non-blocking) |
|---|---|---|
| 没数据时的表现 | 线程被操作系统强行挂起(Sleep/Blocked) | 系统调用立即返回错误码,线程继续保持运行 |
| 线程状态 | 进入阻塞态,出让 CPU | 保持就绪/运行态(Runnable/Running) |
| 对 CPU 的影响 | CPU 毫无压力,转去执行其他就绪线程 | 如果死循环轮询,会导致 CPU 空转、飙高 |
| 典型应用场景 | 传统 Java BIO(ServerSocket)、简单客户端 |
Java NIO、网络高并发内核调优、自旋锁(CAS) |
阻塞就是七态模型的等待态
- 等待态 / 阻塞态(Blocked / Waiting)
- 进程在哪里: 依然在物理内存里。
- 它的待遇: 它虽然不用 CPU,但由于它在等 I/O 数据(比如等你敲键盘),操作系统认为它很快就要醒来,所以还让它在物理内存里占着茅坑。
- CPU消耗: 不用 CPU,在内核的等待队列里睡觉。
- 挂起态(Suspended)
- 进程在哪里: 被操作系统踢出了物理内存,打包丢到了硬盘的 Swap 分区(交换区/虚拟内存文件)里。
- 为什么会被挂起: 物理内存(内存条)严重不够用了!操作系统一看,这个进程既然在等待态睡得死沉死沉的(或者就绪态的进程太多了),却还霸占着宝贵的物理内存。操作系统为了救急,就会触发换出(Page Out),把它的代码和数据从内存条里擦除,同步挪到硬盘的 Swap 分区里暂存。
- CPU消耗: 绝对不用 CPU。它现在连物理内存都没了,CPU 的硬件寻址电路根本摸不到它,它彻底失去了被 CPU 调度的资格,直到它被重新“唤醒并换入”内存。
同步(Synchronous)与异步(Asynchronous)
一个视频告诉你“并发、并行、异步、同步”的区别_哔哩哔哩_bilibili
| 评估维度 | 什么时候选 同步? | 什么时候选 异步? |
|---|---|---|
| 任务类型 | CPU 密集型(算力怪兽、图形渲染、矩阵运算) | I/O 密集型(网络请求、网络爬虫、文件读写) |
| 逻辑关系 | 强因果依赖,前一步不成功,后一步无法开展 | 任务间相互独立,谁先执行完都无所谓 |
| 首要追求 | 数据的一致性、绝对的安全与准确 | 系统的吞吐量、高并发响应能力 |
| 业务阶段 | 系统启动初始化、底层核心事务逻辑 | 业务中后期的用户高频交互接口 |
“并发”和“异步”
很多同学觉得“并发”和“异步”类似,是因为它们最终达到的目的很像——都能让系统在同一段时间内干完更多的事。但它们的本质维度完全不同:
- 并发(Concurrency)是【硬件和时间片】的魔术: 它关注的是 CPU 怎么分配算力。单核 CPU 通过把时间切成碎末,一会儿给线程 A,一会儿给线程 B,交替推进。这叫“并发地处理多个任务”。
- 异步(Asynchrony)是【控制流和消息通知】的解耦: 它关注的是 代码要不要在原地等待结果。比如单线程的 Node.js,它根本没有多线程,自然没有线程间的上下文切换。但它发起一个读文件请求后,代码立刻往下走,等文件读完了由内核发通知来触发回调。这叫单线程异步,它也是并发的一种实现方式。
并发和并行: 聊的是 CPU 硬件怎么干活(单核交替干,还是多核同时干)。
同步和异步: 聊的是 代码逻辑怎么协调(必须在原地等结果,还是交出主动权等通知)。
那其实我理解同步异步的话,就从代码逻辑的角度理解了,比如如果是io密集型的话,就可以使用异步,单线程就可以实现并发;如果是涉及到数学运算的话,就是同步了,可以使用多线程实现并发
1. I/O 密集型:单线程 + 异步 = 极限并发
- 你的理解: 完全没毛病。既然是读写文件、网络爬虫、或者像你的前端工具调用大模型 API(发出去等回复),瓶颈都在网卡和硬盘上。
- AI 场景映射: 假设你要写一个脚本,向 1000 个不同的 LLM 接口发送 Prompt 并收集结果。
- 最优解: 绝对不要开 1000
个线程!直接用单线程异步(比如 Python 的
asyncio)。一个线程把 1000 个请求全扔出去,然后谁先回来就处理谁。全程没有线程切换的开销,单核 CPU 就能把网络带宽跑满。
2. 计算密集型:同步代码 + 多线程/多进程 = 真正的并行(Parallelism)
- 你的理解: 大方向非常准!对于纯数学运算,代码逻辑确实必须是同步的(前一步算不出来,后一步没法走)。但这里我要为你补充一个极为关键的进阶细节——我们要追求的是“并行”,而不仅仅是“并发”。
- AI 场景映射: 假设你在优化大模型的 KV Cache,或者计算 Transformer 里的注意力矩阵乘法(Q × KT)。这是极其狂暴的 CPU/GPU 纯算力消耗。
- 核心细节修正:
- 如果在单核 CPU 上,你开多个线程去算这个矩阵,操作系统会疯狂进行“上下文切换”(并发)。结果不仅不会变快,反而会因为频繁保存/恢复线程现场,导致算得更慢。
- 所以,面对纯数学运算,我们的终极杀招是利用多核硬件(多核 CPU 或 GPU 数以千计的流处理器)。把大矩阵切成几十个小块,分配给几十个物理核心在同一绝对瞬间“同时计算”(并行)。
竞争关系(Mutual Exclusion) 和 协作关系(Synchronization)
| 对比维度 | 竞争关系 (Competition) | 协作关系 (Cooperation) |
|---|---|---|
| 线程间的态度 | 互不关心,我行我素,只认资源不认人 | 明确感知对方,相互配合,存在强因果依赖 |
| 核心解决手段 | 互斥(Mutual Exclusion):只能一个人进 | 同步(Synchronization):按顺序接力 |
| 经典技术道具 | 互斥锁(Mutex)、自旋锁(Spinlock) | 信号量(Semaphore)、条件变量、消息队列 |
| 经典场景问题 | 抢全局变量、抢打印机、抢数据库连接 | 生产者-消费者问题、哲学家就餐问题、流水线线模型 |
临界区
什么是“临界区”?
✅ 核心定义
在并发进程中,与共享变量有关的程序段叫做“临界区”(Critical Section)。
🔍 关键词解析
- “并发进程”:多个进程或线程在宏观上同时运行,它们在不断地争夺 CPU 的执行权。
- “共享变量”:多个进程都能同时访问、读取和修改的公共资源(如全局变量、内存缓冲区、数据库连接、文件句柄等)。
- “程序段”:它特指一段代码。比如
counter += 1或者balance -= 100这样具体的底层操作指令。
💡 简单说: 临界区 = 操作共享资源的那一小段代码。 它不是内存空间,也不是硬件,它就是编辑器里那几行“高危”的代码流。
⚠️ 为什么重要?
因为这段代码在 CPU 底层会被拆解为多条机器指令(读、改、写)。如果它被多个进程同时执行,就会由于执行顺序交错而导致竞态条件(Race Condition),从而产生不可预测的数据崩坏和数据脏读。
🛡️ 如何避免错误?—— 互斥访问临界区
只要能保证一个进程在临界区内执行时,绝不让另一个进程进入,即各个进程对共享变量的访问是强互斥的,就不会造成与时间有关的交错错误。 这就是“进程互斥”的核心思想,也是所有锁机制存在的唯一目的。
⚙️ 临界区调度的三个原则(经典!)
为了完美解决临界区的冲突问题,操作系统的底层的任何同步与锁机制(如互斥锁、信号量),都必须铁律般地同时满足以下三个黄金法则:
1️⃣ 原则 1:一次至多一个进程能够进入临界区内执行 —— 互斥性(Mutual Exclusion)
- 硬核白话: 这是最基本、最不容侵犯的要求。
- 具体表现: 临界区内实行严格的“一夫当关”。任何绝对瞬间,最多只能有一个进程在里面。如果进程 A 已经抢先进入了临界区,进程 B 就必须在门外老老实实地挂起或等待,绝对不允许搞“双人同屏操作”。
2️⃣ 原则 2:如果已有进程在临界区,其他试图进入的进程应等待 —— 忙则等待(Progress)
- 硬核白话: 做到“空闲让进,忙则排队”。
- 具体表现:
- 当临界区里面空无一人时,任何想进去的进程都应该被立刻放行,不准无故拖延(空闲让进)。
- 而一旦临界区已被占用,其他后来试图进入的进程就必须进入等待状态(忙则等待)。在理想的调度机制下,这些等待的进程应该被系统妥善挂起,而不是让 CPU 疯狂做无意义的空转自旋,从而白白浪费算力。
3️⃣ 原则 3:进入临界区内的进程应在有限时间内退出 —— 有限等待(Bounded Waiting)
- 硬核白话: 严防死守,拒绝“无限期白嫖”和“有人被饿死”。
- 具体表现:
- 任何进程进了临界区,办完事必须赶快出来并释放锁,绝对不允许在里面无限期卡死或者做耗时的死循环。
- 对于在外面排队等待的进程,系统必须保证它们在有限的时间或步骤内一定能获得进入的机会(例如通过 FIFO 队列管理)。绝对不能让某个倒霉的进程永远在队列末尾干等,造成严重的“线程饥饿”。
死锁(Deadlock)
一、 什么是死锁?
📌 核心定义: 死锁是指两个或多个进程(线程)在执行过程中,因争夺共享资源而造成的一种互相等待的僵局。若无外力作用,它们都将无法向前推进,永远保持阻塞状态。
二、 死锁产生的四个必要条件(著名的 Coffman 条件)
死锁的发生绝非偶然,它必须同时满足以下四个硬核条件。缺一不可,只要破坏其中任意一个,死锁就无法成立!
1. 互斥条件(Mutual Exclusion)
- 含义: 资源是临界资源,具有排他性。在一个绝对瞬间,某资源只能被一个进程占用。如果别人想用,只能在外面等着。
2. 请求与保持条件(Hold and Wait / 占有并等待)
- 含义: 进程已经至少保持了一个资源,但又提出了新的资源请求;而该新资源已被其他进程占用,此时请求进程阻塞,但它对自己已经获得的资源死死不放。
3. 不可剥夺条件(No Preemption / 非抢占)
- 含义: 进程已获得的资源在未使用完之前,不能被其他进程强行夺走,只能由获得该资源的进程在用完后主动释放。
4. 循环等待条件(Circular Wait)
- 含义: 必然存在一个进程资源的环形链。进程 P0 在等待 P1 占有的资源,P1 在等待 P2 占有的资源……Pn 在等待 P0 占有的资源。
银行家算法
要运行银行家算法,操作系统手里必须死死攥着 4 个核心矩阵/向量(假设有 n 个进程,m 种资源):
- Available(可利用资源向量): 长度为 m 的数组。代表系统当前手里还剩多少闲置的“现金”。
- Max(最大需求矩阵): n × m 的矩阵。代表每个进程总共需要多少资源。
- Allocation(已分配矩阵): n × m 的矩阵。代表每个进程目前手里已经借走了多少资源。
- Need(需求矩阵): n × m 的矩阵。代表每个进程接下来还要申请多少资源。
Need[i][j] = Max[i][j] − Allocation[i][j]
银行家算法在代码实现上,其实由两个嵌套的子算法组成:“资源请求算法”*和*“安全性检查算法”。
1. 资源请求算法(当进程 Pi 提出请求 Requesti 时)
- 第一步: 检查 Requesti ≤ Needi。如果你这次要的钱,超过了你当初申报的最大额度,直接判定非法,拒绝!
- 第二步: 检查 Requesti ≤ Available。如果你要的钱,我银行库房里现在根本没有这么多,对不起,你先去排队等着。
- 第三步(高潮):
银行家在账本上假装把钱借出去,动态修改账本数据:
- Available = Available − Requesti
- Allocationi = Allocationi + Requesti
- Needi = Needi − Requesti
- 第四步: 立刻调用下面的【安全性检查算法】。如果检查结果是“安全”,正式放款;如果结果是“不安全”,立刻账本回滚(Rollback),拒绝放款,让进程挂起等待。
2. 安全性检查算法(灵魂所在:寻找安全序列)
这个算法用来评估当前账本状态下,系统是否安全。
- 设置两个临时辅助变量:
Work向量:初始值等于当前库房余钱 Available。Finish数组:长度为 n 的布尔数组,初始全为false(代表大家都没干完活)。
- 在所有进程中,寻找一个同时满足以下两个条件的进程 Pi:
Finish[i] == false(还没完事)- Needi ≤ Work(它接下来要的全部资源,我手里的
Work够给)
- 如果找到了:
假设把资源全给它,它顺利干完活,把之前吃进去的资源连本带利全吐出来。更新账本:
Work = Work + Allocation_i(把它的存货收回)Finish[i] = true- 返回步骤 2,继续找下一个能拯救的进程。
- 结局判定:
- 如果最后所有进程的
Finish都变成了true,说明我们成功找到了一条让大家都活下去的安全序列(如 P1 → P3 → P2),系统是安全的! - 如果找了一圈,发现有些进程
Finish还是false,但手里剩下的Work已经不够满足任何一个人的Need了,系统就是不安全的!
- 如果最后所有进程的
生产者消费者问题(Producer-Consumer Problem)
1 | semaphore mutex = 1; // 互斥锁 |