今天要早睡_头像
Linux:进程机制_从PCB到上下文切换_第二篇封面图

Linux:进程机制_从PCB到上下文切换_第二篇

从 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 里几乎所有新进程,都是由父进程创建出来的。整台机器上的进程,构成一棵树。

init / systemd
PID = 1

bash
命令行解释器

./myprocess 父进程

fork 出来的第一个子进程

fork 出来的第二个子进程

你在终端里敲 ./myprocess,发生了什么?

不是"系统直接启动了我的程序",而是:bash 先 fork 出一个子进程,然后这个子进程把自己替换成 ./myprocess。

所以你每次敲命令,背后都先多了一个进程。敲 ls 也是,敲 pwd 也是。

2.2 fork 干的事,是复制一份档案再加一份代码数据

问:fork 这个名字,字面意思是"分叉"。它具体做了什么?

答: 站在内核的角度,就一句话:

系统里多了一个进程 = 内核里多了一个 task_struct,再加上一份对应的代码和数据。

再展开一点:

  1. 内核照着父进程的 task_struct,复制出一份新的;
  2. 新档案里的 PID 换成新的,父子关系记上;
  3. 地址空间先跟父进程共享(下一节讲为什么可以共享);
  4. 把新进程挂进就绪队列,等着被调度。

关键点: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 之后发生了什么?

  1. 内核复制出了子进程,父子的执行位置完全一样——都停在 fork() 这一行刚结束的地方;
  2. 内核让两个进程各自从同一个位置继续往下执行;
  3. 于是它们各自执行了一遍"从 fork 返回"这个动作;
  4. 父进程把它那份写成了子进程的 PID,子进程把它那份写成了 0。

所以准确的说法不是"一个函数返回了两次",而是:

一份代码,被两个执行流各跑了一遍;每个执行流都执行了一次 return,各写各的返回值。

这里还有一层必须点破:两个执行流的"现场"是不同的。

内核返回前,会把各自的寄存器恢复成各自的值。父进程的返回值寄存器里是子 PID,子进程的返回值寄存器里是 0。寄存器只有一套,但内核在切换时给它塞的值不一样。

(记住这句话,第 7 节讲上下文切换时,它就是全部答案。)

2.5 反直觉第三层:父子共享代码,但改数据时互不影响

问:既然地址空间都共享了,那父进程改一个变量的值,子进程不也跟着变吗?

答: 不变。这里就是写时拷贝(copy-on-write,COW)在起作用。

先记住结论:

默认情况下,fork 之后,代码和数据都是父子共享的。

但"共享"分两种:

代码段可以真共享。 因为代码是只读的,谁都不会去改它,两份一样的指令没必要存两份。

数据段只是"看起来"共享。 做法很巧妙:

  1. fork 之后,父子的页表都指向同一批物理页;
  2. 内核把这些页在两边都标成只读;
  3. 谁想写,就会触发一次"只读页被写"的异常;
  4. 内核收到异常,发现这是 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 经典五状态模型,一共八个圈

把上面这个动作抽象一下,就能画出经典的状态图。核心只有五个圈,加上挂起就变成八个:

进入就绪队列

被调度,拿到 CPU

时间片用完

等待某个事件(比如等键盘)

事件完成

结束

挂起(内存不够)

激活

挂起

激活

事件先完成了

创建态

就绪态

运行态

阻塞态

结束态

就绪挂起态

阻塞挂起态

看懂这张图的关键,是别把它当成"进程身上贴了不同的标签"。

这套"经典五状态模型"是什么? 一句话:它是操作系统里通用的教学模型,不是某个内核的真实实现。

它是从各种真实系统里抽出来的共同骨架,目的就是让人一眼看懂"进程会在哪几种处境之间转换"。所以真实内核不会真的去维护八个枚举值——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 那一列,常见的字母是这几个:

字母名字意思什么时候能看到
Rrunning / runnable正在跑,或者在运行队列里等着跑死循环、纯计算型程序
Ssleeping可中断睡眠(浅睡)等键盘、等网络、sleep()
Ddisk sleep不可中断睡眠(深睡)卡在磁盘 I/O 上
Tstopped暂停Ctrl+Z、gdb 断点
Zzombie僵尸子进程退出了,父进程还没回收

外加两个不常提的: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)。

子进程先退出,留好退出信息

父进程调用 wait / waitpid 回收

父进程

子进程

Z 僵尸
代码和数据已释放
task_struct 还留着

彻底消失

僵尸的本质,一句话:

代码和数据都释放了,只有那份 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 切换发生的四个时刻

答: 先看切换在什么时候发生。常见的四个:

  1. 时间片用完。 现代计算机都是分时系统,每个进程分到一个时间片(本质是个计数器)。时间片一到,时钟中断把控制权交给内核,内核把它换下来。
  2. 进程自己阻塞了。 比如调了 scanf、read 卡住了,它主动让出 CPU。
  3. 被更高优先级的进程抢占。 来了个更急的,你先让让,借过一下。
  4. 进程退出了。 活儿干完了,位置腾出来。

注意第 1 条里那个反直觉的点:一个进程没执行完,就可能把 CPU 让出去。

这是分时系统最关键的一步——它不是等程序跑完才切换,而是按时间切。所以你会看到"一个进程还没算完,CPU 已经去干别的了"。

7.2 保存的到底是什么

问:把 A 换下来、把 B 换上去,内核到底存了什么、取了什么?

答: 存的是CPU 寄存器里的全部内容。

为什么是"全部"?因为 CPU 的执行现场,就全在寄存器里:

  • 程序计数器 PC:下一条指令在哪。这个最关键,它是"我跑到哪儿了"的唯一记录;
  • 通用寄存器:正在算的那些中间结果;
  • 栈指针 SP:函数调用栈在哪个位置;
  • 状态寄存器:标志位、条件码。

这些一起构成了这个进程的"现场"。全存下来,下次才能一模一样地接着跑。

存在哪? 存在任务自己的栈里,或者它的 PCB 里(老版本 Linux 用 TSS 结构专门存这个)。取回来的时候再逆着来一遍——从栈里把这些值重新装回 CPU 寄存器,然后从 PC 指向的地方继续执行。

这个过程,就叫 context switch(上下文切换),也叫任务切换、CPU 寄存器切换。

7.3 这是整章最该想通的一层:硬件只有一套,软件可以有很多份

问:上面说的"存下来再取回来",到底在物理上是怎么可能的?

答: 关键在于一个事实:

CPU 里的寄存器硬件,只有一套。但上下文,可以有很多份。

切出:把当前寄存器的值
写进 A 的档案

切入:从 B 的档案
把值读回寄存器

CPU 里的寄存器
(硬件,全世界只有一套)

进程 A 的上下文
(数据,可以存很多份)

进程 B 的上下文
(数据,可以存很多份)

想清楚这张图,你就明白了:

  • 寄存器是硬件,全世界就一套,大家分着用;
  • 上下文是数据,可以复制、可以存很多份,每个进程一份。

所以"寄存器是共享的,但寄存器里的数据是进程私有的"。

这句话如果在面试里答出来,基本就到位了。因为很多人会含含糊糊地说"切换就是把寄存器存起来",但说不出为什么可以存、存在哪、凭什么每个进程都有自己的。

7.4 并发是怎么被"骗"出来的

把 7.1 到 7.3 串起来,就能解释一个日常现象:你的电脑只有一个(或者四个)核,为什么能同时开几十个程序?

答案:它其实没在同时跑,它在飞快地轮流跑。

  • 同一时刻,一个 CPU 上只有一个进程在真正执行;
  • 但因为切换得太快(每秒成百上千次),加上每个进程都能"记住自己跑到哪了",从人的角度看,它们就是同时在跑。

这就引出一对必须分清的概念:

并行(parallel)并发(concurrent)
硬件条件多个 CPU 核一个 CPU 核
怎么做到的真的同时执行快速切换,轮流推进
一个比方两台咖啡机,两条队伍一台咖啡机,两条队伍
结果真同时看起来同时

用咖啡机这个比方最省事:

  • 并发(单个CPU核) = 两条队伍,一台咖啡机。队伍换着来,做一杯换一队,看起来都在推进。
  • 并行(多个CPU核) = 两条队伍,两台咖啡机。真的各做各的。

所以"多进程"不等于"并行"。 单核机器上的多进程,是并发;只有多核,才有真正的并行。

7.5 切换不是免费的

问:既然切换这么万能,那把时间片调得极小,不就人人都像在同时跑吗?

答: 不行。切换本身有成本,而且成本不低。

代价至少三块:

  1. 保存和恢复寄存器:把一堆值写进内存再读回来;
  2. 切换页表:换了进程就换了地址空间,MMU 的映射表得换,这一步很贵;
  3. 缓存失效:刚换上来,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 吃光。

下一篇就拆这个:

  1. 调度队列怎么组织 —— 140 个队列、位图、活跃队列和过期队列;
  2. 为什么能做到 O(1) —— 选下一个进程的时间,怎么就跟进程数量无关了;
  3. 这套设计后来为什么被换掉 —— 以及换成了什么。

(两篇的切点很干净:上篇讲"为什么要有进程",这篇讲"进程怎么活"。)


本文是复习笔记的下篇。上一篇《从冯·诺依曼到进程:程序为什么必须先住进内存》;调度队列另有单独一篇《调度:内核怎么在几微秒内挑出下一个进程》。

我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=2zsrerwvd1d

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/lemo_n_/article/details/168558980

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--