fork()系统调用创建一个子进程,是父进程的一个副本,父子进程仅有pid的区别。
子进程拥有与父进程相同的进程虚拟地址空间,但如果在fork()时复制父进程的整个地址空间,虽然实现了创建副本的目的,但这种做法不太聪明,因为直接复制父进程的所有内存页是非常耗费资源的,特别是当父进程占用了大量内存时。
为了解决这个问题,操作系统使用了一种叫做 Copy-On-Write(写时复制) 的技术。
思路:
1.当子进程对地址空间上的数据进行读操作时,没必要重新创建一个副本供子进程来读,直接读父进程的地址可以达到同样的效果
2.当子进程对地址空间上的某一页进行写(修改)操作时,由于逻辑上父子进程拥有独立的地址空间,此时修改的必须是子进程自己的地址空间,此时再分配给子进程一页地址空间,这一页空间才是真正意义上属于子进程自己的
实现
fork()
的实现细节
当父进程调用 fork()
时,操作系统会进行以下操作:
- 创建子进程:内核会为子进程分配一个新的进程控制块(Process Control Block,PCB),其中包括子进程的进程 ID、进程状态等信息。
- 复制页表:页表是一个数据结构,映射进程的虚拟地址空间到物理内存地址。
fork()
时,内核不会复制父进程的所有内存,而是只复制父进程的页表,使子进程的页表指向相同的物理内存页。 - 设置内存页为只读:为了实现 Copy-On-Write 机制,内核会将父进程和子进程的内存页标记为只读。这样,任何对这些页的写操作都会触发一个页面保护异常(page fault)。
- 共享文件描述符:父进程和子进程共享打开的文件描述符,引用计数会增加。
写时复制(copy on write)的实现细节
- 初始状态:
- 当父进程调用
fork()
时,子进程会共享父进程的所有内存页,这些内存页都会被标记为只读。
- 触发写保护:
- 当父进程或子进程尝试写入某个内存页时,由于该页是只读的,会触发页面保护异常(page fault)。
- 处理写保护异常:
- 操作系统捕获这个异常,并执行以下步骤:
- 分配一个新的物理内存页。
- 将原来只读内存页的内容复制到新的物理页中。
- 更新当前进程的页表,使该虚拟地址指向新的物理页。
- 将新的物理页设置为可写。
这样,只有试图写入的内存页会被复制,其他未被修改的内存页依然是共享的和只读的。
示例
假设有一个进程 P
,其内存布局如下:
虚拟地址 | 物理地址 | 内容 |
0x1000 | 0xA000 | Data1 |
0x2000 | 0xB000 | Data2 |
- 调用
fork()
:
- 创建子进程
C
,复制页表并共享内存页。
进程 | 虚拟地址 | 物理地址 | 内容 |
P |
0x1000 | 0xA000 | Data1 |
P |
0x2000 | 0xB000 | Data2 |
C |
0x1000 | 0xA000 | Data1 |
C |
0x2000 | 0xB000 | Data2 |
- 标记为只读:
- 内核将这些内存页标记为只读。
- 子进程修改内存页:
- 假设子进程
C
修改0x1000
地址的内容,触发页面保护异常。
- 处理页面保护异常:
- 分配一个新的物理页
0xC000
。 - 将
0xA000
页的内容复制到0xC000
。 - 更新子进程
C
的页表,使0x1000
虚拟地址指向0xC000
。 - 将
0xC000
设置为可写。
进程 | 虚拟地址 | 物理地址 | 内容 |
P |
0x1000 | 0xA000 | Data1 |
P |
0x2000 | 0xB000 | Data2 |
C |
0x1000 | 0xC000 | Data1 (Modified) |
C |
0x2000 | 0xB000 | Data2 |
优点
- 节省内存:未修改的内存页依然共享,只有被修改的页才会被复制,节省了大量内存。
- 提高效率:避免在
fork()
调用时立即复制整个地址空间,提高了系统调用的性能。