在这里插入图片描述

在前面的开发工具学习中,我们已经基本能够用 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;
}

运行示例:
在这里插入图片描述

这个程序应当满足的是:

  1. before fork 只出现一次,因为它在创建子进程之前执行。
  2. 父、子 PID 不同;父进程得到的正返回值等于子进程的 PID。
  3. 子进程把自己的 value 改为 20,父进程仍然看到 10。
  4. 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 信息。

在这里插入图片描述

Logo

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

更多推荐