Linux 进程与 fork 创建

文章目录
在前面的开发工具学习中,我们已经基本能够用 GCC 编译程序,再通过 ./程序名 运行。此时磁盘上的文件没有消失,而系统里多了一个可以被 ps 看见、可以等待输入、也可以被终止的对象。
这个对象就是进程。理解它,才能继续解释“程序为什么停在那里”“同一个程序为什么能运行多份”“父子进程为什么各有一个 PID”。
1. 程序执行与操作系统
1.1 CPU、内存与磁盘各自承担什么
学习进程可以不需要先完整学习计算机组成原理,但是需要分清其中的三个不同角色:
| 组成部分 | 理解 |
|---|---|
| 磁盘等存储设备 | 长期保存源文件、可执行文件和其他数据 |
| 内存 | 为执行中的程序提供可访问的代码、数据等内容 |
| CPU | 取指令、执行运算,并改变执行位置和寄存器中的值 |
冯诺依曼体系中“程序和数据存放在存储器中”的思想,可以帮助我们理解为什么运行程序需要内存。
这里可以先初步建立一个够用的模型:磁盘上保存程序,运行时建立相应的内存映射,CPU 执行指令。
1.2 操作系统负责把资源协调起来
服务器上通常同时存在 Shell、系统服务和用户程序。CPU 时间、内存、文件和设备需要被协调使用,单靠应用程序各自的约定显然不够。
操作系统内核承担资源管理和执行控制等工作。 应用程序需要这些服务时,通过系统调用接口提出请求。例如,创建进程最终需要内核参与,普通 C 程序不能自行往内核里增加一条进程记录。
下面的几个概念要分开理解:
- 内核管理进程、内存、文件和设备等,是操作系统的核心。
- Shell读取并解释命令,本身也是用户空间程序。
- C 库提供
printf等函数,也提供访问系统服务的接口封装。 - 系统调用是用户程序请求内核服务的接口。库函数与系统调用不是一一对应关系。
例如,printf 会处理格式和缓冲,但真正向文件或终端输出时通常需要都底层的写操作。
2. 进程是程序的一次执行实例
一个可执行文件包含指令及相关信息,属于静态文件。进程是程序运行时的执行实例,具有自己的身份、执行状态和资源关系。
同一个可执行文件可以运行两次,得到两个进程;反过来,一个进程也并非永远绑定最初那份程序,后续也可能发生程序替换。
“进程是正在运行的程序”是一种简写,但其中“运行”指整个执行过程。进程正在等待输入、暂时没有得到 CPU,依然是进程。 它不需要每一刻都在执行机器指令。
2.1 PCB 记录进程的管理信息
操作系统不能只拿到一段代码就完成管理,还需要知道:它是谁、准备好执行没有、上次执行到哪里、使用哪些资源。
描述这些信息的控制结构通常称为 PCB(Process Control Block,进程控制块)。在 Linux 中,可以用 task_struct 来理解这一管理入口。
| 信息类别 | 用来回答的问题 |
|---|---|
| 标识与关系 | PID 是多少?父进程是谁? |
| 状态与调度信息 | 是否可以执行?如何参与 CPU 调度? |
| 执行上下文 | 暂停之前执行到哪里?恢复时需要哪些 CPU 状态? |
| 内存关系 | 使用哪个地址空间?有哪些映射? |
| 文件等资源关系 | 打开了哪些文件?当前工作目录是什么? |

熟悉 C 结构体后,可以把“描述一个对象”理解为建立记录,把“管理很多对象”理解为组织这些记录。但实际内核会按用途采用不同数据结构:寻找进程、组织可运行任务、等待某个事件,都可能需要不同的组织方式。
因此,“进程 = 代码 + 数据 + PCB”适合作为入门概括,更准确的理解是:进程具有执行所需的程序映像,并通过内核中的管理结构关联状态与资源。 代码页、打开的文件等资源还可能存在共享关系,并非每项资源都为进程独占。
3. 观察进程身份与资源
3.1 用一个等待输入的程序留出观察窗口
在服务器的普通用户目录中建立实验目录:
mkdir -p ~/linux-process-lab
cd ~/linux-process-lab
将下面的完整程序保存为 pid_watch.c:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
printf("PID=%ld, PPID=%ld\n", (long)getpid(), (long)getppid());
puts("Press Enter to exit.");
if (fflush(stdout) == EOF)
{
perror("fflush");
return 1;
}
(void)getchar();
return 0;
}
gcc -std=c11 pid_watch.c -o pid_watch
./pid_watch
程序打印两项标识后等待回车。实际数字以服务器输出为准:

getpid()返回当前进程的 PID。getppid()返回当前父进程的 PID。- 它们的返回类型是
pid_t。这里转换为long后配合%ld输出。
在同一个交互式 Shell 中分别启动这个程序,通常会看到不同的 PID,而 PPID 仍指向同一个 Shell。
PID 可以被系统重复利用,不是永久身份证;父进程退出后,PPID 也可能变化。
3.2 使用 ps 定位指定进程
保持程序等待输入,另开一个 SSH 会话,仍以同一用户登录,使用PID观察上一个程序的进程信息:
ps -p 89779 -o pid,ppid,stat,comm,args
运行示例:

这里 -p 选择进程,-o 选择输出列。
| 列 | 含义 |
|---|---|
PID | 当前进程标识 |
PPID | 父进程标识 |
STAT | 状态及附加标记,下一篇详细解释 |
COMMAND 等命令相关列 | comm 通常显示短名称,args 显示命令行 |
ps ajx 适合查看较完整的父子关系和作业相关信息;top 适合持续观察资源使用。
它们展示的是观察时的信息,进程可能在下一刻发生变化。
S:可中断睡眠(sleep,等待事件)+:该进程属于前台终端作业
3.3 /proc 展示进程的内核视图
同样在第二个 SSH 会话执行:
cat /proc/89779/status
readlink /proc/89779/exe
readlink /proc/89779/cwd
(其中cat /proc/89779/status 输出的是一大串进程相关的信息,在此不过多展示)

/proc 是虚拟文件系统。(这些内容通常由内核动态提供)
| 路径 | 重点观察 |
|---|---|
/proc/PID/status | 名称、状态、PID、PPID 等信息 |
/proc/PID/exe | 当前执行文件的符号链接 |
/proc/PID/cwd | 当前工作目录的符号链接 |
exe 和 cwd 很容易混淆:前者回答“执行的是哪个文件”,后者回答“相对路径以哪个目录为起点”。
观察结束后,回到第一个会话按回车即可退出。
4. Shell 与父子进程
执行外部命令时,交互式 Shell 通常会创建子进程,再让子进程执行目标程序。因此,前面示例的 PPID 往往是启动它的 Shell。
但不能据此推断“输入任何命令都会创建进程”。例如:
cd ~/linux-process-lab
export PROCESS_NOTE=hello
cd 需要改变当前 Shell 的工作目录,export 需要改变当前 Shell 后续传给程序的环境,因此通常由 Shell 内部执行。
这就已经体现出了一个进程的边界:修改自己持有的状态,通常不会自动修改另一个进程的对应状态。 后续会分别用变量、地址空间和环境变量观察这一规律。
5. fork 创建子进程
5.1 一次调用在两个进程中继续
Linux 提供 fork() 来创建子进程。调用成功时,原来的进程继续存在,新创建的子进程也继续执行。
最关键的是:子进程是从 fork 返回后的位置继续执行,而不是重新从 main 开始。
pid_t child = fork();
这行代码执行之后,需要根据返回值区分情况:
| 返回位置 | 返回值 | 含义 |
|---|---|---|
| 原进程,创建成功 | 大于 0,值为子进程 PID | 可以用它识别刚创建的子进程 |
| 新创建的子进程 | 0 | 表示当前处于子进程分支;子进程自己的 PID 仍由 getpid() 获取 |
| 原进程,创建失败 | -1 | 没有创建出子进程,需要检查错误 |
“一次调用,两次返回”描述的是成功后两个进程各自获得返回结果,不是同一个进程把一个返回值连续读两遍。

5.2 完整示例:身份不同,私有数据各自变化
#include <errno.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
// 定义局部变量,用来验证fork后父子进程内存独立
int value = 10;
printf("before fork\n");
// 创建子进程,fork调用一次返回两次
pid_t child = fork();
// fork失败返回-1
if (child < 0) {
perror("fork");
return 1;
}
if (child == 0) {
// === 子进程分支:child==0 ===
// 修改value,只会修改子进程自己内存中的副本,不会影响父进程
value = 20;
printf("child: PID=%ld PPID=%ld value=%d\n",
(long)getpid(), (long)getppid(), value);
return 0;
}
// === 父进程分支:child > 0,child存放子进程PID ===
printf("parent: PID=%ld child=%ld value=%d\n",
(long)getpid(), (long)child, value);
pid_t result;
// waitpid等待子进程退出;EINTR代表调用被信号中断,需要重试
do {
result = waitpid(child, NULL, 0);
} while (result == -1 && errno == EINTR);
// waitpid真正出错(不是被信号打断)
if (result == -1) {
perror("waitpid");
return 1;
}
// 等待子进程结束后,父进程的value仍然是原来的值,验证写时拷贝
printf("parent after wait: value=%d\n", value);
return 0;
}
运行示例:

这个程序应当满足的是:
before fork只出现一次,因为它在创建子进程之前执行。- 父、子 PID 不同;父进程得到的正返回值等于子进程的 PID。
- 子进程把自己的
value改为 20,父进程仍然看到 10。 parent after wait在子进程退出后执行。
waitpid(child, NULL, 0)暂时理解为“等待指定子进程退出并完成回收”。如果等待被信号打断,返回-1且errno为EINTR,重试。
5.3 数据独立,不等于立即复制所有物理内存
fork 后,父子进程的普通私有变量在逻辑上独立。一方修改私有数据,不会自动修改另一方看到的值。
但这不意味着内核立刻把所有物理内存完整复制一遍。Linux 通常利用写时拷贝减少不必要的复制。
它的具体实现依赖虚拟地址空间,在后续会详细展开。
6. 输出顺序与缓冲区
6.1 sleep 不能代替执行顺序保证
父子进程都可以继续执行,但具体何时获得 CPU,由调度及当时系统情况决定。即使只有一个 CPU,也只是轮流执行,并不会自动变成“父进程必先、子进程必后”。
给父进程加一秒 sleep,通常能让子进程先输出,但这只是让出一段时间,不是对子进程已经完成工作的证明。系统负载、停止信号等因素都可能改变结果。
6.2 fork 前已有的标准输出缓冲也会被继承
在刚才示例程序的基础上,在 fork 前调用 fflush(stdout)会更加完整安全。原因是 puts、printf 可能先把文字放进 C 库的用户空间缓冲区,稍后才真正写出。
当标准输出连接终端时,换行通常会触发行缓冲输出;重定向到文件后,行为往往变成全缓冲。若创建子进程时缓冲区里还有同一段待输出文字,父子进程后来正常退出,都可能把各自继承的那段内容写出去。
是否出现重复,取决于 fork 时是否还有未刷新的内容,不同 C 库对首次输出和刷新时机的处理也可能影响这次观察。因此,不能把“删除 fflush 就一定重复”当成固定规则;真正需要掌握的是缓冲内容的继承及其后续写出。
接口细节可在服务器中查阅
man 2 fork、man 2 getpid、man 2 waitpid(命令行输入)
7. 小结
-
fork 系统调用用于创建子进程,调用一次,返回两次:父进程返回子进程 PID,子进程返回 0;fork 失败返回 - 1。
-
fork 采用写时拷贝机制,创建初期父子进程共享物理内存,任意一方修改数据时才单独拷贝内存页面。
-
父子进程内存相互独立,子进程对变量的修改不会影响父进程中的同名变量。
-
进程状态可通过 ps 命令查看,能够观察 fork 创建出的父子进程 PID、PPID 信息。

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)