Linux 进程概念

Linux 进程概念
Lichermione进程是 Linux 系统编程的核心概念。后续学习进程控制、信号、线程、进程间通信和网络编程,都建立在进程模型之上。
原笔记中的 20 张技术配图尚未随仓库迁移。为避免线上出现坏图,当前已暂时隐藏图片引用;原文件名保留在源码注释中,补回资源后即可恢复。
本文按照下面的逻辑展开:
- 冯诺依曼体系解释程序为什么必须进入内存;
- 操作系统解释 Linux 如何管理软硬件资源;
- PCB 解释操作系统如何描述和组织进程;
- 进程状态与调度解释多个进程如何共享 CPU;
- 环境变量解释进程运行环境如何传递;
- 虚拟地址空间解释进程独立性与写时拷贝。
1. 冯诺依曼体系结构
1.1 计算机的基本组成
经典冯诺依曼体系主要包含四类部件:
- 输入设备:键盘、鼠标、网卡等;
- 输出设备:显示器、打印机、网卡等;
- 存储器:这里主要指内存;
- 中央处理器:由运算器、控制器和寄存器等部分组成。
磁盘、网卡、键盘和显示器相对于 CPU 与内存都属于外设。其中,磁盘和网卡既可以输入数据,也可以输出数据。
各部件的速度和作用可以简单概括为:
| 部件 | 主要作用 | 速度特征 | 是否掉电丢失 |
|---|---|---|---|
| CPU | 执行指令、处理数据 | 最快 | 寄存器内容会丢失 |
| 内存 | 暂存正在运行的代码和数据 | 较快 | 是 |
| 磁盘等外设 | 长期保存或交换数据 | 相对较慢 | 通常不会 |
CPU 只能执行自身指令集中的机器指令。C/C++ 源代码经过预处理、编译、汇编和链接,最终被转换为 CPU 能够执行的二进制程序。
编译器的重要作用,就是把高级语言翻译成目标平台能够执行的机器指令,并组织为可执行文件。
1.2 为什么需要内存
CPU 的执行速度远高于磁盘、网卡等外设。如果 CPU 每执行一条指令都直接等待慢速外设,整机效率会非常低。因此,正在使用的代码和数据需要先进入内存,再由 CPU 访问。
从学习模型上看,可以把数据流理解为:
1 | 读数据:外设 → 内存 → CPU |
严格来说,现代计算机还包含缓存、设备控制器、DMA、内存映射 I/O 等机制,因此“CPU 只和内存打交道”是一种便于入门的简化表达。它强调的核心是:CPU 执行的指令和主要数据必须处于可被处理器访问的地址空间中。
1.3 程序为什么要加载到内存
写好的程序最初只是磁盘上的可执行文件。程序运行时,操作系统会为它创建进程,并把可执行文件中的代码、数据和所需动态库映射到进程的虚拟地址空间。
这里不一定会一次性把整个程序全部读入物理内存。Linux 通常采用按需分页:进程真正访问某个页面时,内核才把对应内容调入内存。
因此,更准确的表述是:
程序运行时,其代码和数据必须被映射到进程地址空间;真正使用的页面会按需进入物理内存。
1.4 本节小结
- 可执行程序是存放在磁盘上的文件;
- CPU 执行的是机器指令;
- 程序运行前,操作系统必须建立进程及其地址空间;
- 内存承担 CPU 与慢速外设之间的缓冲和运行时存储作用。
2. 操作系统如何管理资源
2.1 操作系统是什么
操作系统是一套管理软硬件资源、向用户和应用程序提供运行环境的系统软件。
它主要承担以下职责:
- 进程管理;
- 内存管理;
- 文件系统管理;
- 设备与驱动管理;
- 网络与安全管理;
- 向上提供系统调用接口。
可以用三个问题理解操作系统:
| 问题 | 回答 |
|---|---|
| 操作系统是什么 | 管理软硬件资源的系统软件 |
| 为什么要管理 | 为应用程序提供稳定、安全、高效的运行环境 |
| 如何管理 | 先描述对象,再使用数据结构组织对象 |
2.2 管理的本质:先描述,后组织
操作系统不会通过“直接观察”管理每一个硬件或进程,而是维护这些对象对应的数据。
管理通常分为两步:
- 先描述:使用结构体或类记录对象属性;
- 后组织:使用链表、队列、树、哈希表等数据结构管理大量对象。
例如,设备可以被抽象为类似下面的数据结构:
1 | struct device { |
操作系统对设备的管理,最终就转化为对这些内核对象的创建、查找、更新和删除。
这个思想同样适用于进程:操作系统先使用 PCB 描述一个进程,再通过各种队列和数据结构组织所有 PCB。
2.3 用户态、内核态与系统调用
为了保护内核和其他进程,普通应用程序不能随意访问内核数据或直接操作硬件。应用程序需要通过系统调用请求内核服务。
1 | 用户 |
常见系统调用包括:
- 进程:
fork、execve、waitpid、exit; - 文件:
open、read、write、close; - 网络:
socket、bind、listen、accept; - 内存:
mmap、brk。
glibc 等 C 库通常会对系统调用进行封装。并不是每个库函数都会进入内核,但凡涉及受保护资源的操作,最终通常需要系统调用参与。
2.4 本节小结
- 操作系统通过管理数据来管理资源;
- 管理方法可以概括为“先描述,后组织”;
- 应用程序通过系统调用请求内核服务;
- 用户态与内核态的隔离提高了系统安全性和稳定性。
3. 进程的基本概念
3.1 程序与进程的区别
程序是磁盘上的静态可执行文件,进程是程序的一次动态执行过程。
一个进程不仅包含代码和数据,还包含操作系统维护的大量运行信息,例如:
- PID 和父进程 PID;
- 进程状态和调度信息;
- 虚拟地址空间;
- 打开的文件描述符;
- 当前工作目录;
- 信号相关信息;
- CPU 执行上下文;
- 用户身份和权限信息。
因此,可以使用下面的关系帮助理解:
1 | 进程 = 内核中的进程描述信息 + 虚拟地址空间中的代码和数据 + 其他运行资源 |
原来常见的“进程 = PCB + 磁盘代码”过于简化,因为程序运行后,代码会被映射进虚拟地址空间,进程还会拥有文件、信号、内核栈等资源。
3.2 PCB 与 task_struct
PCB 是 Process Control Block(进程控制块)的缩写。在 Linux 中,描述任务的核心内核结构是 task_struct。
下面的代码仅用于展示概念,并不是某个具体 Linux 版本中 task_struct 的完整定义:
1 | struct task_struct { |
创建进程时,内核会创建并初始化相应的进程描述对象。调度、状态切换、信号处理和资源回收等操作,本质上都需要更新这些内核数据。
3.3 PID、PPID 与 /proc
常用接口:
1 |
|
常用观察命令:
1 | echo $$ |
**ps -ef **
1 | ps -ef // process status every process full format / full-format listing |
ps aux
1 | ps aux // process status all user-oriented no controlling terminal |
Linux 的 /proc 是一个虚拟文件系统,可以通过 /proc/<PID>/ 查看进程信息:
1 | cat /proc/$$/status |
**/proc 中的内容并不是普通磁盘文件,而是内核按照当前系统状态动态生成的。**
3.4 使用 fork 创建子进程
函数原型:
1 |
|
**fork 调用一次,却会在父子两个进程中分别返回:**
| 执行流 | fork 返回值 |
|---|---|
| 父进程 | 子进程 PID,大于 0 |
| 子进程 | 0 |
| 创建失败 | -1,并设置 errno |
父进程需要获得子进程 PID,方便后续管理和回收;子进程只需要通过返回值 0 判断“自己是子进程”。
fork 之后:
- 父子进程从
fork返回处继续执行; - 父子进程具有独立的执行流;
- 谁先运行由调度器决定,不能依赖输出顺序;
- 初始地址空间内容相同,但逻辑上相互独立;
- Linux 通常使用写时拷贝,避免立刻复制全部物理页面。
3.5 fork 示例
1 |
|
编译并运行:
1 | gcc fork_demo.c -o fork_demo |
调用系统接口时,不仅要知道参数,还要确认成功返回什么、失败返回什么,以及失败时
errno的含义。
4. Linux 进程状态
4.1 状态不是“程序运行到哪一行”
进程状态是 PCB 中的属性,用来描述进程当前是否可运行、在等待什么资源,以及调度器应该如何处理它。
从通用操作系统理论看,经常会出现运行、就绪、阻塞、停止和终止等状态。Linux 对这些状态有自己的具体表示。
4.2 Linux 常见状态
使用下面的命令可以查看状态:
1 | ps -eo pid,ppid,stat,comm |
STAT 列常见字符如下:
| 状态 | 含义 | 说明 |
|---|---|---|
R |
Running / Runnable | 正在 CPU 上运行,或位于运行队列中等待运行 |
S |
Interruptible Sleep | 可中断睡眠,通常在等待定时器、管道、网络等事件 |
D |
Uninterruptible Sleep | 不可中断睡眠,通常在等待某些内核或 I/O 操作完成 |
T |
Stopped | 被作业控制信号停止 |
t |
Tracing Stop | 被调试器跟踪而停止 |
Z |
Zombie | 已退出,但退出状态尚未被父进程回收 |
X |
Dead | 已死亡,通常很难在用户态观察到 |
需要特别注意:
R不等于“此刻一定正在 CPU 上执行”。只要进程已经具备运行条件并处于运行队列,也可以显示为R。
4.3 运行队列与等待队列
可运行任务会进入调度器管理的运行队列。现代 Linux 的具体调度结构比普通链表复杂,但入门时可以先理解为“调度器从可运行任务集合中选择下一个任务”。
阻塞任务并不是统一放入一个全局阻塞队列,而是进入与等待事件相关的等待队列:
1 | 等待键盘输入 → 相关设备或终端的等待队列 |
事件到达后,内核唤醒任务,使它重新具备运行条件。
4.4 R 与 S 状态实验
CPU 密集型循环通常容易表现为 R:
1 | int main(void) |
包含 sleep 的程序大部分时间在等待定时器,通常表现为 S:
1 |
|
观察命令:
1 | watch -n 0.5 'ps -eo pid,ppid,stat,comm | grep process_name' |
4.5 停止与继续
推荐使用信号名称,而不是直接记数字:
1 | kill -STOP <PID> # 停止进程,通常显示 T |
其中,SIGSTOP 和 SIGKILL 不能被进程捕获、阻塞或忽略。SIGTERM 更适合常规退出,因为程序有机会清理资源。
4.6 D 状态
D 表示不可中断睡眠,常见于进程等待某些底层 I/O 或内核操作完成时。
处于 D 状态的进程不会立刻响应普通信号,甚至 SIGKILL 也要等它从不可中断等待中返回后才能生效。这不代表进程永远无法终止,而是内核暂时不能安全地中断当前操作。
如果大量进程长期处于 D 状态,应检查磁盘、网络文件系统、设备驱动或存储故障。
4.7 僵尸进程
子进程退出后,内核需要暂时保存它的退出码和资源使用信息,等待父进程读取。此时子进程已经不再执行,但进程表中仍保留一个条目,这就是僵尸进程。
1 | 子进程退出 |
下面的代码会暂时产生僵尸进程:
1 |
|
在父进程睡眠期间,可以使用下面的命令观察 Z 状态:
1 | ps -o pid,ppid,stat,comm -p <父进程PID>,<子进程PID> |
解决方法是在父进程中调用 wait 或 waitpid。如果父进程长期不回收大量子进程,就可能耗尽 PID 或进程表资源。
4.8 孤儿进程与守护进程
孤儿进程是父进程先退出、子进程仍在运行的进程。它会被 init 进程、systemd 或被设置为 subreaper 的进程接管,后续退出状态由新的父进程回收。
**守护进程(daemon)**是长期在后台提供服务的进程,例如 sshd。守护进程通常会脱离控制终端并建立合适的运行环境,但“孤儿进程”与“守护进程”不是同一个概念。
5. 优先级、并发与进程切换
5.1 进程优先级
CPU 时间是有限资源。当可运行进程多于逻辑 CPU 数量时,调度器需要决定谁先运行以及运行多久。
查看优先级:
1 | ps -eo pid,ppid,stat,pri,ni,comm |
普通进程常见的 nice 值范围是 -20 到 19:
- nice 值越小,进程获得更高调度权重;
- nice 值越大,进程越“谦让”;
- 普通用户通常只能调低自己的调度优先级,即增大 nice 值;
- 修改为更高优先级通常需要相应权限。
常用命令:
1 | nice -n 10 ./program |
PRI 的展示方式会受内核调度类别和工具实现影响,不应把某个固定公式当作所有 Linux 版本和所有调度策略的通用规则。
5.2 进程的四个基本特征
| 概念 | 含义 |
|---|---|
| 竞争性 | 多个进程竞争 CPU、内存、文件和设备等资源 |
| 独立性 | 一个进程通常不能直接读写另一个进程的地址空间 |
| 并发 | 多个任务在一段时间内交替推进 |
| 并行 | 多个任务在同一时刻由不同执行单元真正同时执行 |
单核 CPU 可以通过快速切换实现并发,多核 CPU 可以让多个任务并行执行。
“8 核 16 线程”通常表示 8 个物理核心向系统暴露 16 个逻辑 CPU。超线程能够提高某些负载的吞吐量,但两个逻辑 CPU 会共享同一物理核心的部分资源,不能简单等同于 16 个完整物理核心。
5.3 什么是进程上下文
进程在 CPU 上执行时,会在寄存器和内核数据结构中形成继续执行所必需的状态,例如:
- 程序计数器 PC;
- 栈指针 SP;
- 通用寄存器;
- 状态寄存器;
- 内核栈相关状态;
- 调度和信号相关信息;
- 浮点与 SIMD 寄存器状态。
寄存器硬件由当前 CPU 共享,但寄存器中保存的值属于当前执行任务的上下文。
5.4 进程切换
当时间片耗尽、进程主动阻塞或更高优先级任务被唤醒时,内核可能切换任务:
1 | 保存当前进程上下文 |
进程切换可能涉及:
| 类别 | 可能保存或切换的内容 |
|---|---|
| CPU 上下文 | PC、SP、通用寄存器、标志寄存器 |
| 内核执行状态 | 内核栈和调度相关信息 |
| 地址空间 | 页表或地址空间标识;同进程线程切换时通常不需要更换地址空间 |
| FPU / SIMD | 浮点和向量寄存器 |
| 缓存与 TLB | 可能受到间接影响,具体与架构和地址空间标识机制有关 |
上下文切换本身不完成业务工作,而且可能影响 CPU 缓存和 TLB,因此切换过于频繁会带来性能开销。
6. 环境变量与命令行参数
6.1 环境变量是什么
环境变量是进程环境中的字符串键值对,用来向程序传递配置信息。
常见变量:
| 变量 | 作用 |
|---|---|
PATH |
Shell 查找外部命令时使用的目录列表 |
HOME |
当前用户主目录 |
USER / LOGNAME |
用户名信息 |
PWD |
Shell 维护的当前目录字符串 |
LANG |
语言和字符集设置 |
LD_LIBRARY_PATH |
动态库额外查找路径,使用时需要谨慎 |
查看环境变量:
1 | env |
6.2 Shell 变量与环境变量
在 Bash 中:
1 | name=lic |
这首先创建的是 Shell 变量,只在当前 Shell 中可用。执行:
1 | export name |
才会把它标记为环境变量,使后续创建的子进程能够继承。
也可以一步完成:
1 | export name=lic |
删除变量:
1 | unset name |
环境变量不是“全系统唯一的全局变量”。每个进程都有自己的环境表;子进程通常在创建时获得父进程环境的副本,之后双方修改互不自动同步。
6.3 修改 PATH
临时追加命令目录:
1 | export PATH="$PATH:/home/lic/lesson11" |
Shell 通常根据 PATH 从左到右查找外部命令。排查命令来源时,type 比单纯使用 which 更全面,因为它还能识别别名、函数和内建命令:
1 | type ls |
Shell 启动时读取哪些配置文件,取决于它是登录 Shell、交互式 Shell 还是非交互式 Shell。常见文件包括 /etc/profile、~/.bash_profile、~/.profile 和 ~/.bashrc,不能简单认为每次启动 Bash 都一定读取同一个文件。
6.4 PWD 与当前工作目录
进程的当前工作目录由内核维护。ls 在没有路径参数时,是根据进程的当前工作目录访问文件,并不是依赖 PWD 环境变量才能工作。
PWD 是 Shell 维护的便捷字符串,通常与当前工作目录一致,但它不等同于内核中的目录对象。
6.5 C 语言读取环境变量
1 |
|
修改当前进程环境可以使用 setenv、unsetenv 或 putenv。通常优先考虑语义更清晰的 setenv:
1 | setenv("MY_VALUE", "123", 1); |
6.6 命令行参数
标准 C 的 main 函数可以接收命令行参数:
1 |
|
argc表示参数个数;argv是参数字符串数组;argv[0]通常是程序名或启动程序时使用的路径。
对于复杂选项,应考虑使用 getopt 或 getopt_long,而不是手写大量 strcmp 分支。
6.7 环境变量表的组织
在常见 Unix/Linux 实现中,可以通过 envp 或 environ 遍历环境表:
1 |
|
环境表可以理解为以空指针结尾的字符串指针数组,每个字符串通常采用 NAME=VALUE 形式。
6.8 为什么 cd、export 必须是内建命令
如果 Shell 通过创建子进程执行 cd,被改变的只会是子进程自己的当前工作目录,父 Shell 不会受到影响。因此,cd 必须由当前 Shell 自己执行。
同理,export 和 unset 需要修改当前 Shell 的环境,也必须由 Shell 内部完成。
7. 进程虚拟地址空间
7.1 从一个 fork 实验开始
下面的代码中,父子进程分别修改同一个全局变量:
1 |
|
父子进程通常会打印相同的地址,却得到不同的值。
原因是 C/C++ 程序打印出来的指针通常是虚拟地址。父子进程可以拥有数值相同的虚拟地址,但通过各自的页表映射到不同的物理页面。
7.2 写时拷贝
fork 刚完成时,父子进程的许多虚拟页面可以暂时映射到相同的物理页面,并被标记为只读。
当父进程或子进程尝试写入时,会触发页故障,内核再复制对应页面并更新写入方的页表,这就是 Copy-On-Write(写时拷贝)。
1 | fork 后: |
写时拷贝既保证了进程独立性,又减少了 fork 时不必要的内存复制。
7.3 地址空间的区域划分
典型 Linux 用户进程的虚拟地址空间包含:
1 | 高地址 |
这是一种概念布局。实际地址受体系结构、ELF 类型、PIE、ASLR、动态链接器和内核配置影响。
Linux 使用 mm_struct 等内核结构描述进程地址空间,并使用虚拟内存区域记录各段范围和访问权限。通常使用左闭右开区间 [start, end) 表示地址范围。
需要注意:
malloc可能扩展传统堆,也可能使用mmap;free不保证立即把虚拟地址或物理内存归还给内核;- 栈增长通常由缺页机制和资源限制共同管理;
- 线程通常共享所属进程的地址空间。
7.4 页与页表
虚拟内存以页为基本管理单位。常见页大小是 4 KiB,但系统也可能使用其他基础页或大页。
页表负责建立虚拟页到物理页框的映射,并保存权限信息:
1 | 虚拟地址 |
页表不仅完成地址转换,还可以记录:
- 页面是否存在;
- 是否允许读、写、执行;
- 用户态是否有权访问;
- 页面是否被访问或修改;
- 写时拷贝等机制所需的状态。
7.5 虚拟地址空间的优点
1. 提高安全性
进程不能直接随意访问物理内存。页表权限可以阻止越界、非法写入和用户态访问内核页面。
2. 保证进程独立性
每个进程拥有独立的虚拟地址空间。即使虚拟地址数值相同,也可以映射到不同物理内存。
3. 提供统一的地址视图
编译器、链接器和程序可以按照统一的地址空间模型组织代码和数据,不必直接管理物理内存布局。
4. 支持按需加载和内存映射
程序页面、动态库和文件可以按需映射,未访问的页面不必立刻占用物理内存。
5. 支持共享
不同进程可以把同一个动态库或共享内存映射到各自地址空间,在保证隔离的同时实现高效共享。
7.6 虚拟地址、物理地址与可执行文件地址
- 虚拟地址:进程和 CPU 指令通常使用的地址;
- 物理地址:实际物理内存位置;
- 文件偏移:代码或数据在 ELF 文件中的位置。
链接器会按照 ELF 和目标平台规则组织段与重定位信息。非 PIE 可执行文件通常具有相对固定的链接虚拟地址;PIE 程序可以在加载时结合 ASLR 选择基址。因此,“虚拟地址就是编译阶段已经完全固定的地址”并不适用于所有程序。
常用观察命令:
1 | file ./a.out |
8. 全文总结
8.1 一条主线串联全文
1 | 程序是磁盘上的可执行文件 |
8.2 核心概念速查
| 概念 | 核心结论 |
|---|---|
| 程序 | 磁盘上的静态可执行文件 |
| 进程 | 程序的一次动态执行过程及其全部运行资源 |
| PCB | 操作系统描述和管理进程的数据结构;Linux 核心结构是 task_struct |
fork |
创建子进程;父进程返回子 PID,子进程返回 0 |
R |
正在运行或具备运行条件 |
S |
可中断睡眠,等待事件 |
D |
不可中断睡眠,通常等待底层内核或 I/O 操作 |
Z |
已退出但尚未被父进程回收 |
| 孤儿进程 | 父进程先退出,子进程被其他进程接管 |
| 并发 | 多个任务在一段时间内交替推进 |
| 并行 | 多个任务在同一时刻真正同时执行 |
| 上下文切换 | 保存当前任务状态并恢复另一个任务状态 |
| 环境变量 | 每个进程持有的运行环境键值对,可在创建子进程时继承 |
| 虚拟地址 | 进程看到和使用的地址,由页表映射到物理地址 |
| 写时拷贝 | 父子进程先共享页面,写入时再复制 |
8.3 最容易混淆的五组概念
- 程序与进程:程序是文件,进程是运行中的实例;
- 运行与可运行:
R既可能正在执行,也可能正在运行队列等待; - 僵尸与孤儿:僵尸已经退出但未回收,孤儿仍在运行但父进程先退出;
- 并发与并行:并发强调交替推进,并行强调同一时刻执行;
- 虚拟地址与物理地址:指针通常是虚拟地址,页表负责映射物理内存。
8.4 建议掌握的验证命令
1 | # 进程与父子关系 |
8.5 最终结论
学习 Linux 进程时,不要把知识点孤立记忆。应当始终抓住三个核心问题:
- 操作系统如何描述进程?——使用
task_struct等内核数据结构; - 操作系统如何组织和调度进程?——使用运行队列、等待队列和调度策略;
- 操作系统如何保证进程独立?——使用虚拟地址空间、页表和权限控制。
只要把“PCB、进程状态、调度、环境变量和虚拟地址空间”串成一条完整主线,后续学习进程控制、信号、线程和进程间通信就会更加顺畅。