探秘进程与fork

父子进程和fork
github地址
有梦想的电信狗

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

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

1.1 查看系统内所有的进程
在Linux中,可以通过ps命令查看进程的PID

命令:ps

选项:ajx等

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

我们一般在进行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信号,暴力的杀掉这个进程
演示:

kill -9 760776 # 强制终止PID为760776的进程

kill -9 760776的结果如下:

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

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

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

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

优先使用默认的 kill PID(SIGTERM)
给进程机会优雅退出,避免数据丢失或资源泄漏。
仅在必要时使用 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代码演示

#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命令,制作一个系统内进程监控脚本,监控系统内进程的信息。
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

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

chmod u+x monitor.sh # 增加执行权限
bash monitor.sh # 执行脚本

这样就实现了系统内运行进程的实时监控,我们利用该脚本来监测我们启动的进程

可以看到:
进程启动前,检测脚本未检测到任何内容
进程启动后,检测脚本就检测到了我们启动的proc进程
且ps ajx查看到的PID及PPID和getpid()和getppid()获取到的内容完全一致!!!
这样我们就可以通过PID来对进程进行管理了
1.4 bash与父子进程
观察以下现象,注意proc进程的PID

 

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

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

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

我们来查看PID为761739的进程

ps ajx | head -1 && ps ajx | grep 761739

这里可以清楚的看到,761739在这里就是我们的命令行解释器bash!

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

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

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

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

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

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

父子进程关系特点:

父进程负责管理子进程。
子进程退出后,父进程需要通过wait系统调用回收资源。
若父进程先退出,子进程会成为“孤儿进程”,由init进程(PID=1)接管。
2. 创建进程与fork
2.1 fork创建子进程
以上我们介绍了获取父子进程PID相关的知识,那么我们用户可否创建一个进程呢

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

./exe操作,执行程序或者执行命令时,就是操作系统为我们创建了一个进程,在操作系统上运行
这种方式是我们手动创建进程,那么可否在程序运行时创建进程呢?

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

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

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

man 2 fork

函数名和功能:fork

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

参数类型:void 无需传参

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

根据文档介绍,

创建子进程失败时,在原进程中返回-1,并设置errno
成功时:
在父进程中返回子进程的PID
在子进程中返回0
fork的简单使用:

int main(){
printf("before: only one line\n");
fork();
printf("after: only one line\n");
sleep(1);
}
这里我们不免会有疑惑???

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

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

再看如下代码和现象:

 

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

为什么fork要给子进程返回0,给父进程返回子进程的pid呢?为什么父子进程的返回值不同呢?
一个函数,怎么会有两个返回值,如何做到返回两次呢?
变量id接收fork的返回值,为什么一个变量可以有两个不同的值?
fork函数究竟做了什么?
这些问题我们后文会一一解答

2.2 fork困惑的解释

结合fork的翻译,分支,分叉,代表我们的代码在fork这里要进行分叉。
0. fork的工作原理
创建子进程的PCB:内核为子进程分配新的task_struct。
复制父进程上下文:子进程继承父进程的代码段、数据段、堆栈和文件描述符表。
分流执行: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 理解一个函数做到返回两次
代码示例:

#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()后父子进程谁先运行,无法确定,取决于调度器。

调度器的工作原理

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

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

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

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

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

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

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

常见的调度算法

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

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

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

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

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

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

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

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

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

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

fork():创建子进程

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

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

exec():加载新程序

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

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

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

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

————————————————

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

原文链接:https://blog.csdn.net/2301_80064645/article/details/147850381

阅读剩余
THE END