操作系统|进程管理、线程、系统调用、进程间通信与信号

这篇文章不是按"先背定义,再背 API"的顺序写的,而是沿着我学习时不断产生的疑问,一层层追问:PCB 到底是谁维护的?既然 Linux 里进程和线程都叫 task,那 fork() 为什么不是创建线程?execve() 换掉程序之后,原来的子进程还在吗?文件描述符为什么不会跟着地址空间一起消失?

把这些问题想通后,pipe()、dup2()、posix_spawn()、wait() 甚至 abort() 就不再是彼此孤立的知识点了。

一、PCB 是内核维护,还是 init 进程维护?

最开始我纠结的是:操作系统负责进程管理,那么 PCB、TCB 究竟由谁维护?

PCB(Process Control Block,进程控制块)记录一个进程的管理信息,例如标识、运行状态、调度信息、地址空间和所持资源的关联信息。TCB(Thread Control Block,线程控制块)则强调线程的执行现场与调度信息。

注意,PCB 和 TCB 是教材中的抽象概念,不一定对应某个操作系统里恰好同名的两个结构体 。Linux 用 task_struct 表示可调度的任务,每个线程都有相应的 task_struct,进程内的线程还能引用共同的资源结构。

所以,"操作系统负责管理进程"与"内核负责维护 PCB"并不矛盾:

text 复制代码
操作系统(广义)
├── 内核:进程创建、状态维护、调度、地址空间和资源管理
└── 用户态系统软件:Shell、init/systemd、系统服务等

init 或 systemd 可以决定启动哪个服务、何时重启,却不直接修改内核里的进程控制结构。它也只是一个受到内核管理的进程。

比如执行:

bash 复制代码
systemctl start nginx

从职责上看,是 systemd 发起启动,而内核完成新任务的创建与调度。用户程序必须通过系统调用请求这些操作,不能自行去修改别的进程的 task_struct。

这又引出了另一个问题:开机以后,操作系统的代码难道都在运行吗?

当然不是。代码被装载进内存,不等于 CPU 正在执行它。一个 CPU 核心在某一瞬间只沿着自己的指令流执行;多核可以同时执行不同线程。内核代码通常在系统调用、中断、异常、内核线程运行等场景下被执行。许多功能代码平时根本不会走到,有些驱动模块甚至尚未加载。

因此,操作系统不是"一开机所有函数就同时跑起来",而是内核始终提供受保护的管理机制,相关代码按事件和调度需要执行。

二、既然进程也有执行流,fork() 出来的为什么不是线程?

我一开始的想法很自然:新进程刚创建时也只会有一条执行流,那不就是一个线程吗?以后再 clone() 几次,不就多了几个线程?

这个直觉抓住了 Linux 的实现特点,但还缺了一半:执行流是否独立,与资源归属是否独立,是两个不同维度。

从教材模型看:

text 复制代码
进程 P:资源容器 / 地址空间
├── 线程 T1:自己的寄存器、栈、调度状态
├── 线程 T2:自己的寄存器、栈、调度状态
└── 线程 T3:自己的寄存器、栈、调度状态

同一进程里的线程通常共享代码段、堆、全局变量、地址空间和文件描述符表,但分别拥有自己的栈、执行位置(PC)、寄存器现场、线程 ID 与调度状态。共享资源不意味着几个线程执行相同逻辑:一个可以处理网络请求,一个可以写日志,另一个可以等待用户输入。

在 Linux 中,clone() / clone3() 可以通过标志控制共享范围,例如:

  • CLONE_VM:共享虚拟地址空间;
  • CLONE_FILES:共享文件描述符表;
  • CLONE_THREAD:加入同一个线程组(并受到其他标志的约束)。

因此,不能简单地说"有 task_struct 就是独立进程"。Linux 的各个执行任务在内核中都由 task 描述,它们是不是同一进程的线程,要看线程组关系及资源共享语义。

而普通 fork() 的目的,是创建一个具有独立进程身份和独立虚拟地址空间的新进程。子进程最初看到父进程地址空间的内容,但两者的地址空间逻辑上彼此独立,现代 Linux 通常利用写时复制(COW)避免立即复制全部物理内存。

一句话:

text 复制代码
fork()            → 新进程;独立地址空间
pthread_create()  → 同一进程内的新线程;共享地址空间
clone()/clone3()  → 更底层的创建机制,行为取决于指定的共享关系

如果父进程原本就有三个线程呢?

这是我觉得特别值得追问的一点。

假设:

text 复制代码
父进程 P
├── T1
├── T2
└── T3

由 T2 调用 fork(),结果是:

text 复制代码
父进程 P                  子进程 C
├── T1                    └── T2'(唯一线程)
├── T2
└── T3

**子进程只保留调用 fork() 的那条线程,不会把父进程全部线程都复制过去。**但父进程整个虚拟地址空间的内容仍被复制到子进程的独立地址空间,包括互斥锁等对象的状态。

这就有一个微妙的问题:如果父进程的 T1 正持有某个锁,子进程里 T1 却不存在了,谁来解锁?因此,多线程程序在 fork() 后、execve() 前,对子进程可安全执行的操作有严格限制;一般应尽快执行新程序,而不是随意运行复杂业务逻辑。

三、两次 fork() 为什么产生四个进程,而不是三个或五个?

来看这段代码:

c 复制代码
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>

int main(void) {
    pid_t x = fork();
    pid_t y = fork();
    printf("%ld %ld\n", (long)x, (long)y);
    return 0;
}

这里暂时假设两次 fork() 都成功。

第一次 fork() :原进程 P0 创建 P1,变成两个执行同一段后续代码的进程。区别是 fork() 的返回值:父进程得到子进程的 PID,子进程得到 0。

第二次 fork():不是只有 P0 执行它,P1 也会执行。因此 P0 再创建 P2,P1 再创建 P3:

text 复制代码
P0
├── P1
│   └── P3
└── P2

总数为 4 个进程,输出通常也是 4 行;输出顺序由调度决定,不固定。假设 PID 分别是 1001、1002、1003,可能出现:

进程 x y
P0 1001 1002
P1 0 1003
P2 1001 0
P3 0 0

这个例子最重要的不是记住 2^n,而是想清楚:fork() 之后,父子进程都会从这次调用的返回位置继续执行;所以后续的 fork() 也会由它们分别执行。

连续执行 n 次、没有分支跳过调用且都成功时,最终进程总数才是 2^n。注意,这包含原来的进程,不是"新建了 2^n 个子进程"。

四、Shell 要运行 ls,为什么先复制一个 Shell?execve() 到底换了什么?

这里有一个看似矛盾的地方:Shell 执行 ls,目的显然不是再开一个 Shell。那么为什么 fork() 会先复制原进程?

因为 fork() 解决"谁来运行",execve() 解决"运行什么"。

典型的启动流程可以理解为:

text 复制代码
Shell(PID=100)
    |
    | fork()
    v
子进程(PID=101,起初仍运行 Shell 的程序代码)
    |
    | execve("/bin/ls", argv, envp)
    v
ls(PID=101,仍然是 Shell 的子进程)

execve() 成功时,用新的程序映像替换调用它的当前进程 的用户态程序映像,建立新程序的代码、数据、堆、栈等运行环境。它本身不创建第二个子进程,通常也不改变进程的 PID 和父子关系。

那么,最初那份被 fork() 复制来的地址空间是不是白忙活了?从逻辑上看确实会被新映像取代;但这正是 COW 和 posix_spawn() 等优化有意义的地方。更重要的是,fork() 与 execve() 之间还可以先准备重定向、管道、进程组等资源环境。

顺带纠正一个容易产生的新误解:fork() 创建的不是"空壳进程" 。子进程最初拥有与父进程相似的程序映像和执行现场,并从 fork() 返回点继续执行;只有执行 execve() 成功后,这份用户态程序映像才被替换。

五、地址空间都换了,为什么文件描述符还在?

学到这里,我又产生了一个疑问:

假设父进程 open() 了一个文件,子进程继承了它。但随后子进程 execve(),原先地址空间里的 fd 变量都没了,文件是不是就再也没法关闭了?

要解开这个问题,必须区分三个层次:

text 复制代码
用户空间中的 int fd = 3
          |
          v
进程的文件描述符表:表项 3
          |
          v
内核中的打开文件描述(open file description)
          |
          v
对应的文件 / 管道 / socket 等对象

fd 只是一个进程内部的整数索引 。int fd 这个 C 变量可以在 execve() 后消失,但内核里的 fd 表项并不会因此自动消失。

同理,不是进程使用任何资源都会建立 fd 。open()、pipe()、socket() 等会产生文件描述符;而 malloc() 申请内存、CPU 调度、线程本身、PCB 等并不靠 fd 表管理。

fork() 复制 fd 表,不代表复制底层文件

例如父进程打开 data.txt,获得 fd=3,随后 fork():

text 复制代码
父进程 fd 表                  子进程 fd 表
     3 ───────┐         ┌────── 3
              v         v
            同一个内核打开文件描述
                     |
                  data.txt

父子进程通常各自有一份 fd 表,但对应表项引用同一个打开文件描述 。因此它们可能共享文件偏移量等打开文件状态。这里的"共享文件对象"不等于"共享 fd 表":父进程 close(3),不会直接把子进程的 3 号表项也关掉。

而同一进程的普通 POSIX 线程一般共享 fd 表,因此一个线程关闭某个 fd 会影响其他线程后续使用该 fd。

FD_CLOEXEC:不是"执行 execve 就全关"

默认情况下,execve() 会保留打开的文件描述符;只有设置了 FD_CLOEXEC 的那些 fd,才会在成功执行 execve() 时自动关闭。

c 复制代码
int fd = open("data.txt", O_RDONLY | O_CLOEXEC);

O_CLOEXEC 是在创建 fd 时原子地设置 close-on-exec 标志的方式。也可以对已有 fd 用 fcntl() 设置 FD_CLOEXEC。

例如:

text 复制代码
execve() 之前:
0 → stdin
1 → stdout
2 → stderr
3 → data.txt     [FD_CLOEXEC]
4 → log.txt      [未设置]

execve() 之后:
0 → stdin
1 → stdout
2 → stderr
4 → log.txt

为什么不干脆全部关闭?因为新程序可能故意继承文件描述符:Shell 的输出重定向、进程间管道以及服务进程接收已打开的 socket,都要利用这一机制。

还有一个细节:原来的 fd 变量没了,不代表新程序一定不可能 关闭它。如果新程序知道描述符编号或按约定接收了该编号,仍可以调用 close()。真正需要防范的是不希望继承的 fd 被无意带入新程序,造成泄漏或安全问题。

六、pipe() 返回两个 fd,难道创建了两个子进程?

不是。这个问题要把"管道对象""文件描述符"和"进程"彻底分开。

c 复制代码
int pipefd[2];
pipe(pipefd);

pipe() 创建的是内核里的一个单向数据通道,返回两个 fd:

text 复制代码
pipefd[0] → 读端
pipefd[1] → 写端

它不会创建任何进程。假设随后只调用一次 fork():

text 复制代码
              同一个匿名管道
             ┌────────────┐
父进程 fd[1] ──→ 缓冲区 ──→ fd[0] 子进程
             └────────────┘

刚 fork() 完时父子其实都拥有读端和写端,只不过典型的单向通信约定为:

c 复制代码
if (fork() == 0) {
    close(pipefd[1]);       // 子进程只读
    // read(pipefd[0], ...)
} else {
    close(pipefd[0]);       // 父进程只写
    // write(pipefd[1], ...)
}

关闭不用的一端不仅是为了整洁,还关系到通信能不能正常结束:**当管道缓冲区已读空且所有写端都关闭后,读端的 read() 才能返回 0,表示 EOF。**如果读者自己还握着写端,可能会一直等不到 EOF。

为什么叫"匿名"?

因为 pipe() 创建的对象没有供进程通过路径名重新打开的文件系统名称。它通常在父子进程之间通过继承 fd 来共享。

与之相对:

bash 复制代码
mkfifo mypipe

会创建有路径名的 FIFO(命名管道),别的进程可以通过这个路径打开它。

不过"匿名管道只能给父子进程用"也说得太绝对:无亲缘关系的进程还可以通过 Unix 域套接字传递 fd 等方式取得管道端点。匿名的含义是没有可供 open(path) 查找的路径名,而不是绝对不能共享。

七、stdout 重定向,原来真的只是改 fd 表里的指向

平常执行:

bash 复制代码
ls > out.txt

为什么 ls 不必知道 out.txt 的存在,输出却写到了这个文件?

关键是标准输出通常使用描述符 1:

text 复制代码
0 → stdin
1 → stdout → 终端
2 → stderr → 终端

Shell 在运行 ls 之前,可以做:

c 复制代码
int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO);
close(fd);

假设 open() 返回 3,那么变化是:

text 复制代码
执行 dup2(3, 1) 前:
1 → 终端
3 → out.txt

执行 dup2(3, 1) 后:
1 ─┐
   ├→ 同一个打开文件描述 → out.txt
3 ─┘

执行 close(3) 后:
1 → out.txt

因此,所谓"把 stdout 重定向到文件",并不是把 fd=1 这个索引改成数字 3,而是让 fd 表里的 1 号表项引用原来 3 号表项所指向的打开文件描述。

后来 execve() 启动 ls,ls 照常向标准输出写数据;此时标准输出早已被安排为 out.txt。这个机制也正是 Shell 管道 A | B 的基础:把 A 的 stdout 和 B 的 stdin 分别接到同一个管道的两端。

八、posix_spawn() 为什么把 fork + execve 包成一个接口?

前面这一连串操作,实际上构成了非常固定的模式:

text 复制代码
创建子进程
   ↓
配置 fd、信号、进程组等属性
   ↓
执行新程序

POSIX 因此提供 posix_spawn()。它在使用效果上 相当于创建子进程、进行指定的准备操作,再执行目标程序;但具体实现不保证内部一定先调用传统 fork() 。例如,现代 glibc 就可能使用带特定标志的 clone() 来实现。

其接口可以写成(省略 restrict 等限定符):

c 复制代码
#include <spawn.h>

int posix_spawn(
    pid_t *pid,
    const char *path,
    const posix_spawn_file_actions_t *file_actions,
    const posix_spawnattr_t *attrp,
    char *const argv[],
    char *const envp[]
);
参数 我现在的理解
pid 成功时填写新子进程的 PID;函数自身返回 0 表示成功
path 运行哪个可执行文件
file_actions 子进程执行新程序前做哪些 open、close、dup2 操作
attrp 配置部分信号、调度、进程组等属性
argv 新程序的命令行参数
envp 新程序接收的环境变量

例如,要让新进程的标准输出直接写入 out.txt,可以预先配置 file_actions:

c 复制代码
#include <spawn.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/wait.h>

extern char **environ;

int main(void) {
    pid_t pid;
    posix_spawn_file_actions_t actions;
    char *argv[] = {"echo", "hello from spawn", NULL};

    posix_spawn_file_actions_init(&actions);
    posix_spawn_file_actions_addopen(
        &actions,
        STDOUT_FILENO,
        "out.txt",
        O_WRONLY | O_CREAT | O_TRUNC,
        0644
    );

    int err = posix_spawn(&pid, "/bin/echo", &actions, NULL, argv, environ);
    posix_spawn_file_actions_destroy(&actions);

    if (err != 0) {
        fprintf(stderr, "posix_spawn failed: %d\n", err);
        return 1;
    }

    waitpid(pid, NULL, 0);
    return 0;
}

这里省略了初始化和文件操作配置接口的错误检查,重点是理解:file_actions 是在新程序启动前,为子进程准备好 fd 映射 。posix_spawn() 返回成功,也不保证目标程序之后一定运行成功;具体执行失败仍要结合子进程退出状态判断。

九、main(argc, argv) 和 Shell 的 export 又是怎么接上的?

执行:

bash 复制代码
./calc 100 200

程序可以写:

c 复制代码
int main(int argc, char **argv)

通常有:

text 复制代码
argc    = 3
argv[0] = "./calc"
argv[1] = "100"
argv[2] = "200"
argv[3] = NULL

argc 是参数个数,argv 是由字符串指针构成的数组;char **argv 与这里的 char *argv[] 等价。严格来说,argv[0] 的内容由程序启动方提供,并不保证一定等于真实可执行文件路径。

而 execve(path, argv, envp) 的第三个参数 envp,就是新程序接收的环境变量数组。

比如 Shell 中:

bash 复制代码
name=Bob
export name
bash -c 'echo "$name"'

新启动的 Bash 可以读取到 Bob。

但这儿有一个很重要的区别:**fork() 本身会复制父进程的地址空间;export 的关键作用是在启动另一个程序映像时,让 Shell 把选定的变量放进传给新程序的环境中。**没有 export 的普通 Shell 变量不会自动成为外部命令的新环境变量。

而环境的传递方向也是单向的:子进程修改自己的 name,不会反过来修改父 Shell 的 name。

十、wait() 的"阻塞"到底是什么意思?为什么会出现僵尸进程?

另一个一开始让我觉得别扭的描述是:

"父进程调用 wait() 获得子进程退出通知(子进程退出前会阻塞父进程)。"

这里准确的因果关系是:父进程主动调用 wait();如果还没有符合条件的子进程退出,内核就让父进程等待,而不是子进程主动去阻塞父进程。

text 复制代码
父进程调用 wait()
       |
       | 子进程尚未退出
       v
父进程进入等待状态(不占用 CPU 忙等)
       |
       | 子进程退出,内核记录退出信息并唤醒等待者
       v
父进程重新变为可运行,获得调度后继续执行
       |
       v
wait() 返回,回收子进程剩余的退出记录

如果子进程在 wait() 之前已经退出,wait() 可以直接取得结果,不必先阻塞。waitpid(..., WNOHANG) 还能选择不等待。

子进程退出后,通常会释放大部分资源,但内核还要暂存 PID、退出状态等信息,等父进程收取。这时它就可能成为僵尸进程(Zombie) 。父进程 wait() 之后,内核才能完成这部分收尾工作。

另外,wait(&status) 得到的 status 不是简单的退出码整数,应当这样解析:

c 复制代码
#include <sys/wait.h>

int status;
pid_t child = wait(&status);
if (child > 0) {
    if (WIFEXITED(status)) {
        int code = WEXITSTATUS(status);
    } else if (WIFSIGNALED(status)) {
        int sig = WTERMSIG(status);
    }
}

这里 WIFEXITED 对应正常退出,WIFSIGNALED 对应被信号终止,两者含义不同。

十一、abort() 发出的 SIGABRT,为什么说"不能被 ignore"?

最后一个问题又把我们带回内核和进程状态管理。

信号可以有默认处理方式,也可以在允许的情况下被程序捕获或忽略。例如,SIGINT 通常对应终端中的 Ctrl+C。而:

c 复制代码
#include <stdlib.h>
abort();

abort() 会触发 SIGABRT,其目的就是引发异常终止。

但要区分两句话:

  1. "SIGABRT 这个信号在任何场景下都绝对不能设置为忽略。"------不对。
  2. "调用 abort() 时,即使进程设置了忽略 SIGABRT,abort() 也不会就此失效。"------对。

在 Linux 上,abort() 会解除对 SIGABRT 的阻塞并产生该信号。如果信号被忽略,或者捕获它的处理函数正常返回,abort() 仍会恢复默认动作并再次触发,从而终止进程。通常的结果是异常退出,并可能生成 core dump(取决于系统配置)。

也有值得严谨说明的例外:如果自定义信号处理函数以非正常返回的方式转移控制,例如通过 longjmp 离开,abort() 不一定立即完成终止。因此正确结论不是"程序在任何技术条件下都绝无可能存活",而是:忽略 SIGABRT,或者让普通信号处理函数返回,不能阻止 abort() 的终止语义。

十二、把整条逻辑链重新串起来

回头看,最开始我只是好奇 PCB 到底由谁维护,结果一路牵出了整个进程运行机制:

text 复制代码
                         操作系统 / 内核
                               |
                     维护任务与进程资源
                               |
              ┌────────────────┴────────────────┐
              |                                 |
        fork() 创建子进程                  clone() 等机制创建任务
              |                                 |
    子进程继承初始运行环境              按共享关系组成线程组
              |
        配置 fd / 环境 / 属性
              |
         execve() 替换程序映像
              |
      继续使用保留下来的 fd
       /             |          \
  文件读写        管道通信       stdout 重定向
       \             |          /
              程序运行并退出
                      |
            父进程 wait()/waitpid()
                      |
               获取状态并回收

到这里我觉得,最应该形成的不是一张 API 记忆表,而是四个明确的区分:

  • 进程不等于程序 :fork() 创建进程,execve() 替换程序映像,PID 和父子关系通常不变。
  • 执行流不等于资源容器:一个进程可以包含多个线程,线程共享部分资源,但执行现场独立。
  • 用户态变量不等于内核资源 :用户空间的 fd 变量消失,不代表 fd 表项与内核文件对象就会消失。
  • 发起管理不等于直接管理内核结构:Shell、systemd 可以决定启动、停止、等待谁,真正维护进程状态和内核资源的仍然是内核。

我学习操作系统时越来越明显地感受到:同一个现象只要从"谁决定""谁执行""数据结构放在哪里""哪些东西共享"四个方向追问,很多看起来冲突的结论就能连起来。


相关推荐
艾iYYY20 小时前
【无标题】
linux·开发语言·网络·操作系统
花 满 楼21 小时前
RTA-OS 任务调度机制深度拆解:从 alarm 到期到任务切换的全链路(PowerPC e200)
os·嵌入式软件·汽车电子
打不了嗝 ᥬ᭄2 天前
计算机系统基础:异常、中断与 I/O 的完整脉络
操作系统·计算机系统基础·linux系统调用·异常与中断·io与dma
doujiaozh3 天前
我在 Win7 老电脑上从零手搓了一个操作系统内核:32位分页、中断接管与 Shell 终端
操作系统·汇编语言
辰丁电子4 天前
allegro入门教程-菜单栏Analyze
fpga开发·pcb设计·pcb工艺·pcb·layout
FPGA小徐4 天前
电子工程师面试真题:4-20mA 电流环采集电路(TL431+LM358)原理分析
pcb
打不了嗝 ᥬ᭄4 天前
计算机系统基础:程序的表示、转换与链接
操作系统·计算机系统基础
Coisinier5 天前
docker命令管理镜像及容器
linux·运维·nginx·docker·容器·操作系统
辰丁电子6 天前
allegro入门教程-菜单栏Route
fpga开发·pcb工艺·pcb·layout·allegro软件