【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 的神秘面纱


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

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

相关推荐
小杜今天学AI了吗1 分钟前
如何配置 linux 系统的conda 环境
linux·运维·conda
oMcLin4 分钟前
如何在Ubuntu 22.04 LTS上通过配置ZFS存储池,提升高吞吐量数据库的读写性能与可靠性?
linux·数据库·ubuntu
这就是佬们吗4 分钟前
告别 Node.js 版本冲突:NVM 安装与使用全攻略
java·linux·前端·windows·node.js·mac·web
christine-rr5 分钟前
linux常用命令(9)——查看系统与硬件信息
linux·运维·服务器·网络·后端
CSDN_RTKLIB13 分钟前
【std::vector】vector<T*>与vector<T>*
c++·stl
fpcc13 分钟前
跟我学C++中级篇——对类const关键字的分析说明
c++
阿豪学编程14 分钟前
【Linux】线程同步和线程互斥
linux·开发语言
oMcLin19 分钟前
如何在CentOS Stream 9上通过配置Hyper‑V虚拟化实现高效的资源隔离与虚拟机管理?
linux·运维·centos
liuyunshengsir20 分钟前
Elasticsearch 高级查询must 多个条件同时满足
linux·服务器·elasticsearch
草莓熊Lotso20 分钟前
Linux系统进程调度优化:优先级策略与切换机制深度实践
linux·运维·服务器·c++·人工智能·经验分享·其他