【Linux我做主】探秘进程与fork

进程和fork

  • 父子进程和fork
  • github地址
  • 前言
  • [1. 进程的标识符PID](#1. 进程的标识符PID)
    • [1.1 查看系统内所有的进程](#1.1 查看系统内所有的进程)
    • [1.2 kill杀掉进程](#1.2 kill杀掉进程)
    • [1.3 获取进程的PID](#1.3 获取进程的PID)
    • [1.4 bash与父子进程](#1.4 bash与父子进程)
  • [2. 创建进程与fork](#2. 创建进程与fork)
    • [2.1 fork创建子进程](#2.1 fork创建子进程)
    • [2.2 fork困惑的解释](#2.2 fork困惑的解释)
      • [0. fork的工作原理](#0. fork的工作原理)
      • [1. 为什么给子进程返回0,给父进程返回子进程PID](#1. 为什么给子进程返回0,给父进程返回子进程PID)
      • [2. 一个函数如何做到返回两次?如何理解?](#2. 一个函数如何做到返回两次?如何理解?)
        • [2.1 为什么父子进程共享fork之后的代码](#2.1 为什么父子进程共享fork之后的代码)
        • [2.2 理解一个函数做到返回两次](#2.2 理解一个函数做到返回两次)
        • [2.3 fork函数内做了什么?](#2.3 fork函数内做了什么?)
      • [3. 一个id变量里面怎么会有两个值](#3. 一个id变量里面怎么会有两个值)
        • [3.1 写时拷贝](#3.1 写时拷贝)
        • [3.2 fork中的写时拷贝](#3.2 fork中的写时拷贝)
  • [3. 进程与调度器](#3. 进程与调度器)
  • [4. bash与fork](#4. bash与fork)
  • [5. 结语](#5. 结语)

父子进程和fork

github地址

有梦想的电信狗

前言

​ 在前文从冯诺依曼体系到进程中,我们认识了进程的概念。在操作系统中,进程是程序执行的基本单位Linux下进程是由一个个的task_struct组织起来的。Linux通过task_struct结构体管理进程,其中包含进程的所有属性。本文将从进程的标识符(PID)入手,深入探讨如何通过fork系统调用创建子进程,并分析其背后的深层次原理。


1. 进程的标识符PID

每个进程都有一个唯一的进程标识符(PID),用于在系统中唯一标识该进程。

1.1 查看系统内所有的进程

Linux中,可以通过ps命令查看进程的PID

命令ps

选项ajx

bash 复制代码
ps -ef       # 显示所有进程的详细信息
ps aux       # 显示所有用户的所有进程,重在用户

# 我们一般上使用 ps ajx 查看系统内所有的进程
ps ajx    # 查看系统内所有的进程

./proc  # 以运行proc进程为例
ps ajx | head -1 && ps ajx | grep proc
ps ajx | head -1 && ps ajx | grep proc | grep -v grep

ps ajx演示

我们一般在进行grep进程时,会过滤出grep命令本身,因为grep命令也是一个进程

隐藏掉grep关键字的命令:ps ajx | head -1 && ps ajx | grep proc | grep -v grep

  • && 表示左边的命令执行完,紧接着执行右边的命令左边的命令执行成功,右边的命令也要执行成功
  • ps ajx | head -1 && ps ajx | grep proc 该命令得到的结果会同时显示proc进程和grep进程。
    • 如果我们不想显示grep进程,可以使用管道 ,对上述命令的结果再进行grep
    • ps ajx | head -1 && ps ajx | grep proc | grep -v grep
      • -v选项配合管道 ,在已有的结果中反向匹配grep ,可以隐藏掉grep关键字

1.2 kill杀掉进程

命令kill [选项] PID

常用选项-9

功能

  • kill PID是温柔的杀掉这个进程
  • kill -9 PID向指定PID的某个进程发送9信号,暴力的杀掉这个进程

演示

bash 复制代码
kill -9 760776   # 强制终止PID为760776的进程
  • kill -9 760776的结果如下:

当使用不带 -9 选项的 kill PID 命令时,默认会向目标进程发送 SIGTERM 信号(信号编号为 15)。与 kill -9(发送 SIGKILL 信号)的强制终止不同,SIGTERM 是一种更"友好"的终止方式,(信号部分之后的文章会介绍,)目前介绍区别如下:


kill PID(默认发送 SIGTERM)的行为:

  1. 允许进程优雅退出
    进程收到 SIGTERM 后,可以执行清理操作(如保存数据、关闭文件、释放资源等),然后自行终止。
  2. 进程可以捕获或忽略 SIGTERM
    如果进程代码中注册了信号处理函数(例如通过 signal()sigaction()),它可以自定义对 SIGTERM 的响应(如延迟退出或忽略信号)。
  3. 可能无法立即终止进程
    如果进程因代码缺陷、死锁或无限循环无法响应 SIGTERM,它可能不会退出。此时需手动使用 kill -9 强制终止。

kill -9 PID(发送 SIGKILL)的行为:

  1. 强制立即终止进程
    SIGKILL 信号无法被进程捕获或忽略,操作系统会直接终止进程,不给进程任何清理的机会。
  2. 可能导致资源泄漏
    进程无法执行清理操作,可能导致临时文件残留、内存未释放、文件句柄未关闭等问题。
  3. 适用于"无响应"的进程
    当进程对 SIGTERM 无响应时,SIGKILL 是最后手段。

使用建议:

  1. 优先使用默认的 kill PID(SIGTERM)
    给进程机会优雅退出,避免数据丢失或资源泄漏
  2. 仅在必要时使用 kill -9(SIGKILL)
    例如进程完全卡死、僵尸进程或恶意进程等情况

扩展知识:

  • 查看所有信号:kill -l

  • 常用信号:

    • SIGHUP (1):挂起(重新加载配置)
    • SIGINT (2):中断(同 Ctrl+C
    • SIGTERM (15):终止
    • SIGKILL (9):强制终止

通过合理选择信号,可以更安全地管理系统进程。

1.3 获取进程的PID

  • Linux操作系统中,描述系统进程的task_struct是用双向链表组织的
  • ps ajx的作用,相当于遍历task_struct的链表,拿到所有进程的相关属性 ,打印出来,供我们查看PID
  • 既然可以拿到所有进程 的相关属性,那么对于一个特定进程的PID,应当也是可以获取到的

操作系统不相信任何用户,不能让用户通过task_struct.pid的方式通过结构体直接访问PID。因此操作系统一定对外提供了系统调用接口,供用户访问task_struct内描述进程的相关属性。

Linux中,可通过系统getpid()getppid()调用获取进程的PID

  • pid_t getpid():获取当前进程的PID
  • 进程都是被创建出来的,一个进程除了有自己的PID,也有自己的父进程 ,父进程的PID也可以被获取到
  • pid_t getppid():获取父进程的PID
  • 返回值类型均为pid_t,本质是int的类型别名。typedef int pid_t

getpid代码演示

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

int main() {
    pid_t pid = getpid();
    pid_t ppid = getppid();  // pid_t 本质是有符号整数
    while (1) {
        printf("I am a precess, my ID is %d, my parent ID is %d\n", pid, ppid);
        sleep(1);
    }
}
  • 我们可以手动将ps ajx配合grep命令,制作一个系统内进程监控脚本,监控系统内进程的信息。
bash 复制代码
while :; do ps axj | head -1 ; ps ajx | grep proc | grep -v grep | grep -v .vscode; sleep 1; done
  • sleep 1:每秒执行一次

  • ps axj | head -1 ; ps ajx | grep proc | grep -v grep | grep -v .vscode:过滤我们的proc进程,不显示grep命令本身的进程和.vscode远程连接的进程

  • 我们可以手动在终端中执行以上命令。也可以将以上内容保存在一个后缀为.sh的文件中,这里我选择保存为一个sh文件 ,并命名为monitor.sh

    • 保存后,为当前文件增加执行权限后并执行

      bash 复制代码
      chmod u+x monitor.sh  # 增加执行权限
      bash monitor.sh  # 执行脚本
  • 运行效果如下:

  • 这样就实现了系统内运行进程的实时监控,我们利用该脚本来监测我们启动的进程
  • 可以看到:
    • 进程启动前,检测脚本未检测到任何内容
    • 进程启动后 ,检测脚本就检测到了我们启动的proc进程
    • ps ajx查看到的PIDPPIDgetpid()getppid()获取到的内容完全一致!!!
  • 这样我们就可以通过PID来对进程进行管理了

1.4 bash与父子进程

观察以下现象,注意proc进程的PID

  • 一个程序,多次启动时PID在变。
  • 一个程序,多次启动时PID在变。。

    • 这是因为:PID只保证在每次运行期间有效,下次启动,操作系统为该进程分配的PID可能会变化,这是正常的。
  • 一个程序,多次启动时的PID在变,其父进程的PID一直不变。这是为什么?父进程的PID一直是761739,这里的父进程是什么进程?

  • 我们来查看PID761739的进程

    bash 复制代码
    ps ajx | head -1 && ps ajx | grep 761739
  • 这里可以清楚的看到,761739在这里就是我们的命令行解释器bash!

Bash(命令行解释器)本身是一个进程,用户执行的命令(如ls./a.out等)均为Bash的子进程BashPID单次登录会话中固定 ,但重新登录后会变化

我们在命令行解释器bash中,执行的所有指令(包括命令和./执行的程序)的父进程 ,就是bash本身

  1. 每次重新登陆xshell时,Linux系统会单独为我们创建一个bash进程,即为我们创建一个命令行解释器进程,帮我们在显示器中打印出命令行终端

  2. 我们在命令行解释器中,执行或输入的所有指令和程序,bash都会为我们创建进程,这些程序都是bash的子进程
    bash只负责命令行解释,具体执行出问题时,只会影响对应的子进程。因此在同一登陆状态下,指令和程序的父进程PID不变,也就是bashPID不变

  3. 一个bash有一个PID,多开多个bash,会有多个bashPID。进程主要维护父子关系

./操作执行程序或者命令时,就是操作系统为我们创建了一个进程,在操作系统上运行

父子进程关系特点

  1. 父进程负责管理子进程
  2. 子进程退出后,父进程需要通过wait系统调用回收资源。
  3. 若父进程先退出,子进程会成为"孤儿进程",由init进程(PID=1)接管。

2. 创建进程与fork

2.1 fork创建子进程

以上我们介绍了获取父子进程PID相关的知识,那么我们用户可否创建一个进程呢

我们目前已知的创建进程的方式

  • ./exe操作,执行程序或者执行命令 时,就是操作系统为我们创建了一个进程,在操作系统上运行

这种方式是我们手动创建进程,那么可否在程序运行时创建进程呢?

Linux内核为我们提供了系统调用 fork(),用于为当前进程创建一个子进程

forkLinux中创建子进程的核心系统调用,其独特的行为常引发初学者的困惑

使用man 手册查看fork函数的用法

bash 复制代码
man 2 fork  
  • 函数名和功能fork

    • 为当前调用fork的进程创建一个子进程。
    • 新进程为子进程 (child process),当前调用进程为父进程 (parent process)
    • 子进程复制父进程的代码、数据、堆栈和打开的文件描述符。
  • 头文件<sys/types.h><unistd.h>

  • 参数类型void 无需传参

  • 返回值 :类型为pid_t,这里pid_tint的类型别名。typedef int pid_t

  • 根据文档介绍

    • 创建子进程失败时 ,在原进程中返回-1,并设置errno
    • 成功时
      • 在父进程中返回子进程的PID
      • 在子进程中返回0

fork的简单使用:

cpp 复制代码
int main(){
    printf("before: only one line\n");
    fork();
    printf("after: only one line\n");
    sleep(1);
}

这里我们不免会有疑惑???

==fork之后的代码,执行了两次!!!==这是为什么?

先给出结论:fork之后的代码,父子进程是共享的,既然父子进程各有一份代码(共享) ,那fork之后的代码执行了两次就可以说得通了

再看如下代码和现象

  • 这里./proc是当前进程,fork之后,现象是:代码被一分为二了,两个循环各自在执行,父子进程各执行一个循环。这说明,id == 0id > 0 同时成立了 ,且根据进程的PID,我们可以得出:
    • 执行fork之后当前进程是父进程
    • 其子进程是由fork创建的
    • 当前进程的父进程是bash进程
  • fork之后一定存在两个进程,存在两个执行流

以上种种现象,我们不免产生很多疑问???

    1. 为什么fork要给子进程返回0,给父进程返回子进程的pid呢?为什么父子进程的返回值不同呢?
    1. 一个函数,怎么会有两个返回值,如何做到返回两次呢?
    1. 变量id接收fork的返回值,为什么一个变量可以有两个不同的值?
    1. fork函数究竟做了什么?

这些问题我们后文会一一解答

2.2 fork困惑的解释

  • 结合fork的翻译,分支,分叉,代表我们的代码在fork这里要进行分叉。

0. fork的工作原理

  1. 创建子进程的PCB :内核为子进程分配新的task_struct
  2. 复制父进程上下文子进程继承父进程的代码段、数据段、堆栈和文件描述符表
  3. 分流执行fork返回后,父子进程从同一位置继续执行,但通过返回值区分逻辑分支。

1. 为什么给子进程返回0,给父进程返回子进程PID

一般而言,fork之后的代码,父子共享

解答这个问题,先思考我们为什么要创建子进程

  • 是为了让父子进程协作,执行不同的事情 ,因此需要想办法让父子进程执行不同的代码块为了让父子进程协同,执行不同的执行流,就设计了fork返回值要不同

为什么要父子进程要分别返回不同的值?

  • 是为了在fork之后,可以根据不同的返回值区分父子进程,来让父子进程执行不同的代码片段
  • 返回不同的返回值,是为了区分。让不同的进程执行流,执行不同的代码块

为什么给子进程返回0,给父进程返回子进程的PID?

  • 一个子进程只会有一个父进程,一个父进程可能会有多个子进程

  • 父进程需要管理多个子进程 ,通过PID区分不同子进程。

    • 父进程需要区分不同的子进程 :给父进程返回子进程的PID,用来标识子进程的唯一性,方便直接通过不同的PID,对不同的子进程直接做控制
  • 子进程只需确认自身身份 ,返回0简化逻辑判断。

    • 子进程只有一个父进程标识子进程,只需要在父进程内判断fork的返回值 是否PID == 0即可标识子进程。子进程得到父进程的PID,只需要调用getppid()函数即可。

2. 一个函数如何做到返回两次?如何理解?

2.1 为什么父子进程共享fork之后的代码

原因如下:

原因如下

  • 进程 = 内核数据结构 + 代码和数据

  • fork创建子进程,也就是内存中多了一个进程,Linux操作系统会为该子进程创建一个task_struct

  • 创建子进程前,该进程有自己的task_struct和代码和数据。创建子进程,操作系统层面 只创建了子进程的task_struct子进程没有自己的代码和数据,只能和父进程共享同一份代码 。数据呢?(下文解释)

  • 因此fork之后,父子进程共享后续的代码

fork之后,父子进程共享后续的代码。fork之后父子进程执行的代码一样,那我们为什么要创建子进程呢?我们的目的就是为了让父子进程协同起来分别做不同的事情。因此需要想办法让父子进程执行不同的代码块。让fork函数具有不同的返回值就是为此设计的。

那么fork如何设计实现了以上功能?

2.2 理解一个函数做到返回两次

代码示例

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

int main() {
    printf("begin:我是一个进程, pid: %d, ppid: %d\n", getpid(), getppid());
    pid_t id = fork();
    if (id == 0) {  // 子进程分支
        while (1) {
            printf("我是子进程, pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
        }
    } else if (id > 0) {  // 父进程分支 成功时,子进程的PID > 0  返回到父进程
        while (1) {
            printf("我是父进程, pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
        }
    } else {
        // error
    }
    return 0;
}
  • fork有不同的返回值? 一个函数如何做到返回两次

解释如下

  • 任何一个函数在即将return时,这个函数的核心功能就已经完成了。

  • fork是一个函数,内部的实现是要创建子进程return之前,父子进程都已存在,各自有独立的task_struct且可以被CPU调度 。此时在临界执行return之前 ,父子进程的task_struct(PCB)都已存在了,父子进程也都已存在

return执行之前父子进程均已创建完成,且可以被CPU调度。创建子进程后,父子进程代码共享,return语句也属于代码,因此父子进程共享return语句。CPU分别调度父子进程 ,父子进程分别执行了return,就实现了一个函数返回两次

fork在即将执行return之前,创建子进程的工作已经完成了,子进程也允许被CPU调度 了,之后的return代码父子进程共享,父和子进程分别执行return,因此fork函数就实现了返回两次

2.3 fork函数内做了什么?

综上我们可以总结出fork内部究竟做了什么:

  • 创建子进程
  • 为子进程创建PCB
  • 用父进程的字段初始化子进程
  • 让父子进程实现代码共享
  • ...其他工作

3. 一个id变量里面怎么会有两个值

任何平台下,进程在运行时具有独立性!进程的运行互不干扰

  • 一个id变量里面有两个值的现象,实际是通过写时拷贝实现的
  • 子进程刚被创建时,代码和数据都是父子进程共享的
    代码加载到内存中后,是不可修改的 ,因此代码直接父子共享。

  • 子进程在读取父进程的数据时,数据也是共享的。当子进程尝试修改父进程的数据时,操作系统为子进程新分配一块内存空间,让子进程对待修改数据的拷贝进行修改 ,修改多少内容,就申请多少空间,从而不会影响到父进程的数据,该过程称为写时拷贝

以上称为父子进程之间,数据层面的写时拷贝,写时拷贝保证了父子进程间数据的独立性,避免了父或子进程修改数据后对子或父进程的数据产生影响

3.1 写时拷贝

fork之后父子进程共享代码段,但数据段通过写时拷贝(COW------Copy-On-Write)技术实现高效复制。

原理

  • 子进程创建时,与父进程共享物理内存。
  • 当子进程尝试修改内存时,内核会为该内存页创建副本,确保修改独立。

优点

  • 减少内存全量复制开销。
  • 提高fork的执行效率
3.2 fork中的写时拷贝
  • return时,return是对id做写入。
  • return语句父子进程共享 ,父子进程分别return
  • 父进程return时,直接对id做写入 ,子进程return时,对id写时拷贝

3. 进程与调度器

问题:fork()后父子进程谁先运行?

当调用 fork() 创建子进程后,父子进程会被同时放入操作系统的就绪队列中等待执行。它们的运行顺序由操作系统的进程调度器决定,用户无法预测。这是操作系统的核心设计原则之一:调度器保证公平性,而非确定性 。因此fork()后父子进程谁先运行,无法确定,取决于调度器

调度器的工作原理

  1. 进程的组织形式
    所有进程的PCB(进程控制块)以数据结构(如链表、队列、树等)组织在内存中,例如:

    • 就绪队列:等待CPU时间的进程

    • 阻塞队列:等待I/O或其他资源的进程

  2. 调度器的核心任务

    调度器从就绪队列中选择一个进程分配CPU时间,具体流程如下:

    • 触发时机:时钟中断、进程阻塞、进程退出等。

    • 选择算法:通过调度算法(如时间片轮转、优先级调度)选择下一个进程。

    • 上下文切换:保存当前进程的CPU状态(寄存器、程序计数器等),加载目标进程的上下文。

  3. 常见的调度算法

    算法 特点
    先来先服务 (FCFS) 简单,但可能导致"饥饿"现象
    时间片轮转 (RR) 每个进程分配固定时间片,公平性强,适合交互式系统
    优先级调度 高优先级进程优先执行,需防止低优先级进程"饿死"
    多级反馈队列 结合时间片和优先级,动态调整进程优先级,兼顾响应时间和吞吐量

为什么单核CPU能"同时"运行数百个进程?

  1. 并发假象

    调度器通过快速切换进程(纳秒级时间片)营造"并行"假象。例如:

    • 进程A运行5ms → 切换到进程B运行5ms → 切换回进程A......

    • 人类无法感知微小的时间片切换,因此感觉多个进程在"同时"运行。

  2. 父子进程的竞争

    fork() 后,父子进程进入就绪队列,可能发生以下情况:

    • 父进程先获得CPU时间(常见,因父进程可能处于活跃状态)。

    • 子进程先获得CPU时间(可能因父进程被阻塞或时间片耗尽)。

    • 父子进程交替执行(取决于调度策略和系统负载)。

4. bash与fork

bash也是通过fork创建子进程的

Bash执行命令的流程

当在Bash中输入命令(如 ls -l)时,Bash的底层操作如下:

  1. fork()创建子进程

    • 子进程复制父进程(Bash)的内存、文件描述符、环境变量等。

    • 优化技术:写时复制 (Copy-On-Write),仅在数据被修改时复制内存,减少开销。

  2. exec()加载新程序

    • 子进程调用 exec() 系统调用,用目标程序(如 /bin/ls)替换当前内存空间。
  3. wait()父进程等待

  • Bash(父进程)调用 wait() 阻塞自身,直到子进程结束并回收资源。

  • 若未调用 wait(),子进程退出后会成为僵尸进程(Zombie),占用系统资源。

5. 结语

​ 进程是操作系统资源分配的基本单位,而 fork 作为创建子进程的核心机制,通过代码共享、写时拷贝和调度器 的协同工作,实现了高效的多任务管理。理解父子进程的关系、fork 的"分流"特性以及调度器的随机性,是掌握进程管理的关键。无论是 bash执行命令时的隐式 fork,还是程序内显式创建子进程 ,本质都在通过进程的独立性完成并发任务。写时拷贝 技术平衡了性能与资源隔离,而调度器的公平策略 让单核 CPU 也能营造"并行"假象。希望本文为你揭开了进程与 fork 的神秘面纱


以上就是本文的所有内容了,如果觉得文章写的不错,还请留下免费的赞和收藏,也欢迎各位大佬在评论区交流

分享到此结束啦
一键三连,好运连连!

相关推荐
Darkwanderor几秒前
贪心算法题目合集2
c++·算法·贪心算法
优雅的落幕2 分钟前
从零开始的抽奖系统创作(2)
java·服务器·前端
肥肠可耐的西西公主12 分钟前
前端(小程序)学习笔记(CLASS 1):组件
笔记·学习·小程序
从零开始学习人工智能20 分钟前
Nginx 强制 HTTPS:提升网站安全性的关键一步
运维·nginx·https
刚入门的大一新生1 小时前
C++初阶-vector的模拟实现2
javascript·c++·算法
努力的搬砖人.1 小时前
Docker 镜像打包到本地
运维·docker·容器
Stephen·You1 小时前
(已解决:基于WSL2技术)Windows11家庭中文版(win11家庭版)如何配置和使用Docker Desktop
运维·docker·容器
csdn_aspnet1 小时前
C++ 扇形的面积(Area of a Circular Sector)
开发语言·c++
汇匠源1 小时前
Spring Boot + +小程序, 快速开发零工市场小程序
spring boot·后端·小程序
两斤半1 小时前
Debian重装系统后
linux·debian