这篇文章主要描述了fork的写时复制原理以及源码解析,设计到的一些API有fork()、exec()、wait()。 先让我们来简单的描述一些这几个API(可以通过man手册来查看):
fork(): 允许一个进程(父进程)创建一个新进程(子进程)。子进程是父进程的副本:子进程获得父进程的栈、数据段、堆和代码段的拷贝。注意:子进程和父进程只共享代码段,父子进程并不共享栈、数据段、堆存储空间。
exec():加载一个新程序到当前进程的内存。丢弃现存的代码段,并为新程序重新创建栈、数据段以及堆。exec()没有创建新进程,只是用磁盘上的一个新程序替换当前进程(即子进程)的代码段,数据段,堆段和栈段。 fork()后面经常跟着exec(),这对于shell是非常常见的。
wait():如果子进程尚未终止,那么 wait()会挂起父进程直至子进程终止。
1.1 fork 简介
fork()系统调用通过复制一个现有进程来创建一个全新的进程。调用fork的进程称之为父进程,新产生的进程为子进程。fork()系统调用从内核返回两次:一次回到父进程,另一次回到新产生的子进程。 通常,新产生的进程都会立马执行一个新的不同的程序,通过调用exec系列函数替换掉现存的程序代码段,并构建新的数据段,栈和堆。
应用程序使用系统调用fork()创建子进程,有两种调用方法
(1) int ret = fork();
(2) int ret = syscall(SYS_fork); //SYS_fork是fork的系统调用号
关于 syscall()函数的介绍我们可以通过man手册来简单的看一下:
NAME
syscall - indirect system call
SYNOPSIS
#include <unistd.h>
#include <sys/syscall.h> /* For SYS_xxx definitions */
long syscall(long number, ...); //参数number是系统调用号,后面是传递给系统调用的参数
1.2 写时拷贝的引入
调用fork()后,子进程如果完全拷贝了父进程的数据段,堆,栈内存(前面提到:fork()之后,子进程往往会立马调用exec系列的函数,来执行一个新的不同的程序),那么会产生较高的性能开销。因为如果子进程拷贝父进程的数据段,堆,栈内存,那么又会立马调用exec系列函数覆盖掉刚刚拷贝的内存,那么这么做是毫无意义的。因此Linux引进了写时拷贝(copy - on - write)的技术。写时拷贝可以避免拷贝大量根本就不会使用的数据(地址空间包含的数据多达数十兆)。因此可以看出写时拷贝极大提升了Linux系统下fork函数运行地性能。
写时拷贝指的是子进程的页表项指向与父进程相同的物理页,这也只需要拷贝父进程的页表项就可以了,不会复制整个内存地址空间,同时把这些页表项标记为只读。如果父子进行都不对页面进行操作,那么便一直共享同一份物理页面。只要父子进程有一个尝试进行修改某一个页面,那么就会发生缺页异常(page fault)。那么内核便会为该页面创建一个新的物理页面,并将内容复制到新的物理页面中,让父子进程真正地各自拥有自己的物理内存页面,并将页表中相应地页表项标记为可写。 写时拷贝父子进程修改某一个页面前后变化如下图所示:
1.3 fork + exec 组合分离方式的好处
分离fork和exec的做法在构建Linux shell的时候非常有用,这给了shell在fork之后exec之前运行代码的机会,这些代码可以在运行一个全新的程序前改变环境。 shell也是一个用户程序,它会显示一个提示符,等待用户的输入。 当我们向shell输入一个命令(一个可执行的程序)时,shell就在文件系统中找到这个可执行的程序,通过调用fork()创建新进程,并调用exec系列函数来执行这个可执行的程序,调用wait()等待该命令的完成。子进程执行结束后,shell从wait()返回并再次输出提示符,等待用户的下一条命令。
那么现在我们通过一个小例程来体会一下fork + exec组合分离的强大功能:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <string.h>
int main()
{
int ret = fork();
if(ret == 0){
//fork之后,exec执行之前,来改变一些子进程运行的环境
//关闭 STDOUT_FILENO : 标准输出,对应的文件描述符为 1
close(STDOUT_FILENO);
//打开file.txt文件,这样 wc 的执行结果写入file.txt文件中
open("./file.txt", O_CREAT | O_WRONLY | O_TRUNC , S_IRWXU);
char *my_args[3];
my_args[0] = strdup("wc");
my_args[1] = strdup("fork_exec.c");
my_args[2] = NULL;
//执行exec函数,运行一个新shell程序:wc fork_exec.c
execvp(my_args[0], my_args);
}else if(ret > 0){
wait(NULL);
}else{
printf("fork error\n");
return -1;
}
return 0;
}
从结果可以看出两者运行的结构一致: wc用来统计指定文件的行数、字数,以及字节数。 shell重定向的原理:当shell调用fork完成子进程的创建后,shell在调用exec()之前先关闭了标准输出, 然后打开文件redirect.txt,这样shell命令wc 的输出结果就被发送到文件redirect.txt中,而不是输出给标准输出,打印在屏幕上。
2.1 fork源码简介
fork函数是一个系统调用,其定义如下:
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
......
}
SYSCALL_DEFINE0(fork) 展开就是:asmlinkage long sys_fork(void); SYSCALL_DEFINE后面的数字表示系统调用的参数个数,比如: SYSCALL_DEFINE0:表示系统调用没有参数,这里fork便没有参数。 SYSCALL_DEFINE6:表示系统调用有6个参数。
2.2 写时拷贝源码解析
// kernel/fork.c
_do_fork()
-->copy_process()
-->copy_mm() //新进程复制当前进程的虚拟内存
-->dup_mm()
-->dup_mmap()
// mm/memory.c
-->copy_page_range()
-->copy_pud_range()
-->copy_pmd_range()
-->copy_pte_range()
-->copy_one_pte()
/* * If it's a COW mapping, write protect it both * in the parent and the child */
if (is_cow_mapping(vm_flags)) {
ptep_set_wrprotect(src_mm, addr, src_pte);
pte = pte_wrprotect(pte);
}
dup_mm函数虽然给进程创建了一个新的内存地址空间,但在复制过程中会通过copy_pte_range调用copy_one_pte函数进行是否启用写时复制的处理,如果采用的是写时复制(Copy On Write),将页表设置成写保护,父子进程中任意一个进程尝试修改写保护的页面时,都会引发缺页异常(page fault)。
然后再看看linux内存四级页表的管理和拷贝页表的流程,对比一下,感觉很像是吧。关于这些函数的作用大家可以自己查看内核代码。
// mm/memory.c
-->copy_page_range()
-->copy_pud_range()
-->copy_pmd_range()
-->copy_pte_range()
-->copy_one_pte()
2.3 do_page_fault 源码解析
缺页异常的主要流程如下:
// arch/x86/mm/fault.c
do_page_fault()
-->__do_page_fault()
// mm/memory.c
-->handle_mm_fault()
-->__handle_mm_fault(){
......
pgd_offset();
pud_alloc();
pmd_alloc();
......
}
-->handle_pte_fault(){
.......
pte_alloc();
.......
do_wp_page();
......
}
缺页异常最终会调用do_page_fault(与处理器架构相关), do_page_fault进而调用handle_mm_fault(与处理器架构不相关),handle_mm_fault最终会调用handle_pte_fault,在缺页异常中,如果遇到写保护,则会调用do_wp_page,该函数会负责创建副本,即真正的拷贝。
小结:真正的写时拷贝发生在do_wp_page()函数中,do_wp_page负责创建该页的副本,并Insert到该进程的页表中。
if (vmf->flags & FAULT_FLAG_WRITE) {
if (!pte_write(entry))
return do_wp_page(vmf);
entry = pte_mkdirty(entry);
}
3.1 父子进程各自先运行的优势
通常来说,fork之后是父进程先运行还是子进程先运行这是不确定的,取决于内核的调度算法。 对于父子进程各自先运行的好处:
(1)父进程先运行的理由:fork之后,此时父进程在当前CPU中处于活跃状态,因此CPU硬件内存管理单元TLB(Translation Lookaside Buffer)目前都是缓存的父进程内存的页表项,此时TLB没有子进程的页表项,优先运行父进程可以提升处理器性能。
(2)子进程先运行的理由:fork之后,一般子进程马上会调用exec系列函数,运行一个新的程序,不会进行写时拷贝,这样就会避免写时拷贝带来的额外开销。由于子进程会复制父进程的页表,调用exec函数后,那么则会替换掉复制的程序,然后运行一个新的程序。如果父进程先运行,就有可能就会向地址空间写入数据,进行写时复制,带来额外的性能开销。
3.2 代码实验
目前Linux内核对于普通进程采用的是CFS完全公平调度算法,对于CFS调度策略,Linux下的proc文件系统提供了一个控制接口:
/proc/sys/kernel/sched_child_runs_first
现在我们来做个小实验来证明一下: 注意:这里的实验是在单核CPU,多核CPU下的结果会不一样,多核CPU有负载均衡(load balance),没法保证谁先运行,多核下子进程可能被安排到其它的CPU上运行。
我的实验环境 :vmware + ubuntu20.04,单核处理器。
单核下这样父子进程都是在同一个CPU核心下运行: /proc/sys/kernel/sched_child_runs_first 该值默认是0,父进程优先调度。
// fork.c
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
int ret = fork();
if(ret == 0){
//child ret = 0
printf("i am child: pid = %d\n", getpid());
}else if(ret > 0){
//parent ret = child pid
printf("i am parent : pid = %d ret = %d\n", getpid(),ret);
}else{
printf("fork error\n");
return -1;
}
return 0;
}
// test.sh
#!/bin/bash
for ((i=1; i<100; i++))
do
./a.out
sleep 2
done
/proc/sys/kernel/sched_child_runs_first 是0时,父进程优先调度。 当我把 /proc/sys/kernel/sched_child_runs_first 设置为1后,子进程优先调度,如下:
以上就是fork写时拷贝的原理以及原理解析,顺便说了下fork + exec 组合的好处,以及单核情况下父子进程谁先运行。
Linux 4.10.0 操作系统导论 深入Linux内核架构 Linux内核设计与实现 Linux环境编程:从应用到内核 Linux内核分析及应用 Linux内核深度解析 Linux/unix系统编程手册