进程概念与底层
进程概念与底层机制
1. 冯·诺依曼体系结构与操作系统
1.1 冯·诺依曼体系结构
现代计算机大多遵循此体系,核心原则是存储程序。
- 五大部件:
- 输入设备:键盘、鼠标、磁盘(作为输入时)。
- 输出设备:显示器、磁盘(作为输出时)。
- 存储器 (Memory):即内存。CPU只能直接读写内存,外设与CPU的数据交换必须通过内存。
- 运算器:算术与逻辑运算。
- 控制器:指挥各部件协调工作。
- (注:运算器 + 控制器 = 中央处理器 CPU)
1.2 操作系统 (OS)
- 定位:一款纯正的搞管理的软件。
- 核心功能:
- 对下:管理软硬件资源(驱动管理、内存管理、文件管理、进程管理)。
- 对上:为用户/应用程序提供良好的执行环境。
- 管理哲学:
- 描述:用
struct结构体描述被管理对象(如进程)。 - 组织:用链表、红黑树等数据结构将对象组织起来。
- 描述:用
- 系统调用与库函数:
- 系统调用:OS 暴露给上层的接口,功能基础,直接使用难度高。
- 库函数:对系统调用的封装(如
glibc),便于开发者二次开发。
2. 进程 (Process)
2.1 进程的基本概念
- 定义:
- 课本:程序的一次执行实例。
- 内核:担当分配系统资源(CPU时间、内存)的实体。
- 公式:
进程 = 内核数据结构 (PCB) + 程序代码和数据
- 描述进程 - PCB (Process Control Block):
- Linux 中 PCB 的具体实现是
task_struct结构体。 - 包含信息:
- 标识符 (PID):唯一标识。
- 状态:运行、睡眠、退出代码等。
- 优先级:相对于其他进程的优先权。
- 程序计数器:下一条指令的地址。
- 内存指针:指向代码、数据、共享内存的指针。
- 上下文数据:寄存器中的值(用于切换恢复)。
- I/O 状态:打开的文件列表等。
- Linux 中 PCB 的具体实现是
2.2 进程操作
- 查看进程:
/proc目录:查看进程详细信息(如/proc/1)。- 命令:
ps aux(显示所有进程),ps axj(查看进程组/会话ID),top(动态查看)。
- 获取 ID:
getpid():获取当前进程 PID。getppid():获取父进程 PPID。
- 创建进程 -
fork():- 特点:调用一次,返回两次。
- 父进程:返回子进程的 PID (>0)。
- 子进程:返回 0。
- 失败:返回 -1。
- 写时拷贝 (Copy On Write, COW):父子进程代码共享,数据各自开辟空间(私有)。在未修改数据前,物理内存是共享的;一旦修改,系统会复制一份副本给修改者。
- 特点:调用一次,返回两次。
2.3 进程状态 (Linux 内核视角)
| 状态码 | 状态名称 | 描述 |
|---|---|---|
| R | Running | 运行或在运行队列中等待调度。 |
| S | Sleeping | 可中断睡眠,等待事件完成(如信号)。 |
| D | Disk Sleep | 不可中断睡眠,通常在等待 I/O 结束,不能被信号打断。 |
| T | Stopped | 停止状态,收到SIGSTOP信号暂停,SIGCONT继续。 |
| t | Tracing Stop | 调试停止状态(如 gdb 调试时)。 |
| X | Dead | 死亡状态(瞬间状态,不可见)。 |
| Z | Zombie | 僵尸状态,进程退出但父进程未读取其退出码。 |
- 僵尸进程 (Zombie):
- 成因:子进程退出,父进程未调用
wait()读取状态。 - 危害:PCB 结构体仍驻留内存,导致内存泄漏。若大量产生,会耗尽进程表资源。
- 成因:子进程退出,父进程未调用
- 孤儿进程 (Orphan):
- 成因:父进程先于子进程退出。
- 处理:子进程被1号进程 (init/systemd)领养,由 1 号进程负责回收其资源。
2.4 进程优先级
- 概念:CPU 资源分配的先后顺序。
- PRI (Priority):进程被执行的优先级,值越小优先级越高。
- NI (Nice):优先级的修正数值。
- 公式:
PRI(new) = PRI(old) + NI - 范围:-20 到 19。
- 调整:
top命令中按r修改,或使用nice,renice命令。
- 公式:
- 并发性 vs 并行性:
- 并发:多个进程在一个 CPU 上通过切换实现“同时”推进。
- 并行:多个进程在多个 CPU 上同时运行。
2.5 进程切换
- 上下文 (Context):CPU 寄存器中的数据。
- 过程:
- 保存当前进程的上下文(寄存器数据入栈)。
- 恢复下一个进程的上下文(数据出栈到寄存器)。
- 跳转执行。
- Linux 2.6 O(1) 调度算法:
- 使用
runqueue(运行队列) 和prio_array(优先级数组)。 - 利用位图 (Bitmap) 快速查找最高优先级非空队列,确保调度时间复杂度为常数 O(1)。
- 使用
2.6 进程调度队列与流转机制 (O(1) 调度核心)
在 Linux O(1) 调度器中,进程并不是杂乱无章的,而是被严格分类并放入不同的队列中。理解这三个队列及其流转关系,是掌握进程调度精髓的关键:
1. 三大核心队列
- 优先级队列 (Priority Array):
- 本质:一个包含 140 个链表(对应 140 个优先级)的数组。
- 作用:存放所有**处于可运行状态(R)**的进程。只要进程有资格被 CPU 执行,它就一定在这个数组的某个优先级链表中。
- 等待队列 (Wait Queue):
- 本质:双向链表结构。
- 作用:存放所有**处于阻塞/睡眠状态(S/D)**的进程。当进程需要等待 I/O 完成、等待锁或等待信号时,会被移出优先级队列,挂到特定的等待队列上。
- 过期队列 (Expired Array):
- 本质:与优先级队列结构完全相同的数组。
- 作用:存放时间片耗尽的进程。当进程在优先级队列中耗尽了分配的 CPU 时间片,就会被移到过期队列中等待下一轮调度。
2. 队列之间的流转关系(进程状态机)
进程在生命周期中,会在这三个队列之间不断流转:
- 就绪 -> 运行:调度器从优先级队列中取出最高优先级的进程,分配给它 CPU 执行。
- 运行 -> 阻塞:运行中的进程如果发起 I/O 请求或等待事件,会被移出优先级队列,放入等待队列,状态变为 S 或 D。
- 阻塞 -> 就绪:当 I/O 完成或事件触发时,进程被从等待队列唤醒,重新放回优先级队列,等待下一次被调度。
- 运行 -> 过期:如果进程一直在运行,直到时间片用完,它会被移出优先级队列,放入过期队列。
3. Active 与 Expired 的指针交换
- Active 队列:当前正在被调度器使用的优先级队列(存放还有时间片的进程)。
- Expired 队列:存放时间片用完的进程。
- 核心机制:当 Active 队列里的所有进程都用完了时间片(Active 变空),调度器不会去遍历所有进程重新计算优先级。它只需要执行一个极其简单的操作:交换 Active 和 Expired 的指针(Swap)。
- 结果:原本的 Expired 队列瞬间变成了新的 Active 队列。因为进程在放入 Expired 时,内核已经根据它的静态优先级和 Nice 值计算好了它在下一轮的优先级位置。这就保证了无论系统里有多少个进程,调度时间永远是常数O(1)。
2.7 优先级队列的底层划分:实时与分时
在 Linux O(1) 调度器的 140 个优先级队列中,并非所有队列都是平等的。它们被严格划分为两个完全不同的调度体系:
1. 实时优先级队列 (Real-Time Queue)
- 索引范围:
queue[0]到queue[99](共 100 个队列)。 - 优先级特征:数值越小,优先级越高。
- 调度策略:采用
SCHED_FIFO(先进先出)或SCHED_RR(时间片轮转)。 - 绝对霸权:只要这 100 个队列中有任何一个进程处于就绪状态,调度器就会无条件将 CPU 分配给它们。它们可以瞬间抢占任何普通进程。
- 适用场景:对响应时间要求极其苛刻的场景,如汽车刹车控制、高频交易、音视频实时处理。
2. 分时/普通优先级队列 (Time-Sharing/Normal Queue)
- 索引范围:
queue[100]到queue[139](共 40 个队列)。 - 优先级特征:数值越大,优先级越低(对应 nice 值 -20 到 19)。
- 调度策略:采用时间片轮转机制,追求宏观上的公平性。
- 适用场景:我们日常使用的绝大多数程序(如 Shell、浏览器、文本编辑器、后台服务)。
核心调度逻辑
调度器在寻找下一个要执行的进程时,遵循严格的阶级壁垒:
- 先查实时:通过位图(Bitmap)扫描
0~99的队列,只要有进程,立刻执行。 - 再查分时:只有当
0~99的队列全部为空时,调度器才会去扫描100~139的队列,挑选普通进程执行。
3. 环境变量
3.1 概念与常见变量
- 定义:操作系统用来指定运行环境的参数,具有全局特性。
- 常见变量:
PATH:命令搜索路径。HOME:用户主目录。SHELL:当前 Shell。
- 操作命令:
echo $NAME,export,env,unset。
3.2 代码访问环境变量
- main 函数参数:
int main(int argc, char* argv[], char* env[]) - 全局变量:
extern char** environ; - 系统调用:
getenv("NAME"):获取变量值。putenv("NAME=VALUE"):添加/修改变量。setenv("NAME", "VALUE", 1):安全地设置变量(推荐)。
💡 扩展:putenv 与 setenv 的底层区别
putenv:直接引用传入的字符串指针。风险:如果传入的是局部变量(栈内存),函数结束后内存释放,环境变量表会变成悬空指针,导致崩溃。setenv:在内部malloc一块新内存,复制字符串内容。安全,不受外部变量生命周期影响。
3.3 继承性
- 环境变量具有全局属性,子进程会继承父进程的环境变量。
- 普通局部变量(未 export)不会被子进程继承。
环境变量表如图,实质上是一个字符指针数组,每个指针指向⼀个以’\0’结尾的环境字符串
4. 进程地址空间 (虚拟内存)
4.1 现象与本质
- 现象:父子进程中同一个变量的虚拟地址相同,但物理地址不同(内容不同)。
- 结论:C/C++ 代码中看到的地址都是虚拟地址。OS 负责将虚拟地址映射到物理地址。
4.2 核心结构
mm_struct(内存描述符):描述整个进程的用户空间地址分布。vm_area_struct(VMA):描述具体的内存区域(如代码段、堆、栈),通过链表或红黑树组织。
4.3 为什么需要虚拟地址空间?
- 内存保护:进程只能访问自己的虚拟内存,无法随意读写物理内存(防止木马破坏系统)。
- 解耦合:进程管理(task_struct)与内存管理(mm_struct)分离。
- 统一视图:无论物理内存碎片化如何,进程看到的内存布局都是连续且有序的。
- 延迟分配:
malloc只是在虚拟空间申请,真正访问时才分配物理内存(缺页中断)。
4.4 内存布局 (32位系统典型分布)
| 区域 | 说明 | 增长方向 |
|---|---|---|
| 内核空间 | 1G (高地址),用户不可见 | - |
| 栈 (Stack) | 局部变量、函数调用 | 高 -> 低 |
| 内存映射段 | 动态库、mmap | - |
| 堆 (Heap) | malloc/new分配 | 低 -> 高 |
| BSS 段 | 未初始化全局/静态变量 | - |
| 数据段 | 已初始化全局/静态变量 | - |
| 代码段 (Text) | 二进制代码、常量字符串 | - |
扩展
1. 深入理解fork()的返回值机制
- 问题:为什么
fork一次调用会有两个返回值? - 原理:
fork内部通过系统调用创建子进程。- 子进程创建成功后,内核会将父进程的寄存器上下文(包括
EAX/RAX寄存器,用于存放返回值)复制一份给子进程。 - 修改副本:内核在子进程的 PCB 中,将复制过来的
EAX寄存器的值修改为0。 - 保留原版:父进程的
EAX寄存器中保留的是子进程的 PID。 - 当调度器分别调度父子进程从
fork返回时,它们读取各自的寄存器,就得到了不同的返回值。
2. 虚拟内存与物理内存的映射 (页表)
- 页表 (Page Table):OS 维护的数据结构,记录虚拟地址到物理地址的映射关系。
- 写时拷贝 (COW) 的页表实现:
fork刚结束时,父子进程的页表指向同一块物理内存。- 此时页表属性被标记为**“只读”**。
- 当任意一方尝试写入数据时,触发缺页异常/中断。
- OS 捕获异常,发现是写操作,于是开辟新物理内存,拷贝数据,更新写方的页表映射,并恢复写权限。
3. 环境变量putenv的内存陷阱
putenv(*ptr)可以将储存环境变量路径的指针储存金环境变量表中,但是设置为局部变量可能会造成悬空指针
- 场景:
voidset_env(){charvar[]="MY_VAR=123";// 栈内存putenv(var);// 环境变量表记录了 var 的地址}// 函数结束,var 内存被回收 - 后果:环境变量表中存储的是指向已释放栈内存的指针(悬空指针)。后续访问该环境变量会导致段错误或读取乱码。
- 修正:使用
setenv("MY_VAR", "123", 1),它会在堆上分配内存并复制字符串,保证数据安全。
4. 进程调度 O(1) 算法的精髓
- 背景:Linux 2.6 内核引入,解决进程数增多导致调度变慢的问题。
- 核心:
- Active 队列:还有时间片的进程。
- Expired 队列:时间片用完的进程。
- Bitmap:140 个优先级,用 5 个 32 位整数(位图)表示哪些优先级队列非空。
- 流程:
- 调度时,通过
ffs(find first set) 指令在 Bitmap 中直接找到最高优先级的非空队列(常数时间)。 - 当 Active 队列空了,直接交换指针(
swap(active, expired)),无需遍历所有进程重新计算优先级。
- 调度时,通过
5. 孤儿进程与僵尸进程的处理
- 僵尸进程:
- 危害:占用内核资源(PCB)。
- 解决:父进程调用
wait()或waitpid()获取子进程退出状态;或父进程忽略SIGCHLD信号。
- 孤儿进程:
- 机制:父进程挂掉后,子进程被
init(PID 1) 进程收养。 - 回收:
init进程会周期性调用wait清理其收养的僵尸子进程,防止系统资源泄漏。
- 机制:父进程挂掉后,子进程被
