进程

拥有独立地址空间的运行实体,可以包含一个或多个线程
  • 与线程的线程控制块(TCB)一样,进程也需要一些元信息,用来描述进程,方便操作系统管理,即进程控制块(PCB)

alt text

  • 一个进程是被某个进程所创建的,一个进程也可以创建多个进程
  • 可以构成一个进程树,其中父进程节点指向其所创建的子进程
第一个进程 init

CPU reset \(\rightarrow\) Firmware 代码执行 \(\rightarrow\) 加载操作系统并初始化 \(\rightarrow\) 加载第一个进程

  • init 进程加载完之后,操作系统已经完成了所需要资源的管理,并在后台等待用户命令(syscall)或者中断的发生

1 进程的生命周期

  • 单线程进程(和线程生命周期一致):

alt text

  • 多线程进程:

alt text

  • 终止之后的进程的 “资源” 会被操作系统回收,并通知其父进程其终止状态
为什么要通知?
  • 悬空指针:PCB 中父进程含有指针指向子进程 PCB,如果进程终止之后连 PCB 也都被回收,那么该指针就会指向一个 “已经” 被释放的内存
  • 已终止但其 PCB 信息还没被回收的进程被称为 “僵尸进程”
  • 父进程调用 wait 系统调用会得到子进程的退出通知(子进程退出前会阻塞父进程)和其退出状态,同时移除该子进程的 PCB
  • 但并不是每个父进程都老老实实的 wait 子进程的,其完全可能在子进程终止前就终止了:这时的子进程是没有父进程的!
  • 没有父进程的进程被称为 “孤儿进程”

进程最重要的三类系统调用:fork(创建)、execve(改变)、exit(删除)

2 fork

  • 创建一个新的(子)进程
    • 通过做一份当前进程完整的复制(内存、寄存器现场)
    • 子进程和父进程会各自独立地继续执行 fork 之后的指令
  • 如何区分父子进程?
    • fork 的返回值不同: 子进程返回 0,父进程返回子进程的 process ID
    • fork 出错返回 -1
int x = 42;
int ret = fork();
if (ret < 0) {
    //fork 失败
    fprintf(stderr, "Fork failed\n");
} else if (ret == 0) {
    //子进程
    printf("Child process id: %d, and the value of x is %d \n", ret, x);
} else {
    //父进程
    printf("Parent process id: %d, and the value of x is %d \n", ret, x);
}
  • 立即复制状态机,所有信息的完整拷贝
    • 内存、PCB(如打开的文件描述符号)

alt text

文件描述符

一个指向操作系统内对象的 “指针”

  • 对象只能通过操作系统允许的方式访问
  • 可以通过 open 取得,close 释放,dup 复制
  • 对于数据文件,文件描述符会 “记住” 上次访问文件的位置

多线程的进程进行 fork 时,只有一个线程被复制,就是那个调用 fork 的线程

int main() {
    pid_t x = fork();
    pid_t y = fork();
    printf("%d %d\n", x, y);
}
时间线 →

第 1 个 fork():

    P0 ──────────┬────────── P0 (x=N1, 继续执行)
                 │
                 └────────── P1 (x=0,  继续执行)

第 2 个 fork():  (P0 和 P1 各执行一次)

    P0 (x=N1) ──┬──── P0 (x=N1, y=N2)
                │
                └──── P2 (x=N1, y=0)

    P1 (x=0) ───┬──── P1 (x=0,  y=N3)
                │
                └──── P3 (x=0,  y=0)

但实际输出顺序是不定的
int main() {
    for (int i = 0; i < 2; i++) {
        fork();
        printf("Hello\n");
    }
}

forkstdout

  1. fork() 的内存复制: 当调用 fork() 创建子进程时,子进程会获得父进程数据空间、堆和栈的副本,这也包括了用户态空间中 C 标准库维护的 I/O 缓冲区
  2. stdout 的缓冲策略:
    • 行缓冲: 当标准输出直接连接到终端(显示器)时,遇到换行符 \n,或者缓冲区满,或者进程结束时,缓冲区的数据才会被真正写入内核(系统调用 write)并显示出来。
    • 全缓冲: 当标准输出重定向到文件或管道(如 | cat)时,缓冲策略变为全缓冲。此时遇到 \n 不会触发刷新,只有当缓冲区填满或进程正常结束(exit)时,才会统一刷新。

💻 场景一:直接运行 ./a.out (输出 6)

此时标准输出连接到终端,使用行缓冲,因为 printf 里面有 \n,所以每次执行 printf 都会立即清空缓冲区并输出

  • 初始状态: 只有一个父进程 P0。
  • 第一轮循环 (i = 0):
    • P0 执行 fork(),创建子进程 P1。现在有 P0 和 P1 两个进程。
    • P0 和 P1 分别执行 printf("Hello\n")
    • 【关键】 因为是行缓冲且带有 \n,两个进程各自立即输出 1 次 “Hello”,随后缓冲区被清空
    • 本轮输出:2次。
  • 第二轮循环 (i = 1):
    • P0 执行 fork() 创建 P2;P1 执行 fork() 创建 P3。现在有 P0, P1, P2, P3 四个进程。
    • 四个进程的缓冲区此时都是空的。
    • 这四个进程分别执行 printf("Hello\n"),立即输出。
    • 本轮输出:4次。
  • 最终结果: 2 + 4 = 6 次。

💻 场景二:加上管道 ./a.out | cat (输出 8)

此时标准输出通过管道传给 cat,系统将其识别为非交互式设备,缓冲策略变为全缓冲\n 失去了立即刷新缓冲区的作用。

  • 初始状态: 进程 P0。
  • 第一轮循环 (i = 0):
    • P0 执行 fork(),创建 P1。
    • P0 和 P1 执行 printf("Hello\n")
    • 【关键】 因为是全缓冲,字符串 “Hello\n” 被写进了 P0 和 P1 各自的用户态缓冲区中,并没有被打印出来
    • 此时 P0 缓冲区:["Hello\n"]
    • 此时 P1 缓冲区:["Hello\n"]
  • 第二轮循环 (i = 1):
    • P0 执行 fork() 创建 P2。
    • 高能预警: fork() 会复制父进程的内存!因此,子进程 P2 不仅复制了代码,还原封不动地复制了 P0 此时的缓冲区。所以 P2 诞生时,它的缓冲区里就已经有一个 “Hello\n” 了。
    • P1 执行 fork() 创建 P3。同理,P3 复制了 P1 的缓冲区。
    • 现在有 P0, P1, P2, P3 四个进程。它们各自执行 printf("Hello\n")
    • 新打印的 “Hello\n” 被追加到这四个进程各自的缓冲区中。
    • 此时四个进程的缓冲区状态:
      • P0 缓冲区:["Hello\n", "Hello\n"]
      • P1 缓冲区:["Hello\n", "Hello\n"]
      • P2 缓冲区:["Hello\n" (继承自P0), "Hello\n" (新加的)]
      • P3 缓冲区:["Hello\n" (继承自P1), "Hello\n" (新加的)]
  • 程序结束:
    • 循环结束,四个进程依次调用 exit() 退出。
    • 进程退出时,C 语言底层的退出处理函数会自动清空并刷新尚未输出的缓冲区。
    • 四个进程各自把缓冲区里的 2 个 “Hello\n” 推给操作系统。
  • 最终结果: 4 个进程 × 每个进程缓冲了 2 个输出 = 8 次。

📝 关于一个提示:改为 "Hello\t" 试试

如果把换行符 \n 改为制表符 \t,即使是直接运行 ./a.out,结果也会是 8。

原因非常直白:

  • 即使连接到终端(行缓冲模式),制表符 \t无法触发缓冲区刷新(只有 \n 可以)。
  • 因此,缓冲区里的数据会一直憋着,直到进程结束。这导致即使没有管道,程序的行为也完全退化成了全缓冲的模式。其内部的缓冲区复制过程与 “场景二” 完全一致,最终也会输出 8 次。

  • 由于 fork 的能力,一个简单的应用就是可以用来给进程创建 “快照”
  • 主进程 crash 了,启动快照重新执行

3 execve

将当前进程重置成一个可执行文件描述状态机的初始状态
int execve(const char *pathname, char *const argv[], char *const envp[]);
  • 加载 pathname 指定的可执行文件(数据段、代码段)
  • 重新初始化堆和栈
  • PCB 中相应的 memory mappings 也会改变
  • 将 PC 寄存器设置到可执行文件代码段定义的入口点,该入口点最终会调用 main 函数

execve 的成功调用没有返回值,因为新的进程镜像覆盖了调用进程镜像

  • 创建一个新的子进程的组合拳(一个简易 shell 的示意)
extern char **environ; // environment variables of current process
while(1){
    type_prompt();     // display prompt on the screen
    read_command(&filename, &parameters); //read the input command
    int pid = fork();
    if (pid == -1) {
        perror("fork");
    } else if (pid == 0) { // Child
        execve(filename, parameters, environ);
    } else {
        int status; // Parent
        waitpid(pid, &status, 0);
    }
}

3.1 管道*

  • execve 不会改变 PCB 中的文件描述符,那些打开的文件描述符还会保持打开
  • 比如 shell 里开启的进程打印字符都会在同样的终端
  • 管道的实现(进程间的通信)

alt text

1. 建立水管:pipe() 系统调用

  • 父进程调用 int pipe(int pipefd[2]);,让操作系统在内核里开辟了一块专门的内存缓冲区(图里黄色的 pipe 实体)。
  • 同时,系统给父进程返回了两个文件描述符(File Descriptors, FDs)
    • fd[0]读口
    • fd[1]写口
  • 此时,父进程自己既能往里写,也能往外读。

2. 分身并继承:fork() 的魔法

  • 父进程调用 fork() 产生了一个子进程。
  • 关键点来了!fork() 会完美克隆父进程的 PCB,子进程也会原封不动地继承这两个文件描述符 fd[0]fd[1]
  • 现在,父子两个进程的 FD 都指向了内核中同一个管道(黄色的 pipe 实体)。

3. 确立单向水流:关闭多余端口

  • 虽然现在父子进程都能读能写,但管道在设计上通常是单向通信的。为了防止混乱(比如父进程写的数据被自己不小心读回来了),需要剪断多余的连接。
  • 在上图中:
    • 父进程想把数据发给子进程。所以父进程把自己的读口关掉(close(fd[0])),只保留写口。
    • 子进程负责接收数据。所以子进程把自己的写口关掉(close(fd[1])),只保留读口。
  • 至此,一条完美的单向数据流就建成了:父进程 fd[1] 写入 \(\rightarrow\) 管道缓冲 \(\rightarrow\) 子进程 fd[0] 读取
为什么 execve 的特性这么重要?

底层原因:execve 不会改变 PCB 中的文件描述符,原先打开的 FD 会继续保持打开状态。

这解释了你在 Linux 终端里常用的 | 符号(比如 ls | cat)是怎么工作的:

  1. Shell(父进程)先创建一个管道。
  2. Shell fork 出两个子进程 A 和 B。
  3. 通过一系列操作,把 A 的标准输出绑到管道的写口,把 B 的标准输入绑到管道的读口。
  4. 最关键的一步:子进程 A 和 B 分别调用 execve 去加载真正的程序(比如 lscat)。
  5. 由于 execve 不会关闭之前配置好的管道 FD,所以当 ls 程序试图往屏幕打印东西时,数据实际上顺着保留下来的管道,偷偷流进了 cat 程序的嘴里!

但如果是父进程打开了一个普通文件,在地址空间里有一个相应的 file 变量索引,但你的子进程重置了整个地址空间,因此那个文件描述符对应的 file 变量也没有了,此时你无法 close 这个文件了

这会造成资源的泄漏,当然这些资源(PCB)都会随着进程的终止而最终被回收,但在运行期间还是有一些资源的损耗

3.2 写时复制(Copy-On-Write,COW)

fork 后面往往跟着 execve 来加载子进程,那么 fork 的过程还必要吗?

  • 早期的 fork 就是这么无脑的复制父进程的一切,这个过程是低效的
    1. 有些内存是只读的,比如代码段、共享代码库,这些没必要复制
    2. 立即执行 execve 会加载新的可执行文件,重置地址空间,之前的内存拷贝没有任何意义

对于那些可以改变的内存,人们给出了一个聪明的设计:写时复制

  • 即两个进程共享同一份物理内存
  • 只有当一个进程尝试去写这个物理内存时才会真正在物理内存中复制一份副本用来给这个进程去写
  • 问题是,写自己的内存是一个 “用户态” 事件,内核又怎么知道呢?
  • 标记这个共享的内存为 “只读”,一旦发生 “写” 操作会发生权限错误陷入内核,操作系统获知这是一个 COW 事件,复制内存!

alt text

4 exit

进程的终止:清理其所占内存空间(包括代码区、堆、栈),这个过程需要系统调用来做

进程一般有 5 种退出机制

  • 正常退出:从 main 函数返回,调用库函数exit,调用 _exit
  • 异常退出:调用 abort,由信号终止

4.1 exit()

void exit(int status); // 从不返回
  • 调用 atexit(),指定在程序终止时执行自己的清理动作
  • 关闭所有打开的流 (stdio),这将导致写所有被缓冲的输出
  • 移除所有的临时文件
  • 最后调用 _exit() 函数终止进程

4.2 return from main()

  • main 函数的返回值和调用 exit() 的传入参数是同样的语义
  • 0 是函数是符合预期终止,非 0 是函数出现了错误终止

4.3 _exit()

void _exit(int status);
  • 所有属于这个进程的文件描述符都会被关闭
  • 所有该进程的子进程都会被 init 进程接管
  • 向该进程的父进程发送 SIGCHLD 信号,通知该父进程其已经终止

注意:_exit() 只会终止当前的线程,但 libc 做了一层 wrapper,其实真实调用 exit_group(),关闭所有线程

4.4 abort()

void abort(void);
  • atexit 注册的函数不会调用
  • io 流不会关闭
  • 其行为就是产生一个 SIGABRT 信号发送给调用 abort() 的进程,然后该进程就会异常终止

4.5 信号(Signal)

区别于条件变量机制下的 signal,用于唤醒正在休眠等待的线程

  • 本质上是对中断的模拟

alt text

默认信号处理程序通常直接终止进程

signal(SIGNAL_NUMBER, handler); 

signal 函数可以注册信号处理函数,只要传入相应的信号和函数名即可

  • handler 设为 SIG_IGN 时表示忽略这个信号
  • signal(SIGCHLD, SIG_IGN) 表示忽略子进程的终止信号,此时子进程结束会直接被内核完全清除(而不必先变为僵尸进程,然后再被回收)

有了信号机制,可以完成很多异步的操作:

  • signal(SIGCHLD, handler),可以在 handler 里进行 wait,而不是在 main 函数里 wait/waitpid 从而阻塞父进程
  • signal(SIGIO, handler) 可以不用等待 I/O 完成,先做其他事情,如果文件描述符所指向的数据传输完成,会产生 SIGIO 的事件,就可以通过回调函数 handler 来处理

信号也是一种进程间通信(IPC)的机制之一,此外还有消息传递、共享内存、管道等

标题:进程

作者:Zwing

创建于:2026-08-09 00:10:00

更新于:2026-08-08 16:25:54

链接:https://zanytriumph.github.io/posts/虚拟化-进程.html

版权声明:本文章采用 CC BY-NC-SA 4.0 进行许可