目录
一、fork函数的基本概念
fork()是Unix/Linux系统中用于创建新进程的系统调用函数。当调用fork()时,操作系统会创建一个与父进程几乎完全相同的子进程。
pid_t
是 进程 ID 的数据类型,在 Unix/Linux 系统编程中用于表示进程标识符(Process ID)。
pid_t的
关键特性
定义位置
通常在
<sys/types.h>
或<unistd.h>
头文件中定义。实际类型可能是
int
或long
(取决于系统)。
用途
存储进程 ID(
getpid()
、getppid()
返回值)。fork()
的返回值(父进程返回子进程 PID,子进程返回 0)。用于进程控制函数(如
waitpid()
、kill()
)。
fork的基本特性
两个返回值:
成功时:
在父进程中返回子进程的PID(进程标识符)、在子进程中返回0
失败时:
在父进程中返回-1、不会创建子进程、同时会设置相应的errno(错误码)
(注:errno是Linux系统记录错误状态的全局变量,可通过perror()或strerror()函数获取具体错误信息)刚开始父进程与子进程的代码与数据都是同一份,因为子进程是由父进程创建的,而且子进程没有自己的代码和数据,同时目前也没有其他程序加载到内存中。
代码共享,数据独立的阶段
发生在其中任意一个进程想要对数据修改的时候,此时数据段、堆栈等采用"写时复制"(Copy-On-Write)技术,各自拥有独立空间,但父子进程共享相同的代码段。
二、fork的基本使用
1、简单示例
#include <stdio.h>
#include <unistd.h>
int main()
{
fork(); // 创建子进程
while(1)
{
printf("I am a process...PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
return 0;
}
运行结果
程序运行后会输出两行交替出现的信息:
关键点
进程关系:子进程的PPID(父进程ID)就是父进程的PID
执行流程:fork()调用后,父进程和子进程都从fork()返回处继续执行
PCB创建:操作系统会为每个新进程(包括fork创建的)创建进程控制块(PCB)
总的来说,遇到fork函数时,会进行以下操作:
- 为子进程申请新的PCB
- 子进程拷贝父进程PCB
- 将子进程加入到进程列表
- 然后再将子进程放入调度队列
注意事项
父子进程执行顺序不确定,取决于系统调度
父子进程共享代码段,但有各自独立的数据空间
fork()在父进程中返回子进程PID,在子进程中返回0
2、进一步探讨
我们知道加载到内存当中的代码和数据是属于父进程的,那么fork函数创建的子进程的代码和数据又从何而来呢?
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("I am running...\n"); // 只有父进程执行
fork(); // 创建子进程的分界点
while(1) // 父子进程都从这里开始执行
{
printf("I am a process...PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
return 0;
}
运行结果说明
关键机制解析
代码共享机制:
fork()之前的代码只由父进程执行
fork()之后的代码由父子进程共同执行
父子进程共享相同的代码段(text segment)
数据管理机制:
采用写时拷贝(Copy-On-Write)技术
初始时父子进程共享相同的数据空间
当任一进程尝试修改数据时,操作系统才会为该进程创建数据的独立副本
进程调度特性:
父子进程被调度的顺序不确定
执行顺序取决于操作系统的调度算法
可能先执行父进程,也可能先执行子进程
实际应用提示
fork()前执行的代码不会被子进程重复执行(如示例中的第一个printf)
fork()后父子进程可以通过返回值区分(父进程得到子进程PID,子进程得到0)
合理利用写时拷贝机制可以提高fork效率,减少不必要的内存复制
三、写时复制(Copy-On-Write)技术示例
一个典型的写时复制(COW)场景发生在fork()系统调用创建子进程时:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main() {
int data = 42; // 数据段变量
pid_t pid = fork(); // 创建子进程
if (pid == 0) {
// 子进程
printf("Child process - initial data: %d\n", data);
data = 100; // 修改数据
printf("Child process - modified data: %d\n", data);
} else if (pid > 0) {
// 父进程
sleep(1); // 确保子进程先执行修改
printf("Parent process - data remains: %d\n", data);
} else {
// fork失败
perror("fork");
return 1;
}
return 0;
}
执行流程分析:
fork()调用前:只有一个进程,data变量值为42
fork()调用后:
创建子进程,此时父子进程共享相同的代码段
数据段、堆栈等内存区域也暂时共享(标记为COW)
两个进程的页表都指向相同的物理内存页
子进程修改data时:
操作系统检测到写操作发生在COW页面
内核为该页面创建副本,更新子进程的页表指向新副本
子进程修改自己的副本(data=100),父进程的data保持不变
输出结果:
关键点:
写时复制延迟了实际的内存复制,直到真正需要时才进行
父子进程共享相同的代码段(只读,无需复制)
未修改的数据段/堆栈保持共享,节省内存
修改操作触发页面错误,导致内核执行实际的复制
三、fork的工作原理
进程创建:
调用fork()后,操作系统创建子进程
子进程获得父进程的代码段、数据段、堆栈等副本
操作系统为子进程创建新的PCB(进程控制块)
写时复制:
初始时父子进程共享物理内存
当任一进程尝试修改内存时,操作系统才会真正复制该内存页
执行流程:
fork()之前的代码只由父进程执行
fork()之后的代码默认父子进程都会执行
四、关键问题解释
为什么fork有两个返回值?
fork()在父进程和子进程中各返回一次
父进程得到子进程PID,子进程得到0
返回值如何分配?
父进程:返回子进程的PID(>0)
子进程:返回0
错误:返回-1(仅在父进程中)
if和else if如何同时成立?
实际上不是同时成立,而是分别在两个不同的进程中执行
父进程执行else if(ret > 0)分支
子进程执行if(ret == 0)分支
五、实际应用示例
如前所述,fork函数创建的子进程会与父进程共享代码段。但如果父子进程执行完全相同的操作,创建子进程就失去了实际意义。
通常在实际应用中,我们会通过判断fork的返回值来区分父子进程的执行路径:
fork函数的返回值有以下几种情况:
- 成功创建子进程时:
- 父进程获得子进程的PID
- 子进程获得返回值0
- 创建失败时,父进程获得返回值-1
区分父子进程的标准方法
通过检查fork()的返回值来区分父子进程:
pid_t id = fork();
if(id == 0) {
// 子进程执行的代码
}
else if(id > 0) {
// 父进程执行的代码
}
else {
// fork失败处理
}
正是由于父子进程获取的返回值不同,我们可以据此编写不同的执行逻辑。例如:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("I am running...\n");
pid_t id = fork();
if(id == 0){ //child
while(1){
printf("I am child process...PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
}
else if(id > 0){ //parent
while(1){
printf("I am parent process...PID:%d\n", getpid());
sleep(1);
}
}
else { //fork error
perror("fork failed");
return 1;
}
return 0;
}
fork创建出子进程后,子进程会进入到 if 语句的循环打印当中,而父进程会进入到 else if 语句的循环打印当中。
六、注意事项
调度顺序:父子进程的执行顺序由操作系统调度决定,是不确定的
资源管理:
子进程会继承父进程打开的文件描述符
需要适当处理文件描述符和避免僵尸进程
并发控制:需要同步机制来协调父子进程的执行顺序(如管道、信号量等)
理解fork()的工作原理对于Unix/Linux系统编程至关重要,它是实现多进程应用程序的基础。