前置知识: 计算机基础

操作系统:双击图标之后发生了什么

15 min中级

以「双击一个程序到窗口出现」引入:亲手制造一场多线程竞态并修好它,理解操作系统三大抽象(进程、虚拟内存、文件)与系统调用入口,调度算法平均等待时间实测,死锁四条件自查,以及本模块深水篇的阅读地图。

前置知识

  • 已完成 计算机概述,知道 CPU、内存、磁盘各管什么;
  • 会写基本的 Python 循环与函数(本文实验用 Python,三端通用)。

学习目标

读完本文你将能够:

  1. 讲清「双击图标到窗口出现」之间操作系统做了哪些事;
  2. 亲手制造一场多线程竞态(counter 丢失更新),并用锁修复,理解「为什么并发 bug 平时测不出来」;
  3. 说清操作系统的三大抽象——进程、虚拟内存、文件——各自把什么硬件藏在了什么接口后面;
  4. 用模拟实验对比 FCFS 与 SJF 调度的平均等待时间,理解 Linux CFS 的公平思路;
  5. 背出死锁的四个必要条件,并用它做一次代码自查;
  6. 知道本模块每篇深水篇(调度、线程、中断、分页、文件、IPC)分别在什么时候读。

预计 60 到 90 分钟。

1. 你现在要解决什么问题

你每天双击图标几十次,但从没想过中间这几百毫秒发生了什么:磁盘上的可执行文件被读进内存,一个「进程」被创建出来,CPU 开始按指令执行,窗口画到屏幕上。这背后站着一位总管家——操作系统。它同时做两件事:

  • 面向上,给应用发「道具」:让每个程序以为自己独占一台计算机。你写的代码从不直接碰硬件,而是在三个抽象上工作:进程(假装独占 CPU)、虚拟内存(假装独占内存)、文件(假装磁盘是一棵目录树);
  • 面向下,管住所有硬件:几十个进程抢几颗 CPU 核、内存不够时谁该让路、磁盘请求先服务谁——全是它的日常调度。

理解这位管家,你才能回答工程里最常见的一批问题:程序为什么卡、CPU 为什么 100%、内存为什么爆、两个线程为什么算错账。本文是操作系统八篇(150 到 260)的主线入口,每讲一个概念都会告诉你深水篇在哪。

2. 最小可运行实验:亲手制造一场竞态

操作系统给了你「线程」这个抽象:同一进程内的多条执行流,共享全部内存。共享是效率,也是事故源头。看一场经典的算错账:

python
import threading

counter = 0

def work():
    global counter
    for _ in range(100000):
        counter += 1        # 三步:读出来、加一、写回去

threads = [threading.Thread(target=work) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(f"期望 400000,实际 {counter}")

预期输出(每次运行都不同,且几乎总小于 400000):

text
期望 400000,实际 213547

counter += 1 在机器层面是三步:从内存读到寄存器、加一、写回。线程 A 刚读完还没写回,线程 B 也读走了旧值——两人各加了一次,结果只加了一次,一次更新凭空蒸发。这就是竞态条件(race condition):结果取决于线程交织的时序,所以「平时好好的、压测就出错」。

修复只需要一把锁——进入这三步前先占住,做完再放:

python
import threading

counter = 0
lock = threading.Lock()

def work():
    global counter
    for _ in range(100000):
        with lock:          # 同一时刻只有一个线程能进来
            counter += 1

threads = [threading.Thread(target=work) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)              # 400000,稳定复现

记住这个实验的手感:竞态的修复不难,难的是意识到「这行代码有竞态」。凡是「检查然后使用」「读改写」的代码,多线程下都可能是坑。

3. 发生了什么:三个核心抽象与一个入口

3.1 进程:独占 CPU 的幻觉

双击图标后,操作系统为程序建一个进程:分配一份独立的虚拟内存、一套记账信息(Linux 里叫 task_struct,进程控制块),挂进调度队列。进程是资源分配的基本单位。

进程怎么造进程?Unix 的答案是 fork:把自己复制一份,父子各得一个返回值(父进程拿到孩子的编号,子进程拿到 0),之后两者走不同的路。亲手验证「进程的内存互相隔离」——用跨平台的 multiprocessing:

python
import multiprocessing as mp

shared = []                     # 父进程的全局变量

def child():
    shared.append(1)            # 改的是子进程自己的那份拷贝
    print("子进程:", shared, "pid =", mp.current_process().pid)

if __name__ == "__main__":
    p = mp.Process(target=child)
    p.start()
    p.join()
    print("父进程:", shared, "pid =", mp.current_process().pid)

预期输出(pid 每次不同):

text
子进程: [1] pid = 18264
父进程: [] pid = 18260

子进程改了 shared,父进程纹丝不动——进程之间内存隔离,想通信必须走管道、共享内存等专门通道(进程间通信 IPC)。这与第 2 节「线程共享一切」形成精确的对照:进程隔离、线程共享,前者安全后者快,所有并发选型都在这根轴上找位置(进程、线程、协程的三级对比见 协程与并发模型,PCB 内部结构见 PCB 与 TCB)。

3.2 系统调用:通往内核的唯一正门

应用想「叫管家办事」(读文件、开进程、发网络包),只能通过系统调用(syscall):把参数放好,执行一条特殊指令(x86-64 的 syscall、ARM 的 svc),CPU 切到内核态、换到内核栈执行内核代码,办完再切回来。一次系统调用的开销约几百个 CPU 周期——比普通函数调用贵百倍,所以才有「批量读写缓冲」「用户态网络栈」这类优化。特权级与切换细节在中断与系统调用与用户态内核态切换深挖。

3.3 虚拟内存:独占内存的幻觉

每个进程都以为自己从地址 0 开始拥有一整条内存,真相是虚拟地址经页表翻译后才落到物理内存。这一层带来了三个直接好处:进程间天然隔离(第 3.1 节实验的原因)、内存可以比物理的大(暂时不用的页挪到磁盘)、同一份代码库可以被多个进程共享。分页与地址翻译的完整机制见分段与分页,页放不下时的置换算法见页面置换算法。

3.4 文件:磁盘的幻觉

open("/home/me/a.txt") 这一行背后:内核逐级解析路径找到文件的 inode(文件系统与 inode),分配一个整数文件描述符给你;之后的 read 先查页缓存(内存里留一份最近读过的磁盘数据),命中就不用碰磁盘。这就是「第二次读同一个文件快得多」的原因——磁盘很慢,内存替它挡了绝大多数请求。

4. 核心概念一:调度——几个进程怎么分一颗 CPU

可运行的进程永远比 CPU 核多,调度器决定谁上。算法没有绝对优劣,用一道可计算的标准量一量——平均等待时间:

python
def average_wait(burst_order):
    """按给定顺序运行任务,返回平均等待时间"""
    t, total = 0, 0
    for burst in burst_order:
        total += t              # 开跑前攒下的等待
        t += burst
    return total / len(burst_order)

print(average_wait([24, 3, 3]))   # 先来先服务:17.0
print(average_wait([3, 3, 24]))   # 最短优先:3.0

同样的三个任务,只换顺序,平均等待从 17 掉到 3——先来先服务(FCFS)会让短任务排在长任务后面干等(护航效应),最短作业优先(SJF)数学上最优但需要预知运行时长,且长任务可能永远排不上(饥饿)。时间片轮转(RR)用「每人跑一小段就换人」换取公平与响应,是交互系统的底座。

Linux 的 CFS 调度器是这套思想的现代合体:给每个进程记一笔虚拟运行时间 vruntime(权重高的进程它的涨得慢),谁欠得最少谁先跑——数据结构上就是一颗按 vruntime 排序的红黑树,永远取最左节点(树里那个「取最左」的用法)。调度算法全景与死锁在操作系统进阶展开。

进程切换本身有成本:保存一方寄存器、换内核栈、恢复另一方,外加缓存与 TLB 变冷。所以线程不是越多越快——核数就是并行度的天花板,超出的线程只是在排队和切换。

5. 核心概念二:同步、互斥与死锁

第 2 节的锁是「互斥」最朴素的形式。工程上还有一个绕不开的组合场景——生产者与消费者:一边产数据、一边吃数据,中间一个有界缓冲区。经典配方是三个信号量:mutex 保护缓冲区本体,empty 数空位(生产者等它),full 数存货(消费者等它)。把配方背下来的意义不在背,而在于它示范了一个通用方法:把「等某个条件」显式建模成一个可以等待的计数器,线程就不会空转或踩踏。

锁本身又是怎么实现的?靠 CPU 提供原子的读改写指令。硬件级的 Test-And-Set(读出旧值并写入 true,一步完成不可分割)就能搭出自旋锁:拿到 false 才进临界区;更现代的 Compare-And-Swap(CAS,内存值等于期望值才更新)是无锁计数器、无锁队列的基石。Python 与 Java 里的锁、threading.Lock、synchronized,追到底都是这一两条指令——硬件原子性是所有同步工具的地基。

而锁用多了会撞出更糟的局面——死锁。四个必要条件同时成立才会发生,缺一不可:

  1. 互斥:资源一次只能一人用;
  2. 占有并等待:攥着手里的,还想要别人的;
  3. 不可抢占:不能从别人手里硬抢;
  4. 循环等待:A 等 B、B 等 A(或更长成环)。

经典演绎是哲学家就餐:五人围桌、五根筷子,人人「先拿左、再拿右」,可能五人各攥一根左筷集体饿死。防法就是破坏四个条件之一——例如给筷子编号、人人先拿小编号(破坏循环等待),或「两根都拿得到才动手」(一次性申请,破坏占有并等待)。写多锁代码时,把四条件当自查清单过一遍:锁的获取顺序全工程统一,就能消灭绝大多数死锁。

6. 核心概念三:内存与文件——另外两个幻觉的深水区

第 3 节给了直觉,这里只补两个必须进直觉的事实,细节留给深水篇:

  • 缺页不全是坏事:进程访问一个「页表上标记为不在内存」的地址时触发缺页中断,内核把页从磁盘搬进内存再恢复执行。fork 之所以快,正是因为父子先共享页面并标只读(写时复制),谁写谁才真正复制一份——第 3.1 节的实验能瞬间启动子进程,靠的就是它;
  • 页缓存无处不在:read 读的其实是内存里的一份拷贝,写也先写进内存(脏页)再择机落盘。这解释了两个日常现象:拷贝文件后立刻断电可能丢数据(脏页没落盘);fsync 存在的意义(强制落盘)。零拷贝这类「少搬家」技术的完整账本见零拷贝;
  • 块层还有一个 IO 调度器:请求进磁盘前,内核先把相邻扇区的请求合并、按方向排序(电梯思想,机械盘时代有 NOOP、Deadline、CFQ 等算法),让磁头少走冤枉路;SSD 随机访问均匀,调度器退化成简单 FIFO。它与 CPU 调度是同一个问题的两个战场:谁先被服务。磁盘臂寻道的完整推演见磁盘调度。

7. 调试实录:三个高频事故与 OS 视角的诊断

事故 1:压测才复现的计算错误。九成是竞态。诊断思路:找出被多线程共享、又被「读改写」的变量(计数器、字典、集合),检查是否每一处都握着同一把锁。第 2 节的实验是它的最小模型。

事故 2:程序卡死不动。先分清是死循环还是死锁:死锁的标志是「多个线程互相等、CPU 占用反而低」。诊断清单按第 5 节四条件走:谁攥着谁的锁、顺序是否统一。

事故 3:内存一路涨不回落。先问三个问题:是不是缓存设计如此(页缓存、连接池,属正常)?是不是某个集合只进不出(应用层泄漏)?是不是进程数失控(每个进程一份独立内存,第 3.1 节的隔离性反噬)?OS 工具(任务管理器、top、ps)看到的「内存」包含共享页,数字需要解读而不是照单全收。

8. 修改实验

  1. 把第 2 节竞态实验改成 counter = counter + 1(而不是 +=)与 counter -= 1 混合跑四个线程,验证丢失更新同样发生;再把循环次数降到 100,观察「竞态几乎测不出来」——体会为什么并发 bug 在测试环境难以复现;
  2. 用 timeit 测量「空函数调用」与「读一个字节文件并关闭」的耗时,估算系统调用的相对代价(提示:后者至少一次 open/close 系统调用,量级差距会非常明显);
  3. 给调度模拟加一个 RR 版本:时间片 q = 4,对 [24, 3, 3] 手算再代码验证平均等待时间(提示:每轮先跑 4,跑不完的回队尾),比较它与 SJF 的差距;
  4. 用 multiprocessing 开 4 个子进程同时给各自的全局计数器加 100000 次,确认总和互不干扰;再用 4 个线程跑同样的代码,对比两者的最终结果与耗时。

9. 小练习

预测题(先写答案再运行):四个线程各执行 25000 次 counter += 1、无锁,结果可能等于 100000 吗?加上锁之后呢?把线程数改成 1 再去掉锁呢?

修改题:把生产者消费者配方落到 Python:用 queue.Queue(它内部就是锁 + 条件变量)实现 2 个生产者、3 个消费者、容量 5 的缓冲,生产 20 件后让全部消费者正常退出(提示:约定一个「毒丸」哨兵值)。

修 Bug 题:同事的转账代码「先查余额是否足够,足够就扣款」两个线程同时执行时偶发超扣。指出竞态点(检查与扣款之间可被打断),用一把锁把「检查 + 扣款」包成原子操作,并写一个高并发用例验证修复前后差异。

挑战题(不看提示):用两个线程互相 lock_a.acquire() 后 lock_b.acquire()、另一个线程反序 lock_b 后 lock_a,制造一次稳定死锁;然后只调整其中一个线程的加锁顺序修复它,并用第 5 节的四条件清单解释为什么这样修有效。

10. 什么时候你会需要这些知识

排查性能问题时:CPU 100% 看调度与上下文切换(第 4 节);内存暴涨看进程隔离与泄漏三分法(第 7 节);磁盘 IO 慢想页缓存与置换(第 6 节)。写并发代码时:先问共享了什么(第 2 节),再问锁的顺序(第 5 节)。选技术方案时:进程隔离还是线程共享(第 3.1 节),是消息队列、多进程爬虫、线程池等一切并发架构的第一道选择题。

学概念不必贪深,操作系统的每一块都值得在「出了问题再回来查」的模式下反复加深——深水篇就是为此准备的。

11. 与之前和之后的知识的关系

12. 官方文档

13. 自我检查

  • 能完整讲述「双击图标」的六步故事:读文件、建进程、建地址空间、调度、执行、画界面;
  • 能制造并修复一次丢失更新,说出竞态为什么测试难复现;
  • 能用一句话分别讲清进程、线程、协程的隔离与共享程度;
  • 能推导 FCFS 与 SJF 的平均等待时间差,并解释 CFS 用什么结构实现公平;
  • 能默写死锁四条件并用它检查一段多锁代码。

本章总结

操作系统同时是道具师与管家:对上用进程、虚拟内存、文件三个抽象把硬件藏起来,对下用调度、页表、页缓存把有限资源分给所有人。系统调用是两个世界唯一的门,竞态与死锁是共享资源的两份账单。本文建立了全模块的地图——每个概念都能在深水篇里找到自己的一章。

下一步

按主线继续:操作系统进阶把调度算法与死锁展开成完整体系;或者先跳去与你当下问题最相关的深水篇(卡顿看调度,内存看分页,IO 看页缓存)。想看这套抽象在数据世界的对应物,随时可切到计算机网络。