OS:进程1
进程(Process)是操作系统中一个正在运行的程序实例。代码段(程序指令)数据段(变量、堆、栈)寄存器状态(CPU上下文)内核控制块 PCB(Process Control Block),记录该进程的所有信息:PID、状态、打开的文件、内存映射等。每个程序运行时,操作系统都会为它分配一个独立的进程空间。函数功能操作系统层面作用fork()创建子进程复制父进程的 PCB、页表等,形成新的进程exe
1 fork.c
#include <sys/types.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
pid_t pid;
pid = fork();
if (pid < 0) {
fprintf(stderr, "Fork Failed");
exit(-1);
}
else if (pid == 0) {
execlp("/bin/ls", "ls", NULL);
}
else {
wait(NULL);
// 问题:为什么需要 wait()?
// 当子进程结束时,操作系统不会立即回收它的资源。
// 它会保留一部分信息(退出状态、运行时间等),把子进程状态标记为 “僵尸进程(zombie)”。
// 这些信息会留在内核中,直到父进程调用 wait() 取走。
// 如果父进程从不 wait(),这些僵尸进程就会堆积在系统中,占用 PID 和资源。
printf("Child Complete\n");
exit(0);
// 表示进程正常结束,返回状态码 0
// 操作系统会回收进程的所有资源(内存、打开的文件等)
}
}
代码解释
操作系统中几个核心概念:
- 进程(Process)
- 进程创建与复制(fork)
- 程序替换(exec 系列)
- 进程同步(wait)
- 父子进程的关系与内存空间
下面我会带你从操作系统的角度,逐行解释代码背后的理论知识,让你不仅知道“代码在做什么”,更知道“操作系统在做什么”。
1.1 fork():操作系统中的进程创建机制
pid = fork();
1️⃣ 什么是进程?
进程(Process)是操作系统中一个正在运行的程序实例。
它包含:
- 代码段(程序指令)
- 数据段(变量、堆、栈)
- 寄存器状态(CPU上下文)
- 内核控制块 PCB(Process Control Block),记录该进程的所有信息:PID、状态、打开的文件、内存映射等。
每个程序运行时,操作系统都会为它分配一个独立的进程空间。
2️⃣ fork() 是怎么创建子进程的?
fork() 是一个 系统调用(system call),由用户程序请求操作系统执行。
当父进程调用 fork() 时,内核会执行以下步骤:
-
复制当前进程的 PCB(进程控制块)
→ 新建一个子进程,它拥有和父进程相同的代码、数据、堆栈等信息。 -
复制页表,但不立即复制物理内存
→ 现代操作系统采用“写时复制(Copy-On-Write)”技术:
父子进程最初共享同一份内存的物理页,只有在任意一方修改内存时,才真正复制一份新的内存页。 -
为子进程分配唯一的 PID(进程号)
-
将子进程加入就绪队列,等待调度运行。
3️⃣ 为什么 fork() 只调用一次却返回两次?
因为:
- 在父进程中,
fork()返回子进程的 PID(一个正整数)。 - 在子进程中,
fork()返回 0。 - 如果失败,返回 -1。
所以
fork()实际上被执行了一次,但系统返回了两个不同的结果,从而让同一份代码被“复制执行”了两次——一次在父进程里,一次在子进程里。
4️⃣ 父子进程的区别
| 对比项 | 父进程 | 子进程 |
|---|---|---|
| PID | 自己的 PID | 新分配的 PID |
| fork() 返回值 | 子进程 PID | 0 |
| 内存内容 | 初始时完全相同(复制) | 初始时完全相同(复制) |
| 打开的文件描述符 | 相同(共享文件表项) | 相同(共享文件表项) |
| PCB | 不同 | 不同 |
| 调度执行顺序 | 不确定,由操作系统决定 | 不确定,由操作系统决定 |
1.2 execlp():程序替换(exec 系列系统调用)
execlp("/bin/ls", "ls", NULL);
1️⃣ 理论背景
exec 系列函数族用于在当前进程中执行另一个程序。
换句话说,子进程通过 fork() 创建出来后,还在运行原来的代码,而 execlp() 会让它“变身”为另一个程序(这里是 ls)。
调用 exec 后:
- 当前进程的代码、数据、堆栈、程序计数器全都被新的程序替换;
- PID 不变(子进程还是子进程,只是“内容”被换成了 ls)。
这就是为什么“fork 复制进程”,“exec 替换程序”。
2️⃣ execlp 参数详解
"/bin/ls":要执行的程序路径(绝对路径)。"ls":传给程序的第一个参数(一般是程序名)。NULL:参数列表的结束标志。
execlp 中的 “l” 表示传参是列表(list),
“p” 表示会自动在 PATH 环境变量中查找程序。
1.3 wait():进程同步与收尸(Reaping)
wait(NULL);
1️⃣ 问题:为什么需要 wait()?
当子进程结束时,操作系统不会立即回收它的资源。
它会保留一部分信息(退出状态、运行时间等),把子进程状态标记为 “僵尸进程(zombie)”。
这些信息会留在内核中,直到父进程调用 wait() 取走。
如果父进程从不 wait(),这些僵尸进程就会堆积在系统中,占用 PID 和资源。
2️⃣ wait() 的作用
- 阻塞父进程,直到一个子进程结束;
- 回收子进程的资源;
- 可选择性获取子进程的退出状态。
这样父进程可以“知道”哪个子进程完成了,也防止僵尸进程出现。
1.4 父子进程的执行顺序与并发
父进程和子进程是并发执行的(由内核调度器决定谁先运行)。
执行顺序可能是:
- 子进程先执行 → 执行
ls→ 父进程wait()→ 打印 “Child Complete” - 父进程先执行
wait(),但会阻塞等待子进程结束 - 输出顺序:
ls的结果 → “Child Complete”
1.5 程序整体的 OS 执行流程图
┌──────────────┐
│ 父进程开始 │
└──────┬───────┘
│ fork()
▼
┌───────────────────────┐
│ 系统创建子进程 │
│ (复制内存 + PCB 等) │
└─────────┬────────────┘
│
┌────────────┴─────────────┐
│ │
┌────▼─────┐ ┌─────▼────┐
│ 子进程 │ │ 父进程 │
│ execlp() │ │ wait() │
│ → 执行ls │ │ 阻塞等待 │
└────┬─────┘ └─────┬────┘
│ │
▼ ▼
ls 输出完成 “Child Complete”
│ │
└──────────→ 子进程退出 ←──┘
1.6 exit():终止进程
exit(0);
- 表示进程正常结束,返回状态码
0。 - 操作系统会回收进程的所有资源(内存、打开的文件等)。
✨ 总结
| 函数 | 功能 | 操作系统层面作用 |
|---|---|---|
fork() |
创建子进程 | 复制父进程的 PCB、页表等,形成新的进程 |
execlp() |
执行新程序 | 替换当前进程的代码段与内存空间 |
wait() |
等待子进程结束 | 回收僵尸进程资源,实现进程同步 |
exit() |
结束当前进程 | 通知内核清理资源、更新状态 |
2 不解的概念
三个关键点:
- 什么是 页表(page table)?
- fork() 创建的子进程与父进程(main) 的关系是什么?
- 想系统性学习进程管理 —— 我会在最后为你规划一个完整的学习路线图和逻辑讲解顺序。
2.1 页表(Page Table)是什么?
🌍 背景:进程与虚拟内存
操作系统中,每个进程看到的都是一个“独立的内存空间”,
例如在 64 位系统中,它看到的地址范围是:
0x0000000000000000 ───────────────┐
│
[代码段 Text Segment] │
[数据段 Data Segment] │
[堆 Heap] │ ← 动态内存分配
[栈 Stack] │ ← 函数调用、局部变量
│
0x7fffffffffffffff ─────────────┘
但实际上,物理内存(RAM)是整个系统共享的,
因此操作系统需要一种机制,把「虚拟地址」转换为「物理地址」。
🧠 页表的作用
页表(Page Table)就是用来完成这种虚拟地址 → 物理地址映射的结构。
操作系统将内存划分为固定大小的块:
- 虚拟页(Virtual Page)
- 物理页框(Physical Frame)
比如:
- 一页大小 = 4 KB;
- 进程的虚拟地址
0x00400000可能对应物理内存的0x7f012000。
页表保存了这种映射关系。
🔍 举个例子:
假设一个进程有如下页表(简化):
| 虚拟页号 | 物理页框号 | 权限(读/写/执行) |
|---|---|---|
| 0x001 | 0x1A3 | 读/执行 |
| 0x002 | 0x2C7 | 读/写 |
| 0x003 | 0x1F9 | 读/写 |
那么当 CPU 访问虚拟地址 0x00200050 时:
- 虚拟页号 =
0x002 - 页内偏移 =
0x00050 - 页表查出物理页框号 =
0x2C7 - 最终物理地址 =
0x2C700050
这个过程由硬件的 MMU(内存管理单元, Memory Management Unit) 完成。
⚙️ fork() 与页表的关系
当我们调用 fork() 时,操作系统为子进程创建一个新的页表。
子进程的页表最初与父进程完全相同(映射到相同的物理页)。
但是!
内核并不会立即复制整块内存——因为那太浪费了。
它采用 写时复制(Copy-On-Write, COW) 技术:
- 父子进程共享相同的物理页(只读);
- 当任意一方修改数据时,才真正复制出一份新的物理页。
🔸 这样做的好处:
- 节省内存;
- 加快 fork 的速度;
- 子进程可以快速启动。
2.2 父进程与子进程的关系
🧩 谁是父进程?
在操作系统中:
- 执行
main()的进程,就是程序被加载后操作系统创建的那个进程。 - 当这个进程调用
fork()时,它就是「父进程」。 fork()创建出来的新进程,就是「子进程」。
例如:
int main() {
pid_t pid = fork();
if (pid == 0)
printf("This is child\n");
else
printf("This is parent\n");
}
流程:
-
操作系统最初加载并运行
main()→ 产生一个初始进程; -
当
fork()被调用时,操作系统在内核中:- 复制父进程的页表;
- 复制进程控制块(PCB);
- 分配一个新的 PID;
- 返回两次:在父进程返回子PID,在子进程返回0;
-
于是从这一行开始,“世界分裂”了:
- 父进程执行
else分支; - 子进程执行
if (pid == 0)分支。
- 父进程执行
📖 父子进程的“血缘关系”
-
子进程继承父进程的大多数属性:
- 环境变量
- 打开的文件描述符
- 当前工作目录
- 信号处理方式
-
但有独立的:
- PID(进程号)
- 内存空间(逻辑上)
- PCB(控制块)
操作系统维护着一个进程树(process tree),ps -ef --forest 命令可以看到这种层次结构:
systemd─┬─bash───a.out
└─(其他系统进程)
3 系统性讲解:进程管理的学习路线图
3.1 进程基础与模型:进程与PCB(进程控制块)——操作系统中进程的“灵魂”
目标:理解“进程”是什么、它在操作系统中的地位。
学习内容:
-
程序 vs 进程 vs 线程
-
进程的组成(代码段、数据段、堆、栈)
-
进程的状态机:
new → ready → running → waiting → terminated -
进程控制块 PCB(Process Control Block)
-
上下文切换(context switch)
可视化工具:htop、ps aux
一、什么是进程(Process)?
在操作系统中,程序(Program)不是进程。
- 程序(Program):是一个静态的文件,存放在磁盘上,例如
/bin/ls或a.out。 - 进程(Process):是程序被加载到内存中并执行的一个动态实例。
🧩 类比理解
程序就像是“菜谱”,进程就是“正在按照菜谱做菜的厨师”。
同一个菜谱(程序)可以有多个厨师(进程)同时照着做,只是各自的步骤和食材(内存数据)不同。
二、进程的组成
操作系统为了管理进程,会给每个进程分配一个独立的虚拟地址空间,其中包含:
| 内存区 | 内容 | 说明 |
|---|---|---|
| 代码段(Text Segment) | 可执行指令 | 程序的机器码 |
| 数据段(Data Segment) | 全局变量、静态变量 | 程序运行时共享的静态数据 |
| 堆(Heap) | 动态内存(malloc/new) | 程序运行时向上增长 |
| 栈(Stack) | 函数调用栈 | 局部变量、函数参数,向下增长 |
| 内核栈(Kernel Stack) | 系统调用时的栈 | 用于系统调用、异常处理 |
🧠 每个进程都拥有自己的「用户空间 + 内核空间」结构。
三、进程的生命周期与状态
每个进程从创建到销毁,经历几个典型状态:
┌──────────────┐
│ new(新建) │
└──────┬───────┘
│
▼
┌──────────────┐
│ ready(就绪)│ ←─── I/O 完成后从 waiting 回来
└──────┬───────┘
│(CPU 调度器选中)
▼
┌──────────────┐
│ running(运行)│
└──────┬───────┘
│
┌─────────┴──────────┐
▼ ▼
waiting(等待) terminated(结束)
| 状态 | 说明 |
|---|---|
| new | 正在创建进程结构 |
| ready | 可运行,等待被调度 |
| running | 占用 CPU 执行中 |
| waiting / blocked | 等待 I/O 或事件 |
| terminated | 执行完毕或被杀死 |
四、进程控制块(PCB:Process Control Block)
PCB 是操作系统中记录一个进程“全部信息”的数据结构。
就像身份证 + 档案袋,是进程在内核中的灵魂载体。
内核中通常称它为 task_struct(Linux)或 proc(Unix)。
🧠 PCB 包含哪些信息?
| 分类 | 典型内容 |
|---|---|
| 进程标识信息 | PID(进程号)、PPID(父进程号)、UID(用户号) |
| 进程状态 | new / ready / running / waiting / terminated |
| CPU 寄存器上下文 | 程序计数器(PC)、堆栈指针(SP)、通用寄存器 |
| 内存管理信息 | 页表地址、段表、代码段和堆栈的起始地址 |
| 调度信息 | 优先级、时间片、调度队列指针 |
| I/O 状态 | 打开的文件表、I/O 请求、信号处理函数 |
| 统计信息 | CPU 时间、开始时间、使用资源量等 |
📦 所有这些信息都由内核维护,当你调用 ps aux、top 时看到的,就是从这些 PCB 中提取的。
五、上下文切换(Context Switch)
🔁 什么是上下文切换?
在多任务系统中,CPU 同时“运行多个进程”,其实是操作系统快速地切换执行不同的进程。
当切换时,内核必须:
- 保存当前进程的寄存器、程序计数器(PC)等信息到它的 PCB;
- 从下一个进程的 PCB 恢复寄存器和 PC;
- CPU 跳转到新的进程继续执行。
这就是上下文切换(Context Switch)。
⚙️ 举例
假设你运行两个程序:
./a.out &
./b.out &
系统内核会交替让 a.out 和 b.out 占用 CPU:
时间片1: a.out 执行 → 保存状态 → PCB_A
时间片2: b.out 执行 → 保存状态 → PCB_B
时间片3: a.out 恢复 → 继续运行
操作系统通过 调度器(scheduler) 管理谁能先执行。
六、系统中的进程查看与验证
在 Linux 中你可以观察进程:
📄 查看进程列表
ps -ef
显示所有进程(包括 PID、父进程、状态等)。
🌳 查看进程树
pstree
可以看到父子进程的继承关系(比如 bash → a.out → ls)。
🧩 查看进程信息
cat /proc/[PID]/status
可以直接看到 Linux 内核维护的 PCB 信息,比如:
Name: a.out
State: R (running)
Pid: 2489
PPid: 2012
Threads: 1
VmSize: 7648 kB
七、总结
| 概念 | 含义 |
|---|---|
| 程序 | 磁盘上的静态代码 |
| 进程 | 程序运行的动态实例 |
| 虚拟地址空间 | 每个进程看到的独立内存 |
| 页表 | 虚拟地址 → 物理地址的映射 |
| PCB | 操作系统记录进程全部状态的结构 |
| 上下文切换 | 在不同进程之间切换执行的过程 |
3.2 进程的创建与终止:操作系统的进程管理(Process Management)
目标:理解操作系统如何“造”进程、如何“收”进程。
学习内容:
- 系统调用:
fork()、exec()、wait()、exit() - 写时复制(Copy-On-Write)
- 僵尸进程与孤儿进程
- init/systemd 如何接管孤儿进程
- 进程号(PID)与父进程号(PPID)
练习建议:
- 写多个嵌套
fork()的程序,看产生多少个进程; - 用
pstree查看进程树结构。
🧩 一、什么是“进程”(Process)
1️⃣ 概念
进程是操作系统中正在运行的程序的实例(instance)。
一个程序(如 a.out)只是静态的指令和数据;
而进程是它在内存中被加载、执行时的动态存在。
进程由三大部分组成:
- 代码段(Code segment):程序的指令。
- 数据段(Data segment):全局变量、静态变量。
- 栈(Stack):函数调用、局部变量。
- 堆(Heap):动态分配的内存(如
malloc)。
💡 可以这样理解:
程序是蓝图,进程是房子。
🧠 二、fork() 创建进程的机制
当你在程序中调用:
pid = fork();
发生了什么?
1️⃣ 操作系统复制当前进程(父进程),创建一个几乎一模一样的子进程。
2️⃣ 子进程得到父进程的全部资源副本:
- 页表(page table)被复制(但最开始是写时复制,Copy-On-Write)。
- 打开的文件描述符、堆栈、寄存器状态也被复制。
3️⃣ 子进程有自己的 PID(进程号),但其他内容几乎相同。
❓为什么说 fork “一次调用,两次返回”?
因为:
- 在父进程中,
fork()返回子进程的 PID(正数); - 在子进程中,
fork()返回 0; - 如果出错,则返回 -1。
这就是为什么你的代码里可以写:
if (pid == 0)
// child
else
// parent
——系统帮你自动分岔了两条执行路径。
🧮 三、页表(Page Table)与内存隔离
既然父子进程几乎一模一样,它们的内存怎么不会互相干扰?
这就涉及操作系统核心机制:虚拟内存(Virtual Memory)。
每个进程看到的都是独立的“虚拟地址空间”,通过页表映射到物理内存。
🧱 页表是什么?
页表是一个数据结构,操作系统用它来保存:
“虚拟地址 → 物理地址”的映射关系。
例如:
| 虚拟页号 | 物理页框 |
|---|---|
| 0x001 | 0xA12 |
| 0x002 | 0xB77 |
| 0x003 | 0xC01 |
每个进程都有自己的页表。
因此,即使父子进程拥有“相同的虚拟地址”,也不会冲突。
🧩 Copy-On-Write(写时复制)
当 fork() 发生时,操作系统不会立即复制所有内存,而是:
- 父子进程共享同一份物理页;
- 一旦任意一方写入某个页时,系统才复制那一页。
这样做节省了大量内存与时间。
⚙️ 四、execlp():进程替换(Program Replacement)
在你的代码中:
execlp("/bin/ls", "ls", NULL);
这行命令的作用是:
用一个新的程序(这里是
ls)替换当前进程的内容。
也就是说:
- 子进程原本和父进程一样;
- 执行到
execlp后,它的内存空间被/bin/ls的代码替换; - PID 不变,但进程的内容完全换了;
- 此时它成为一个“执行 ls 命令”的进程。
🪄 五、wait():进程同步与回收
父进程调用:
wait(NULL);
表示:
“等待子进程结束,并回收其资源(PCB、页表等)。”
否则,子进程结束后会成为一个“僵尸进程”(zombie process)——占着系统资源但不再运行。
当 wait() 返回后:
printf("Child Complete\n");
父进程继续执行。
🧩 六、父进程 vs. 子进程的区别总结
| 项目 | 父进程 | 子进程 |
|---|---|---|
| PID | 不同(独立) | 不同(独立) |
| 内存 | 相同副本(写时复制) | 相同副本(写时复制) |
| 文件描述符 | 复制了父进程的打开文件 | 同上 |
| 执行路径 | fork 后继续原逻辑 | fork 后可能执行 execlp 替换程序 |
| 调度 | 由操作系统决定谁先运行 | 同上 |
3.3 CPU 调度(Scheduling)
目标:理解操作系统如何决定“哪个进程先跑”。
学习内容:
-
调度的概念:上下文切换、抢占与非抢占
-
调度算法:
- FCFS(先来先服务)
- SJF(最短作业优先)
- RR(时间片轮转)
- 优先级调度(Priority)
- 多级反馈队列(MLFQ)
-
进程优先级(nice值)
一、进程状态(Process States)
操作系统中,每个进程在其生命周期中会处于不同状态。
状态图(经典模型):
┌───────────────┐
│ New │ ← 进程刚创建,还没准备好运行
└───────┬───────┘
│ admit
▼
┌───────────────┐
│ Ready │ ← 已准备好运行,等待 CPU 调度
└───────┬───────┘
│ dispatch
▼
┌───────────────┐
│ Running │ ← 占用 CPU 正在执行
└───────┬───────┘
┌─────┴─────┐
▼ ▼
I/O or Event exit
Wait │
Blocked │
└─────────┘
1️⃣ 状态说明
| 状态 | 含义 | 例子 |
|---|---|---|
| New(新建) | 操作系统刚创建进程,尚未调度 | fork() 被调用后,子进程刚生成 |
| Ready(就绪) | 等待 CPU 分配时间片 | 子进程创建完成后还没执行,或者运行被抢占后等待 |
| Running(运行) | 占用 CPU 执行程序 | 当前正在执行 execlp("/bin/ls") 的子进程 |
| Waiting / Blocked(等待/阻塞) | 等待 I/O 或事件完成 | read() 等待磁盘数据,或者 wait() 等待子进程结束 |
| Terminated / Exit(结束) | 执行完成,被内核回收 | exit(0) 调用完成后,进程资源被回收 |
2️⃣ 状态转换示例
结合你之前的 fork() 程序:
pid = fork();
if (pid == 0) {
execlp("/bin/ls", "ls", NULL);
} else {
wait(NULL);
printf("Child Complete\n");
}
- 子进程创建时:
New → Ready - 调度器让子进程运行:
Ready → Running execlp执行ls时仍在Runningls执行完后调用exit:Running → Terminated- 父进程调用
wait()时,如果子进程未完成:Running → Blocked(父进程等待) - 子进程结束后父进程恢复:
Blocked → Running
二、进程调度(Process Scheduling)
1️⃣ 为什么需要调度?
现代操作系统是多任务的:
- CPU 数量有限(单核或多核);
- 进程数量可能非常多;
- 每个进程都希望能运行。
**调度器(Scheduler)**负责分配 CPU 时间片。
2️⃣ 调度策略示例
(1) FCFS:先来先服务
- 最简单策略,按照进程进入就绪队列的顺序运行。
- 优点:实现简单。
- 缺点:短进程可能被长进程“阻塞”,存在 convoy effect。
(2) SJF:最短作业优先
- 先执行预计运行时间最短的进程。
- 优点:平均等待时间短。
- 缺点:难以预测执行时间;可能导致长作业饥饿。
(3) RR:时间片轮转
- 每个进程分配一个固定时间片,轮流执行。
- 优点:响应快,公平。
- 缺点:时间片太短频繁切换,开销大;太长又变成 FCFS。
(4) 多级反馈队列(MLFQ)
- 将进程分级,不同优先级队列分配不同时间片。
- 根据运行情况动态调整优先级。
- Linux、Windows 等现代系统采用类似策略。
3️⃣ 实例演示
假设就绪队列有 3 个进程:P1、P2、P3
CPU 单核,时间片 2ms,执行顺序:
| 时间 | 执行进程 | 备注 |
|---|---|---|
| 0-2 | P1 | 时间片到 |
| 2-4 | P2 | 时间片到 |
| 4-6 | P3 | 时间片到 |
| 6-8 | P1 | 轮转回来 |
| … | … | … |
这就是 RR 时间片轮转调度。
三、阻塞与抢占
-
阻塞(Blocking):
- 进程在等待 I/O 或事件完成。
- CPU 可以切换给其他进程。
-
抢占(Preemption):
- CPU 时间片结束,正在运行的进程被挂起。
- 调度器选其他就绪进程运行。
所以即使只有一个 CPU,多个进程也能“同时运行”,通过快速切换让用户感觉并发。
四、进程优先级
-
每个进程有一个 优先级(Priority)
-
调度器可能:
- 优先调度高优先级进程;
- 或者结合 RR 时间片轮转,让低优先级也能运行。
Linux 系统:
- 普通用户进程优先级一般是 0~19,nice 值越小优先级越高。
- 实时进程优先级更高。
五、总结
-
进程状态:
- New / Ready / Running / Waiting / Terminated
fork()创建子进程 → Ready → Running → Terminated
-
调度器:
- 决定哪个进程获得 CPU
- 策略:FCFS / SJF / RR / MLFQ
-
阻塞 vs 抢占:
- 阻塞:等待 I/O 或事件
- 抢占:CPU 时间片结束,调度器切换进程
💡 下讲预告(第 3 讲):
进程间通信(IPC)与同步
- 管道、FIFO、共享内存
- 信号、消息队列
- 为什么父子进程需要通信
用 一个具体的 fork + pipe + wait 的示例程序,带你实际看到:
- 父子进程状态切换
- CPU 调度
- 数据通信
这部分会直观又实用。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
int main() {
int pipefd[2];
pid_t pid;
char buf[100];
// 创建管道
if (pipe(pipefd) == -1) {
perror("pipe");
exit(1);
}
pid = fork(); // 创建子进程
if (pid < 0) {
perror("fork");
exit(1);
}
else if (pid == 0) {
// ========== 子进程 ==========
close(pipefd[0]); // 关闭读端
char msg[] = "Hello from child!";
write(pipefd[1], msg, strlen(msg)+1); // 写入管道
printf("Child: message sent\n");
close(pipefd[1]); // 关闭写端
exit(0);
}
else {
// ========== 父进程 ==========
close(pipefd[1]); // 关闭写端
printf("Parent: waiting for child to finish...\n");
wait(NULL); // 等待子进程结束
read(pipefd[0], buf, sizeof(buf));
// 从管道读取消息:如果子进程还没写完,父进程可能阻塞
完成后打印收到消息 → 结束
printf("Parent received: %s\n", buf);
close(pipefd[0]);
printf("Parent: child complete, exiting.\n");
}
return 0;
}
3.4 进程同步与死锁
太好了 👍,我们直接来看一个 父子进程使用信号量/互斥锁同步 的示例,直观理解 进程同步与死锁。
这里我们用 POSIX 信号量(sem_t) 来演示父子进程访问临界区的同步。
3.4.1 父子进程共享资源同步
一、示例程序:父子进程共享资源同步
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <semaphore.h>
#include <fcntl.h> // For O_CREAT
#include <sys/mman.h> // For shared memory
int main() {
sem_t *sem;
pid_t pid;
// 创建一个命名信号量,初始值 1
sem = sem_open("/mysem", O_CREAT | O_EXCL, 0644, 1);
if (sem == SEM_FAILED) {
perror("sem_open");
exit(1);
}
pid = fork();
if (pid < 0) {
perror("fork");
exit(1);
}
else if (pid == 0) {
// ========== 子进程 ==========
sem_wait(sem); // 请求进入临界区
printf("Child: Entering critical section\n");
sleep(2); // 模拟操作临界资源
printf("Child: Leaving critical section\n");
sem_post(sem); // 释放临界区
exit(0);
}
else {
// ========== 父进程 ==========
sleep(1); // 确保父子进程竞争临界区
sem_wait(sem);
printf("Parent: Entering critical section\n");
sleep(2);
printf("Parent: Leaving critical section\n");
sem_post(sem);
wait(NULL); // 等待子进程结束
sem_unlink("/mysem"); // 删除信号量
printf("Parent: Child complete, exiting\n");
}
return 0;
}
二、程序分析
1️⃣ 信号量 sem
sem_open("/mysem", O_CREAT | O_EXCL, 0644, 1);
- 创建一个 命名信号量
- 初始值 = 1 → 表示 临界区一次只允许一个进程访问
2️⃣ 子进程执行
sem_wait(sem);
// 申请进入临界区,如果信号量为0就阻塞
- 成功后,信号量减1
- 临界区访问完成后:
sem_post(sem);
// 离开临界区,信号量加1,唤醒阻塞进程
3️⃣ 父进程执行
- 父进程睡 1 秒,制造 竞争条件
- 也会通过
sem_wait申请临界区,确保父子不会同时访问
4️⃣ wait(NULL)
- 父进程等待子进程结束,保证资源清理顺序正确
三、运行效果示例
Child: Entering critical section
Child: Leaving critical section
Parent: Entering critical section
Parent: Leaving critical section
Parent: Child complete, exiting
✅ 说明:
- 临界区被严格互斥访问
- 父子进程没有同时进入临界区 → 同步成功
- 信号量保证了 互斥访问资源
四、状态与同步理解
| 时间 | 子进程 | 父进程 | 状态 & 原因 |
|---|---|---|---|
| t0 | Ready → Running | Ready | fork() 后进入就绪队列 |
| t1 | Running | Ready | sem_wait 成功进入临界区 |
| t2 | Running | Blocked | 父进程 sem_wait 阻塞,等待信号量 |
| t3 | Running → Terminated | Ready → Running | 子进程 sem_post → 父进程唤醒 |
| t4 | Terminated | Running → Terminated | 父进程退出临界区、wait 等待子进程完成 |
这就是父子进程同步与状态切换的完整演示。
五、关键概念总结
-
临界区(Critical Section):
- 访问共享资源的代码段
- 必须保证 互斥访问
-
信号量(Semaphore):
- 控制访问临界区的数量
sem_wait()阻塞,sem_post()释放
-
死锁(Deadlock):
- 如果两个进程互相等待对方释放信号量,就会进入死锁
- 示例中只有一个信号量,故不会死锁
-
进程状态与同步结合:
- 阻塞(Blocked)状态是同步的核心机制
- 进程在等待信号量或 I/O 时不会占用 CPU
- 调度器可以切换其他就绪进程运行
💡 下一步可以继续:
- 演示 死锁场景,两个进程各持有一个信号量并等待另一个 → 直观理解死锁
- 探索 多进程同步策略(避免死锁、优先级反转)
3.4.2 父子进程死锁
好的 👍,我们来演示一个 父子进程死锁(Deadlock) 的示例,帮助你直观理解阻塞与死锁状态。
一、示例程序:父子进程死锁
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <semaphore.h>
#include <fcntl.h>
#include <sys/mman.h>
int main() {
sem_t *sem1, *sem2;
pid_t pid;
// 创建两个信号量,初始值为1
sem1 = sem_open("/sem1", O_CREAT | O_EXCL, 0644, 1);
sem2 = sem_open("/sem2", O_CREAT | O_EXCL, 0644, 1);
if (sem1 == SEM_FAILED || sem2 == SEM_FAILED) {
perror("sem_open");
exit(1);
}
pid = fork();
if (pid < 0) {
perror("fork");
exit(1);
}
else if (pid == 0) {
// ========== 子进程 ==========
sem_wait(sem1);
printf("Child: acquired sem1\n");
sleep(2); // 模拟操作临界资源
printf("Child: waiting for sem2\n");
sem_wait(sem2); // 这里可能阻塞 → 死锁
printf("Child: acquired sem2\n");
sem_post(sem2);
sem_post(sem1);
exit(0);
}
else {
// ========== 父进程 ==========
sem_wait(sem2);
printf("Parent: acquired sem2\n");
sleep(2);
printf("Parent: waiting for sem1\n");
sem_wait(sem1); // 这里可能阻塞 → 死锁
printf("Parent: acquired sem1\n");
sem_post(sem1);
sem_post(sem2);
wait(NULL);
sem_unlink("/sem1");
sem_unlink("/sem2");
printf("Parent: child complete, exiting\n");
}
return 0;
}
二、程序分析
1️⃣ 两个信号量
sem1和sem2初始值为 1- 分别控制不同的共享资源(临界区)
2️⃣ 死锁发生原因
-
子进程:
- 获取
sem1 - 等待
sem2
- 获取
-
父进程:
- 获取
sem2 - 等待
sem1
- 获取
此时子进程持有
sem1等sem2,父进程持有sem2等sem1
相互等待 → 死锁
两个进程都进入 Blocked 阻塞状态,CPU 无法继续执行临界区
3️⃣ 状态切换示意
| 时间 | 子进程状态 | 父进程状态 | 原因 |
|---|---|---|---|
| t0 | Ready → Running | Ready | fork 创建子进程 |
| t1 | Running | Ready | sem_wait(sem1) 成功 |
| t2 | Running → Blocked | Running | 父进程 sem_wait(sem2) 成功;父等待 sem1 → 阻塞 |
| t3 | Blocked | Blocked | 子等待 sem2 → 阻塞 |
CPU 会空转或执行其他非相关进程,父子进程卡住 → 死锁
4️⃣ 运行效果
Child: acquired sem1
Parent: acquired sem2
Child: waiting for sem2
Parent: waiting for sem1
# 程序卡住,永远不会输出下一行
- 可以看到父子进程都被阻塞,程序无法继续
- 死锁真实再现了 操作系统进程同步问题
三、避免死锁的策略
-
固定资源顺序
- 所有进程按相同顺序获取信号量
- 例如先获取
sem1再获取sem2
-
使用 try-wait / 超时机制
- 尝试获取信号量,如果失败,释放已持有的资源
- 防止无限阻塞
-
资源分配图与死锁检测
- 系统定期检查资源占用状态,发现死锁时杀死某些进程或回滚
-
一次性获取所有资源
- 进程只有在可以同时获取所需所有资源时才进入临界区
四、总结
-
死锁(Deadlock)
- 是进程在临界区竞争资源时相互等待导致的“僵局”
-
阻塞状态(Blocked)
- 是死锁的核心表现
-
信号量
- 保证互斥,但如果顺序不当也可能导致死锁
-
操作系统调度器
- 能切换就绪进程,但无法自动解决死锁,需要策略
💡 下一步学习可以是:
父子进程共享内存 + 互斥锁同步示例
- 更接近多线程/生产者-消费者模型
- 可以演示并发访问和同步机制
3.4.3 父子进程同步 / 生产者-消费者模型
太好了 👍,我们继续,用 共享内存 + 互斥锁 来演示 父子进程同步 / 生产者-消费者模型,这是操作系统同步的经典案例。
一、示例程序:共享内存 + 互斥锁同步
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <pthread.h>
#include <sys/mman.h> // 共享内存
#include <string.h>
typedef struct {
int buffer; // 共享资源(简单示例,只存一个整数)
pthread_mutex_t mutex; // 互斥锁
pthread_cond_t cond; // 条件变量
int full; // 标志:1=有数据,0=空
} shared_data_t;
int main() {
shared_data_t *data;
// 创建共享内存
data = mmap(NULL, sizeof(shared_data_t),
PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (data == MAP_FAILED) {
perror("mmap");
exit(1);
}
// 初始化互斥锁和条件变量(共享进程)
pthread_mutexattr_t mattr;
pthread_condattr_t cattr;
pthread_mutexattr_init(&mattr);
pthread_mutexattr_setpshared(&mattr, PTHREAD_PROCESS_SHARED);
pthread_mutex_init(&data->mutex, &mattr);
pthread_condattr_init(&cattr);
pthread_condattr_setpshared(&cattr, PTHREAD_PROCESS_SHARED);
pthread_cond_init(&data->cond, &cattr);
data->full = 0;
pid_t pid = fork();
if (pid < 0) {
perror("fork");
exit(1);
}
else if (pid == 0) {
// ========== 子进程(生产者)==========
for (int i = 1; i <= 5; i++) {
pthread_mutex_lock(&data->mutex);
while (data->full) {
pthread_cond_wait(&data->cond, &data->mutex);
}
data->buffer = i;
data->full = 1;
printf("Child (Producer): produced %d\n", i);
pthread_cond_signal(&data->cond);
pthread_mutex_unlock(&data->mutex);
sleep(1);
}
exit(0);
}
else {
// ========== 父进程(消费者)==========
for (int i = 1; i <= 5; i++) {
pthread_mutex_lock(&data->mutex);
while (!data->full) {
pthread_cond_wait(&data->cond, &data->mutex);
}
int value = data->buffer;
data->full = 0;
printf("Parent (Consumer): consumed %d\n", value);
pthread_cond_signal(&data->cond);
pthread_mutex_unlock(&data->mutex);
sleep(2);
}
wait(NULL); // 等待子进程结束
printf("Parent: child complete, exiting\n");
}
return 0;
}
二、程序分析
1️⃣ 共享内存
mmap(... MAP_SHARED | MAP_ANONYMOUS ...)
- 父子进程共享同一块内存
- 通过
data->buffer存放生产的数据
2️⃣ 互斥锁 mutex
pthread_mutexattr_setpshared(&mattr, PTHREAD_PROCESS_SHARED);
- 互斥锁设置为 进程间共享
- 确保父子进程同时访问 buffer 时不会冲突
3️⃣ 条件变量 cond
- 用于 生产者-消费者同步
- 子进程生产数据后
signal父进程 - 父进程消费完数据后
signal子进程
4️⃣ full 标志
- 1 → buffer 有数据
- 0 → buffer 空
- 避免生产者覆盖数据或消费者读空数据
三、运行效果示例
Child (Producer): produced 1
Parent (Consumer): consumed 1
Child (Producer): produced 2
Parent (Consumer): consumed 2
Child (Producer): produced 3
Parent (Consumer): consumed 3
Child (Producer): produced 4
Parent (Consumer): consumed 4
Child (Producer): produced 5
Parent (Consumer): consumed 5
Parent: child complete, exiting
- 父子进程交替运行
- buffer 数据同步正确
- 互斥访问 + 条件变量成功避免冲突
四、状态与同步理解
| 时间 | 子进程状态 | 父进程状态 | 原因 |
|---|---|---|---|
| t0 | Ready → Running | Ready | fork() 创建子进程 |
| t1 | Running | Blocked | 子进程 mutex lock 成功;父进程等待条件变量 |
| t2 | Running → Blocked | Ready → Running | 子进程 signal 父进程,父进程获取 buffer |
| t3 | Ready → Running | Running → Blocked | 轮换继续生产/消费 |
| … | … | … | 循环完成生产/消费 |
五、总结
-
共享内存 + 互斥锁 + 条件变量:
- 真实模拟多进程并发访问共享资源
- 保证 互斥 + 同步
-
生产者-消费者模型:
- 经典 OS 并发问题
- 避免数据冲突、死锁、竞态条件
-
操作系统调度结合同步机制:
- 阻塞状态用于等待资源
- CPU 可以切换给其他就绪进程
- 条件变量唤醒等待进程 → 继续运行
💡 下一步可以做:
-
画一张完整的父子进程状态切换图,结合:
- fork → Ready → Running
- 等待互斥锁 → Blocked
- signal 条件变量 → Ready → Running
-
或者把生产者-消费者模型扩展到 多个进程/多个缓冲区,模拟真实系统调度和同步。
我可以帮你画出 完整状态切换图 + 时间序列示意图,让这整个父子进程同步模型一目了然。
3.4.4 状态切换图和时间序列示意图
好的 👍,我们把刚才的 父子进程生产者-消费者模型 用 状态切换图和时间序列示意图 来直观展示,让整个流程一目了然。
一、状态切换图(父子进程 + 互斥锁 + 条件变量)
┌─────────────┐
│ New │ ← fork() 创建子进程
└─────┬───────┘
│
▼
┌─────────────┐
│ Ready │ ← 等待 CPU 调度
└─────┬───────┘
│ dispatch
▼
┌─────────────┐
│ Running │ ← 获取 mutex 成功,进入临界区
└─────┬───────┘
┌───────────┴───────────┐
▼ ▼
Blocked (等待条件变量) Terminated
▲ │
└───────── signal ──────┘
- Blocked:等待条件变量(例如父进程等待子进程生产数据,子进程等待父进程消费完)
- signal:唤醒等待的进程,Ready → Running
父子进程都遵循同样的状态切换规律,只是互相等待和唤醒。
二、时间序列示意图(生产/消费交替)
假设生产者生产 5 个数据,时间轴如下(每格表示 1 秒):
时间: 0 1 2 3 4 5 6 7 8 9 10
------------------------------------------------
Child: P1 P1 B P2 P2 B P3 P3 B ...
Parent: B C1 C1 B C2 C2 B C3 C3 ...
- P1, P2, P3 → 子进程生产数据 1、2、3
- C1, C2, C3 → 父进程消费数据 1、2、3
- B → 阻塞状态,等待条件变量
- 可以看到父子进程交替执行,互斥访问 buffer
三、分析
-
互斥保证
- 同一时间只有一个进程在临界区访问 buffer(mutex lock/unlock)
-
同步保证
- 父进程消费前,子进程必须生产(条件变量 signal/wait)
- 子进程再次生产前,父进程必须消费
-
阻塞与唤醒
- 条件变量实现阻塞(Blocked)和唤醒(Ready → Running)
- CPU 切换让另一个进程运行
-
状态切换规律
- Ready → Running → Blocked → Ready → Running
- 进程终止:Running → Terminated
四、总结
通过共享内存 + 互斥锁 + 条件变量 + fork + wait,我们系统性地演示了:
-
父子进程状态切换
- New → Ready → Running → Blocked → Running → Terminated
-
阻塞与同步
- 条件变量阻塞等待资源
- signal 唤醒另一个进程继续执行
-
操作系统调度
- CPU 在阻塞时可切换其他就绪进程
- 多进程并发得以实现
💡 下一步学习建议:
- 扩展模型:多个生产者 + 多个消费者 + 多个缓冲区
- 死锁检测:加入多个互斥锁,观察死锁发生条件
- 实际系统实验:用
top/htop观察进程状态,结合代码理解调度
明白了 👍,我们从 进程管理 继续,进入下一个核心板块:
3.5:进程间通信(IPC)
这是操作系统中非常重要的内容,因为父子进程、不同进程之间往往需要交换数据或者协同工作。
一、IPC 的概念
IPC(Inter-Process Communication):进程间通信。
-
不同进程拥有独立的地址空间,不能直接访问对方的内存
-
IPC 提供了一种机制,让进程之间可以安全地交换信息
-
IPC 机制分为:
- 管道(Pipe)
- 命名管道(FIFO)
- 信号(Signal)
- 消息队列(Message Queue)
- 共享内存(Shared Memory)
- 套接字(Socket)
二、常用 IPC 方式
1️⃣ 管道(Pipe)
-
半双工:单向通信
-
父子进程常用
-
内核在内存中开辟缓冲区
-
示例:
int fd[2]; pipe(fd); fork(); write(fd[1], "msg", 4); read(fd[0], buf, 4); -
优点:简单
-
缺点:只能用于有亲缘关系的进程(父子/兄弟进程)
2️⃣ 命名管道(FIFO)
-
全双工
-
进程无亲缘关系也可通信
-
创建方式:
mkfifo("mypipe", 0666); -
一个进程写,一个进程读
3️⃣ 信号(Signal)
-
异步通知机制
-
用于通知进程某个事件发生
-
常用信号:
SIGINT(Ctrl+C)SIGCHLD(子进程结束)
-
示例:
kill(pid, SIGUSR1); signal(SIGUSR1, handler);
4️⃣ 消息队列(Message Queue)
-
内核维护的队列,支持有序、异步通信
-
支持消息优先级
-
示例:
int msgid = msgget(key, IPC_CREAT | 0666); msgsnd(msgid, &msg, size, 0); msgrcv(msgid, &msg, size, 0, 0);
5️⃣ 共享内存(Shared Memory)
-
最快的 IPC,进程直接读写同一块内存
-
必须配合 互斥锁或信号量 保证安全
-
示例:
shm = shm_open("/shm", O_CREAT | O_RDWR, 0666); ftruncate(shm, size); ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, shm, 0);
6️⃣ 套接字(Socket)
- 跨机器通信,支持本地进程通信和网络通信
- 例如 TCP/UDP 网络编程
三、父子进程常用 IPC 模式
| 方式 | 是否需要亲缘关系 | 是否同步 | 典型用途 |
|---|---|---|---|
| Pipe | 是 | 阻塞 | 父子进程数据传递 |
| FIFO | 否 | 阻塞 | 跨进程文件通信 |
| Signal | 否 | 异步 | 事件通知、子进程结束 |
| Message Q | 否 | 阻塞/非阻塞 | 有序消息传递 |
| Shared Memory | 否 | 需额外同步 | 高速数据共享 |
| Socket | 否 | 阻塞/非阻塞 | 跨主机通信 |
四、选择 IPC 的原则
- 数据量小 + 父子进程 → 管道
- 多进程 + 高速共享数据 → 共享内存 + 互斥锁
- 事件通知 → 信号
- 多进程有序通信 → 消息队列
- 跨主机通信 → 套接字
💡 小结
- IPC 是操作系统进程管理的延伸
- 互斥、同步和阻塞概念仍然核心
- 父子进程通信在实际系统中非常常用,例如生产者-消费者模型
我可以接下来帮你写一个完整的父子进程使用管道 + 消息队列 + 信号 + 共享内存的综合示例程序,让你把 IPC 全部实操一遍。
你希望我直接演示吗?
更多推荐
所有评论(0)