进程的创建
进程的创建
在正式讲解今天的内容之前,我们先补充一些和进程有关的知识点:
补充:
proc目录
在上一节讲查看进程信息中,我们提到了Linux中的proc目录,proc目录里存储的是每一个进程的详细信息。每当创建出一个新进程,proc目录都会创建一个新的目录,目录名称就是进程ID,用来存储新进程的信息。
当a进程被杀掉后,proc目录中a进程的目录也会被删除。
进程运行中删掉相应可执行文件会发生什么?
接着我们来简单回答一下这个问题。
在 Linux 中,当你删除一个正在运行的程序的可执行文件时,已经运行起来的进程会继续正常运行,不会立即崩溃或停止。但是,后续与该文件相关的操作会受到限制。原因在于 Linux 文件系统处理文件删除的方式:
📌 核心原理:unlink()
系统调用
- 删除的本质:在 Linux/Unix 中,"删除"文件实际上是调用
unlink()
系统调用。 - 文件链接计数:每个文件都有一个
inode
,其中记录指向该文件的硬链接数量(link count
)。创建文件时默认链接数为 1。 unlink()
的作用:- 将文件的链接数减 1。
- 当链接数降为 0 且没有进程打开该文件时,文件占用的磁盘空间才会被释放。
- 关键点:正在运行的程序在启动时已经打开了该可执行文件。因此:
- 删除操作只会减少链接数(通常变为 0),但不会立即释放磁盘空间。
- 进程仍可通过已打开的文件描述符访问文件内容(即内存中的代码段)。
🛠️ 对运行中进程的影响
- 进程继续运行:
- 进程的代码段(
.text
段)在启动时已加载到内存中。 - 删除磁盘上的文件不影响已加载到内存的指令执行。
- 进程会像什么都没发生一样继续运行。
- 进程的代码段(
- 文件操作受限:
- 无法重启该进程:重启时需要重新从磁盘读取可执行文件,此时会因文件不存在而失败(
bash: ./program: No such file or directory
)。 - 无法动态加载依赖:若程序运行时动态加载库(如
dlopen()
),且被删除的文件是动态库,则加载会失败。 - 调试信息丢失:调试器(如
gdb
)可能无法关联源代码或符号(如果可执行文件包含调试信息)。
- 无法重启该进程:重启时需要重新从磁盘读取可执行文件,此时会因文件不存在而失败(
- 磁盘空间何时释放:
- 当该进程终止时,操作系统会关闭所有打开的文件描述符。
- 此时文件的链接数已为 0,系统会真正释放磁盘空间。
🔍 验证实验
我们写了一个死循环的test.c,生成了可执行程序myprocess
#include <stdio.h>
#include <unistd.h>int main()
{while(1){printf("当前进程PID:%d\n", getpid());sleep(2);} return 0;
}
大家也可以尝试一下。
⚠️ 注意事项
-
写时复制(Copy-on-Write):
- 若进程尝试修改代码段(如自修改代码),会触发写时复制(写时拷贝),此时修改的是内存副本,与原文件无关。
-
动态库的特殊性:
- 动态库(
.so
文件)在运行时按需加载。如果删除正在使用的动态库,已加载的函数不受影响,但新加载的库会失败。
- 动态库(
-
/proc 文件系统:
-
/proc/<pid>/exe
是已删除文件的符号链接(显示为program (deleted)
)。 -
可通过该路径恢复文件(需 root 权限):
cp /proc/12345/exe /path/to/restored_demo
-
💎 结论
- ✅ 已运行进程:不受影响,继续执行。
- ❌ 后续操作:无法重启进程、加载新库或调试。
- 🗑️ 磁盘空间:进程退出后释放。
- 🔧 恢复建议:通过
/proc/<pid>/exe
恢复文件(需在进程退出前操作)。
实际建议:生产环境中避免删除正在运行的可执行文件。如需更新程序,应使用原子替换(先写新文件再
rename()
覆盖),确保无缝重启。
fopen函数
在C语言中,fopen
是标准库(stdio.h
)中用于打开文件的函数,它会建立程序与文件之间的连接,并返回一个用于后续文件操作的“文件指针”(FILE*
类型)。
基本信息
-
函数原型:
FILE *fopen(const char *filename, const char *mode);
-
参数说明:
filename
:字符串,表示要打开的文件路径(可以是相对路径或绝对路径,如"test.txt"
或"/home/data/file.dat"
)。mode
:字符串,表示文件的打开模式,决定了文件的操作方式(读、写、追加等)。
常用打开模式(mode
)
模式 | 含义 |
---|---|
"r" | 只读模式。文件必须存在,否则打开失败。 |
"w" | 只写模式。文件不存在则创建;文件存在则清空原有内容。 |
"a" | 追加模式。文件不存在则创建;写入时数据会添加到文件末尾(不覆盖原有内容)。 |
"r+" | 读写模式。文件必须存在,可读取也可写入。 |
"w+" | 读写模式。文件不存在则创建;文件存在则清空原有内容。 |
"a+" | 读写模式。文件不存在则创建;写入时数据添加到末尾,可读整个文件。 |
注:若操作二进制文件(如图片、音频),需在模式后加 b
(如 "rb"
、"wb"
),避免文本模式下的自动换行转换等处理。
返回值
- 成功:返回一个指向
FILE
结构体的指针(后续文件操作如fread
、fwrite
等都需要这个指针)。 - 失败:返回
NULL
(如文件不存在、权限不足等),此时可通过perror()
打印错误原因。
重点
我们这里的重点不是fopen这个函数本身,重点是fopen的特性。
在 fopen
函数中使用只写模式("w"
)时,如果文件不存在,则文件会被创建。文件的创建位置取决于我们在 filename
参数中指定的路径:
-
如果指定的是相对路径(如
"test.txt"
),文件会创建在程序的“运行目录”(即执行程序时所在的工作目录),而不是程序可执行文件本身所在的目录。例如:你的程序编译后生成
a.out
,存放在/home/user/programs
目录下,但你在/home/user
目录下执行./programs/a.out
,那么用"test.txt"
创建的文件会出现在/home/user
目录(运行目录),而不是/home/user/programs
(程序所在目录)。 -
如果指定的是绝对路径(如
"/home/user/data/test.txt"
),文件会严格按照该绝对路径创建。
这里我们不关注绝对路径,我们关注的是相对路径。假设现在有这样一个程序:
#include <stdio.h>
#include <unistd.h>int main() {printf("当前进程ID:%d\n",getpid());// 以只读模式打开文件 "test.txt"FILE *fp = fopen("test.txt", "w");// 操作完成后,必须关闭文件释放资源fclose(fp);sleep(90);//这里主要是为了不要让程序过快执行完,我得获取进程信息。获取完后可以ctrl+creturn 0;
}
所以说,为什么一个程序运行起来后,可以帮你在程序运行时所在目录下创建文件呢?主要原因就是当可执行程序运行起来的时候,形成的进程里有存储程序运行时所在目录。这个程序运行时所在目录就是工作目录,进程信息里面的cwd
就是记录的 “当前工作目录”(Current Working Directory)。所以进程才能在指定的相对路径下创建文件。
如果进程运行中途更改了工作目录,那么新文件会被创建到哪里呢?
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>int main()
{printf("self pid: %d\n", getpid());printf("更改当前工作目录之前!\n");sleep(20);chdir("../");printf("更改当前工作目录之后!\n");sleep(10);FILE *fp = fopen("110.txt", "w");if (fp == NULL) return 1;fclose(fp);printf("新建文件完成\n");sleep(50);return 0;
}
简单介绍 chdir
函数:
- 功能:改变当前进程的“当前工作目录”(Current Working Directory)。
- 头文件:需要包含
<unistd.h>
(类Unix系统,如Linux)。 - 函数原型:
int chdir(const char *path);
- 参数:
path
是要切换到的目标目录路径(可以是绝对路径,如/home/whb
;也可以是相对路径,如../test
)。 - 返回值:
- 成功:返回
0
; - 失败:返回
-1
(例如目标目录不存在、权限不足等),此时可通过errno
查看具体错误原因。
- 成功:返回
- 注意:
chdir
仅影响当前进程的工作目录,不会改变启动该进程的父进程(如终端shell)的工作目录。
在这段代码中,chdir("../")
的作用是将程序(当前进程)的“当前工作目录”切换到 当前目录的上一级目录。这会影响后续所有使用“相对路径”的文件操作(比如代码中后续的 fopen("110.txt", "w")
会在上一级目录下创建 110.txt
,而不是之前的工作目录)。
正文
接下来就是正儿八经的关于进程的创建的内容了。其实进程的创建部分我在十万个为什么之—进程中就有讲过。我们讨论了进程是如何诞生的,不过只是粗略的讲述了一下概念。接下来我们就来详细聊聊与进程创建有关的知识点。
一、核心思想:fork()
+ exec()
Linux进程创建的精髓可以概括为一个经典的两步模型:fork()
+ exec()
。这个设计哲学源于早期的Unix系统,并一直延续至今。它的核心思想是将进程的创建和进程的执行分离开。
fork()
:创建副本- 作用:复制当前进程(父进程),创建一个几乎完全相同的子进程。
- 关键特性:写时复制(Copy-On-Write, COW)。这是为了性能优化。
fork()
之后,内核并不会立即复制父进程的数据段、堆和栈,而是将这些内存空间标记为只读(只能读,不能修改),并共享给子进程。只有当父或子进程尝试修改某一块数据时,内核才会为该块数据创建一个真正的副本。这使得fork()
的效率非常高。 fork()
的特性会在下文详细说明。
exec()
:执行新程序- 作用:加载一个全新的程序(例如
/bin/ls
,/usr/bin/vim
),并将其代码、数据替换到当前进程的内存空间中。 - 效果:调用
exec()
后,当前进程运行的代码完全变成了新程序的代码,只有进程ID(PID)等少数属性保持不变。
- 作用:加载一个全新的程序(例如
这个“先复制,再替换”的模型非常灵活和高效。
补充:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>int main()
{printf("当前进程的PID是:%d\n",getpid());printf("当前进程的父进程的PID是:%d\n",getppid());printf("\n");return 0;
}
我们先用这个程序给大家补充一个知识点:
# 这是我例子中使用的命令,用了查看进程信息的
ps ajx | head -1 && ps ajx | grep 进程的PID
也就是说我们写的 可执行程序运行后,形成的进程 是由bash
进程进过fork()
+ exec()
创建出来的。
二、深入 fork()
系统调用
这一节我们主要了解fork()
函数,exec()
这个函数我还没仔细学。
发生了什么?
实验1:
我们从现象入手,使用的是下面这个代码:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>int main()
{printf("before fork: I am a process, pid: %d, ppid: %d\n", getpid(), getppid());fork();printf("after fork: I am a process, pid: %d, ppid: %d\n", getpid(), getppid());sleep(2);//这里与我们本节课内容无关return 0;
}
从上面的现象中我们可以发现,当fork()
执行后,父进程和子进程是共享fork()
函数之后的代码的,因为我们很明显的看到,printf("after fork: I am a process, pid: %d, ppid: %d\n", getpid(), getppid());
被执行了两次。
且后续代码执行的顺序不确定:(是父进程先执行,还是子进程先执行,不知道,不清楚)
// 可能的输出顺序1:
before fork...
after fork (父进程)...
after fork (子进程)...// 可能的输出顺序2:
before fork...
after fork (子进程)...
after fork (父进程)...
- 具体顺序取决于系统调度器
- 父子进程执行顺序是不确定的
实验2:
结合实验1的结果,大家想一下,fork()
函数创建子进程后,与父进程共享代码,让代码先后运行两次有啥意义吗?或者说有什么作用吗?这样做还麻烦,还不如使用循环执行,两者没啥太大区别。
我们实验1的用法肯定是没啥作用的。那么fork()
函数真正的用途是在哪里呢?
fork()
函数执行后,会有两条执行流,一个父进程一个子进程,那么程序又是如何区分父进程和子进程的呢?
想要了解fork()
函数的用途,我们得好好看看fork()
的完整面貌:
我们可以在命令行执行下面那条指令查看和fork()
函数相关的信息:
man 2 fork
由此,我们得知fork()
函数是有返回值的,在父进程中返回的是子进程的PID,在子进程中返回的是0。那么很明显了,程序就是通过fork()
的返回值判断是父进程还是子进程的。
这里我们补充一下:为什么fork()
函数的返回值在父进程中返回的是子进程的PID,在子进程中返回的是0呢?主要原因就是:
一个父亲可以有多个孩子,一个孩子只有一个亲生父亲。而且创建进程的时候是会记录父进程PID的,子进程找自己的父亲是很简单的。但是父进程就很难找子进程了。所以fork()
函数的返回值在父进程中返回的是子进程的PID,这样父进程才方便找自己的孩子。
那么知道这些之后,我们先来用一下fork()
,把它用在正途上。我们使用fork()
函数创建出一个子进程的目的肯定不是为了让父子进程干同一件事情嘛,而是让父子进程干不同事情才对嘛。这样才有意义嘛。我们可以借助fork()
函数的返回值,分辨是父还是子,进而实现父进程干父进程的事情,子进程干子进程的事情。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>int main()
{printf("before fork: 我是当前程序形成的进程, pid: %d, ppid: %d\n", getpid(), getppid());sleep(5);printf("开始创建进程!\n");sleep(1);pid_t id = fork();//pid_t是fork()函数返回值的类型,稍后会解释if (id < 0) return 1;else if (id == 0){// 子进程while(1){printf("after fork, 我是子进程, pid: %d, ppid: %d, return id: %d\n", getpid(), getppid(), id);sleep(1);}}else{// 父进程while(1){printf("after fork, 我是父进程, pid: %d, ppid: %d, return id: %d\n", getpid(), getppid(), id);sleep(1);}}sleep(2);return 0;
}
接下来我们将程序运行起来,使用下面这条指令检视进程状态:
while :; do ps ajx | head -1 && ps ajx | grep myprocess | grep -v grep ; sleep 1; done
可以发现,两个while循环都在被执行。这个场面大家以前应该是没有见过的,一个程序里面有两个死循环同时在运行(伪同时)。
fork()
函数真正的用途就是利用其返回值的特性,结合条件判断,让父子进程干不一样的事情。那么这里程序运行起来的整个过程大概是怎么样的呢?
这段代码生动展示了Linux进程创建的完整生命周期,特别是父子进程的分离执行过程。让我为你详细解析整个执行流程和底层机制:(使用上面图中的例子)
阶段1:单进程运行(0-6秒)
- 初始状态:
- 程序启动时是单个进程(父进程)
- 执行第一个
printf
:打印当前进程信息 - 示例输出:
before fork: 我是当前程序形成的进程, pid: 27724, ppid: 23596
- 延迟创建:
sleep(5)
:让父进程休眠5秒(给用户观察时间)- 打印
开始创建进程!
sleep(1)
:再休眠1秒
阶段2:进程分裂(第6秒)
pid_t id = fork();
fork()执行时的底层操作:
- 内核创建新的进程控制块(task_struct)
- 分配新的PID(如27762)给子进程
- 复制父进程的:
- 内存空间(使用写时复制技术)
- 打开的文件描述符
- 信号处理设置
- 设置子进程的PPID为父进程PID(27724)
- 关键返回值:
- 父进程中返回子进程PID(27762)
- 子进程中返回0
阶段3:双进程并行执行(6秒后)
if (id < 0) return 1;
else if (id == 0) { /* 子进程 */ }
else { /* 父进程 */ }
子进程执行流:
-
进入
id == 0
分支 -
每秒打印:
printf("after fork, 我是子进程, pid: %d, ppid: %d, return id: %d\n", getpid(), getppid(), id);
- 输出示例:
after fork, 我是子进程, pid: 27762, ppid: 27724, return id: 0
- 注意:
id
值为0,符合fork设计
- 输出示例:
父进程执行流:
-
进入
else
分支 -
每秒打印:
printf("after fork, 我是父进程, pid: %d, ppid: %d, return id: %d\n",getpid(), getppid(), id);
- 输出示例:
after fork, 我是父进程, pid: 27724, ppid: 23596, return id: 27762
- 注意:
id
值为子进程PID(27762)
- 输出示例:
关键现象与技术细节
1. 进程关系可视化
Bash(pid:23596)
└── 父进程(pid:27724, ppid:23596)└── 子进程(pid:27762, ppid:27724)
2. 输出顺序不确定性
可能的输出序列:
开始创建进程!
after fork, 我是父进程... // 父进程先被调度
after fork, 我是子进程... // 子进程后被调度
after fork, 我是父进程...
after fork, 我是子进程...
...
或:
开始创建进程!
after fork, 我是子进程... // 子进程先被调度
after fork, 我是父进程... // 父进程后被调度
after fork, 我是子进程...
after fork, 我是父进程...
...
取决于内核调度器决策
3. 进程终止问题
- 最后的
sleep(2); return 0;
永远不会执行 - 因为父子进程都进入了无限循环
- 需要外部干预终止(如Ctrl+C)
fork()函数中的继承
在讲进程的时候十万个为什么之—进程我给大家说:进程 = 可执行文件的代码和相关数据 + 进程控制块(PCB)
所以在fork()
函数创建子进程的时候,继承父进程的主要流程也集中在这两部分展开:
fork函数继承机制
一、fork()执行的整体流程
1. 用户空间调用fork()
- 程序执行到
pid_t pid = fork();
语句 - 触发软中断(系统调用),CPU从用户模式切换到内核模式
2. 内核空间处理
- 内核分配新的进程描述符(task_struct结构)
- 为新进程分配唯一的PID
- 复制父进程的上下文(寄存器状态、程序计数器等)
3. 资源复制阶段(关键继承步骤)
4. 返回用户空间
- 内核设置fork()返回值:
- 父进程:返回子进程PID
- 子进程:返回0
- CPU切换回用户模式
- 程序继续执行fork()之后的代码
二、详细继承内容解析
1. 内存空间继承(写时复制机制)
- 初始状态:父子进程共享所有物理内存页
- 访问控制:所有共享页被标记为只读
- 写时复制:
- 当任一进程尝试写入共享页时
- 触发页错误异常(page fault)
- 内核复制该页到新物理地址
- 更新进程的页表指向新副本
- 恢复进程执行
// 示例:演示COW行为
int global = 10; // 共享全局变量int main() {pid_t pid = fork();if (pid == 0) {// 子进程修改全局变量global = 20; // 触发COW,创建新副本printf("Child global: %d\n", global); // 输出20} else {sleep(1); // 确保子进程先执行printf("Parent global: %d\n", global); // 输出10}
}
这里的这个机制很重要。意思就是,本来父子进程默认是共享数据的,父进程的数据,子进程可以看,可以读。但是如果其中一个进程要修改这共享的数据的时候,就会触发写时复制,也就是把原来共享的那份数据,拷贝出来一份,这样就有两份数据了,两个进程管理的就不是同一块数据了,此时父进程有自己的数据,子进程有自己的数据,这样任何一方修改数据,都不会影响另外一方。
如果没有写时复制,两个进程共享一块数据,有一个进程修改后,另外一个进程怎么办?另外一个进程本来没有要修改数据的意图,但是后续用到的数据却是已经被修改过了的,这样就会导致进程出现问题。
看完上面的机制对于初学者就差不多了,后续的fork()
函数机制可以先不看。(我还没学,没办法给大家做解释。容易看懵)
2. 文件描述符继承
- 精确复制:子进程获得父进程文件描述符表的副本
- 共享文件状态:
- 文件偏移量(lseek位置)
- 文件状态标志(O_APPEND等)
- 文件锁(fcntl锁)
// 示例:文件描述符共享
int main() {int fd = open("test.txt", O_WRONLY);write(fd, "Parent", 6);pid_t pid = fork();if (pid == 0) {// 子进程写入(共享相同文件偏移)write(fd, "Child", 5);close(fd);} else {wait(NULL);close(fd);}
}
// 文件内容:ParentChild
3. 信号处理继承
- 信号处理程序:继承所有已注册的信号处理函数
- 信号掩码:继承当前的信号阻塞掩码(sigprocmask)
- 挂起信号:不继承父进程的挂起信号(pending signals)
4. 进程属性继承
属性 | 是否继承 | 说明 |
---|---|---|
PID | 否 | 子进程获得新PID |
PPID | 是 | 子进程PPID=父进程PID |
进程组ID | 是 | 保持相同进程组 |
会话ID | 是 | 保持相同会话 |
控制终端 | 是 | 保持相同控制终端 |
有效用户ID | 是 | 保持相同权限 |
工作目录 | 是 | 继承当前工作目录 |
根目录 | 是 | 继承根目录 |
资源限制 | 是 | 继承ulimit设置 |
环境变量 | 是 | 完全复制环境变量 |
定时器 | 否 | 不继承alarm/setitimer |
5. 命名空间继承
- PID命名空间:默认在相同PID命名空间
- 网络命名空间:继承相同网络栈
- 挂载命名空间:继承相同的文件系统视图
- 用户命名空间:继承相同的用户映射
三、不继承的内容
1. 进程特定资源
- 内存锁:mlock/mlockall设置的锁不继承
- 文件锁:fcntl设置的记录锁不继承
- 定时器:setitimer/alarm设置的定时器不继承
- 异步I/O操作:未完成的异步I/O操作不继承
2. 父进程状态
- 挂起信号:父进程已接收但未处理的信号
- 资源使用统计:CPU时间、内存使用等计数器重置
- 性能监控计数器:PMC寄存器状态不继承
四、内核实现细节
1. 关键数据结构复制
// 内核源码片段(简化版)
static __latent_entropy struct task_struct *copy_process(struct pid *pid,int trace_flags)
{// 1. 分配新的task_structstruct task_struct *p = dup_task_struct(current);// 2. 复制文件系统信息retval = copy_files(clone_flags, p);// 3. 复制文件描述符表retval = copy_fs(clone_flags, p);// 4. 复制信号处理retval = copy_sighand(clone_flags, p);// 5. 复制内存空间retval = copy_mm(clone_flags, p);// 6. 设置写时复制dup_mm_exec(current, p);
}
2. 写时复制(COW)实现
-
页表项标志:设置PTE为只读
-
缺页处理:
do_page_fault()if (fault & VM_WRITE) {if (page_is_cow(page)) {copy_page(); // 复制物理页set_pte_writable(); // 设置可写}}
3. 性能优化
- 轻量级进程创建:实际只复制内核数据结构
- 延迟复制:内存页直到修改时才实际复制
- 共享页表:初始共享顶级页目录(PGD)
五、特殊场景处理
1. 多线程进程中的fork()
-
问题:只复制调用fork()的线程
-
风险:其他线程持有的锁不会被复制
-
解决方案:
pthread_atfork(prepare, parent, child); // prepare: fork前调用,获取所有锁 // parent: 父进程中调用,释放锁 // child: 子进程中调用,释放锁并重置状态
2. 文件描述符继承问题
-
关闭不需要的fd:
// 设置close-on-exec标志 fcntl(fd, F_SETFD, FD_CLOEXEC);
-
安全实践:在子进程中关闭不需要的文件描述符
3. 僵尸进程预防
-
正确做法:
if (pid > 0) {// 父进程waitpid(pid, &status, 0); // 等待子进程退出 }
六、总结
fork()的继承机制体现了Linux进程管理的核心设计哲学:
- 高效性:通过写时复制最小化开销
- 隔离性:进程间资源隔离但允许显式共享
- 兼容性:保持Unix传统进程模型
理解fork()的继承机制对于:
- 编写安全的多进程程序
- 优化进程创建性能
- 调试进程间资源冲突
- 设计容器化应用
fork()
函数的返回值如何理解?
来到最后一部分,fork()
函数的返回值如何理解?它是怎么做到能返回两个值的?一个值>0,一个值==0。一个变量又是如何能接收两个返回值的呢? pid_t id = fork();
一、返回值的基本含义
返回值 | 含义 | 执行进程 |
---|---|---|
>0 | 子进程的PID | 父进程 |
=0 | 标识子进程 | 子进程 |
<0 | 错误代码 | 父进程 |
二、设计哲学解析
1. 为什么这样设计?
前面我已经给大家说过了,父亲可以有多个孩子,但是孩子只有一个父亲。子进程可以自己看自己的进程PID,也可以查看自己的父进程是谁。但是父进程只能知道自己的PID和自己的父亲,但是无法查找到自己孩子的PID,所以为了方便父进程管理创建出来的子进程,fork()
函数在父进程中的返回值就是其创建的子进程的PID,fork()
函数在子进程中的返回值就是0。
那么为毛在子进程中fork()
函数的返回值是0呢?因为0是唯一不可能成为有效PID的值(PID≥1)。
2. 经典代码模式
pid_t pid = fork();if (pid < 0) {// 错误处理
} else if (pid == 0) {// 子进程代码printf("I'm child! My PID is %d\n", getpid());
} else {// 父进程代码printf("Created child with PID %d\n", pid);
}
3. 一个变量又是如何能接收两个返回值的呢?
pid_t id = fork();
一开始我是这样理解的:id这个变量本来只属于父进程,fork()创建了子进程后,在还没返回值之前,id这个变量父子进程是共享的。但是当fork()返回值后,id就会被改变,这样就触发写时拷贝,使得id不被父子进程共享,而是各有一个,所以id在每一个进程中只接收了一次fork的返回值。
但是不是特别准确,应该是这样的:
上面对写时复制(COW)机制的理解基本正确,但需要更精确地描述其触发时机和内存状态变化。让我们通过一个完整的流程来澄清这个机制:
精确的执行流程(结合COW机制)
关键点解析
-
fork()调用前
- 父进程内存中存在
id
变量(栈或堆空间) - 此时
id
未初始化或为随机值
- 父进程内存中存在
-
fork()执行瞬间
pid_t id = fork(); // 此时发生分裂
- 内核创建子进程的页表,指向相同的物理页
- 所有内存页被标记为只读(COW标志)
- 内核预设:
- 父进程的返回寄存器 = 子进程PID
- 子进程的返回寄存器 = 0
-
赋值操作触发COW
// 这行代码在两个进程中都会执行 id = fork返回值; // 汇编: mov [id_addr], eax
- 父进程:
- 尝试写
id
变量 → 触发页错误 - 内核复制该物理页 → 新页标记可写
- 父进程的
id
= 子进程PID
- 尝试写
- 子进程:
- 尝试写
id
变量 → 触发页错误 - 内核复制该物理页 → 新页标记可写
- 子进程的
id
= 0
- 尝试写
- 父进程:
-
最终状态
- 父子进程拥有各自独立的物理内存页
- 每个进程的
id
变量互不影响 - 未修改的内存页仍保持共享(没有修改的内容依旧是父子进程两者共享)
您理解正确的部分
-
✅ 共享初始状态:
fork瞬间父子进程确实共享物理内存(包括
id
所在页) -
✅ COW触发条件:
写操作(赋值)是触发复制的必要条件
-
✅ 独立副本结果:
赋值后父子进程拥有各自的
id
副本
需要修正的细节
-
“返回值前共享”的表述:
更准确的说法是:直到首次写操作前保持共享。返回值本身是通过寄存器传递的,不直接涉及内存写。
-
赋值操作的实质:
id = ...
不是简单的“改变值”,而是:- 触发页错误异常
- 引发物理内存复制
- 更新页表映射关系
-
时间节点:
内存分离发生在赋值操作时,而非fork返回时。fork返回时内存仍是共享状态。
通过代码验证
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>int main() {int shared = 100; // 共享变量pid_t id;printf("pre-fork: %p\n", &shared); // 相同虚拟地址id = fork();if(id == 0) {// 子进程先修改shared = 200;printf("child: %d at %p\n", shared, &shared);_exit(0);} else {// 父进程后读取wait(NULL);printf("parent: %d at %p\n", shared, &shared);}
}
输出:
pre-fork: 0x7ffd4a3b4abc
child: 200 at 0x7ffd4a3b4abc // 相同虚拟地址
parent: 100 at 0x7ffd4a3b4abc // 相同虚拟地址但值不同!
证明:
- 虚拟地址相同(证明最初共享映射)
- 值不同(证明COW创建了独立物理页)
总结前面的理解
“id这个变量在fork后直到首次写操作前保持共享状态,当任一进程执行
id = ...
赋值时,会触发写时复制,导致该内存页被复制,此后父子进程拥有各自独立的id变量副本,每个进程的id只接收一次fork返回值。”
这个修正后的描述完全准确地反映了Linux的COW机制和fork执行流程。
补充:
最后我们再来理一遍程序执行的顺序:
深入解析:fork()后父子进程的执行起点
核心结论
在fork()
调用后,父子进程都从紧接fork()调用的下一条指令开始执行,也就是id = fork()
这个赋值语句本身。
详细执行流程分析
代码示例:
#include <unistd.h>
#include <stdio.h>int main() {printf("A: 父进程初始状态 (PID=%d)\n", getpid()); // 位置1pid_t id = fork(); // 位置2:分水岭// 位置3:fork()后的代码if (id == 0) {printf("C: 子进程 (PID=%d)\n", getpid()); // 位置4} else {printf("P: 父进程 (PID=%d)\n", getpid()); // 位置5}printf("Z: 公共结束部分 (PID=%d)\n", getpid()); // 位置6
}
执行流程详解:
关键点解析:
- fork()调用前:
- 只有父进程执行位置1的printf
- 输出:
A: 父进程初始状态 (PID=1234)
- fork()调用瞬间:
- 内核创建子进程副本
- 设置:
- 父进程返回值:子进程PID (如5678)
- 子进程返回值:0
- fork()返回后:
- 父子进程都从同一位置继续执行:
id = fork()
这一行 - 但:
- 父进程执行:
id = 5678
- 子进程执行:
id = 0
- 父进程执行:
- 父子进程都从同一位置继续执行:
- 后续代码执行:
- 父子进程都执行位置3开始的代码
- 通过id值分流:
- 子进程执行位置4
- 父进程执行位置5
- 最后都执行位置6
技术原理:程序计数器(PC)的复制
关键机制:
- 当fork()创建子进程时
- 精确复制父进程的寄存器状态
- 包括程序计数器(PC)寄存器
PC寄存器的作用:
- 指向下一条要执行的指令
- fork()时,PC指向
id = fork()
这条语句
结果:
- 子进程"诞生"时:
- 拥有和父进程完全相同的寄存器状态
- PC寄存器指向相同位置
- 因此从相同位置开始执行
通过汇编代码验证
C代码:
pid_t id = fork();
对应的汇编代码(x86):
; 调用fork()
call fork; 将返回值(eax)存储到id变量
mov [id], eax
执行过程:
- 父进程:
- 执行
call fork
→ 进入内核 - 返回后:执行
mov [id], eax
(eax=子进程PID)
- 执行
- 子进程:
- 被内核创建时,设置:
- eax = 0
- PC =
mov [id], eax
的地址
- 首次执行:
mov [id], eax
(eax=0)
- 被内核创建时,设置:
特殊场景:fork()在复杂表达式中的情况
示例1:fork()在条件中
if (fork() == 0) {// 子进程代码
} else {// 父进程代码
}
- 执行点:
- 父子进程都从if语句块开始执行
- 子进程:计算
(0 == 0) → true
- 父进程:计算
(PID == 0) → false
示例2:fork()在函数参数中
printf("Fork returned: %d\n", fork());
-
输出结果:
Fork returned: 5678 // 父进程输出 Fork returned: 0 // 子进程输出
-
说明:
- 父子进程都执行整个printf语句
- 但参数中的fork()返回不同值
总结
- 执行起点:父子进程都从
id = fork()
这一行开始执行 - 赋值操作:
- 父进程:
id = 子进程PID
- 子进程:
id = 0
- 父进程:
- 触发COW:赋值操作导致写时复制,使id变量分离
- 后续执行:通过id值分流执行不同代码路径
这个机制是Linux进程模型的精髓:
- 最小化进程创建开销
- 最大化代码复用
- 提供清晰的进程区分方式
深入解析:id = fork()
的执行本质
id = fork()
这句代码能不能理解为是:第一句代码fork()
和 第二句id = fork()的返回值
?
这个理解非常接近真相,但需要更精确地从系统调用和进程分裂的角度来解释。让我们拆解这个看似简单的语句:
一、代码的实质:两个阶段
pid_t id = fork();
确实可以理解为两个逻辑阶段:
阶段1:系统调用阶段
// 内核操作
syscall_result = fork_system_call();
阶段2:赋值阶段
// 用户空间操作
id = syscall_result;
二、关键区别:父子进程的不同经历
父进程的完整流程:
子进程的"诞生"流程:
三、技术真相:一条语句,两种体验
1. 父进程视角:
-
完整执行:确实经历了"调用fork → 接收返回值 → 赋值给id"的过程
-
时间顺序:
// 步骤1: 调用fork() // 步骤2: 接收返回值(PID) // 步骤3: 赋值给id
2. 子进程视角:
- 跳跃开始:内核直接将其"放置"在赋值语句处
- 预设状态:
- 返回值寄存器(EAX)已预设为0
- 直接执行
id = EAX
,相当于id = 0
- 没有"调用fork"的体验:子进程从未实际执行fork系统调用//这句话是重点!!!!!
四、通过汇编代码验证
C代码:
pid_t id = fork();
编译后的汇编(x86):
; 1. 调用fork()
mov eax, 2 ; fork系统调用号
int 0x80 ; 触发软中断; 2. 结果处理
test eax, eax
js error_handler ; 处理错误; 3. 赋值给id
mov [id], eax
父子进程的不同路径:
步骤 | 父进程 | 子进程 |
---|---|---|
1. int 0x80 | ✓ 实际执行 | ✗ 从未执行 |
2. test eax | ✓ 执行 | ✓ 执行 |
3. mov [id] | ✓ 执行 | ✓ 执行 |
五、内存状态变化时序图
六、编程语言视角的精确描述
在C语言标准层面:
fork()
是一个返回pid_t类型的函数id = fork()
是一个赋值表达式
但在操作系统实现层面:
- 对父进程:是函数调用 → 返回值 → 赋值
- 对子进程:是直接执行赋值操作(值预设为0)
七、建议:如何正确理解
推荐理解方式:
“
id = fork()
在父进程中是一个完整的函数调用和赋值过程,而在子进程中则是从赋值点开始的首次操作,且值被内核预设为0。”
类比解释:
想象父进程在复印机前:
- 按下"复印"按钮(fork调用)
- 获得复印件(子进程)
- 在原件上写编号(id = PID)
而复印件(子进程):
- 诞生时已经带着"0"的标记
- 直接开始工作(执行后续代码)
八、特殊场景验证
场景1:fork()在表达式中
int status = (id = fork());
- 父进程:正常调用 → 赋值 → 返回赋值结果
- 子进程:直接执行赋值(id=0) → 返回0
场景2:多级fork
if (fork() && fork()) { ... }
- 实际创建3个进程
- 每个进程根据自己获得的返回值决定行为
总结
初始理解基本正确:
- 父进程确实经历两个阶段:
- 阶段1:执行
fork()
系统调用 - 阶段2:
id = 返回值
- 阶段1:执行
- 子进程只经历一个阶段:
- 直接执行
id = 0
(内核预设)
- 直接执行
这个看似矛盾的现象,正是Linux进程创建机制的绝妙设计:
- 对程序员:提供统一的代码视图
- 对内核:高效实现进程复制
- 对硬件:充分利用寄存器架构
写时复制(COW)的精确复制机制
当因为其中一个进程要修改其中一个变量(假设是一个整型变量int a
),触发COW的时候。拷贝数据副本,是只拷贝要修改的变量a,还是全部数据(变量a和变量a以外的数据)都拷贝一份?
答:
当触发写时复制(COW)时,内核只复制被修改的内存页,而非整个进程内存空间。这是Linux内存管理的核心优化设计。让我们深入解析这个机制:
一、COW触发时的精确操作
graph TDA[进程尝试写内存] --> B{页是否被标记为COW?}B -->|是| C[触发页错误异常]C --> D[内核分配新物理页]D --> E[复制原页内容到新页]E --> F[更新进程页表:指向新页]F --> G[标记新页为可写]G --> H[恢复进程执行]B -->|否| I[正常写入]
二、关键特性:页级粒度复制
1. 复制单位:内存页
- 最小操作单元:4KB(x86默认页大小)
- 触发条件:页内任一字节被修改
- 复制内容:整页4096字节数据
2. 示例场景:
假设进程内存布局:
0x1000-0x1FFF: 页1 (包含变量A,B)
0x2000-0x2FFF: 页2 (包含变量C)
- 修改变量A:触发页1的COW,复制整个页1
- 修改变量C:触发页2的COW,复制整个页2
- 变量B未被修改:但仍随页1被复制
三、技术实现深度解析
1. 页表项标志位
// 页表项结构
struct page_table_entry {unsigned long present : 1; // 页是否存在unsigned long writable : 1; // 是否可写 → fork后设为0unsigned long cow : 1; // COW标志位// ...其他标志位unsigned long frame : 40; // 物理页帧号
};
- fork后:所有页标记为只读(writable=0)并设置COW标志
2. 缺页处理流程
// 简化的缺页处理
void handle_page_fault(vaddr) {if (fault_type == WRITE_ACCESS) {pte = get_pte(vaddr);if (pte->cow) { // 是COW页new_page = alloc_page();copy_page(old_phys, new_page); // 复制整页update_pte(pte, new_page);set_writable(pte);clear_cow(pte);return;}}// ...其他错误处理
}
四、性能优化设计
1. 惰性复制策略
- 不复制:未修改的页保持共享
- 按需复制:仅复制实际修改的页
- 效果:典型场景节省>90%的内存复制
2. 工作集优化
- 局部性原理:进程通常集中访问少量页
- 实际效果:多数fork后只有10-20%的页被复制
五、不同内存区域的COW行为
内存区域 | COW行为 | 典型大小 |
---|---|---|
代码段(.text) | 永远共享(只读) | 数百KB |
数据段(.data) | 写时复制 | 数十KB |
BSS段(.bss) | 写时复制 | 数百KB |
堆空间 | 写时复制 | MB~GB级 |
栈空间 | 写时复制 | MB级 |
内存映射文件 | 取决于映射标志 | 可变 |
六、实际影响示例
场景:修改结构体中的单个字段
struct Data {int id;char name[4000]; // 占用约4KB
} data;int main() {pid_t pid = fork();if (pid == 0) {data.id = 100; // 修改4字节} else {wait(NULL);}
}
内存操作:
data
位于单个4KB页- 修改
data.id
触发整个页复制 - 实际复制:4096字节(尽管只修改4字节)
七、高级优化技术
1. 巨页(Huge Pages)
- 2MB/1GB大页:减少缺页中断次数
- COW代价:复制整个大页,适合连续访问
2. 用户态COW控制
// 主动跳过COW
void* ptr = mmap(NULL, size, PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);// 预写所有页强制复制
for(int i=0; i<size; i+=4096) {ptr[i] = 0; // 触发COW
}
八、设计哲学思考
为什么选择页级复制?
- 硬件支持:MMU以页为单位管理内存
- 效率平衡:
- 细粒度(字节级):管理开销过大
- 粗粒度(整个内存):浪费严重
- 实现简洁:页表机制天然支持
总结
当触发COW时:
- 复制单位:被修改的整个内存页(通常4KB)
- 复制内容:页内所有数据(包括未修改部分)
- 优化效果:未修改页保持共享,大幅节省内存
- 设计本质:空间换时间,用额外内存避免不必要的复制开销
理解这个机制对以下场景至关重要:
- 优化fork密集型应用(如Redis)
- 调试内存增长问题
- 设计高效进程间通信
- 容器技术资源隔离实现
COW的精妙之处在于:它通过精确的页级复制,在进程隔离和资源效率间取得了完美平衡。
90%的内存复制
2. 工作集优化
- 局部性原理:进程通常集中访问少量页
- 实际效果:多数fork后只有10-20%的页被复制
五、不同内存区域的COW行为
内存区域 | COW行为 | 典型大小 |
---|---|---|
代码段(.text) | 永远共享(只读) | 数百KB |
数据段(.data) | 写时复制 | 数十KB |
BSS段(.bss) | 写时复制 | 数百KB |
堆空间 | 写时复制 | MB~GB级 |
栈空间 | 写时复制 | MB级 |
内存映射文件 | 取决于映射标志 | 可变 |
六、实际影响示例
场景:修改结构体中的单个字段
struct Data {int id;char name[4000]; // 占用约4KB
} data;int main() {pid_t pid = fork();if (pid == 0) {data.id = 100; // 修改4字节} else {wait(NULL);}
}
内存操作:
data
位于单个4KB页- 修改
data.id
触发整个页复制 - 实际复制:4096字节(尽管只修改4字节)
七、高级优化技术
1. 巨页(Huge Pages)
- 2MB/1GB大页:减少缺页中断次数
- COW代价:复制整个大页,适合连续访问
2. 用户态COW控制
// 主动跳过COW
void* ptr = mmap(NULL, size, PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);// 预写所有页强制复制
for(int i=0; i<size; i+=4096) {ptr[i] = 0; // 触发COW
}
八、设计哲学思考
为什么选择页级复制?
- 硬件支持:MMU以页为单位管理内存
- 效率平衡:
- 细粒度(字节级):管理开销过大
- 粗粒度(整个内存):浪费严重
- 实现简洁:页表机制天然支持
总结
当触发COW时:
- 复制单位:被修改的整个内存页(通常4KB)
- 复制内容:页内所有数据(包括未修改部分)
- 优化效果:未修改页保持共享,大幅节省内存
- 设计本质:空间换时间,用额外内存避免不必要的复制开销
理解这个机制对以下场景至关重要:
- 优化fork密集型应用(如Redis)
- 调试内存增长问题
- 设计高效进程间通信
- 容器技术资源隔离实现
COW的精妙之处在于:它通过精确的页级复制,在进程隔离和资源效率间取得了完美平衡。