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() 时,内核会执行以下步骤

  1. 复制当前进程的 PCB(进程控制块)
    → 新建一个子进程,它拥有和父进程相同的代码、数据、堆栈等信息。

  2. 复制页表,但不立即复制物理内存
    → 现代操作系统采用“写时复制(Copy-On-Write)”技术:
    父子进程最初共享同一份内存的物理页,只有在任意一方修改内存时,才真正复制一份新的内存页。

  3. 为子进程分配唯一的 PID(进程号)

  4. 将子进程加入就绪队列,等待调度运行。


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 父子进程的执行顺序与并发

父进程和子进程是并发执行的(由内核调度器决定谁先运行)。

执行顺序可能是:

  1. 子进程先执行 → 执行 ls → 父进程 wait() → 打印 “Child Complete”
  2. 父进程先执行 wait(),但会阻塞等待子进程结束
  3. 输出顺序: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 不解的概念

三个关键点:

  1. 什么是 页表(page table)
  2. fork() 创建的子进程与父进程(main) 的关系是什么?
  3. 系统性学习进程管理 —— 我会在最后为你规划一个完整的学习路线图和逻辑讲解顺序。

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 时:

  1. 虚拟页号 = 0x002
  2. 页内偏移 = 0x00050
  3. 页表查出物理页框号 = 0x2C7
  4. 最终物理地址 = 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");
}

流程:

  1. 操作系统最初加载并运行 main() → 产生一个初始进程;

  2. fork() 被调用时,操作系统在内核中:

    • 复制父进程的页表;
    • 复制进程控制块(PCB);
    • 分配一个新的 PID;
    • 返回两次:在父进程返回子PID,在子进程返回0;
  3. 于是从这一行开始,“世界分裂”了:

    • 父进程执行 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)

可视化工具:htopps aux


一、什么是进程(Process)?

在操作系统中,程序(Program)不是进程

  • 程序(Program):是一个静态的文件,存放在磁盘上,例如 /bin/lsa.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 auxtop 时看到的,就是从这些 PCB 中提取的。


五、上下文切换(Context Switch)

🔁 什么是上下文切换?

在多任务系统中,CPU 同时“运行多个进程”,其实是操作系统快速地切换执行不同的进程。

当切换时,内核必须:

  1. 保存当前进程的寄存器、程序计数器(PC)等信息到它的 PCB;
  2. 从下一个进程的 PCB 恢复寄存器和 PC;
  3. CPU 跳转到新的进程继续执行。

这就是上下文切换(Context Switch)


⚙️ 举例

假设你运行两个程序:

./a.out &
./b.out &

系统内核会交替让 a.outb.out 占用 CPU:

时间片1: a.out 执行 → 保存状态 → PCB_A
时间片2: b.out 执行 → 保存状态 → PCB_B
时间片3: a.out 恢复 → 继续运行

操作系统通过 调度器(scheduler) 管理谁能先执行。


六、系统中的进程查看与验证

在 Linux 中你可以观察进程:

📄 查看进程列表
ps -ef

显示所有进程(包括 PID、父进程、状态等)。

🌳 查看进程树
pstree

可以看到父子进程的继承关系(比如 basha.outls)。

🧩 查看进程信息
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 时仍在 Running
  • ls 执行完后调用 exitRunning → 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 值越小优先级越高。
  • 实时进程优先级更高。

五、总结

  1. 进程状态

    • New / Ready / Running / Waiting / Terminated
    • fork() 创建子进程 → Ready → Running → Terminated
  2. 调度器

    • 决定哪个进程获得 CPU
    • 策略:FCFS / SJF / RR / MLFQ
  3. 阻塞 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 等待子进程完成

这就是父子进程同步与状态切换的完整演示。


五、关键概念总结
  1. 临界区(Critical Section)

    • 访问共享资源的代码段
    • 必须保证 互斥访问
  2. 信号量(Semaphore)

    • 控制访问临界区的数量
    • sem_wait() 阻塞,sem_post() 释放
  3. 死锁(Deadlock)

    • 如果两个进程互相等待对方释放信号量,就会进入死锁
    • 示例中只有一个信号量,故不会死锁
  4. 进程状态与同步结合

    • 阻塞(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️⃣ 两个信号量
  • sem1sem2 初始值为 1
  • 分别控制不同的共享资源(临界区)
2️⃣ 死锁发生原因
  • 子进程:

    1. 获取 sem1
    2. 等待 sem2
  • 父进程:

    1. 获取 sem2
    2. 等待 sem1

此时子进程持有 sem1sem2,父进程持有 sem2sem1
相互等待 → 死锁
两个进程都进入 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
# 程序卡住,永远不会输出下一行
  • 可以看到父子进程都被阻塞,程序无法继续
  • 死锁真实再现了 操作系统进程同步问题

三、避免死锁的策略
  1. 固定资源顺序

    • 所有进程按相同顺序获取信号量
    • 例如先获取 sem1 再获取 sem2
  2. 使用 try-wait / 超时机制

    • 尝试获取信号量,如果失败,释放已持有的资源
    • 防止无限阻塞
  3. 资源分配图与死锁检测

    • 系统定期检查资源占用状态,发现死锁时杀死某些进程或回滚
  4. 一次性获取所有资源

    • 进程只有在可以同时获取所需所有资源时才进入临界区

四、总结
  1. 死锁(Deadlock)

    • 是进程在临界区竞争资源时相互等待导致的“僵局”
  2. 阻塞状态(Blocked)

    • 是死锁的核心表现
  3. 信号量

    • 保证互斥,但如果顺序不当也可能导致死锁
  4. 操作系统调度器

    • 能切换就绪进程,但无法自动解决死锁,需要策略

💡 下一步学习可以是:

父子进程共享内存 + 互斥锁同步示例

  • 更接近多线程/生产者-消费者模型
  • 可以演示并发访问和同步机制

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 轮换继续生产/消费
循环完成生产/消费

五、总结
  1. 共享内存 + 互斥锁 + 条件变量

    • 真实模拟多进程并发访问共享资源
    • 保证 互斥 + 同步
  2. 生产者-消费者模型

    • 经典 OS 并发问题
    • 避免数据冲突、死锁、竞态条件
  3. 操作系统调度结合同步机制

    • 阻塞状态用于等待资源
    • 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

三、分析
  1. 互斥保证

    • 同一时间只有一个进程在临界区访问 buffer(mutex lock/unlock)
  2. 同步保证

    • 父进程消费前,子进程必须生产(条件变量 signal/wait)
    • 子进程再次生产前,父进程必须消费
  3. 阻塞与唤醒

    • 条件变量实现阻塞(Blocked)和唤醒(Ready → Running)
    • CPU 切换让另一个进程运行
  4. 状态切换规律

    • Ready → Running → Blocked → Ready → Running
    • 进程终止:Running → Terminated

四、总结

通过共享内存 + 互斥锁 + 条件变量 + fork + wait,我们系统性地演示了:

  1. 父子进程状态切换

    • New → Ready → Running → Blocked → Running → Terminated
  2. 阻塞与同步

    • 条件变量阻塞等待资源
    • signal 唤醒另一个进程继续执行
  3. 操作系统调度

    • CPU 在阻塞时可切换其他就绪进程
    • 多进程并发得以实现

💡 下一步学习建议:

  • 扩展模型:多个生产者 + 多个消费者 + 多个缓冲区
  • 死锁检测:加入多个互斥锁,观察死锁发生条件
  • 实际系统实验:用 top/htop 观察进程状态,结合代码理解调度

明白了 👍,我们从 进程管理 继续,进入下一个核心板块:

3.5:进程间通信(IPC)

这是操作系统中非常重要的内容,因为父子进程、不同进程之间往往需要交换数据或者协同工作。


一、IPC 的概念

IPC(Inter-Process Communication):进程间通信。

  • 不同进程拥有独立的地址空间,不能直接访问对方的内存

  • IPC 提供了一种机制,让进程之间可以安全地交换信息

  • IPC 机制分为:

    1. 管道(Pipe)
    2. 命名管道(FIFO)
    3. 信号(Signal)
    4. 消息队列(Message Queue)
    5. 共享内存(Shared Memory)
    6. 套接字(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 的原则

  1. 数据量小 + 父子进程 → 管道
  2. 多进程 + 高速共享数据 → 共享内存 + 互斥锁
  3. 事件通知 → 信号
  4. 多进程有序通信 → 消息队列
  5. 跨主机通信 → 套接字

💡 小结

  • IPC 是操作系统进程管理的延伸
  • 互斥、同步和阻塞概念仍然核心
  • 父子进程通信在实际系统中非常常用,例如生产者-消费者模型

我可以接下来帮你写一个完整的父子进程使用管道 + 消息队列 + 信号 + 共享内存的综合示例程序,让你把 IPC 全部实操一遍。

你希望我直接演示吗?

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐