在计算机中完成任何操作都是进程,当用终端打开Linux时,会打开大多数发行版Linux的默认解释器-----bash。bash本事也是一个进程,且前文中我所提到的全部进程都是由bash创建的子进程。bash通过什么创建子进程呢?
fork
fork是一个系统调用,用于创建子进程,调用fork的进程叫父进程,被创建的进程叫子进程。
cpp
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("我是一个进程:%d\n",getpid());
fork();
printf("我是一个进程:%d\n",getpid());
}

有两个进程pid,说明确实创建了一个子进程。进程=PCB+代码和数据。在fork之后,OS会给子进程分配PCB,子进程会继承拷贝父进程的代码和部分数据继续执行接下来的代码,下面的代码被父进程执行一次后又被子进程执行。

如果子进程创建成功,fork会将子进程的pid返回给父进程(一个父进程可以有很多个子进程,父进程通过pid管理子进程),0会被返回给子进程。

如果要让父进程和子进程执行不同的代码,可以使用if-else结构,子进程继承父进程的代码和部分数据,但是子进程的返回值id是0,父进程的返回值id是子进程的pid大于0。注意:fork之后父子进程就分开了,虽然逻辑上代码相同,但每个进程有独立的程序计数器、寄存器上下文和内核栈。相互独立,互不影响。
cpp
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("父进程开始运行!%d\n",getpid());
pid_t id = fork();
if(id==0)
{
while(1)
{
sleep(1);
printf("我是子进程:%d,我的父进程是%d\n",getpid(),getppid());
}
}
else if(id > 0)
{
while(1)
{
sleep(1);
printf("我是父进程:%d,我的父进程是%d\n",getpid(),getppid());
}
}
else
{
perror("fork");
return 1;
}
}

进程独立
深入理解进程的独立性:fork之后的写时复制机制
在前文中我们了解到,通过**fork()**系统调用创建子进程时,子进程会继承父进程的代码和数据。但这引发了一个关键问题:既然父子进程初始时指向相同的代码和数据,它们又如何保持独立性呢?
这个问题的答案在于Linux内核的一项重要优化技术:写时复制(写时拷贝)(Copy-on-Write )。
父子进程的"共享"真相
当fork()成功创建子进程后,父子进程的关系可以从两个维度理解:
虚拟地址空间相同:
子进程获得与父进程完全相同的虚拟内存布局,相同的代码段地址、数据段地址、堆栈地址;从程序视角看,它们访问相同的内存地址。
物理内存的巧妙共享:
-
代码段:由于代码通常是只读的,父子进程真正共享同一份物理内存中的代码页
-
数据区域(包括全局数据、堆、栈):
内核将这些页面的权限标记为只读,父子进程的页表项最初指向相同的物理内存页;这创建了一种"共享幻觉"。
写时复制:按需分离的智慧
写时复制的工作原理如下:
初始状态:
父进程页表 → [物理页A]
子进程页表 → [物理页A] ← 相同物理页
当父进程写入时:
-
父进程尝试写入只读的物理页A → 触发页错误
-
内核为父进程分配新物理页B
-
将物理页A的内容复制到物理页B
-
父进程页表改为指向[物理页B]
-
允许父进程完成写入操作
最终状态:
父进程页表 → [物理页B](已修改)
子进程页表 → [物理页A](保持不变)
写时复制通过按需分离的策略,在默认共享以节省资源的同时,实现了修改时的完全透明隔离。
为什么需要这样的设计?
这种设计主要优化了最常见的fork()使用模式:fork() + exec()
exec
exec 是 Linux/Unix 系统中一组系统调用的统称 (如 execl、execv、execvp 等),核心作用是用全新程序替换当前进程的内存空间------ 简单来说,它不会创建新进程,而是让当前进程 "抛弃" 原有代码和数据,转而执行另一个程序,且进程的 PID、父进程 ID 等核心属性保持不变。

以 Shell 执行新命令的典型场景为例,Shell 进程会先通过fork()创建子进程,随后子进程立即调用exec()加载新程序(如ls),而新程序会替换子进程的全部内存空间;如果fork()时就立即复制父进程的全部内存,就会复制 Shell 进程的几十 MB 内存,可子进程马上就会丢弃这些内存去加载ls仅几 KB 的代码,造成巨大的复制开销,而写时复制则完美解决了这个问题,它让fork()几乎瞬间就能完成,因为这个过程只需要复制页表等元数据,后续如果子进程很快执行exec(),就几乎不会产生实际的内存复制操作,只有当子进程需要修改数据时,内核才会按需为其复制对应的内存页。
父子进程的独立性体现
尽管初始时共享内存,但父子进程仍在多方面保持完全独立:二者的内存空间通过写时复制机制实现修改互不干扰,拥有各自独立的程序计数器与寄存器状态以保障执行流独立,操作系统会对它们进行独立调度,二者甚至可能在不同 CPU 核心上运行;进程属性层面,父子进程不仅拥有不同的进程 ID 与父进程 ID,还具备独立的文件描述符表(尽管子进程会继承父进程的文件描述符,但后续操作相互独立)与独立的信号处理逻辑;此外,二者的 CPU 时间、内存使用等资源统计各自独立,生命周期也互不影响,一方终止不会直接导致另一方终止。
实际编程中的体现
cpp
int shared_data = 100; // 全局变量
pid_t pid = fork();
if (pid == 0) {
// 子进程
shared_data = 200; // 触发写时复制!
printf("子进程: %d\n", shared_data); // 输出200
} else {
// 父进程
sleep(1); // 确保子进程先修改
printf("父进程: %d\n", shared_data); // 输出100,不受影响!
}

重要注意事项
并非所有资源都会采用写时复制机制:进程控制块(PCB)会被完全复制,而打开的文件描述符则是父子进程共享的,这里需要小心文件偏移量同步的问题;从性能角度考量,fork()本身的开销很小,但如果父子进程都大量修改不同的内存页面,最终还是会产生两份完整的内存拷贝;此外,fork()和早期 Unix 中的vfork()也存在区别,vfork()会让父子进程真正共享内存,且要求子进程必须立即执行exec(),不过在现代 Linux 中fork()已经得到高度优化,vfork()几乎不再需要使用。
总结
fork()与写时复制机制的配合,以高效性、透明性与灵活性的精妙平衡,让Linux在保障进程安全隔离的同时实现高效的进程创建与管理,支撑起复杂的多任务环境。