2

操作系统

·22 分钟

1. 进程和线程有什么区别?为什么要有线程?

进程是操作系统分配资源(内存、打开的文件等)的基本单位,线程是 CPU 调度(时间片分配)的基本单位。一个进程至少有一个线程,同一进程内的多个线程共享这个进程的地址空间、打开的文件、全局变量,但各自有独立的栈、寄存器和程序计数器——共享的是"东西放在哪儿",独立的是"执行到哪一步"。

之所以要有线程,是因为进程太重了。创建进程要复制一整套地址空间和资源表;进程切换要换页表、清空 TLB(地址翻译的硬件缓存),代价很高;进程之间地址空间互相隔离,想共享数据还得绕道 IPC。而一个程序内部的并发任务(比如 Web 服务器同时处理多个请求)本来就该共享同一份数据,用线程既能并发,切换又轻——线程切换只需要换寄存器和栈指针,页表不用动。

代价是隔离性没了。一个线程写坏了内存,整个进程都会崩;多个线程同时改同一份数据,还要自己加锁保证正确。所以浏览器这类要求"一个页面崩了不能拖垮整个浏览器"的场景,反而会退回多进程。

2. 进程有哪几种状态?什么时候从运行态变成阻塞态?

三个基本状态:就绪(万事俱备只差 CPU)、运行(正在 CPU 上执行)、阻塞(在等某个事件,就算给它 CPU 也跑不了)。加上创建态和终止态就是五态模型。

转换关系里有个容易混的点:就绪→运行和运行→就绪是调度器决定的(时间片用完、被更高优先级的进程抢占),进程本身没有主动权;而运行→阻塞是进程自己触发的——它调用了一个暂时完不成的操作,比如读磁盘、等网络数据、申请一把已经被别人拿走的锁,操作系统就把它挂起,把 CPU 让给别人。阻塞结束后它进的是就绪态,不是直接回到运行态,因为这会儿 CPU 多半已经被别的进程占了。

Linux 里 ps 看到的 STAT 列就是这套状态:R 是运行或就绪,S 是可中断睡眠(等 IO、等信号,最常见),D 是不可中断睡眠(通常卡在磁盘 IO,kill -9 都杀不掉),Z 是僵尸态——进程已经退出,但父进程还没回收它的退出码。

3. 进程间怎么通信?管道、共享内存、消息队列有什么区别?

进程地址空间互相隔离,A 进程的指针在 B 进程里毫无意义,所以进程间通信必须借助内核提供的通道。常说的有六种:管道、消息队列、共享内存、Socket、信号、信号量,其中前四种才是真正用来传数据的。

方式怎么传特点
管道内核里的一段缓冲区,一端写一端读单向、字节流、无消息边界。匿名管道只能父子进程用,命名管道(FIFO)在文件系统里有名字,无亲缘关系的进程也能用
消息队列内核维护一个链表,按消息为单位收发有边界,支持按类型取,收发双方不用同时在线
共享内存两个进程把同一块物理内存映射到各自地址空间最快,数据不经内核中转;但内核不管同步,要自己配信号量
Socket走网络协议栈唯一能跨机器的,本机通信可以用 Unix domain socket 省掉协议栈开销

速度差异的根源在拷贝次数。管道和消息队列都要走"用户态 → 内核缓冲区 → 用户态"两次拷贝,共享内存映射好之后一次都不用拷,进程直接读写同一块物理页。代价就是内核完全不介入,谁先写谁后读得自己管,通常要配一个信号量来同步。

信号和信号量是另一类,它们都不传数据。信号只传一个事件编号,用来通知进程发生了什么,比如 SIGTERM 让你退出、SIGHUP 让你重读配置;信号量传的是一个计数,用来协调多个进程访问共享资源的先后,最常见的搭配就是给共享内存配一个信号量。

4. 互斥锁和信号量有什么区别?什么时候用哪个?

互斥锁只有两个状态(锁上、没锁),语义是"这段临界区同一时刻只能有一个线程进",而且有归属——谁加的锁谁来解。信号量是一个计数器,P 操作(申请)减一、减到负数就阻塞,V 操作(释放)加一,语义是"有 N 份资源可用",任何线程都能 V,不要求 V 的人一定是 P 的人。

值为 1 的信号量看起来跟互斥锁等价,但因为没有归属,它可以被 A 线程 P、B 线程 V——这正好用来做线程间的顺序同步:A 干完活 V 一下,B 在那儿 P 着等 A。

所以选哪个看你要表达什么。保护一份共享数据不被并发修改,用互斥锁;控制资源池的并发数量(比如连接池最多 10 个连接),用计数信号量;让线程 B 等线程 A 完成某件事,用信号量或条件变量,别用互斥锁硬凑。

5. 死锁的四个必要条件是什么?怎么预防?

四个条件必须同时成立才会死锁:互斥(资源同一时刻只能被一个进程占有)、占有并等待(拿着已有资源的同时去申请新资源)、不可剥夺(资源只能由持有者主动释放)、循环等待(多个进程首尾相接地等对方手里的资源)。

预防就是破坏其中任意一个。互斥通常破坏不了,那是资源本身的性质。剩下三个:破坏"占有并等待",让进程一次性申请齐所有资源,要么全拿到要么一个不拿;破坏"不可剥夺",申请不到就把已经拿到的全部放掉、过会儿重来,对应代码里拿不到锁就立刻返回的 trylock;破坏"循环等待"最实用——给所有资源编个全局序号,规定必须按序号从小到大申请,环就不可能形成。

工程上真正常用的就是最后一条,也就是约定加锁顺序。经典的转账死锁:线程 A 锁住账户 1 再去锁账户 2,线程 B 锁住账户 2 再去锁账户 1,改成两边都按账户 ID 从小到大加锁,问题直接消失。

除了预防还有另外两条路。避免是指每次分配资源前先判断会不会进入不安全状态(银行家算法),开销大,实际系统很少用。检测加恢复是指定期检查资源分配图里有没有环,有就挑一个进程杀掉解环——数据库的死锁检测走的就是这条路,因为它没法要求业务 SQL 按固定顺序加锁。

6. 常见的进程调度算法有哪些?FCFS 和 RR 有什么区别?

算法规则问题
FCFS 先来先服务按到达顺序排队,非抢占一个长任务排在前面,后面的短任务全被拖死(护航效应)
SJF 短作业优先选预计运行时间最短的平均等待时间最优,但要预知运行时间,长任务可能饿死
RR 时间片轮转每个进程跑一个时间片,用完排到队尾响应快、公平;时间片太小则切换开销占比过高,太大则退化成 FCFS
优先级调度按优先级选低优先级可能饿死,需要"老化"机制——等得越久优先级越高
多级反馈队列多个优先级队列,新进程进最高级,用完时间片降一级综合了上面几种,实际系统用的就是这类

FCFS 和 RR 的核心区别是抢不抢占。FCFS 一旦开始执行就跑到底或跑到主动阻塞为止,追求的是吞吐量和实现简单;RR 靠时钟中断强制打断,追求的是响应时间。交互式系统必须用 RR 这类抢占式算法,否则你敲一个键要等前面那个跑一小时的任务结束。

多级反馈队列之所以是实际系统的选择,是因为它不需要预知运行时间就近似实现了短作业优先:短任务在最高优先级队列里就跑完了,长任务会一级级往下掉,自然让出高优先级给新来的交互任务。

7. 虚拟内存解决什么问题?页表是什么?

虚拟内存让每个进程都以为自己独占一整块连续的地址空间,实际的物理内存由操作系统在背后分配。它同时解决三个问题:隔离——进程 A 的地址 0x1000 和进程 B 的地址 0x1000 映射到不同物理页,互相访问不到;不连续——进程看到的连续地址在物理上可以是零散的页,不要求物理内存连续;容量——物理内存装不下时可以把暂时不用的页换到磁盘,让程序用的内存超过实际内存。

页表就是这个映射表,记录"虚拟页号 → 物理页框号",还带着有效位、权限位、修改位这些标记。CPU 每访问一个地址,MMU(内存管理单元)就查一次页表,把虚拟地址翻译成物理地址。因为页表本身也存在内存里,每次访存都要额外读一次内存太慢,所以 CPU 里有个 TLB(快表)缓存最近用过的页表项;又因为 32 位地址空间按 4KB 分页会有一百多万个页表项、全放一张表太占内存,实际用的是多级页表——只给真正用到的那段地址空间分配下一级页表。

访问的页不在物理内存里就触发缺页中断,操作系统去磁盘把它换进来,必要时挑一页换出去。进程切换要换页表,TLB 里缓存的是旧进程的映射,必须失效掉重新填——这正是进程切换比线程切换贵的地方。

8. LRU 怎么实现?和 FIFO 有什么区别?

物理内存装不下时要挑一页换出去,挑的策略就是页面置换算法。FIFO 按进入内存的先后淘汰,最早进来的先走;LRU 淘汰最久没被访问过的那一页。

区别在于拿什么预测未来。FIFO 只看"什么时候进来的",一个从头到尾都在被频繁访问的页,只因为进来得早也会被换出去;LRU 看"什么时候被访问过",依据的是程序的局部性原理——刚用过的数据很可能马上还要用,所以命中率通常明显高于 FIFO。FIFO 还有个反直觉的 Belady 异常:增加物理页框数,缺页次数反而可能变多,LRU 不会有这个问题。

LRU 的标准实现是哈希表加双向链表。哈希表存 key 到链表节点的映射,用来 O(1) 定位;双向链表按访问时间排序,头部是最近用过的,尾部是最久没用的。get 命中就把节点摘下来挪到头部;put 时如果超出容量,就删掉尾部节点,同时把它的 key 从哈希表里删掉。两个结构都是 O(1),合起来 get/put 都是 O(1)。用双向链表而不是单链表,是因为要在 O(1) 时间内摘除任意一个中间节点,必须能拿到它的前驱。

操作系统内核其实不会用严格的 LRU——每次访存都去改链表代价太高。实际用的是时钟算法这类近似方案:给每页一个访问位,硬件在访问时置 1,淘汰时指针转一圈,遇到 1 就清零放过,遇到 0 就淘汰。严格 LRU 更多出现在应用层缓存和面试手撕题里。

9. 分段和分页有什么区别?为什么现代操作系统用分页?

分段按程序的逻辑结构切——代码段、数据段、栈段,每段长度不一样,地址写成"段号 + 段内偏移"。分页按固定大小切(通常 4KB),跟程序逻辑无关,地址写成"页号 + 页内偏移"。

分段分页
划分依据程序的逻辑单元,长度可变固定大小,跟逻辑无关
地址形式二维,段号加段内偏移,程序员可见一维线性地址,程序员不可见
碎片外部碎片——段与段之间留下用不上的空隙内部碎片——最后一页装不满,平均浪费半页
共享与保护天然,按逻辑段设权限靠页表项里的权限位拼出来

现代系统选分页,关键在内存分配。段长可变意味着反复分配回收之后,物理内存会被切得七零八落,出现大量"每块都不够大"的外部碎片,只能靠内存紧缩来整理,代价极高。分页把所有单位定死成同样大小,任何一个空闲页框都能装任何一页,外部碎片彻底消失,只剩下最后一页装不满造成的内部碎片——平均浪费半页,也就是 2KB 左右,完全可以接受。而且固定大小让换入换出的粒度统一,跟磁盘块大小也能对齐。

x86 实际是段页式:逻辑地址先经段机制转成线性地址,再经页机制转成物理地址。但 Linux 把所有段的基址都设成 0、界限设成整个地址空间,等于把分段架空,只留分页真正干活。

10. 什么是系统调用?用户态怎么切到内核态?

CPU 有特权级,内核态能执行任何指令、访问任何内存和硬件,用户态既不能直接碰硬件也不能访问内核内存。应用程序想读文件、发网络包、创建进程,只能请内核代劳——这个请求接口就是系统调用。

切换靠的是中断和异常机制。用户程序执行一条陷入指令(x86-64 上是 syscall),CPU 硬件自动完成特权级切换、跳到内核预设的入口,内核根据寄存器里的系统调用号查表找到对应的处理函数,执行完再返回用户态。关键在于入口是内核规定死的——用户程序不能任意跳进内核的某一行代码,只能从这扇固定的门进来,这才保证了隔离不被绕过。

除了主动陷入,还有两种情况会进内核:硬件中断(网卡收到包、时钟到点)和异常(缺页、除零)。三者走的是同一套切换机制,区别只在触发源是主动还是被动。

系统调用是有成本的,要保存用户态上下文、切特权级、回来时再恢复。所以性能优化里常见的思路就是减少系统调用次数,比如用缓冲区攒一批数据再一次性 write,或者用 mmap 把文件直接映射进地址空间,省掉 read 的那次拷贝。