为了更深入的理解进程,我们先对冯诺依曼体系结构加深一下认识
冯诺依曼体系结构

由于外设的运行速度通常远慢于 CPU,如果 CPU 直接和外设进行数据交互,计算机的整体效率就会被最慢的外设限制(木桶原理),整体效率很低。
冯・诺依曼体系结构中,存储器是核心部件之一,用来存放运行中的程序和数据。外设的数据传输由 CPU 控制,输入输出的数据都会先经过内存中转:外设输入的数据先送入内存,再交给 CPU 处理;CPU 要输出的数据也先写入内存,再送到外设。通过内存的中转,高速的 CPU 不用再等待慢速的外设,效率被解放出来,也让计算机能以较低的成本获得不错的性能,得以普及到大众生活。
补充:现代计算机中,CPU 和外设之间也可以通过设备控制器、I/O 接口等模块交换数据,具体方式取决于系统设计,这些设计的目的同样是提升效率。
输入、输出是相对于计算机主机(内存 / CPU)而言的:我们的程序平时存放在磁盘中,运行时会被加载到内存里,由 CPU 读取指令、执行指令。数据从外设进入主机就是输入,从主机送到外设就是输出。另外我们常说的 "效率、速度",大多指数据拷贝和传输的速度;而计算能力,核心取决于 CPU 中运算器的工作效率。
举个例子:我们用微信聊天时,发送的消息会先到内存中,再输出到网卡发送出去;同时消息也会输出到显示器上显示出来,所以显示器属于输出设备。对方的网卡收到消息后,数据先输入到内存,再输出到对方的显示器上显示。
存储器层次结构
注意:下图中的存储器指的是内存(主存),不是磁盘。磁盘因为速度慢,属于输入 / 输出设备。

存储器从上到下(距离 CPU 由近到远),通常容量越来越大、访问速度越来越慢、单位容量的成本越来越低。主存之上(更靠近 CPU)是寄存器和缓存,它们都在 CPU 内部。
做这么多层的划分,核心目的是用更低的成本获得更高的整体性能:离 CPU 越近的存储,速度越快、成本越高,容量就做小一点;离 CPU 越远的存储,速度越慢、成本越低,容量就做大一点。通过层次搭配,让计算机在可接受的成本下获得很高的运行效率。
操作系统
并不是所有计算机系统都有操作系统,但通用计算机都会有一套基础的程序集合,这个集合就是操作系统(比如 Linux、Windows、鸿蒙等)。通俗来说,没有操作系统的计算机就只是一堆硬件,没法直接方便地使用;我们买电脑时自带的系统软件,就是操作系统。
上面是通俗的理解,更准确的定义是:操作系统是一款管理软硬件资源的软件。程序员口中常说的 "操作系统",一般指狭义上的操作系统内核。

操作系统的意义可以分成两层:对下,管理和调度软硬件资源,方便我们和硬件交互(这是手段);对上,给用户提供简单易用的使用环境(这是目的)。
我学习操作系统时,会贯彻 "先描述,再组织" 的基本思路: 操作系统不需要知道底层硬件的具体电路细节,只需要拿到硬件的信息、把硬件 "描述" 清楚,就能实现 "组织" 管理。但硬件种类繁多,具体操作各不相同,所以中间需要引入驱动程序,由驱动来对接具体硬件,给操作系统提供统一的描述信息。
简单来说:操作系统是管理者,硬件是执行者。操作系统用结构体来存储硬件的描述信息,再把这些结构体通过链表、数组等数据结构组织起来,就像一张管理清单,方便遍历和调度。管理的本质就是对这些数据结构做增、删、查、改,这也是数据结构在操作系统中的核心意义 ------ 操作系统里充斥着大量数据结构,用来实现对软硬件资源的高效管理。

系统调用接口
系统调用接口的意义在于:用户程序不能直接、随意地操作操作系统内核,否则很容易误操作导致系统崩溃。用户只需要在上层通过封装好的系统调用接口使用系统功能,就能满足需求,同时保障系统稳定。
我们平时好像没直接接触过系统调用,但其实很多 C/C++ 标准库函数,底层就是对系统调用的封装;我们日常使用的所有软件,本质上都在通过系统调用接口使用操作系统的能力。
系统调用本身功能比较基础,直接使用对开发者要求较高。所以开发者会对部分系统调用做进一步封装,形成各种函数库。有了库之后,更上层的用户和开发者就能更方便地进行二次开发。
进程
通俗来说,进程就是运行起来的、加载在内存中的程序。操作系统管理软件资源,核心就是管理进程。
结合 "先描述,再组织" 的思路,进程更准确的定义是:描述进程信息的结构体(PCB) + 进程对应的程序和数据。静态的程序就是存放在磁盘上的代码和数据的组合,本质是一个文件(在磁盘特定路径下的文件)。我们在 Linux 里执行命令、启动 APP、运行自己写的程序,本质都是启动一个进程。
题外话:如果同一时间只能有一个程序在内存里运行,必须等它跑完才能跑下一个,那就不需要复杂的进程管理了。现实里我们的电脑都是同时跑很多进程,所以才需要操作系统来管理调度。
进程控制块(PCB)
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
课本上称之为PCB(process control block),Linux操作系统下的PCB是:task_struct,在Linux系统下它会被装载到内存(RAM)内
task_struct内存放了进程的哪些信息呢(先简单了解,后面再深入),如:
标示符:描述本进程的唯一标示符,用来区别其他进程(获取方式------系统调用getpid())
状态:进程的状态
优先级:相对于其他进程的优先级
程序计数器:程序中即将被执行的下一条指令的地址。(CPU 里有专门记录执行位置的寄存器 ------ 指令指针寄存器 EIP,它就是硬件层面的程序计数器。当进程被暂停、切换去执行其他进程时,会把当前 EIP 的值拷贝保存到 PCB 的程序计数器字段里;等下次恢复执行这个进程时,再把这个值写回 EIP,就能从上次暂停的位置继续执行。进程不是一口气跑完的,中间会被不断切换暂停,我们先有这个概念)
内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
上下文数据:保存进程执行时CPU的寄存器中的数据(进程执行时,CPU 所有寄存器里的数据都会被保存在这里。因为进程切换时,寄存器会被新的进程占用,如果不保存原来的寄存器数据,恢复运行时就会出错。寄存器就是给程序运行时保存临时数据的,数据丢失会导致程序运行异常)。
I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
其他信息
另外要注意的一点是:Linux经过这么久的发展,它的内核代码已经更新了比较多,但它的整体逻辑是大体不变,新版本的Linux中的task_struct虽然我们看不到这些信息,但是这些信息一定可以通过task_struct对应的部分找到
进程理解
Linux 系统中,新进程基本都由父进程创建而来,几乎每个进程都有对应的 ppid(父进程号),可以通过 getppid () 系统调用获取。我们在命令行执行的指令、运行自己编写的代码启动的进程,都是 bash 的子进程,bash 就是 Linux 的命令行解释器。从内核视角看,创建一个进程,本质就是在内核中新增一份 task_struct(进程控制块),并为其分配对应的程序代码与数据空间。
那么如何创建进程呢? 我们可以通过 fork () 系统调用来创建子进程。fork () 的底层工作机制我们后面再深入,先从使用层面讲:调用 fork () 就可以创建出一个子进程。

fork 之后父子进程的代码段是只读共享的。从程序输出可以看到,fork () 确实创建出了子进程,且它的父进程就是调用 fork 的当前进程。
创建进程肯定是为了让它完成指定的工作,否则没有创建的必要。但 fork () 创建的子进程会复制父进程的 task_struct,且代码段与父进程共享,那怎么让子进程去执行自己的任务呢?这就要靠 fork () 的返回值来区分。

虽然父子进程初始共享同一段代码,但可以通过 fork 的返回值区分身份:子进程收到的返回值是 0,父进程收到的返回值是子进程的 PID;如果返回负数,代表进程创建失败。
这里我们通常会有三个疑问:
1、为什么给子进程返回 0,给父进程返回子进程的 PID?
因为子进程只有一个父进程,自身 task_struct 里已经记录了 ppid,不需要额外返回;但父进程可能同时管理多个子进程,必须拿到子进程的 PID 才能区分具体是哪一个子进程。 补充说明:子进程的 task_struct 虽然复制自父进程,但内核会修改其中的部分字段,ppid 就是其中之一。
2、一个函数怎么能返回两个值?
fork () 在执行 return 语句之前,就已经完成了进程创建的全部内核工作 ------ 此时系统中已经存在两个独立的进程。接下来父子进程会各自执行 return 语句,因此会得到不同的返回值,本质是两个进程分别执行了同一段代码。
3、接收返回值的同一个变量 n,为什么既等于 0 又等于子进程 PID?
这背后涉及虚拟地址空间与写时拷贝(Copy-On-Write)机制,我们后面再详细展开。
proc目录
Linux 会把所有运行中进程的信息暴露在 /proc 目录下,每个进程对应一个以 PID 命名的子目录,里面包含了该进程 task_struct 的全部可读信息。
补充:每个进程启动后都有自己的当前工作目录(cwd),就记录在 /proc/pid/cwd 中。比如我们在 C 语言中写 FILE* pf = fopen("data.txt", "w");,如果不指定路径,默认就会在进程的当前工作目录下创建文件。通过 chdir () 系统调用可以修改进程的工作目录,之后再执行刚才的文件操作,就会切换到修改后的工作路径下创建 data.txt。
进程状态

进程处在 CPU 的调度队列中时(无论当前是否正在 CPU 上执行),就是运行状态;如果进程排在硬件设备(比如键盘)的等待队列中,就是阻塞状态。 CPU 有调度队列很好理解,操作系统为了管理进程,把进程组织在调度队列里方便调度。那硬件的队列是什么?我们之前提过,操作系统的核心逻辑是 "先描述,再管理":用结构体描述每一个硬件设备,再通过链表、队列等数据结构把等待该设备的进程组织起来,就像一张管理清单,方便遍历和调度。 举个例子:C 语言程序执行到 scanf 时,需要等待键盘输入,这时操作系统会把这个进程从 CPU 调度队列,移到键盘设备结构体对应的等待队列中。这类等待队列可以简单理解为链表,设备结构体里存放链表的头节点。
因此,判断一个进程是运行态还是阻塞态,核心就看它排在哪个资源的队列里。换个角度理解:硬件是资源,本质上 CPU 也是一种资源,二者都提供对应的队列,供进程排队使用。
补充:如果键盘输入准备就绪,是进程先知道还是操作系统先知道?答案是操作系统。操作系统是所有硬件资源的管理者,硬件中断先由内核接收,再由内核调度对应进程。
接下来说挂起状态。挂起从字面上看就是进程被 "挂起来",本质是退位让贤:把进程的内存数据置换到磁盘的 swap 分区,腾出内存空间给其他更紧急的进程。
从这里也能看出来,是系统物理内存不足时,才需要把部分进程换出到磁盘。swap 分区存在的本质就是 "用时间换空间"。 如果进程从阻塞态进入挂起,就是阻塞挂起;如果从运行态进入挂起,就是运行挂起。不难理解,运行挂起对系统的影响更大:连本该运行的进程都要换出内存,整体执行效率会下降,用户直观感受就是电脑变卡,属于更严重的情况。如果内存压力进一步加剧,Linux 操作系统会触发 OOM 机制,选择性地杀死进程来释放内存。
上述是通用的进程状态,也就是所有操作系统提炼出来的,下面是Linux操作系统特有的进程状态
R运行状态(running):不代表进程一定正在 CPU 上执行,只说明进程在可运行队列中,要么正在运行,要么等待调度。
S睡眠状态(sleeping):也可以叫可中断睡眠/浅度睡眠状态,进程在等待某一事件完成,可以被信号唤醒。
D磁盘休眠状态(Disksleep):也可以叫不可中断睡眠状态/深度睡眠状态,处于该状态的进程通常在等待 IO 操作完成(比如磁盘写入)。这是 Linux 特有的状态,进程不能被常规信号中断,强制终止可能造成数据损坏。
T停止状态(stopped):向进程发送 SIGSTOP 信号可使其暂停,发送 SIGCONT 信号可恢复运行。
t追踪停止状态(tracing stop):调试断点触发的临时状态,日常场景很少接触。
Z僵尸状态(zombie):进程代码已经执行完毕,进程所占用的内存(代码、栈、堆、全局变量)已经全部释放,但 task_struct 还保留在内核中,用来存放退出状态码,等待父进程读取。相当于进程彻底销毁前的中间状态,是 "临死前留下的最后信息"。
X死亡状态(dead):进程资源被完全释放后的最终状态,只是一个瞬时状态,不会在进程列表中显示。
加深理解:
1、进程退出(进入僵尸状态)时,退出信息是什么?
比如 C 语言 main 函数结尾的 return 0;,其中的数值就是退出码:0 代表正常退出,非 0 代表异常退出,这个值会作为退出信息被内核保存下来。
2、进程退出后,退出信息保存在哪里?
答案其实已经很明确了,进程退出后用户空间都被释放了,只有 task_struct 还保留在内核,退出信息自然就存在 task_struct 里。
3、检测并回收僵尸进程,本质是在做什么?
本质是父进程通过系统调用读取子进程 PCB(task_struct)中的退出信息,然后通知内核彻底释放子进程的task_struct。
4、具体怎么回收?由谁回收?(先简单了解,后面深入)
由父进程通过 wait 系列系统调用来回收。
如果子进程进入僵尸态后,父进程一直不回收,它的 task_struct 就会一直占用内核内存,造成内核内存泄漏。
关于内存泄漏的危害要分两种情况看: 如果是短期运行的进程,哪怕有内存泄漏也没太大影响 ------ 比如我们初学者写的小程序,就算代码里忘了释放内存,只要程序一退出,操作系统会把整个进程的用户空间全部回收,不会遗留系统级的内存问题。 但对于日常长期运行的软件(如我们不主动关闭某些软件,它们不会退出)、互联网公司的服务器程序来说,进程会持续运行很久不会退出除非手动结束,这时如果出现内存泄漏,就会越积越多,最终可能耗尽系统资源导致崩溃。
最后补充孤儿进程的概念。和现实中的含义类似:如果父进程比子进程先退出,子进程就成了孤儿进程,它的退出信息就没有原本的父进程来回收。
为了解决这个问题,Linux 会将孤儿进程交由 1 号进程(init /systemd)领养,由 1 号进程负责后续回收,不会一直占用内核资源。 另外补充一个细节:如果孤儿进程原本是前台进程,父进程退出后它会自动变为后台进程。
什么是前台进程,什么是后台进程? 其实我们日常用电脑、手机时一直在接触前后台进程。正在和用户交互的软件对应的进程就是前台进程;不直接交互、在后台静默运行的就是后台进程。
举个例子:打开音乐软件选歌时,音乐软件就是前台进程;把它切到后台播放,去做其他事情时,它就变成了后台进程。 简单区分:谁能获取键盘、触屏等输入设备的交互权限,谁就是前台进程。同一时刻前台进程只能有一个,后台进程可以有多个,这样的设计非常符合交互逻辑。
进程优先级
搞懂这三个问题,我们就清楚进程优先级了。
1、什么是优先级?优先级与权限的关系?
权限是能与不能的问题,那么优先级就是在能的基础上先后的问题
2、为什么要有优先级?
优先级的本质是资源稀缺。如果资源无限充足,所有进程可以同时全速运行,就不需要优先级,大家平等享有即可。正因为系统资源(硬件、软件资源)有限,为了更合理地分配资源、更好地满足用户需求,才设计了进程优先级。
3、Linux是如何设计优先级的?
这个问题相对复杂,我们逐步说。Linux 中进程的优先级由 PRI(基础优先级)和 NI(nice 值)共同计算得出,数值越小,优先级越高。公式:PRI(new) = PRI(old) + NI
Linux 中修改进程优先级,本质就是修改 nice 值。修改 nice 值的命令有多个,需要时查阅即可。
那 nice 值可以随便改吗?答案是不可以。 nice 值的范围是 [-20, 19),普通进程的基础 PRI 默认约为 80,因此普通进程的最终优先级范围是 [60, 99)。 为什么要限制优先级范围?因为 Linux 是分时操作系统(也可以配置为实时系统,但分时系统更符合通用桌面 / 服务器需求),通过时间片调度保证相对公平,让不同进程都能获得一定的 CPU 资源。 补充一个细节:Linux 不允许高频修改 nice 值,避免频繁调整优先级打乱调度秩序。
优先级相关的概念:竞争、独立、并行和并发
竞争:是优先级出现的根源。系统中进程数量很多,CPU 资源却很有限,进程之间天然存在竞争。为了更合理地分配资源、高效完成任务,才设计了优先级机制。
独立:进程之间地址空间相互隔离,一个进程崩溃不会直接导致其他进程崩溃,保持相对独立性。
并行:多个进程在多个 CPU 核心上同时执行,是真正意义上的 "同时运行"。
并发:单个 CPU 核心通过快速切换进程,让多个任务在一段时间内都得到推进,宏观上看起来像同时运行。
进程切换
讲进程切换之前,先回顾一下进程上下文的概念。 CPU 的寄存器只有一套。当发生进程切换时,当前进程要占用寄存器,上一个进程的寄存器数据就必须被换走。如果上一个进程还没执行完,寄存器里的数据还有用,就必须先保存下来。 实际的设计是:每个进程切换出 CPU 时,都会把寄存器里的上下文数据保存到自己的 task_struct 中;下次被调度上 CPU 时,再把上下文从 PCB 恢复到寄存器里,这样就能接着上次的位置继续执行。
为什么不让一个进程执行完再跑下一个,非要切换? 现代计算机都是分时操作系统,每个进程分配一个时间片(本质是一个计数器)。时间片用完,操作系统就会把当前进程换下来,调度下一个进程。 这样设计是为了支持多任务并发。用户使用电脑时通常会同时运行多个程序,比如边打游戏边听歌,只有通过进程切换,才能让所有任务都看起来在 "同时运行",保证交互体验。
再补充两个前置知识:1、对数组、结构体这类连续存储多个数据的类型取地址时,得到的地址都是其首元素的地址(也就是整个结构体 / 数组所占连续内存空间中地址最低的那一个);2、如果已知结构体中某个元素的地址,同时也知道该结构体的类型,就可以反推出整个结构体的起始地址,原理如下图所示。

注:将地址 0 强制转换为该结构体指针类型,再取对应成员的地址,得到的数值就是该成员的偏移量,这也是 Linux 内核中 container_of 宏的核心原理。
讲到这里,我们就能把之前提到的「先描述,再组织」的内核设计思路讲清楚了。 我们已经知道,内核用 task_struct 来描述每个进程的全部信息,那这些进程控制块是怎么组织管理的呢?答案是通过双向链表串联起来,但和我们数据结构课上学的普通双向链表设计不太一样,如下图所示。

在 task_struct 内部嵌入一个链表节点结构体,通过这个节点把所有进程串联成链表。拿到链表节点的地址后,就可以通过偏移量反推出整个 task_struct 的起始地址,进而访问其中的所有成员,这就是用嵌入节点方式组织结构体的核心逻辑。 这种设计的好处是扩展性强:链表管理的代码只需要实现一套,就可以复用到不同的结构体上。站在操作系统的视角,同一个进程的 task_struct 可以同时挂载在多个不同的链表中 ------ 只需要在结构体里定义多个链表节点即可。比如一个进程可以同时存在于全进程链表、运行队列、等待队列中。甚至不同类型的结构体,也可以通过同样的链表节点结构挂载到同一条链表上;后续如果想把链表替换成红黑树、哈希表等其他结构,改造起来也很方便。
有了上述知识,我们就可以进一步学习进程切换的底层实现了。

Linux 的运行队列核心是一个大小为 140 的 queue 数组,数组的每个元素对应一个优先级的进程链表头,里面挂载的就是上面说的进程链表节点。
这 140 个优先级分为两部分:0~99 对应实时进程优先级(暂不关心),100~139 对应分时进程的 40 个普通优先级。普通进程的 PRI 范围是 60, 99,和数组下标一一对应:下标 = PRI + 40。相同优先级的进程会通过链表节点依次挂载到对应下标的链表中,这种按优先级分桶的设计思路和哈希表非常类似,进程增删查操作的时间复杂度都是 O (1)。
调度器要选出下一个运行的进程时,只需要按优先级从高到低遍历数组,找到第一个非空的链表,取出队首进程运行即可。最坏情况下也只需要遍历 40 个优先级,对计算机来说开销极低。
Linux 还在此基础上做了进一步优化:引入了 bitmap 位图数组,用每一个比特位标记对应优先级的链表是否非空。当某个优先级有就绪进程时,就把 bitmap 中对应的比特位置 1。这样一来,查找最高优先级的操作就从数组遍历转化成了位图运算,效率更高。
另外还有一个 nr_active 计数器,用来标记当前队列中就绪进程的总数。如果 nr_active 为 0,说明当前没有需要调度的进程,调度器就可以直接进入空闲流程,无需再做遍历计算。
以上两优化操作本质上都是用空间换时间,以此降低调度开销。
但是还有一个问题,如果持续有高优先级进程进入队列,低优先级进程就会一直得不到调度,最终产生「进程饥饿」问题。
为了解决这个问题,Linux 的运行队列设计了两组优先级数组:一组叫活跃队列,存放当前轮次需要调度的进程;另一组叫过期队列,存放已经执行过时间片的进程以及新加入运行队列的进程。也就是说CPU将活跃队列的进程执行完,这样活跃队列就空了(进程都被转移到过期队列),然后将活跃队列和过期队列的指针交换,让原来的过期队列成为新的活跃队列,继续执行活跃队列的进程,这样长期来看不会存在进程饥饿的问题。
接下来我们来欣赏一下Linux是如何处理优先级修改的,如果我们在CPU执行活跃队列的时候修改进程的优先级(修改nice值)呢,如果是未执行的进程,操作系统不会立即修改优先级并将它在活跃队列转移位置,而是等到它按原来的优先级执行完转移至过期队列时,再按新的优先级PRI(new)存放。如果是已经处于过期队列的进程修改优先级,内核则会直接调整它在过期队列中的挂载位置,按照新的优先级重新归类。
这样设计既保证了当前调度轮次的完整性,又能在下一轮调度中生效新的优先级,兼顾了优先级调整的灵活性和调度器的执行效率,避免了频繁改动活跃队列带来的额外调度开销。
太nice了。