序言
在上一篇文章 进程概念以及进程状态,我们提到了 fork
函数,该函数可以帮我们创建一个子进程。在使用 fork
函数时,我们会发现一些奇怪的现象,举个栗子:
1 #include <stdio.h>2 #include <unistd.h>3 4 int main(){5 int val = 1;6 pid_t pid = fork();7 8 // 创建子进程失败9 if(pid < 0){10 printf("Failed to create child process.");11 return 1;12 }13 // 子进程14 else if(pid == 0){15 val += 1;16 printf("I am child process, pid is %d, val = %d, &val = %p.\n", getpid(), val, &val);17 }18 // 父进程19 else{ 20 printf("I am parent process, pid is %d, val = %d, &val = %p.\n", getpid(), val, &val);21 }22 23 return 0;24 }
注意:这里我为了简化代码以便大家更为方便的理解,并没有回收子进程,这是不规范的,会导致僵尸进程的产生QAQ。
运行上段代码,我们得到以下的输出结果:
I am parent process, pid is 4092, val = 1, &val = 0x7ffdde232328.
I am child process, pid is 4093, val = 2, &val = 0x7ffdde232328.
我们在上篇文章中也提到了,fork
函数会返回两个返回值,这对于我们来说简直就是不符合常识呀!
通过程序的运行结果,我们得出以下疑问:
- 为什么
fork
函数会返回两个返回值? - 为什么程序走了
if else
条件控制结构的两个分支? - 为什么同一个存储
val
的地址,存储的数值还不同?
1. 进程地址空间
首先我们肯定的是同一个物理地址只能存储一个值,不可能存储多个值。但是上面变量的地址确实是相同的呀?这又怎么解释呢?有没有可能我们打印出的地址不是真正的物理地址,那是什么呢?虚拟地址。
1.1 物理内存空间
物理内存可以根据操作系统和程序的需要,按照存储的数据类型和用途来划分为不同的段。以下是按照存储的数据类型进行的分类:
在创建一个进程时,操作系统通常会为该进程分配以上几种数据段,这些段共同构成了进程的空间。可以发现这些进程空间在物理上是并不连续的,当系统正常的运行时,每时每刻会产生大量的进程完成各项任务,内存管理复杂性是相当高的,所以说我们肯定不会直接在物理内存上进行操作。
1.2 虚拟地址空间
大家在一定都看过这幅描述进程地址空间的图像吧:
大家到这里肯定会有疑问,你刚才才说进程的空间在物理内存上是分散的,不连续的,那你为什么还给出这幅图呢?这确实不是在物理内存上的图像,这是我们的虚拟地址空间,它包含了进程执行所需要的所有代码、数据、堆栈等信息。
一个程序的虚拟地址空间是连续的,这大大地简化了我们的内存管理。 但是我们的数据最终肯定是在物理内存上,那和这个虚拟地址空间有什么关系呢?
1.3 页表 — 连通物理和虚拟的桥梁
定义:
页表是一种数据结构,通过页表,操作系统能够将程序中的虚拟地址转换为实际的物理地址,从而实现内存的访问。
作用:
- 地址映射:页表实现了虚拟地址到物理地址的映射,使得程序可以使用连续的虚拟地址空间来访问物理内存中可能不连续的内存块。
- 内存保护:页表还包含了每页的访问权限信息(如可读、可写、可执行等),从而实现对内存访问的控制,提高系统的安全性。
综上所述,通过页表我们可以将在虚拟内存上的操作映射到物理内存上,并且还会提前预警不合法的访问修改操作,确保进程的正确执行和系统的稳定运行。
1.4 进程地址空间的优点
- 有序化空间地址:将物理地址空间将无序变为有序,让进程以统一的视角看待物理内存以及自己的各个区域
- 进程隔离:每个进程都拥有自己独立的地址空间,这确保了不同进程之间的内存是相互隔离的。
- 读写保护:进程地址空间通过页表等机制实现了对内存访问权限的控制。操作系统可以设定页表的权限字段,如只读、可写等,从而限制进程对内存的访问方式。
- 提高内存使用效率:当我们使用
malloc
等等库函数动态申请空间,却并没有立即使用时,会在页表先登记申请的虚拟空间地址,不立即在物理内存的堆上申请,等到你要使用时再申请
1.5 进程地址空间和总结
进程地址空间中的虚拟地址通过操作系统的地址转换机制(如页表)映射到物理地址。当程序试图访问某个虚拟地址时,操作系统会查找页表,将虚拟地址转换为对应的物理地址,然后访问物理内存中的数据。
2. fork 函数背后的逻辑
2.1 进程复制
- 复制进程:
fork
函数会复制当前进程的上下文来创建一个新的进程。这个复制过程包括进程的PCB
内的部分信息、虚拟地址空间(写时复制
)、页表、文件描述符、环境变量等。 - 共享与独立:虽然子进程是父进程的复制品,但两者在操作系统中被视为独立的进程,拥有各自的进程
ID(PID)
。它们各自独立地执行程序,且可以通过不同的返回值来区分是父进程还是子进程。
2.2 返回值
- 父进程中的返回值:在父进程中,
fork
函数会返回新创建的子进程的PID(一个正整数)。 - 子进程中的返回值:在子进程中,
fork
函数返回 0 。如果fork
调用失败,则在父进程中返回 -1,并设置errno
以指示错误原因。
2.3 执行起点
子进程的执行起点:子进程的执行起点是从 fork
调用之后的那条指令开始的。这意味着 fork
在这里插入代码片
调用之前的所有变量赋值、文件操作等都会被子进程继承。
3. 虚拟地址中的写时复制
3.1 使用场景
上面提到当我们创建一个子进程的时候,子进程会直接复制父进程的虚拟地址空间,页表等信息,很明显直接复制这是一个浅拷贝:
就比如,在上附图中,父进程一个变量 A
,他的页表信息也被子进程所直接复制。
当我们的子进程想要修改该变量 A
的值时,如果不进行额外处理,那么父进程的值也会改变,这违背了 父子进程相互独立
规则。所以,会为子进程重新申请一个新的空间,放置修改后 A
的值,这也会使子进程页表中对应的物理地址发生改变。
3.2 写时拷贝的优点
- 减少不必要的资源分配,不需要为不用修改的变量申请空间
- 提高复制效率,在写时拷贝机制下,资源的复制操作被延迟到实际需要修改资源内容时进行。这种懒惰复制的方式减少了在资源初始分配时的开销,提高了系统的整体效率。
4. 解释开头的疑问
为什么 fork
函数会返回两个返回值?
使用 fork
函数后,会变为两个进程,一个父进程,一个子进程,两个进程中接收的返回值是不同的,父函数接收的为子进程的 pid
,子进程接收的为 0
。
为什么程序走了 if else
条件控制结构的两个分支?
不是同一个进程同时走两个分支,是两个进程走各自的分支。
为什么同一个存储 val
的地址,存储的数值还不同?
子进程直接拷贝了父进程的进程地址空间和页表内的信息,并且打印出来的是页表内的虚拟地址,而非真是物理地址,所以地址相同。但是子进程修改变量时,发生写时拷贝,给该变量一个新的物理空间。总的来说,父子进程的 val
各自存放在不同的物理地址,但是虚拟地址相同。