
OSTEP by Remzi H. Arpaci-Dusseau & Andrea C. Arpaci-Dusseau
操作系统导论
目录
我对这本书的评价是:通俗易懂而不失准确性,是非常好的一本书。从虚拟化、并发、持久性三个方面揭开了操作系统神秘的面纱,如何让每个程序自己看起来像是独占 CPU、内存?如何提高 CPU 的利用率?数据是如何存储的?
大纲 大纲
介绍 介绍
操作系统实际上做了什么?它取得CPU、内存或磁盘等物理资源(resources),并对它们进行虚拟化(virtualize)。它处理与并发(concurrency)有关的麻烦且棘手的问题。它持久地(persistently)存储文件,从而使它们长期安全。
- 多个程序同时运行:CPU 虚拟化
- 每个进程好像都有独立的内存:内存虚拟化
- 同时处理很多事情时会出现一系列问题:并发
- 内存易失,需要存储数据:持久性
无法直接预览PDF文档
CPU 虚拟化 CPU 虚拟化
操作系统如何启动并运行一个程序?进程创建实际如何进行?操作系统运行程序必须做的第一件事是将代码和所有静态数据(例如初始化变量)加载(load)到内存中,加载到进程的地址空间中。然后在运行此进程之前还需要执行其他一些操作,必须为程序的运行时栈(run-time stack或stack)分配一些内存。操作系统也可能为程序的堆(heap)分配一些内存。操作系统还将执行一些其他初始化任务,特别是与输入/输出(I/O)相关的任务。最后启动程序,在入口处运行,即main()。OS将CPU的控制权转移到新创建的进程中,从而程序开始执行。
- 进程,运行中的程序,PCB 里存了不少东西,是上下文切换必须的,比如寄存器的值、栈指针等,OS 有进程列表
- OS 要跟踪进程的状态(就绪、运行、阻塞、初始、最终)、 OS 提供进程的 API(创建、销毁、等待等)
- CPU 虚拟化有底层机制和上层策略,机制是受限直接执行,策略是各种调度算法。
- 在受限直接执行中,引入了硬件不同的执行模式(用户模式、内核模式),硬件(CPU)原生提供了陷入和从陷阱返回的指令,CPU 出厂就支持中断(时钟、键盘、网卡)、异常(缺页、除零、非法指令)、系统调用门(
syscall/int 0x80)这些事件,也提供了iret/sysret这种返回指令由操作系统(内核汇编代码)调用。 - 时钟中断可以让 OS 重新获得控制权,然后便能决定是否要切换到其他进程,这是调度器要做的事。
- 进程切换时要进行上下文切换,操作系统、硬件密切合作。
- 调度算法很多,先通过工作负载假设把问题简化,引出了 FIFO、SJF、STCF 算法并计算了平均周转时间,然后是考虑到响应时间提出了 RR 算法。一步步放宽假设,最后介绍了多级反馈队列(五大规则,能利用最近预测未来)、比例(公平)调度(彩票 & 步长,随机近似与确定性)。
- 单处理调度之后,是多处理器调度,他俩的核心区别是缓存的使用和共享数据的方式,多核处理器有底层的缓存一致性机制,但仍避免不了共享数据需要的加锁才能保证预期结果。可以简单用单队列,但存在缓存亲和性和加锁的问题,用多队列就需要考虑负载不均,需要工作窃取。
- Linux 社区对采用哪种调度策略,一直没有共识。
无法直接预览PDF文档
内存虚拟化 内存虚拟化
用户程序生成的每个地址都是虚拟地址(every address generated by a user program is a virtual address)。操作系统只是为每个进程提供一个假象,具体来说,就是它拥有自己的大量私有内存。在一些硬件帮助下,操作系统会将这些假的虚拟地址变成真实的物理地址,从而能够找到想要的信息。
虚拟内存系统负责为程序提供一个巨大的、稀疏的、私有的地址空间的假象,其中保存了程序的所有指令和数据。操作系统在专门硬件的帮助下,通过每一个虚拟内存的索引,将其转换为物理地址,物理内存根据获得的物理地址去获取所需的信息。操作系统会同时对许多进程执行此操作,并且确保程序之间互相不会受到影响,也不会影响操作系统。整个方法需要大量的机制(很多底层机制)和一些关键的策略。
- 抽象是地址空间,给进程一个私有的大大的地址空间,怎么把虚拟地址映射到物理地址?
- 整个放进去:如果是整块地址空间放进物理内存,那么硬件有基址寄存器+界限寄存器就可以了,
物理地址=虚拟地址+base。但是这有很大的空间浪费,地址空间那么大,栈和堆之间那些空间都没用上。 - 分段:将地址空间分为代码段、栈段、堆段,每个段都有一对基址寄存器+界限寄存器,在虚拟地址中用几个位体现一下用的是哪个段,
物理地址=虚拟地址的偏移量+段 base。可是会造成外部碎片。 - 分页:将地址空间、物理内存都分成小的页,一般是 4kB, 虚拟页对应了物理页帧,完全避免了外部碎片。此时需要 OS 帮忙存储页表,
物理地址=页表(虚拟地址对应的页号)+虚拟地址的偏移量。可是这会造成两个问题:一是地址转换变慢了,二是页表非常大且大量的页表项都没有用。 - TLB 快速地址转换:硬件上了个缓存,利用时间和空间局部性能在一定程度上避免访问页表,先看 TLB 有没有,TLB 是并行查找,性能大大提升。
- 分段+分页:混合方法,只存有用的部分的页表,分段的那些个寄存器都保留,此时段基址寄存器就要存对应的页表地址了,界限寄存器就是页表的结尾了。可是又带来了外部碎片,分段框定连续空间都做法总有内存浪费。
- 多级页表:这是现代系统用的多的,引入了页目录,就是页表的页表,把页表也按页大小拆分,此时页表就能分散放在内存的任何位置了,并且如果整片页表没用就在页目录项标记无效即可,不用存了,故而非常紧凑。如果页表还是太大,超过物理内存了怎么办?
- 页的换出:交换到硬盘上,硬件触发缺页中断/异常,让操作系统来重新加载页。决定换出哪个页呢?FIFO、随机都没有体现页的重要性,LRU 不错,但是实现起来复杂,每次访存都得记录,故而用时钟算法来近似 LRU。
无法直接预览PDF文档
并发 并发
单个线程的状态与进程状态非常类似。线程有一个程序计数器(PC),记录程序从哪里获取指令。每个线程有自己的一组用于计算的寄存器。所以,如果有两个线程运行在一个处理器上,从运行一个线程(T1)切换到另一个线程(T2)时,必定发生上下文切换(context switch)。线程之间的上下文切换类似于进程间的上下文切换。对于进程,我们将状态保存到进程控制块(Process Control Block,PCB)。现在,我们需要一个或多个线程控制块(Thread Control Block,TCB),保存每个线程的状态。但是,与进程相比,线程之间的上下文切换有一点主要区别: 地址空间保持不变(即不需要切换当前使用的页表) 。
线程和进程之间的另一个主要区别在于栈。在简单的传统进程地址空间模型(我们现在可以称之为单线程(single-threaded)进程)中,只有一个栈,通常位于地址空间的底部。然而,在多线程的进程中,每个线程独立运行,当然可以调用各种例程来完成正在执行的任何工作。不是地址空间中只有一个栈,而是 每个线程都有一个栈 。
锁就是一个变量,因此我们需要声明一个某种类型的锁变量(lock variable,如上面的mutex),才能使用。这个 锁变量(简称锁)保存了锁在某一时刻的状态。它要么是可用的(available,或unlocked,或free),表示没有线程持有锁,要么是被占用的(acquired,或locked,或held),表示有一个线程持有锁,正处于临界区。 我们也可以保存其他的信息,比如持有锁的线程,或请求获取锁的线程队列,但这些信息会隐藏起来,锁的使用者不会发现。锁让程序员获得一些控制权。通过给临界区加锁,可以保证临界区内只有一个线程活跃。锁将原本由操作系统调度的混乱状态变得更为可控。
线程可以使用条件变量(condition variable),来等待一个条件变成真。 条件变量是一个显式队列,当某些执行状态(即条件,condition)不满足时,线程可以把自己加入队列,等待(waiting)该条件。 另外某个线程,当它改变了上述状态时,就可以唤醒一个或者多个等待线程(通过在该条件上发信号),让它们继续执行。Dijkstra最早在“私有信号量”中提出这种思想。Hoare 后来在关于观察者的工作中,将类似的思想称为条件变量。
事实上,Dijkstra及其同事发明了信号量,作为与同步有关的所有工作的单一原语。你会看到,可以使用信号量作为锁和条件变量。信号量是有一个整数值的对象,可以用两个函数来操作它。在POSIX标准中,是
sem_wait()和sem_post()。因为信号量的初始值能够决定其行为,所以首先要初始化信号量,才能调用其他函数与之交互。sem_wait()要么立刻返回(调用sem_wait()时,信号量的值大于等于1),要么会让调用线程挂起,直到之后的一个post操作。sem_post()并没有等待某些条件满足。它直接增加信号量的值,如果有等待线程,唤醒其中一个。当信号量的值为负数时,这个值就是等待线程的个数。
- 新的抽象是线程,一个进程可以有多个线程,这些线程共享一个地址空间。
- 线程是由操作系统调度的,多个线程可以共享变量,当并发更新时会产生让人意想不到的结果,这就是竞态条件。而对应的代码被称为临界区。临界区一定不能让多个线程同时执行。解决这个问题需要硬件支持,提供同步原语。相应的工具有锁、条件变量,以及信号量。
- 锁能提供互斥,也被称为互斥量。锁的实现有三种,控制中断缺点多,硬件比较并交换等的支持却有不必要的自旋,最好的方法是硬件支持+操作系统
yield调用在自旋时放弃 CPU。有了锁之后,就可以构造出各种基于锁的并发数据结构,比如并发散列表。 - 条件变量在线程检查某个条件满足之后才运行的情况下非常有用,条件变量本身是个队列,当执行状态不满足时,线程可以把自己加入队列,来等待该条件。它有两个操作
wait和signal代表了睡眠和唤醒。调用 wait 时线程释放锁并睡眠,被signal唤醒时会进入就绪状态,会从暂停的地方开始继续执行。一个利用两个条件变量 empty 和 full 来解决的经典问题就是生产者和消费者问题,也称有界缓冲区问题。除了signal唤醒一个线程外,还有broadcast可以唤醒所有线程,通常用于不确定要唤醒哪个线程的场景。 - 信号量是一个有数值的对象,当他的值为 1 时是锁,值为 0 时是条件变量。它有
wait和post两个操作,wait会-1,要么立即返回要么进入等待队列,post会+1,若有等待线程就会唤醒一个。用信号量也能解决有界缓冲区问题,还有哲学家就餐问题、读写锁等。可以用锁和条件变量来实现信号量。 - 并发编程容易出缺陷,主要分为非死锁缺陷和死锁缺陷。死锁的四个必要条件破坏一个就能预防,也可以检查死锁并重启,还可以用银行家算法来进行聪明的调度。非死锁主要指的是原子性、顺序问题。当然了,更好的解决方案是开发新的并发编程模型,比如 map-reduce、事件循环这种无锁的。
- 事件循环的并发编程模型很容易理解,就是一个单线程循环检查,串行处理事件。但也存在一些问题,比如如果有阻塞的系统调用会让整个系统卡死,因而得用异步 IO等。
无法直接预览PDF文档
持久性 持久性
TODO: 待更新
问题研讨 问题研讨
上下文切换 = 操作系统 (软件) + CPU (硬件) 分工合作
硬件负责进内核、保存最基本现场(被动、机械、只认规则,发生中断 / 异常时,自动帮你压几个关键寄存器到内核栈);
每个进程(不管是用户进程还是内核线程)都被操作系统分配了一块内核栈,这块栈属于进程的内核态上下文,用户程序完全看不见、摸不到、访问不了。 它存在的唯一目的,是进程进入内核态时,给内核代码提供安全、隔离的栈空间。 一面是用户态:用「用户栈」,跑自己的代码(比如你的 App、游戏); 另一面是内核态:用「内核栈」,跑操作系统帮它干活的代码(比如调用 read 读文件)。
操作系统负责保存全部现场、选下一个进程、恢复下一个现场(做真正的调度、保存 / 恢复完整上下文、切换页表、切换内核栈);
硬件负责返回。
| 操作系统(内核模式) | 硬件(CPU) | 应用程序(用户模式) |
|---|---|---|
| 进程A… | ||
| 时钟中断 1. 把进程A的用户栈、寄存器(SS、RSP、RFLAGS、CS、RIP/IP/PC)自动压入当前进程A的内核栈 2. 转向内核模式(用户态 → 内核态) 3. 跳到中断(陷阱)处理程序 | ||
| 处理中断:进入内核后,操作系统的汇编/代码开始执行 1. 将A剩下的通用寄存器(rax, rbx, rcx, rdx, rsi, rdi, rbp, r8~r15…)全部压入进程A的内核栈 2. 将栈指针保存到A的进程结构 PCB: A->thread.sp = rsp;,此时A 的所有现场 = 全在 A 的内核栈里 + 栈指针存在 PCB3. 调度器决定下一个运行B 4. 切换内核栈(A -> B): rsp = B->thread.sp;,此时栈是B 的内核栈5. 切换地址空间:若B是不同进程,则把 CR3 寄存器指向 B 的页表,将虚拟地址空间切换为B的 6. 把 B 的内核栈中所有通用寄存器弹回到CPU硬件上 7. 执行从陷阱返回指令 | ||
| 1. 从 B 的内核栈弹出寄存器到CPU硬件:PC、CS、RFLAGS、RSP、SS 2. 转向用户模式(内核态 → 用户态) 3. 跳到程序计数器PC指向的指令继续执行 B | ||
| 进程 B… |
- RSP = Stack Pointer 是 x86_64 里的 栈指针寄存器。永远指向当前栈顶的地址。进中断、系统调用、进程切换,RSP 会变。
- RIP = Instruction Pointer 是里的 指令指针寄存器。永远指向 CPU 即将执行的下一条指令地址。PC(Program Counter)是通用概念,所有 CPU 都有,存下一条指令地址。RIP = x86_64 硬件里的 PC。PC = 所有架构的统称。
- CR3 = Control Register(控制寄存器)是 x86 CPU 里专门存放「页表基地址」的寄存器。切换虚拟地址空间 = 必须换 CR3。当前进程页表的 “入口地址”。
- 有 5 类寄存器:
- 通用寄存器:存数据、存地址、做运算。你平时写代码、函数调用、运算用的,RAX, RBX, RCX, RDX, RSI, RDI, RBP, RSP,R8~R15
- 指令寄存器:指向下一条指令。RIP,就是 PC
- 控制寄存器:控制 CPU 工作模式、内存管理。
- CR0:开启保护模式、分页
- CR2:缺页异常地址
- CR3:页表基地址(页表指针)
- CR4:更多控制位
- 段寄存器:
- CS:Code Segment 代码段寄存器,存两个东西,段选择子+当前权限 CPL(0 = 内核,3 = 用户);CPU 看权限,只看 CS 里的 CPL 位。切换到内核态时,CS 里的权限位 CPL 从 3 变成 0,同时指向内核的代码段。硬件要保存原来用户的 CS 和加载内核的 CS,这才叫进入内核态。从内核态进入用户态是内核 CS 直接被用户的 CS 覆盖,不用保存,它是固定的、全局唯一的、操作系统早就设置好的。
- SS:Stack Segment 栈段寄存器
- DS, ES, FS, GS…(数据段)
- 标志寄存器:RFLAGS 存进位、符号、中断开关等。
- 只有进入内核那一刻,CPU 自动压入内核栈(进内核那一刻,用户态的 RIP、RSP 马上就要丢了!硬件必须保存最关键的几个,这几个能决定程序能不能回来,也即安全地进入内核):
- RIP(下一条指令,也就是 PC)
- CS(代码段寄存器)
- RFLAGS(状态寄存器:进位、中断标志等)
- RSP(用户栈指针)
- SS(栈段寄存器,存段选择子)
- 进入内核后,操作系统汇编代码会把剩下所有寄存器全部压栈(进程切换时,所有寄存器都属于当前进程 A,要切到 B 就必须把 A 的现场保存起来):
- 剩下的通用寄存器:RAX, RBX, RCX, RDX, RSI, RDI, RBP, R8, R9, R10, R11, R12, R13, R14, R15
- 有的架构还要保存浮点、向量寄存器
- 系统调用 = 硬件提供 “门” + OS 提供 “里面的服务”。
- 怎么进内核:硬件提供指令(syscall /int/trap)
- 进去后干什么:OS 提供系统调用功能,功能是 OS 实现的
- iret 返回用户:硬件指令,是硬件电路写死的,OS 汇编代码调用
在传统的并发编程(如多线程 / 多进程)中,锁的本质是解决 “共享数据的竞态条件”:多个执行单元同时读写同一块内存 / 数据,会导致数据不一致(比如两个线程同时给一个计数器 + 1,最终结果可能少 1)。
为了避免这个问题,必须用锁把 “修改共享数据” 的操作变成原子操作,但锁会带来死锁、活锁、性能开销、编程复杂度高等问题。
而 MapReduce 的核心是 “无共享(Shared-Nothing)” 架构,它从根源上消除了 “多个执行单元竞争共享数据” 的场景,因此完全不需要锁。
具体靠三个关键设计:
- 数据不可变性:所有输入数据都是只读的,计算过程中不会修改原始数据,只生成新的中间 / 最终数据;
- 任务完全独立:把大计算拆成多个小任务,每个任务只处理自己的专属数据分片,任务之间没有任何数据共享;
- 严格的阶段划分:计算分为 Map(映射)、Shuffle(洗牌)、Reduce(归约)三个阶段,前一阶段完成后才会开始后一阶段,跨阶段无并发竞争。
比如一个统计单词数量的并发程序,按传统的并发编程模型需要启动多线程分别计数不同的片段会修改并发统计数据,而 map-reduce 则是对不同的分段进行计数然后合并最后统计数据,过程中所有数据都是 “只读” 或 “局部私有” 的。
MapReduce 无锁模型的适用场景:
它并非适用于所有并发场景,特别适合 “数据并行” 类计算:
- 批量数据处理(如日志分析、数据统计、报表生成);
- 可拆分、可独立计算、最终可聚合的任务(如图片处理、搜索引擎的倒排索引构建);
- 不适合:需要频繁交互、实时修改共享数据的场景(如在线交易、实时聊天)。
所有并发模型的本质:怎么把大任务拆成小任务 + 小任务之间怎么交互 / 协同。
- 基于线程的并发 —— 最基础的并发,也最容易 “抢东西” :所有线程共享进程的内存空间,多个线程同时修改共享数据时,会出现 “抢数据” 的竞态条件,必须加锁。适合少量共享数据、任务交互频繁的场景。
- 基于进程的并发 —— 最 “独立” 的并发,天生不会抢东西:每个进程有操作系统分配的独立内存,天生无共享数据,IPC(进程间通信)是进程间的交流方式,主流用「无共享内存的 IPC」(管道、套接字),完全不用锁。适合安全性/隔离性要求高的场景(比如多租户服务、银行系统)、CPU 密集型任务(比如视频解码、大数据计算,避开 Python GIL 限制)。
- 基于事件的并发(事件驱动)—— 单线程的 “高效调度员”,天生无锁:全程单线程执行,所有任务排队被 “事件循环” 调度,没有多个执行者抢数据。适合高并发 I/O 密集型任务(比如网页服务器、异步爬虫、即时通讯 APP)。
- 基于协程的并发(轻量级线程)—— 单线程里的 “迷你任务”,天生无锁:本质是「基于事件驱动的升级版」,把事件驱动的 “回调逻辑” 改成了更易写的 “同步式代码”,是现在高并发 I/O的首选模型。比如Go 运行时会把大量协程调度到多个操作系统线程上并行执行。
- Actor 模型 —— 独立的 “小角色”,靠传消息协作,天生无锁:把任务拆成多个独立的 “小角色(Actor)”,每个 Actor 有自己的私有数据和消息队列,彼此之间只靠发消息交互,完全不共享数据,是分布式并发的 “天然适配模型”。无任何共享数据,Actor 的私有数据只有自己能改,彼此靠消息传递,没的抢。
- MapReduce 模型(数据并行)—— 大数据的 “分工流水线”,天生无锁:专门为大数据批量处理设计的并发模型,核心是 “拆分 - 计算 - 聚合”,把海量数据拆成小块,分给多个任务并行处理,最后汇总结果,是大数据领域的基础模型。全程是 “一次性计算”,不适合实时计算。任务完全独立,数据完全隔离,每个 Map 任务只处理自己的数分片,Reduce 任务只处理自己的聚合组,彼此无交互、无共享,天生无锁。适合大数据批量处理(比如日志分析、搜索引擎建索引、数据统计、图片批量处理),Hadoop/Spark 的核心就是 MapReduce。
- 函数式并发 —— 纯 “数学函数” 的并发,天生无锁:基于函数式编程的并发模型,核心是用 “纯函数” 拆分任务,纯函数无副作用、不修改外部数据,多个纯函数可以随便并行执行。纯函数无状态、无副作用,不修改任何外部数据,只根据输入生成输出,任务之间完全无共享。适合批量纯计算任务(比如数据清洗、机器学习模型训练、数值计算),Scala/Haskell/Python 的并行流都基于这个模型。
- SIMD(单指令多数据)—— CPU 硬件级的 “并行计算”,天生无锁:硬件层面的并发,不是程序员手动拆任务,而是 CPU 本身支持 “一条指令同时处理多个数据”,是最底层、最快的并发方式,专门解决数值计算问题。由 CPU 硬件保证指令的原子性,一条指令同时处理多个数据,没有软件层面的竞争。只适合密集型数值计算,不适合普通业务逻辑。
- 基于消息队列的并发 —— 解耦的 “任务快递站”,天生无锁:通用的解耦型并发,核心是引入一个 “中间快递站(消息队列)”,任务之间不直接交互,而是把数据放到快递站,由快递站转发,是企业开发中最常用的 “解耦并发方式”。任务之间通过消息队列间接交互,无共享数据,生产者只往队列里放消息,消费者只从队列里取消息,没的抢。适合企业级分布式系统(比如日志收集、任务调度、微服务通信、订单处理),RabbitMQ/Kafka/RocketMQ 是主流的消息队列框架。