从 PCB 到上下文切换:进程是怎么被"管"起来的
这是复习进程笔记的下篇。
上篇推到了一句等式:进程 = 内核数据结构 + 自己的代码和数据。
这篇就从这份数据结构出发,把进程的一生走一遍——它怎么被描述、怎么被创建、怎么在几条队列之间来回搬、怎么结束,最后怎么被换下 CPU。
0. 全景:这篇只回答五个问题
上篇解决的是"为什么会有进程"。这篇解决的是"进程到底长什么样、怎么活"。
| 顺序 | 问题 | 答案落点 |
|---|---|---|
| ① | 内核眼里,一个进程到底是什么? | 一份档案,task_struct |
| ② | 进程是从哪儿冒出来的? | fork,父进程生出来的 |
| ③ | 一个函数怎么能返回两次? | 不是函数返回两次,是一份代码跑了两遍 |
| ④ | 为什么说"运行"和"阻塞"要看它在谁的队列里? | 状态不是标签,是它挂在哪个链表上 |
| ⑤ | 切进程的时候,到底在保存什么? | CPU 寄存器里的全部内容,因为寄存器只有一套 |
一路推到底,落在这句话上:
进程的一生,就是它那份数据结构在几条队列之间搬家的一生。
这句话是这篇的主线。剩下的全是细节。
1. PCB:内核看你,看的是那份档案
问:上篇说内核用"先描述、再组织"来管理。那描述一个进程,具体描述的是什么?
答: 一份档案。就像找工作要交简历——内核手里,每个进程都有一份简历。
1.1 一份简历里该写什么
想象你自己是内核,你要管一个进程,你得知道哪些事?
- 它是谁——名字、编号;
- 它现在什么情况——在跑、在等、还是停了;
- 它跑到哪儿了——下一条指令在哪;
- 它占着哪些东西——开着哪些文件、用了多少内存;
- 它和谁有关系——谁生的它、它生了谁。
把这些写成一个结构体,就长这样:
/* 概念性伪代码 */
struct task_struct {
/* 标识:它是谁 */
pid_t pid;
pid_t ppid;
/* 状态与调度:它现在什么情况、跑到哪了 */
long state;
int prio;
unsigned int time_slice;
unsigned long pc; /* 程序计数器:下一条要执行的指令地址 */
unsigned long sp; /* 栈指针 */
/* 资源:它占着什么 */
struct files_struct *files;
struct mm_struct *mm;
sigset_t blocked;
/* 组织:它和别人的关系 */
struct task_struct *parent;
struct list_head tasks; /* 挂到全局进程链表上 */
struct list_head run_list;/* 挂到调度队列上 */
};
PCB(process control block,进程控制块)是这个东西的通用叫法,Linux 里叫 task_struct。
一句话:PCB 就是这个进程的档案 + 现场快照。
1.2 这份档案为什么必须放在内核里
问:既然档案是描述"我"的,那让"我自己"来维护不是更方便?
答: 不行。这是整个进程模型的地基,值得停一下。
如果档案由进程自己维护,会出什么事?
- 进程可以把自己标成"已退出",于是内核不再管它,它却还在偷偷跑;
- 进程可以把自己的优先级改成最高,把别人饿死;
- 进程可以改掉"我占着哪些内存"的记录,让内核找不到它占的那块地。
档案一旦可以被被管理者修改,管理就不成立了。
所以这份结构体只能待在内核空间,由内核维护;用户程序只能通过系统调用去"申请修改",改不改、怎么改,内核说了算。
这也顺带解释了上篇提过的那句:进程 = 内核数据结构 + 自己的代码和数据——前半截在内核空间,后半截在用户空间,中间隔着一道门。
2. fork:一个函数怎么造出第二个自己
问:那进程是从哪儿来的?总不能是凭空出现的。
2.1 Linux 里,进程都是"生"出来的
答: Linux 里几乎所有新进程,都是由父进程创建出来的。整台机器上的进程,构成一棵树。
你在终端里敲 ./myprocess,发生了什么?
不是"系统直接启动了我的程序",而是:bash 先 fork 出一个子进程,然后这个子进程把自己替换成 ./myprocess。
所以你每次敲命令,背后都先多了一个进程。敲 ls 也是,敲 pwd 也是。
2.2 fork 干的事,是复制一份档案再加一份代码数据
问:fork 这个名字,字面意思是"分叉"。它具体做了什么?
答: 站在内核的角度,就一句话:
系统里多了一个进程 = 内核里多了一个
task_struct,再加上一份对应的代码和数据。
再展开一点:
- 内核照着父进程的
task_struct,复制出一份新的; - 新档案里的 PID 换成新的,父子关系记上;
- 地址空间先跟父进程共享(下一节讲为什么可以共享);
- 把新进程挂进就绪队列,等着被调度。
关键点:fork 的返回值,是这份设计里最反直觉的地方。
2.3 反直觉第一层:fork 真的有两个返回值
fork 的原型看着平平无奇:
pid_t fork(void);
但它会返回两次,而且两个值不一样:
| 谁 | fork 返回什么 | 含义 |
|---|---|---|
| 父进程 | 子进程的 PID(> 0) | “你生了个孩子,编号在这” |
| 子进程 | 0 | “你就是那个孩子” |
| 出错时 | -1 | 没生成,父进程拿到错误码 |
所以标准写法长这样:
pid_t id = fork();
if (id < 0) {
/* 出错 */
} else if (id == 0) {
/* 这里是子进程在跑 */
} else {
/* 这里是父进程在跑,id 是子进程的 PID */
}
为什么父进程要拿到子进程的 PID? 因为父进程将来要用这个编号去管这个孩子——等它、查它、给它发信号。你得先知道它叫几号,才能对它做事。
为什么子进程返回 0? 因为子进程不需要"我是谁"这个编号,它想知道自己是谁,直接调 getpid() 就行了。而且 0 有个好处:它一定不会和任何真实 PID 撞车(PID 从 1 开始),拿来当"我是子进程"的标志最安全。
2.4 反直觉第二层:不是函数返回两次,是一份代码跑了两遍
问:这才是真正别扭的地方。一个函数,怎么可能返回两次?
答: 换个角度问会豁然开朗——return 这个动作,本质是什么?
return 的本质,就是"写入"。 把返回值的那个数字,写进一个约定的地方(寄存器),然后跳到调用者那里继续执行。
那么 fork 之后发生了什么?
- 内核复制出了子进程,父子的执行位置完全一样——都停在
fork()这一行刚结束的地方; - 内核让两个进程各自从同一个位置继续往下执行;
- 于是它们各自执行了一遍"从 fork 返回"这个动作;
- 父进程把它那份写成了子进程的 PID,子进程把它那份写成了 0。
所以准确的说法不是"一个函数返回了两次",而是:
一份代码,被两个执行流各跑了一遍;每个执行流都执行了一次 return,各写各的返回值。
这里还有一层必须点破:两个执行流的"现场"是不同的。
内核返回前,会把各自的寄存器恢复成各自的值。父进程的返回值寄存器里是子 PID,子进程的返回值寄存器里是 0。寄存器只有一套,但内核在切换时给它塞的值不一样。
(记住这句话,第 7 节讲上下文切换时,它就是全部答案。)
2.5 反直觉第三层:父子共享代码,但改数据时互不影响
问:既然地址空间都共享了,那父进程改一个变量的值,子进程不也跟着变吗?
答: 不变。这里就是写时拷贝(copy-on-write,COW)在起作用。
先记住结论:
默认情况下,fork 之后,代码和数据都是父子共享的。
但"共享"分两种:
代码段可以真共享。 因为代码是只读的,谁都不会去改它,两份一样的指令没必要存两份。
数据段只是"看起来"共享。 做法很巧妙:
- fork 之后,父子的页表都指向同一批物理页;
- 内核把这些页在两边都标成只读;
- 谁想写,就会触发一次"只读页被写"的异常;
- 内核收到异常,发现这是 COW 页,于是给写的那一方复制一份新的物理页,改掉它的页表指向,然后让它继续写。
于是:你不写,我们就一直共用;你一写,我们立刻分家。
为什么要这么设计? 因为效率。fork 之后最常见的动作,紧接着就是"我要执行另一个程序"(exec)。如果 fork 时老老实实把几个 G 的内存全复制一遍,然后马上又被整个替换掉,那这几个 G 的复制纯属白干。COW 让"复制"这件事推迟到真正需要的时候。
这里还埋着一个特别好用的推论:
int a = 10;
pid_t id = fork();
if (id == 0) a = 20;
printf("%d %p\n", a, &a); /* 父子打印出来的地址是一样的,值却不同 */
变量地址一样,值却不同——因为这里的地址是虚拟地址,父子各有一套页表,同一个虚拟地址可以映射到不同的物理内存。这件事在第 7 节的"地址空间"里还会再碰一次。
2.6 三个顺手该知道的细节
① getpid / getppid 为什么必须走系统调用?
因为 PID 是 task_struct 里的一个字段。用户程序没法直接读内核里的结构体,所以只能求内核代劳:
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void); /* 我是几号 */
pid_t getppid(void); /* 我爸是几号 */
凡是"读自己档案里的字段",都得走系统调用。
② 进程在 /proc 里是看得见的
$ ls /proc
1 20 31 487 7626 ...
$ ls /proc/10962 -l
lrwxrwxrwx ... cwd -> /home/whb/code/116_117/code/lesson14
/proc 下的每个数字目录,就是一个进程。cwd 是它的当前工作路径——你在哪个目录下启动它,它就是哪。
这解释了一件小事:程序里写 fopen("log.txt", "w"),文件到底建在哪?建在当前工作路径下,也就是 cwd。而 cwd 是可以用 chdir() 改的。
③ 父子的 ID 关系是不变的
同一个程序跑好几次,你会发现每次 pid 都变,ppid 却一直不变:
$ ./myprocess
pid: 8711, ppid: 7633
$ ./myprocess
pid: 8792, ppid: 7633
因为 ppid 一直是 bash 的 PID。每次执行,bash 都新 fork 一个孩子。
3. 状态:进程不可能一直在跑
问:CPU 只有一个(或者没几个),可进程有几百个。那"正在跑"的到底是谁?
3.1 先把这句话说死:同一瞬间真正在跑的,只有一个
答: 这个问题不正面回答,后面全是空的。
同一瞬间,每个 CPU 核上真正在跑的进程,最多只有一个。
注意,这话不是"几百个里有一个在跑",而是能跑的位置就那么几个。一台 8 核的机器,此刻正在被执行的指令流就是 8 条——第 9 条想进来,得先有人让位。剩下那几百个进程,这一瞬间一个都没在跑。
那它们在哪儿?在两条队伍里排着:
| 它们现在的样子 | 待在哪条队伍 | 在等什么 |
|---|---|---|
| 能跑,但没轮到 | 就绪队列(运行队列) | 等 CPU 空出来 |
| 跑不了,在等外部事件 | 某个设备的等待队列 | 等键盘、磁盘、网络 |
所以"正在跑"的那个不是碰巧走运,是内核调度器从就绪队列里挑出来的。挑谁、让它待多久,是调度策略管的事(第 6 节讲优先级,第 7 节讲怎么换人);挑完把 CPU 递过去那一下,就是上下文切换。
那为什么我打开 top,看上去几百个都在动?
因为换得太快。一次时间片只有毫秒级,而人眼能分辨的间隔是几十毫秒。几百个进程轮流上 CPU,一轮接一轮,你根本察觉不到中间的空档。
不是几百个一起跑,是几百个轮流跑,快到你以为是同时。 这就是"并发"这个词的真正含义——它描述的是看起来同时,不是真的同时。
3.2 为什么"阻塞"是一个必须存在的状态
既然 CPU 的位置金贵,那"占着位置却不干活"就是最不能忍的浪费。先看一段谁都会写的代码:
int main()
{
int a = 0;
scanf("%d", &a); /* 卡在这里 */
printf("a = %d\n", a);
return 0;
}
scanf 执行时,程序在干什么?
在等。 等键盘上有人敲数字。
这时候问题来了:CPU 该陪着你等吗?
不能。CPU 一秒钟能执行几十亿条指令,让它干等着一个人敲键盘(毫秒级),是极大的浪费。所以内核会做一件事:
把这个进程从"能跑的队列"里拿下来,扔进一个"等键盘"的队列里,然后让 CPU 去跑别人。
这就是阻塞。不是程序卡住了,是内核主动把它挪走了。
3.3 经典五状态模型,一共八个圈
把上面这个动作抽象一下,就能画出经典的状态图。核心只有五个圈,加上挂起就变成八个:
看懂这张图的关键,是别把它当成"进程身上贴了不同的标签"。
这套"经典五状态模型"是什么? 一句话:它是操作系统里通用的教学模型,不是某个内核的真实实现。
它是从各种真实系统里抽出来的共同骨架,目的就是让人一眼看懂"进程会在哪几种处境之间转换"。所以真实内核不会真的去维护八个枚举值——Linux 也只用了五个字母,而且和这五个圈不是一一对应(下一节就拿来对照)。
看它的正确姿势也就清楚了:记转换条件,别记圈数。 骨架懂了,落到哪个系统都能自己对上。
3.4 这一章最值钱的一句话:状态就是"你挂在谁的队列上"
把刚才的机制想透,你会得到一个非常统一的视角:
阻塞与运行的本质,是看你的
task_struct在谁提供的队列里。
task_struct挂在运行队列(就绪队列)里 → 它就是"就绪"或"运行";task_struct挂在某个设备的等待队列里 → 它就是"阻塞"。
这不是比喻,是真的这么实现的。每个设备在内核里都有这么一个结构体,里面挂着一个等待队列:
/* 概念性伪代码 */
struct device {
/* 硬件的所有属性:类型、状态、厂商…… */
struct device *next;
struct task_struct *wait_queue; /* 谁在等我,挂在这条链上 */
};
键盘一个、显示器一个、磁盘一个、网卡一个。你在等谁,你的档案就挂在谁的 wait_queue 上。
于是"状态转换"也就不神秘了:所谓"从阻塞变就绪",本质是把你的 task_struct 从设备的等待队列上摘下来,挂回运行队列。
那谁来做这个搬运动作? 操作系统。因为操作系统是硬件管理者——硬件就绪了(键盘有数据了、磁盘读完了),最先知道的是它,不是你的进程。它知道之后,才有资格去动那条链。
就绪和运行为什么要分成两个圈? 3.1 那张表已经答了一半:就绪是"排着队等 CPU",运行是"已经在 CPU 上"。"就绪"是资格,"运行"是事实。 资格随时有,事实只有一个。
3.5 挂起:内存不够时,用时间换空间
图上还有两条虚线,指向"挂起"。这是另一回事,值得单独说。
场景:内存不够了。
系统里进程太多,或者某个进程要吃一大块内存,物理内存装不下。怎么办?
内核会把某些进程的代码和数据,从内存搬到磁盘的 swap 分区上,腾出内存给别人用。
- 搬出去叫 swap out(换出);
- 搬回来叫 swap in(换入)。
这里有个必须说清的细节:换出去的只有代码和数据,task_struct 一定留在内存里。
为什么?因为档案要是也被换到磁盘上,内核就没法管它了——你连"有这么个进程"都查不到,还怎么把它换回来?档案必须在手边,才能管理。
所以挂起状态也分两种:
| 状态 | 代码和数据 | 档案 | 能直接跑吗 |
|---|---|---|---|
| 就绪挂起态 | 在磁盘上 | 在内存 | 不能,得先换回来 |
| 阻塞挂起态 | 在磁盘上 | 在内存 | 不能,得先换回来 |
挂起的本质是"用时间换空间":把"马上要用"的优先级,让给"内存够不够"。
两个要注意的副作用:
- swap 用久了会卡。 因为换进换出本身就是磁盘 I/O,比内存慢几个数量级。系统如果频繁 swap,你会听到硬盘狂响、鼠标一顿一顿——这叫抖动。
- swap 不建议开太大。 开得太大,系统会"以为自己内存很多",于是不着急回收,一直往磁盘上倒腾;开得小,反而逼它更早做取舍。这是个工程上的权衡,不是越大越好。
4. Linux 里真实看到的五个状态
问:经典模型里的状态是抽象的。ps 里打出来的那串字母,跟那几个圈怎么对应?
答: 对应得上,但 Linux 把模型里的几个圈,切得更细。
4.1 ps 那一列的 STAT,其实只回了五个字母
ps ajx 输出的 STAT 那一列,常见的字母是这几个:
| 字母 | 名字 | 意思 | 什么时候能看到 |
|---|---|---|---|
R | running / runnable | 正在跑,或者在运行队列里等着跑 | 死循环、纯计算型程序 |
S | sleeping | 可中断睡眠(浅睡) | 等键盘、等网络、sleep() |
D | disk sleep | 不可中断睡眠(深睡) | 卡在磁盘 I/O 上 |
T | stopped | 暂停 | Ctrl+Z、gdb 断点 |
Z | zombie | 僵尸 | 子进程退出了,父进程还没回收 |
外加两个不常提的:I(空闲的内核线程)、X(真正死亡,一瞬间的事,基本抓不到)。
注意 R 的名字里藏着一个字:runnable。 它不是"正在运行",而是"可运行"。这一列里同时包含了模型里的"运行态"和"就绪态"——因为对内核来说,能不能跑是一回事,此刻在不在 CPU 上是另一回事,而且这个区别每秒变几百次,没必要单独标。
4.2 S 和 D 为什么要分开
问:都是"在等",为什么还要分可中断和不可中断?
答: 因为能不能被信号打断。
S(可中断睡眠):能收到信号,能被叫醒。你kill一个sleep中的进程,它能立刻响应。日常见到的 90% 都是这个。D(不可中断睡眠):连kill -9都杀不掉。它通常卡在磁盘 I/O 上,正在等硬件把数据交回来。
为什么不让它被杀?因为内核正提心吊胆一个场景:这个进程正在修改某些关键数据结构(比如文件系统里的一致性信息),这时候把它杀掉,数据就烂了。
所以内核规定:卡在 I/O 上的这段时间,不给你处理信号的机会。 等 I/O 回来,或者内核自己处理完,它自然会醒。真的遇到 D 卡很久,通常意味着磁盘出问题了——这时候你唯一能做的,往往只有等或者重启。
5. 僵尸与孤儿:跑到边界的两个必然产物
问:进程结束了,是不是就彻底没了?
答: 不是。这里有两个坑,都是这套模型的必然产物,不是 bug。
5.1 进程退出时,留下的东西比你想的多
一个进程跑完 return 0,它要交代的事还没完。
第一件事:退出信息是什么?
两种来源:main 的返回值,或者它收到的信号。
exit(0); /* 正常退出,退出码 0 */
exit(1); /* 正常退出,但表示出错 */
/* 或者被信号杀掉,退出信息里记的是那个信号 */
第二件事:这些信息存在哪?
存它自己的 task_struct 里——有专门的字段记退出状态和退出码。
第三件事:为什么要留着?
因为父进程要知道"我交给你的活干没干成"。你派出去一个人办事,他回来总得给你个结果。进程退出也是一样:退出信息就是它给父进程的最后一份交代。
5.2 僵尸:一份没人签收的快递
问:那如果父进程一直不来看这份交代呢?
答: 那就变成了僵尸(zombie)。
僵尸的本质,一句话:
代码和数据都释放了,只有那份
task_struct还赖在内存里。
为什么不能直接销毁?因为退出信息就在里面,父进程还没读,销毁了就没法交代了。所以内核只能先留着——它已经死了,但它的档案还没被签收。
僵尸有什么危害?
- 占内存:每个僵尸占一份
task_struct,数据结构本身就要占地方。 - 占 PID:PID 号是被占着的,不能复用。
- 杀不掉:
kill -9也没用,因为它已经死了,没有"再死一次"这回事。
如果父进程一直不回收,僵尸就一直堆着。数量多了,内存和 PID 号都会被吃掉——这就是一种内存泄漏,只不过漏的是内核里的结构体。
怎么回收? 父进程调用 wait() 或 waitpid():
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status); /* 等任意一个子进程 */
pid_t waitpid(pid_t pid, int *status, int options); /* 等指定的那个 */
这就是为什么"子进程必须被回收"是一条硬规矩。 不是习惯问题,是不回收就漏。
5.3 孤儿:系统给的兜底机制
问:反过来。如果父进程先退出了,子进程还活着,那谁来回收它?
答: 这就是孤儿(orphan)。
父进程先退出,子进程就成了孤儿。这时候内核会做一件事:
把孤儿挂到 1 号进程(
init或systemd)名下,由它当养父。
于是这个孩子将来退出时,是 1 号进程来回收它。
为什么必须有这个领养机制? 因为如果没有,这个进程退出后会永远变成僵尸——没人有资格回收它,那份 task_struct 就永远赖着。孤儿被领养,本质上是给"没有父进程"这种情况补上一个兜底的回收者。
所以孤儿不是坏事。它恰恰说明系统考虑到了边界情况。
5.4 一张表分清楚
| 僵尸(Z) | 孤儿 | |
|---|---|---|
| 谁先退出 | 子进程先退 | 父进程先退 |
| 还活着吗 | 已经死了,只剩档案 | 活着,正常跑 |
| 代码和数据 | 已释放 | 都在 |
| 谁负责处理 | 父进程(wait) | 1 号进程领养后负责 |
| 是不是问题 | 是,不回收就泄漏 | 不是,是兜底机制 |
记法:僵尸是"死了没人埋",孤儿是"活着没人管"。
6. 优先级:CPU 只有一个,要跑的有很多
问:就绪队列里有十个进程,CPU 先跑哪个?
6.1 优先级回答的是"先后",不是"能不能"
答: 这里最容易混的是优先级和权限的区别,先把它掐掉:
- 权限回答的是"能不能做"——你被允许访问这个文件吗?
- 优先级回答的是"先做哪个"——你已经有资格了,但前面还有别人。
所以优先级的前提是"资源不够分":CPU 只有一个,进程有几百个,必然要排队。
优先级 = 进程获得某种资源的先后顺序。
6.2 nice 是修正值,PRI 才是最终结果
ps -la 能同时看到两个数:
$ ps -la
F S UID PID PPID C PRI NI ... CMD
1 S 1000 11508 1 0 80 0 ... processTest
PRI:最终优先级,越小越优先;NI:nice 值,你可以调的修正量。
它们的关系就一句话:
PRI(新) = PRI(老) + nice
nice 这个名字特别传神:它衡量的是你"有多客气"——数值越大,你越谦让,越愿意让别人先跑,所以你的优先级越低。
nice 的可调范围是 -20 到 19,一共 40 个刻度。对应过去,PRI 的范围大约是 60 到 99。
改优先后,ps 里能直接看到变化:
$ ps -al
... PRI NI ... CMD
... 80 0 ... processTest # 默认
$ nice -n 19 ./processTest # 变得很客气
... PRI NI ... CMD
... 99 19 ... processTest
$ sudo renice -n -20 -p 12223 # 变得很霸道
... PRI NI ... CMD
... 60 -20 ... processTest
程序里对应的系统调用是:
#include <sys/time.h>
#include <sys/resource.h>
int getpriority(int which, int who);
int setpriority(int which, int who, int prio);
6.3 为什么范围卡死在 -20 到 19
问:为什么不能随便设?我搞个 1000 不行吗?
答: 两个理由,一个关于公平,一个关于稳定。
理由一:不然穷的会饿死。
如果能随便设到很夸张的值,那就变成"谁不要脸谁先跑"。一个进程把自己设成极端优先,其他进程永远轮不到——这叫饥饿。范围有限,就是在物理上限制"能不能把人饿死"。
而且注意:只有 root 能把 nice 调成负数(变得更优先)。普通人只能让自己变得更客气(0 到 19),不能让自己变得更霸道。这是一个很聪明的设计——你可以自愿吃亏,但不能强制占便宜。
理由二:分时系统要的是"大家都能推进"。
普通操作系统(分时系统)的调度目标,不是"让某一个任务最快完成",而是"在一段时间内,让所有进程都能往前挪一点"。这叫相对公平。
而实时操作系统的目标完全不同:它要的是"必须在规定时间内完成",所以优先级范围可能拉得很开,甚至允许极端抢占。不同的系统,优先级策略不一样,因为它要服务的需求不一样。
7. 上下文切换:寄存器只有一套,上下文却有很多份
问:最后一步。既然只有一个 CPU,那多个进程是怎么"同时"跑的?
7.1 切换发生的四个时刻
答: 先看切换在什么时候发生。常见的四个:
- 时间片用完。 现代计算机都是分时系统,每个进程分到一个时间片(本质是个计数器)。时间片一到,时钟中断把控制权交给内核,内核把它换下来。
- 进程自己阻塞了。 比如调了
scanf、read卡住了,它主动让出 CPU。 - 被更高优先级的进程抢占。 来了个更急的,你先让让,借过一下。
- 进程退出了。 活儿干完了,位置腾出来。
注意第 1 条里那个反直觉的点:一个进程没执行完,就可能把 CPU 让出去。
这是分时系统最关键的一步——它不是等程序跑完才切换,而是按时间切。所以你会看到"一个进程还没算完,CPU 已经去干别的了"。
7.2 保存的到底是什么
问:把 A 换下来、把 B 换上去,内核到底存了什么、取了什么?
答: 存的是CPU 寄存器里的全部内容。
为什么是"全部"?因为 CPU 的执行现场,就全在寄存器里:
- 程序计数器 PC:下一条指令在哪。这个最关键,它是"我跑到哪儿了"的唯一记录;
- 通用寄存器:正在算的那些中间结果;
- 栈指针 SP:函数调用栈在哪个位置;
- 状态寄存器:标志位、条件码。
这些一起构成了这个进程的"现场"。全存下来,下次才能一模一样地接着跑。
存在哪? 存在任务自己的栈里,或者它的 PCB 里(老版本 Linux 用 TSS 结构专门存这个)。取回来的时候再逆着来一遍——从栈里把这些值重新装回 CPU 寄存器,然后从 PC 指向的地方继续执行。
这个过程,就叫 context switch(上下文切换),也叫任务切换、CPU 寄存器切换。
7.3 这是整章最该想通的一层:硬件只有一套,软件可以有很多份
问:上面说的"存下来再取回来",到底在物理上是怎么可能的?
答: 关键在于一个事实:
CPU 里的寄存器硬件,只有一套。但上下文,可以有很多份。
想清楚这张图,你就明白了:
- 寄存器是硬件,全世界就一套,大家分着用;
- 上下文是数据,可以复制、可以存很多份,每个进程一份。
所以"寄存器是共享的,但寄存器里的数据是进程私有的"。
这句话如果在面试里答出来,基本就到位了。因为很多人会含含糊糊地说"切换就是把寄存器存起来",但说不出为什么可以存、存在哪、凭什么每个进程都有自己的。
7.4 并发是怎么被"骗"出来的
把 7.1 到 7.3 串起来,就能解释一个日常现象:你的电脑只有一个(或者四个)核,为什么能同时开几十个程序?
答案:它其实没在同时跑,它在飞快地轮流跑。
- 同一时刻,一个 CPU 上只有一个进程在真正执行;
- 但因为切换得太快(每秒成百上千次),加上每个进程都能"记住自己跑到哪了",从人的角度看,它们就是同时在跑。
这就引出一对必须分清的概念:
| 并行(parallel) | 并发(concurrent) | |
|---|---|---|
| 硬件条件 | 多个 CPU 核 | 一个 CPU 核 |
| 怎么做到的 | 真的同时执行 | 快速切换,轮流推进 |
| 一个比方 | 两台咖啡机,两条队伍 | 一台咖啡机,两条队伍 |
| 结果 | 真同时 | 看起来同时 |
用咖啡机这个比方最省事:
- 并发(单个CPU核) = 两条队伍,一台咖啡机。队伍换着来,做一杯换一队,看起来都在推进。
- 并行(多个CPU核) = 两条队伍,两台咖啡机。真的各做各的。
所以"多进程"不等于"并行"。 单核机器上的多进程,是并发;只有多核,才有真正的并行。
7.5 切换不是免费的
问:既然切换这么万能,那把时间片调得极小,不就人人都像在同时跑吗?
答: 不行。切换本身有成本,而且成本不低。
代价至少三块:
- 保存和恢复寄存器:把一堆值写进内存再读回来;
- 切换页表:换了进程就换了地址空间,MMU 的映射表得换,这一步很贵;
- 缓存失效:刚换上来,CPU 缓存和 TLB 里全是上一个进程的数据,一开始几乎全不命中。
所以时间片不能太小:切得太勤,CPU 大部分时间都在做切换,而不是干活。 反过来,时间片太大,交互就会变卡——你敲个字得等半秒。
这就是为什么"调度"本身是一门专门的学问。
8. 面试速记卡
考前扫这一张就够。
| 问题 | 一句话答案 |
|---|---|
| PCB 是什么? | 进程控制块,进程属性的集合;Linux 里是 task_struct |
| 进程 = 什么? | 内核数据结构(PCB)+ 自己的代码和数据 |
| 为什么 PCB 不能放用户空间? | 档案若能被子进程自己改,管理就不成立 |
| 进程从哪来? | Linux 里基本都由父进程 fork 出来,构成一棵树 |
| fork 的返回值? | 父进程得到子 PID(>0),子进程得到 0,失败 -1 |
| fork 为什么能"返回两次"? | 不是函数返回两次,是一份代码被两个执行流各跑了一遍,各执行一次 return |
| fork 后父子共享什么? | 代码和数据默认共享,靠写时拷贝在写时才分家 |
| 写时拷贝怎么实现? | 页表指向同一物理页并标只读,写触发异常,内核再复制那一页 |
| 为什么父子里同一个地址值不同? | 地址是虚拟地址,父子页表不同 |
| 什么叫阻塞? | task_struct 被从运行队列挪到了某个设备的等待队列上 |
| 状态的本质是什么? | 看你的 task_struct 在谁的队列里 |
| "就绪"和"运行"区别? | 就绪是有资格,运行是在 CPU 上 |
| 挂起换出去的是什么? | 只换代码和数据,task_struct 必须留在内存 |
| Linux 的 R/S/D/T/Z? | 运行或就绪 / 可中断睡眠 / 不可中断睡眠 / 停止 / 僵尸 |
D 状态为什么杀不掉? | 卡在 I/O 上,内核不给它处理信号的机会,怕破坏正在改的数据 |
| 僵尸怎么来的? | 子进程先退,退出信息留在自己 task_struct 里,父进程一直不回收 |
| 僵尸的危害? | 占内存、占 PID,且杀不掉 |
| 僵尸怎么没的? | 父进程 wait/waitpid 回收 |
| 孤儿怎么来的? | 父进程先退,子进程被 1 号进程领养 |
| 孤儿有害吗? | 无害,是兜底回收机制 |
| 优先级和权限的区别? | 权限是能不能,优先级是先后顺序 |
| nice 和 PRI 关系? | PRI(新) = PRI(老) + nice,nice 越大越谦让 |
| nice 范围? | -20 ~ 19,40 个刻度;只有 root 能调负值 |
| 上下文切换保存了什么? | CPU 寄存器里的全部内容(PC、通用寄存器、SP、状态寄存器) |
| 为什么寄存器能"一人一份"? | 寄存器是硬件只有一套,上下文是数据可以有很多份 |
| 并行和并发的区别? | 并行要多核,并发是单核快速切换 |
| 切换的成本? | 存读寄存器、换页表、缓存/TLB 失效 |
9. 追问预演
第一问都是热身,第二问才是真考。这里是我预演过的连环追问。
Q1:fork 到底复制了什么?
复制了一份
task_struct(PID 换新、父子关系记上、挪进就绪队列),地址空间先是共享的——父子页表指向同一批物理页,页标记为只读。所以不是"复制一遍内存",而是"复制一份档案,数据先共用,谁写谁分家"。
Q2:那"一个函数返回两次"这个说法,严格吗?
不严格。准确说是:fork 之后内核让父子两个执行流都从 fork 返回处继续跑,两个执行流各自执行了一次 return,各写各的返回值。之所以感觉像"返回两次",是因为我们习惯了一个调用只对应一个返回。真正的机制是:
return的本质是写入,写的是各自的寄存器。
Q3:写时拷贝为什么能省事?有没有代价?
省的是"fork 之后大概率马上 exec"这种情况下的整份内存复制。代价是:写的时候要触发一次缺页异常,内核要介入复制,比直接写慢;而且页表要额外维护一套只读标记。用一次异常的开销,换掉一次大范围的内存复制,是划得来的。
Q4:进程状态到底该记几个?
经典模型是五个核心圈(创建、就绪、运行、阻塞、结束),加挂起变八个。Linux 实际用字母表示:R/S/D/T/Z。记的时候别死记圈数,记转换条件——所有转换的本质都是"档案在队列之间搬家"。
Q5:什么情况下会产生僵尸?必须避免吗?
子进程先退出、父进程一直不
wait就会产生。任何多进程程序都可能遇到,关键是要回收。避免办法就是父进程及时wait/waitpid,或者用信号处理SIGCHLD来收。注意:僵尸杀不掉,只能让父进程收,或者把父进程杀掉让 1 号进程领养。
Q6:孤儿进程有危害吗?
没有,反而是好事。它说明系统有兜底:父进程提前退出,子进程被 1 号进程领养,将来由 1 号进程回收。要是没有这个机制,孤儿退出后就成了永远没人回收的僵尸。
Q7:nice 值为什么是 -20 到 19 这 40 个刻度?
一层是公平:范围有限,防止有人把优先级设到极端,把别人饿死。一层是权限:只有 root 能调负值,普通人只能让自己更谦让,不能让自己更霸道。还有一层是系统目标:分时系统追的是"大家都能推进",所以不需要拉得很开;实时系统追求"必须在期限内完成",策略就完全不同。
Q8:上下文切换为什么贵?
三块:一是保存恢复寄存器,量不大但频繁;二是切换页表,地址空间变了,MMU 的映射要换;三是缓存和 TLB 失效,新进程上来时缓存里全是别人的数据,一开始几乎全不命中。所以时间片不能太小,否则 CPU 光在切换不干活。
Q9:单核机器上开 100 个进程,是并行还是并发?
并发。同一时刻只有一个进程在真正执行,靠快速切换造成"同时"的错觉。要并行,必须多核——而且核数决定了真正同时执行的上限。
Q10:子进程是由父进程fork出来的,那最初的父进程是怎么来的?
最初的父进程叫0好进程,是由内核静态定义的,ppid是0或自己。0 号进程(idle / swapper) 它是整棵进程树的根,但它不是 fork 产生的。它在内核源码里是一个静态定义好的
task_struct(老的叫init_task),编译时就已经存在,内核启动时直接拿它用。0 号进程创建 1 号和 2 号 内核启动到后期,0 号进程会创建出几个内核线程:
1 号(
kernel_init)2 号(
kthreadd,专门负责再创建别的内核线程)从 1 号开始,才全是 fork 之后系统里所有用户进程——你敲的每条命令、每个服务、每个窗口——全部是 1 号进程及其后代 fork 出来的。
所以严格说法是:
fork 描述的是"用户进程如何繁衍",不适用于进程树的顶端。 链条的起点是内核启动代码直接创建的那几个进程(0、1、2 号),它们不通过 fork 产生。
这里有个很自然的呼应:为什么孤儿进程要交给 1 号进程领养?因为 1 号是整棵树的祖宗,只要系统还在跑它就一定活着——它是唯一一个"必然存在、必然有资格回收"的兜底者。
面试如果被追问"那 1 号的爸爸是谁",答: 0 号进程(idle)。而 0 号不是谁生的,是内核静态定义的,它的 ppid 是 0 或者自己。另外空闲时 CPU 就跑 0 号——这就是它叫 idle 的原因。
10. 易错点对照表
这些是我这次复习真实卡过的地方,说错一句就露馅。
| 你可能以为…… | 其实是这样 | 错在哪 |
|---|---|---|
| PCB 是进程的代码 | PCB 是档案,代码和数据是另一半 | 混淆了数据结构与程序本体 |
| fork 复制了整个内存 | 只复制档案,内存靠写时拷贝先共用 | 以为 fork 很贵,其实它故意做便宜 |
| fork 真的返回了两次 | 是两个执行流各执行了一次 return | 描述不严谨,追问就露馅 |
| 子进程拿到的是自己的 PID | 子进程拿 0,父进程才拿到子 PID | 记反了 |
| fork 后父子改同一个变量会互相影响 | 不会,写时各复制一份 | 忽略了 COW |
| 父子打印同一个地址值却不同是 bug | 正常,那是虚拟地址 | 不知道有页表 |
| 阻塞就是程序卡住了 | 是内核主动把档案挪到设备等待队列 | 把现象当机制 |
| 进程状态是贴在进程上的标签 | 状态是它在谁的队列里 | 没抓住实现本质 |
| 挂起时整个进程都被换到磁盘 | task_struct 必须留在内存 | 忘了档案要能被找到 |
D 状态杀不掉是权限不够 | 内核故意不给它处理信号的机会 | 原因在保护数据一致性 |
| 僵尸进程还在运行 | 已经死了,只剩 task_struct | 把"没消失"当成"还活着" |
僵尸可以 kill -9 干掉 | 杀不掉,只能父进程回收 | 对"死透"没有概念 |
| 孤儿进程是系统缺陷 | 是兜底机制,由 1 号进程领养 | 把设计当 bug |
| 优先级高 = 权限大 | 权限是能不能,优先级是先后 | 两个维度混在一起 |
| nice 值越大优先级越高 | nice 越大越谦让,优先级越低 | 记反了 |
| PR 和 NI 是两回事 | PRI = 基础值 + nice | 不知道它们的换算关系 |
| 上下文切换 = 换掉代码 | 换的是寄存器和页表,代码一直在内存里 | 和上篇同一个坑 |
| 寄存器每个进程各有一套 | 硬件只有一套,存下去的数据才有许多份 | 这一条最能区分懂没懂 |
| 时间片越小越流畅 | 太小会切太勤,CPU 光在切换 | 只想到响应快,没想到成本 |
| 单核上多进程就是并行 | 是并发,并行要多核 | 两个词混用 |
11. 下一篇讲什么
这篇把进程的"一生"走完了:
内核用 task_struct 描述它 → 父进程用 fork 生出它 → 它在几条队列之间搬来搬去,于是有了状态 → 它退出后留下档案,于是有了僵尸和孤儿 → CPU 只有一个,所以要用优先级排队 → 排队就要换人,于是有了上下文切换。
但还有一个问题没回答:换人的时候,到底选谁?
就绪队列里躺着几百个进程,内核凭什么在几微秒内挑出下一个?这一挑,如果实现得笨,光选人就能把 CPU 吃光。
下一篇就拆这个:
- 调度队列怎么组织 —— 140 个队列、位图、活跃队列和过期队列;
- 为什么能做到 O(1) —— 选下一个进程的时间,怎么就跟进程数量无关了;
- 这套设计后来为什么被换掉 —— 以及换成了什么。
(两篇的切点很干净:上篇讲"为什么要有进程",这篇讲"进程怎么活"。)
本文是复习笔记的下篇。上一篇《从冯·诺依曼到进程:程序为什么必须先住进内存》;调度队列另有单独一篇《调度:内核怎么在几微秒内挑出下一个进程》。
我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=2zsrerwvd1d
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/lemo_n_/article/details/168558980




