操作系统基础
开篇:操作系统是什么?为什么需要它?
想象你是一家大型公司的 CEO。公司里有各种资源:办公室、打印机、网络带宽、会议室。员工们各自有各自的任务,他们需要公平地共享这些资源,而你需要协调一切,确保公司高效运转。
操作系统就是计算机世界里的那位 CEO。 它管理着 CPU、内存、磁盘、网络等硬件资源,协调着各个应用程序的运行,让你可以"同时"听歌、写代码、刷网页——尽管 CPU 在任意一个瞬间其实只在做一件事。
当你按下电源键,计算机会依次完成这些步骤:
- BIOS 自检(POST):主板固件检查 CPU、内存、硬盘、显卡等硬件是否正常
- 加载引导程序:从预设启动设备(硬盘/U盘/光盘)中找到并加载操作系统的引导程序
- 初始化内核:加载操作系统内核(Kernel),设置必要的数据结构和内核变量
- 加载设备驱动:让硬盘、网卡、显卡等硬件设备正常工作
- 启动系统服务:网络服务、防火墙、远程登录等服务就绪
一切就绪后,操作系统进入空闲状态,等待你的指令。
本文会从进程与线程、内存管理、死锁三大核心模块出发,配合 CPU 硬件基础和 IO 相关知识,带你真正理解操作系统的底层逻辑。
一、进程与线程
1.1 进程 vs 线程:公司与员工
我们编写的代码,存储在硬盘上,是静态的文件。经过编译后变成二进制可执行文件,装载到内存中,CPU 读取并执行它的每一条指令——这个正在运行的程序,就叫做进程。
因为硬盘读取速度很慢,CPU 不可能一直等着硬盘返回数据,而是去执行另外的进程。当硬盘数据准备好时,CPU 收到中断信号,再回来继续执行之前的进程。在切换之前,必须记录当前进程的运行状态,这样下次回来时才能接着上次的位置继续——由此可以得出,一个进程在活动期间至少具备运行、就绪、阻塞三种基本状态。
如果说进程是一家公司,那线程就是公司里的员工。
| 维度 | 进程(公司) | 线程(员工) |
|---|---|---|
| 资源 | 拥有完整的资源:办公室、设备、资金 | 共享公司资源,只有自己的工位和笔记本 |
| 独立性 | 公司之间相互独立 | 同一公司的员工共享数据,沟通方便 |
| 开销 | 注册/注销公司成本高 | 招聘/辞退员工成本低 |
| 崩溃影响 | 一家公司倒闭不影响另一家 | 一个员工搞砸可能拖垮整个公司 |
用更技术的话来说:
- 进程是资源分配的单位,拥有独立的内存空间、文件描述符等
- 线程是 CPU 调度的单位,共享所属进程的代码段、数据段、打开的文件,但各自拥有独立的寄存器和栈
- 线程同样具有就绪、阻塞、运行三种基本状态,以及状态之间的转换关系
- 线程能显著减少并发执行的时间和空间开销
进程的生命周期
一个进程的状态转换如下所示:
- 运行态:正在占用 CPU 执行指令
- 就绪态:万事俱备,只等 CPU 分配时间片
- 阻塞态:在等待某个事件(比如磁盘读完数据)
阻塞的进程仍然占用物理内存。物理内存是有限的,为了避免浪费,操作系统会把阻塞进程的内存空间换出到硬盘。等需要再次运行时,再从硬盘换入到物理内存。这时,该进程处于挂起状态。
进程控制块(PCB)
操作系统用 PCB(Process Control Block,进程控制块) 来描述每个进程。PCB 是进程存在的唯一标识,记录了进程的 PID、状态、程序计数器、寄存器内容、内存映射等信息。
所有 PCB 通过链表连接,把具有相同状态的进程链在一起,组成各种队列(就绪队列、阻塞队列等)。为什么用链表而不是数组?因为进程随时会创建或销毁,链表的插入删除更灵活。
上下文切换
多个进程共享 CPU 资源,操作系统需要在它们之间来回切换。多个任务交给 CPU 执行,执行每个任务之前,CPU 需要知道任务从哪里加载、从哪里开始运行——这就用到了 CPU 寄存器和程序计数器,它们被合称为 CPU 上下文。
CPU 上下文切换的具体步骤:
- 把前一个任务的 CPU 上下文(寄存器、程序计数器等)保存起来
- 加载新任务的上下文到寄存器和程序计数器
- 跳转到程序计数器所指的新位置,运行新任务
进程是由内核管理和调度的,所以进程的切换只能发生在内核态。进程的上下文切换不仅包含虚拟内存、栈、全局变量等用户空间资源,还包括内核堆栈、寄存器等内核空间资源。切换是有成本的——就像你同时做多件事,每次切换都要花时间"回忆上次做到哪了"。
1.2 进程间通信:隔壁公司怎么传话?
进程之间是隔离的(就像不同公司),要交换数据需要专门的通信机制。常见的进程间通信(IPC)方式如下:
| 方式 | 原理 | 类比 | 适用场景 |
|---|---|---|---|
| 管道(Pipe) | 半双工,一端写一端读 | 水管,水只能单向流 | 父子进程间通信(shell 管道) |
| 命名管道 | 通过文件系统中的特殊文件通信 | 公共信箱 | 不相关进程间通信 |
| 消息队列 | 进程往队列发消息,其他进程读取 | 公告栏贴纸条 | 异步通信、分布式系统(如 MQ) |
| 共享内存 | 多个进程映射同一块内存 | 共用白板 | 高频大数据量交换 |
| 信号量 | 用于同步和互斥的计数器 | 停车场的车位牌 | 资源访问控制 |
| 套接字(Socket) | 基于网络协议通信 | 打电话 | 跨网络进程通信 |
| 文件映射(mmap) | 将文件映射到进程地址空间 | 共用笔记本 | 不同进程共享文件内容 |
共享内存是最快的 IPC 方式(不需要数据拷贝),但需要配合信号量等同步机制来避免冲突。套接字最通用,既能本机通信也能跨网络。
说到管道是半双工的,顺便厘清三种通信模式:
- 单工:数据只能单向传输。类比广播电台,你只能听不能说。
- 半双工:可以双向传输,但不能同时。类比对讲机,说话时对方只能听。
- 全双工:双向同时传输。类比电话,两个人可以同时说话互不干扰。
TCP 连接就是全双工的,通信双方可以同时发送和接收数据。
1.3 进程调度算法:谁先用 CPU?
CPU 同一时刻只能运行一个进程,当多个进程都想用 CPU 时,就需要调度算法来决定谁先上。这里先理解一个前置概念——时间片。
现代操作系统都是多用户多任务分时操作系统:把 CPU 时间切成长短基本相同的时间区间(时间片),通过操作系统的管理,轮流分配给各个进程使用。如果某个任务在时间片结束前没完成,就暂停下来放弃 CPU,等待下一轮循环。由于计算机处理速度极快,只要时间片间隔取得适当,用户根本察觉不到中间的"停顿",好像整个系统全由自己"独占"似的。
想象一个电话亭(CPU),多个人(进程)排队打电话。管理员按照不同规则分配使用权:
先来先服务(FCFS)
谁先排队谁先打。简单公平,但如果前面有人打一小时电话,后面的人全得等——这就是"短进程饥饿"问题。
短作业优先(SJF)
预估每个人打电话的时间,短的先打。分为非抢占式(一旦被选中就执行完)和抢占式(有更短的任务来了就让位,也叫 SRTF)。理论上可以最小化平均等待时间和周转时间,但实际中如何准确预测执行时间是个难题,而且长任务可能永远排不上。
时间片轮转(RR)
每人分配固定时间(比如 100ms),时间到了不管打没打完都换下一个人,没打完的重新排到队尾。公平,每个进程都能执行到,不会出现饥饿。但频繁切换带来额外的上下文切换开销。
优先级调度
VIP 客户优先。为每个进程分配优先级数值,高优先级的先调度。灵活,如果某些任务真的很重要,可以让它更快执行。但优先级低的进程可能一直拿不到时间片。同样分为抢占式和非抢占式。
多级反馈队列(MLFQ)
现代通用操作系统最常用的方案。设置多个队列,不同优先级,时间片从短到长:
运作机制:
- 新进程进入最高优先级队列(时间片短)
- 用完时间片没做完?降级到下一个队列(时间片更长)
- 低优先级队列长时间没运行?升级回高优先级(防止饥饿)
这样短任务在高优先级队列就能快速完成,提高系统响应速度。长任务虽然会被降级,但获得更长的时间片,最终也能完成。兼顾了交互体验和吞吐量。
完全公平调度(CFS)
Linux 系统的默认调度器。核心思想是基于虚拟运行时间(vruntime) 分配 CPU,确保所有进程按权重公平获得执行时间。使用红黑树(Red-Black Tree)数据结构快速选择 vruntime 最小的进程,时间复杂度 O(log n)。高公平性、低延迟,适合多任务混合负载。
| 算法 | 抢占性 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|---|
| FCFS | 非抢占 | 简单公平 | 短进程饥饿 | 早期批处理 |
| SJF | 可选 | 理论最优等待时间 | 长任务饥饿,依赖预知时间 | 已知时长的批处理 |
| RR | 抢占 | 公平,响应快 | 上下文切换开销 | 交互式系统(如分时 OS) |
| 优先级 | 可选 | 灵活定制 | 低优先级饥饿 | 实时系统 |
| MLFQ | 抢占 | 自适应,平衡长短作业 | 实现复杂 | 通用操作系统 |
| CFS | 抢占 | 高公平性、低延迟 | 实时任务需额外配置 | 现代 Linux |
1.4 特殊进程状态:孤儿与僵尸
孤儿进程:父进程已经退出,但子进程还在运行。在 Unix/Linux 系统中,有一个 init 进程(PID=1),操作系统会把孤儿进程"收养"到 init 名下——就像孤儿院一样。孤儿进程本身不会造成资源问题。
举个例子:进程 A 启动了子进程 B。如果进程 A 先结束了(无论正常还是异常终止),进程 B 就成了孤儿进程,操作系统将它的父进程指向 init。
僵尸进程:子进程已经退出了,但它的退出状态还没有被父进程回收。一个进程结束后会向父进程发送 SIGCHLD 信号,并保存退出状态。如果父进程没有及时通过 wait() 处理这个信号,子进程就变成了僵尸进程——虽然已经不执行任何任务了,但仍然占据着进程号。
举个例子:进程 A 启动了子进程 B,B 完成工作退出了,但 A 没有调用 wait() 获取 B 的退出状态,B 就变成僵尸进程。僵尸进程不消耗 CPU,但占用 PID 资源。大量僵尸进程可能导致系统无法创建新进程。
1.5 线程的实现方式
线程的实现主要有三种方式:
1. 内核线程(Kernel-Level Thread)
由操作系统内核直接支持。应用程序通过轻量级进程(LWP,Light Weight Process)来使用内核线程。每个 LWP 对应一个内核线程。
优点:一个线程在系统调用中阻塞了,不影响其他线程继续工作。
缺点:线程的创建、销毁、同步都要走系统调用,需要用户态/内核态切换,开销较大。每个轻量级进程要消耗内核资源(如内核线程的栈空间),系统支持的数量有限。
2. 用户线程
在用户空间通过线程库实现,由运行时系统(Run-time System)完成线程管理。内核完全不知道线程的存在,管理的仍然是进程。
优点:线程切换快、不需要内核参与,可以运行在任何操作系统上。
缺点:所有线程操作需要用户程序自己处理。因为大多数系统调用是阻塞的,一个线程阻塞会导致整个进程(包含所有线程)阻塞。多处理器下的线程映射也是难题。
3. 混合实现
线程创建在用户空间完成(通过线程库),但线程调度由内核负责。多个用户线程通过多路复用映射到多个内核线程,兼具两者的优点。
1.6 进程、线程与协程
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 调度方 | 操作系统 | 操作系统 | 用户程序 |
| 拥有资源 | 独立内存空间 | 共享进程资源 | 共享线程资源 |
| 切换开销 | 最大(涉及内存映射) | 较小(共享地址空间) | 最小(用户态完成) |
| 通信方式 | IPC 机制 | 共享内存(需同步) | 直接访问 |
| 典型实现 | JVM 进程 | Java Thread | Go goroutine、JDK19 虚拟线程 |
为什么有了进程还需要线程?
一个浏览器进程启动后,需要同时做渲染、网络请求、用户交互等多件事。如果全靠多进程,每次切换都是完整的上下文切换,开销巨大。线程共享进程资源,切换成本低得多——就像 Word 可以同时做打字、拼写检查、字数统计,这些子任务不需要各自启动一个进程。
为什么有了线程还需要协程?
多线程仍然有操作系统抢占式调度的切换开销和资源竞争问题(更不用说加锁阻塞了)。协程由用户程序自己决定何时让出执行权(协作式调度),不走操作系统的调度器,切换成本极低。对于大量 IO 操作的场景,协程可以极大提升并发能力,同时避免 callback hell,保持代码的可读性。
Go 语言的 goroutine 和 JDK 19 引入的虚拟线程都是协程的典型实现。
二、内存管理
2.1 虚拟内存:为什么需要它?
如果每个程序直接访问物理内存,会面临两个致命问题:
- 安全性:程序 A 可以随意读写程序 B 的内存,一个 bug 就能搞崩其他程序
- 碎片化:多个程序反复启动退出,内存被切得七零八落,找不到连续的大块空间
虚拟内存就像是给每个程序发了一张无限额度的虚拟信用卡。程序以为自己拥有一整块连续的大内存,实际上操作系统在背后把这些虚拟地址映射到真实的物理内存上。
每个进程拥有独立的虚拟地址空间,通过 MMU(内存管理单元) 翻译成物理地址。这样进程之间完全隔离,一个进程崩溃不会影响另一个。
2.2 分页与分段
分页:把内存切成标准方块
分页技术把物理内存和虚拟内存都划分成固定大小的块(通常 4KB)。物理内存的块叫"页框"(Page Frame),虚拟内存的块叫"页"(Page)。虚拟页到物理页框的映射关系记录在页表中。
从数学角度说,页表是一个函数,参数是虚拟页号,结果是物理页框号。
当程序访问一个虚拟地址时:
- 从虚拟地址中提取虚拟页号和页内偏移量
- 查页表,找到对应的物理页框号
- 物理页框号 + 偏移量 = 实际物理地址
如果要访问的页不在内存中(还没分配物理地址),会触发缺页中断。操作系统将需要的页从磁盘加载到内存中的某个页框,修改 MMU 中的映射关系,然后重新执行访问。
由于增加了页表映射,中间一定会有时间和空间的损耗,需要解决两个问题:
问题一:虚拟地址到物理地址的映射需要时间。 解决:引入 TLB(快表/转换后援缓冲器) 加速页表访问。TLB 是一个高速缓存,存放最近使用的页表项。命中 TLB 时无需访问内存中的页表,速度快得多。
问题二:虚拟地址空间很大时,页表本身会占用巨大空间。 假设 64 位操作系统,虚拟地址大小为 2^64,每页 4KB(2^12),需要 2^52 个页表项。每个进程都有独立的页表,这显然不现实。解决方案:
- 多级页表:核心思想是"不用的页表项不保存在内存中",按需创建,大幅节省空间
- 倒排页表:常用于 64 位系统,往往需要和 TLB 结合使用
分段:按逻辑分区
一个进程包含代码段、常量、以及运行时产生的堆和栈。如果只用一维的方式把虚拟空间分给它们,就很难保证空间的有效利用。比如堆空间用完了,但代码段还有大片空闲,你没法简单地把多余的空间挪过去。程序员需要手动管理这些一维空间,非常复杂。
分段技术让每个逻辑区域拥有独立的、可自然增长的地址空间,且和其他段相互隔离:
每个段有自己的基址和界限,段内空间可以自然增长而不影响其他段。
分页 vs 分段对比
| 维度 | 分页 | 分段 |
|---|---|---|
| 程序员是否感知 | 否(对程序透明) | 是(逻辑概念) |
| 大小 | 固定(如 4KB) | 可变 |
| 发明目的 | 获得更大的线性地址空间 | 使程序和数据被划分为逻辑上独立的空间 |
| 地址空间能否超过物理空间 | 可以 | 可以 |
| 不同用户是否方便共享 | 不方便 | 方便(可共享代码段) |
现代操作系统通常结合使用分段和分页:先分段确定逻辑结构,再在段内分页管理物理内存。
2.3 页面置换算法:内存满了怎么办?
物理内存有限,当所有页框都被占满时,新的页面要加载进来,就必须先"请"一个旧页面出去。选谁出去?这就是页面置换算法要解决的问题。
FIFO(先进先出)
最早进来的页面最先被换出。实现简单但效果差——最早加载的页可能恰好是最常用的。
LRU(最近最少使用)
谁最长时间没被访问就换谁。效果好但实现开销较大。
想象你的书架只能放 10 本书,每次看完一本就放在最上面。要腾位置时把最底下那本拿走——它是你最久没看过的,大概率以后也不会看。这就是 LRU 的思想。
LFU(最不经常使用)
谁被访问次数最少就换谁。需要为每个页维护一个访问计数器,存储和维护开销都较大。
实际系统中,Linux 使用的是 LRU 的近似算法(时钟算法/Clock 算法),在效果和性能之间取得平衡。
三、死锁
3.1 死锁四大条件
生活中有个经典场景:两个人在窄桥上相向而行,都不愿让路,结果谁也过不去。这就是死锁。
在操作系统中,死锁是指两个或多个进程互相持有对方需要的资源,同时又在等待对方释放资源,导致所有人都无法继续执行。
死锁必须同时满足以下四个条件(缺一不可):
- 互斥:资源一次只能被一个进程使用(窄桥一次只能走一个人)
- 持有并等待:进程持有至少一个资源,同时等待其他资源(占着桥的一半,等对方让另一半)
- 不可剥夺:已分配给进程的资源不能被强制收回(不能把人从桥上推下去)
- 循环等待:存在一个进程链,每个进程都在等待链中下一个进程持有的资源
3.2 死锁预防与避免
预防死锁的思路是破坏上述四个条件中的至少一个:
- 破坏"持有并等待":要求进程一次性申请所有需要的资源,不允许分步获取
- 破坏"不可剥夺":进程拿不到新资源时,必须释放已持有的所有资源,重新申请
- 破坏"循环等待":给所有资源统一编号,进程必须按编号递增顺序申请资源
(注意:互斥条件通常无法破坏,因为很多资源天然就是互斥的。)
银行家算法(Dijkstra) 是经典的死锁避免算法。核心思想类似银行放贷:
银行(操作系统)有一定数量的资金(资源),多个客户(进程)各自有贷款需求。银行每次放贷前都会检查:如果把钱借出去,剩余的资金还能不能让至少一个客户完成业务并还款?如果能(安全状态),就借;如果不能(会导致所有人都卡住),就暂缓。
算法步骤:
- 每个进程预先声明自己的最大资源需求
- 每次有进程申请资源时,系统模拟分配后的状态
- 检查是否仍存在一种进程执行顺序(安全序列),使得所有进程都能获得足够资源并顺利完成
- 如果存在安全序列(安全状态),就分配资源;否则让申请的进程等待
四、CPU 与硬件基础
4.1 GPU vs CPU
CPU 像诸葛亮——核心少(通常 4~64 个)但每个核心极其强大,能文能武处理各种复杂任务。GPU 像一群士兵——核心数量极多(成百上千甚至上万),但每个核心只擅长简单的重复计算。
CPU 核心数少、单核能力强,适合各种复杂任务。GPU 核心数多、单核能力弱,适合大量重复性计算。
挖矿(加密货币的哈希计算)和大模型训练(矩阵运算、神经网络计算)的本质都是大量可并行化的重复数学运算,天然适合 GPU 的架构。大模型的训练可以分解成许多小任务,在 GPU 的多个核心上同时进行,显著加快训练速度。
4.2 多级缓存与 MESI 协议
CPU 的执行速度远快于内存的读写速度。为了弥补这个鸿沟,在 CPU 和内存之间加入了多级缓存:
当程序运行时,会将运算需要的数据从主内存复制到高速缓存中。CPU 直接从缓存读写数据,运算结束后再刷新回主内存。
读取数据时,先查 L1,没有找到就查 L2,再没有查 L3,最后才访问主内存。这三级缓存的制造成本递减、容量递增。单核 CPU 拥有一套 L1/L2/L3;多核 CPU 中每个核心有独立的 L1(甚至 L2),共享 L3。
但多核 CPU 各有各的 L1/L2 缓存,同一个变量可能在不同缓存中有不同副本——这就是缓存一致性问题。
早期通过在总线加 LOCK 锁解决,但这会阻塞其他 CPU 对内存的访问,效率低下。后来发展出缓存一致性协议。
MESI 协议是 Intel 提出的最著名的缓存一致性协议,每个缓存行有四种状态:
| 状态 | 含义 |
|---|---|
| M(Modified) | 数据被修改了,与主内存不一致,仅存在于本缓存 |
| E(Exclusive) | 数据未被修改,与主内存一致,仅存在于本缓存 |
| S(Shared) | 数据未被修改,与主内存一致,存在于多个缓存 |
| I(Invalid) | 数据无效 |
核心思想:当一个 CPU 写数据时,如果发现操作的是共享变量(其他 CPU 也有副本),就发信号通知其他 CPU 将对应的缓存行置为 Invalid。其他 CPU 需要读取这个变量时,发现缓存无效,就从内存重新加载,从而保证了数据一致性。
4.3 用户态与内核态
内核是操作系统的核心,负责管理和控制计算机硬件资源。如果所有应用程序都能直接访问硬件资源,可能导致系统崩溃、数据丢失等问题。
因此,操作系统将应用程序和内核进行了隔离:
- 内核态:操作系统运行在特权模式下,拥有最高权限,可以访问所有硬件资源和底层系统资源(处理器、内存、IO 等),可以执行所有指令。
- 用户态:应用程序运行在非特权模式下,只能访问被授权的资源,只能执行受限的指令集,不能直接操作硬件。
当应用程序需要读写文件等系统调用时,会触发用户态向内核态的切换。CPU 暂停当前进程,保存状态(程序计数器、寄存器、栈指针等),切换到内核态执行操作,完成后再切回用户态恢复进程执行。
频繁的用户态/内核态切换有不可忽视的性能开销。这也是为什么零拷贝、mmap 等技术很重要——它们减少了不必要的切换和数据拷贝。
4.4 按位与比取模快的底层原因
在 HashMap 的哈希计算和分库分表中,经常用按位与(&)代替取模(%)运算。原因在于:
按位与是 CPU 底层的基本操作,直接在二进制位上进行,由简单的 AND 逻辑门实现。它的时间复杂度是 O(1),在一个时钟周期内完成,编译器可以直接映射到单条机器指令。
取模涉及除法操作,在很多处理器架构中通过迭代或流水线等复杂机制实现,执行时间远长于按位操作。
当然,按位与代替取模有前提:除数必须是 2 的幂。此时 n % m 等价于 n & (m - 1)。这也是 HashMap 容量为何设计成 2 的幂的原因之一。
五、IO 与零拷贝
5.1 IO 密集 vs CPU 密集
IO 密集型:程序大部分时间在等待磁盘读写、网络传输、数据库查询。等待时间长,计算量小。常见于文件读写、网络请求、数据库查询。适合多线程(等待时让其他线程干活)或 IO 多路复用。
CPU 密集型:程序大部分时间在做计算。等待时间短,计算量大。常见于图像处理、数据计算、音视频编解码。适合多进程,充分利用多核 CPU。增加线程数意义不大(CPU 已经忙不过来了),只能加 CPU 才有效。
5.2 Page Cache:磁盘的内存缓存
操作系统在磁盘之上维护了一层页缓存(Page Cache),本质上是内存的一部分,将磁盘数据缓存在内存中,减少实际的磁盘 IO 操作。因为访问内存的速度比访问磁盘快得多。
页缓存中的"页"就是内存管理的基本单位,通常 4KB。
读取过程:
- 应用程序请求读取文件时,操作系统先检查 Page Cache
- 命中(数据在缓存中)→ 直接从内存读取,跳过磁盘 IO
- 未命中 → 从磁盘读取数据到 Page Cache,再返回给应用
- 读取大小采用预读策略:即使只需要少量数据,至少读一整页(4KB),还可能额外读取连续的多个页,减少后续磁盘 IO
写入过程:
- 数据先写入 Page Cache(不直接写磁盘),标记为"脏页"(dirty page)
- 操作系统不立即刷盘,等待合适时机批量写入——这就是"延迟写/写回缓存"(write-back caching)
- 写入完成后,脏页变为"干净页"
脏页刷盘触发条件:
- 页缓存大小达到设定阈值
- 系统内存压力增大,需要释放内存
- 显式调用
fsync/sync强制同步
这就是 write 和 fsync 的区别:write 只是写到 Page Cache(内存缓冲区),fsync 才是强制将数据从缓存刷到磁盘。如果 write 后还没刷盘就断电了,数据会丢失。不过即使不显式调用 fsync,操作系统也会在后台通过 pdflush 等机制异步刷盘——前提是不断电。
另外还有 fdatasync,它只刷数据部分到磁盘,可能不刷文件元数据(修改时间、权限等),比 fsync 更轻量。
Page Cache 的优点:显著提升 IO 性能,减少磁盘损耗、延长磁盘寿命。缺点:占用内存资源,断电时有数据丢失风险。
5.3 零拷贝
传统的"读文件 + 发网络"流程涉及 4 次数据拷贝和 4 次上下文切换:
所谓零拷贝,就是通过各种方式,在特殊场景下减少数据拷贝次数,或减少 CPU 参与数据拷贝的次数。
DMA:CPU 的搬运工助理
正常 IO 流程中,无论物理设备间的数据拷贝(磁盘→内存)还是内存间的拷贝(用户态→内核态),都需要 CPU 参与。对于大文件,这严重浪费 CPU 算力。
DMA(Direct Memory Access,直接内存访问)是一个专门的硬件单元,CPU 只需告诉 DMA 源地址、目标地址和数据量,DMA 就自动搬运数据,搬运期间 CPU 可以去做其他事。搬完后 DMA 发中断通知 CPU。
mmap + write
把内核缓冲区直接映射到用户空间,省掉"内核→用户"的 CPU 拷贝。拷贝 4→3 次,上下文切换仍是 4 次。
mmap 基于缺页异常的懒加载模式,申请 1000G 内存可能只占用少量虚拟空间,真正访问时才通过缺页中断分配物理内存。但 mmap 不适合变长文件,随机写密集场景效率不一定高,且在 32 位系统上对大文件支持受限。
sendfile
数据不经过用户空间,直接在内核中从文件缓冲区→Socket 缓冲区→网卡。拷贝 4→3 次,上下文切换 4→2 次。适合静态文件传输(HTML/JS 发送到浏览器)。
sendfile + DMA Scatter/Gather
Linux 2.4 引入的终极方案。CPU 只将数据的位置和长度描述信息写入 Socket 缓冲区,DMA 引擎根据这些"提货单"直接从内核缓冲区收集数据发到网卡。结果:0 次 CPU 数据拷贝,只有 2 次 DMA 拷贝和 2 次上下文切换。
Direct I/O
绕过内核 Page Cache,用户态直接与磁盘设备交互。拷贝次数减少,但缓存管理需要应用程序自己做。MySQL 就是用 Direct I/O 配合自己的缓存系统实现的。不过文件元信息仍需通过 fsync 刷到内核。
5.4 IO 多路复用 vs 多线程
两者解决的问题不同:
- IO 多路复用(select/poll/epoll):用单线程同时监控多个 IO 连接,哪个就绪处理哪个。减少线程创建和上下文切换开销。适合 IO 密集型场景,如高并发网络服务器。
- 多线程:多个线程并行处理任务,充分利用多核 CPU。适合 CPU 密集型场景。但需处理同步和并发控制的复杂性(死锁、竞态条件等)。
实际的高性能服务器通常结合两者:用 IO 多路复用高效管理大量连接,用多线程/多进程提升计算能力,从而优化整体性能。
六、Linux 常用命令实战
6.1 系统监控
# 查看负载(1/5/15 分钟平均值)
uptime
# 输出示例:load averages: 1.74 1.87 1.97
# 实时查看进程资源占用
top
# 查看某进程的线程级 CPU 占用
top -Hp <pid>
# 查看 CPU 详细统计(每 2 秒采一次,共 3 次)
vmstat 2 3CPU 利用率衡量的是 CPU 的忙碌程度(非空闲时间占比),系统负载(Load) 衡量的是等待 CPU 处理的工作总量。
用餐厅来理解:4 张餐桌(4 核 CPU),3 张有人在用餐 = CPU 利用率 75%。3 人用餐 + 5 人排队 = 负载为 8。利用率关注资源的使用效率,负载关注服务的压力大小。
关于负载的参考值(单核):持续 > 0.7 需关注,> 1.0 需排查,达到 5.0 说明严重过载。多核按核心数等比放大(如 4 核的安全线约 2.8)。
6.2 文件与日志操作
# 查看文件末尾并实时跟踪(看日志必备)
tail -f application.log
tail -f application.log | grep ERROR
# 在文件中搜索关键字
grep "ERROR" application.log
grep "ERROR" application.log | grep "Biz"
# 查找文件
find . -name "*.log" -mtime -7 # 最近 7 天修改的 log 文件
find . -size +10M # 大于 10MB 的文件
# 磁盘使用情况
df -h
du -sh *6.3 进程管理
# 查找 Java 进程
ps aux | grep java
# 查看 JVM 参数
ps aux | grep java | grep --color Xmx
# 按 QQ 号文件去重
sort -u filename.txt -o filename.txt6.4 软链接 vs 硬链接
Linux 文件由三部分组成:文件名(目录项)、元数据(inode)、数据内容(数据块)。inode 是文件的身份信息,记录文件类型、权限、大小、时间戳、数据块指针等——但不存储文件名。文件名存在目录结构中,目录本质上是"文件名→inode 号"的映射。
# 查看文件的 inode 号
ls -li
# 创建硬链接
ln original.txt hardlink.txt
# 创建软链接
ln -s original.txt softlink.txt硬链接:同一个文件的不同名字,共享 inode 和数据块,不额外占用存储空间。删除原文件不影响硬链接(只要还有一个硬链接指向该 inode,数据就不会被回收)。
软链接:一个特殊文件,内容是目标文件的路径(类似 Windows 快捷方式)。拥有自己独立的 inode。删除原文件后软链接失效("断链")。
| 特性 | 硬链接 | 软链接 |
|---|---|---|
| 本质 | 共享 inode | 存储目标路径 |
| 跨文件系统 | 不支持 | 支持 |
| 删除原文件 | 仍可访问 | 失效 |
| 链接对象 | 仅文件 | 文件或目录 |
| 空间占用 | 不额外占用 | 少量路径存储 |
6.5 rm 正在写入的文件会怎样?
Linux 中文件名和文件数据是分开的。rm 实际调用的是 unlink(),只是删除了目录中文件名到 inode 的链接——文件在目录中看不到了。
但如果该文件被其他进程打开着,inode 和数据块不会立即释放,进程还可以继续写入。等所有进程关闭该文件后,磁盘空间才会被真正回收。
如果想清空一个正在持续写入的日志文件(不删除,只清空内容):
> application.log
# 或
cat /dev/null > application.log
# 或
echo "" > application.log七、常见面试题精选
Q1:进程和线程的区别是什么?
进程是资源分配的基本单位,线程是 CPU 调度的基本单位。线程共享所属进程的资源(内存、文件等),但有独立的寄存器和栈。线程切换开销比进程小得多,因为不需要切换内存映射等重量级资源。
Q2:死锁的四个必要条件?如何避免?
互斥、持有并等待、不可剥夺、循环等待——四者缺一不可。预防方法是破坏其中任意一个条件(如资源排序法破坏循环等待)。避免方法使用银行家算法,在每次分配资源前检查系统是否仍处于安全状态。
Q3:虚拟内存的作用是什么?
给每个进程提供独立的地址空间,实现进程隔离和内存保护。通过页表将虚拟地址映射到物理地址,配合缺页中断实现按需加载,让程序可以使用超过物理内存大小的地址空间。
Q4:什么是零拷贝?有哪些实现方式?
零拷贝是减少数据在内核态和用户态之间不必要拷贝的技术。实现方式包括 mmap(减少一次 CPU 拷贝)、sendfile(数据不经过用户态)、sendfile + DMA Scatter/Gather(零 CPU 拷贝)、Direct I/O(绕过内核缓存)。
Q5:select、poll、epoll 的核心区别?
select 有 1024 fd 上限且需全量遍历;poll 无数量限制但仍需全量遍历且每次重复传递 fd;epoll 通过内核红黑树避免重复传递 fd,通过就绪链表实现 O(1) 获取就绪 fd。
Q6:CPU 利用率和 Load 有什么区别?
CPU 利用率描述 CPU 忙碌的程度(百分比),Load 描述等待 CPU 处理的总工作量(包括正在运行和等待运行的进程数)。利用率关注资源使用效率,负载关注系统承受的压力。高利用率 + 低负载 = 系统高效运转;低利用率 + 高负载 = 可能有大量 IO 等待。
小结
操作系统的核心使命就是管理资源、协调进程。进程和线程是执行的载体,调度算法决定谁先用 CPU,内存管理让每个进程都以为自己独占了全部内存,死锁处理保证系统不会因资源争抢而陷入僵局。
理解这些底层原理,不仅能帮你通过面试,更能让你在排查线上问题(CPU 飙高、内存泄漏、死锁)时有据可循。技术到最后,拼的就是对底层的理解深度。