Linux fork 写时复制

前言

这篇文章主要描述了fork的写时复制原理以及源码解析,设计到的一些API有fork()、exec()、wait()。
先让我们来简单的描述一些这几个API(可以通过man手册来查看):

fork():通过复制调用进程来创建一个新的进程,新进程被称为子进程,而原始进程则被称为父进程。
子进程和父进程运行在独立的虚拟地址内存空间中。在调用fork()时,两个内存空间的内容是相同的。这意味着在fork()调用时,子进程初始时具有父进程的内存副本。
然而,之后由其中一个进程执行的内存写入、文件映射(mmap())和取消映射(munmap())操作不会影响另一个进程。这是因为子进程和父进程拥有独立且互不影响的内存空间。在一个进程的内存空间做出的修改不会反映在另一个进程的内存空间中。
备注:虽然父子进程有自己独立的虚拟地址空间mm,但是共享物理内存空间。

exec():加载一个新程序到当前进程的内存。丢弃现存的代码段,并为新程序重新创建栈、数据段以及堆。exec()没有创建新进程,只是用磁盘上的一个新程序替换当前进程(即子进程)的代码段,数据段,堆段和栈段。
fork()后面经常跟着exec(),这对于shell是非常常见的。

wait():如果子进程尚未终止,那么 wait()会挂起父进程直至子进程终止。

一、fork 简介

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)。那么内核便会为该页面创建一个新的物理页面,并将内容复制到新的物理页面中,让父子进程真正地各自拥有自己的物理内存页面,并将页表中相应地页表项标记为可写。
写时拷贝父子进程修改某一个页面前后变化如下图所示:
在这里插入图片描述
父子进程只是复制内存描述符给子进程而已,没有复制实际的物理内存。

struct task_struct {
	......
	struct mm_struct		*mm;	
	......
}
SYSCALL_DEFINE0(fork)
	-->_do_fork(SIGCHLD, 0, 0, NULL, NULL, 0)
		-->copy_process()
			-->copy_mm()
				-->dup_mm()
/*
 * Allocate a new mm structure and copy contents from the
 * mm structure of the passed in task structure.
 */
static struct mm_struct *dup_mm(struct task_struct *tsk)
{
	struct mm_struct *mm, *oldmm = current->mm;

	mm = allocate_mm();

	memcpy(mm, oldmm, sizeof(*mm));

	mm_init(mm, tsk, mm->user_ns);

	dup_mmap(mm, oldmm);

	......
}

(1)函数首先声明了两个指向mm_struct结构体的指针变量mm和oldmm,并将当前进程的mm结构体保存在oldmm中。

(2)调用allocate_mm()分配一个新的mm_struct结构体,并将返回的指针存储在mm中。

(3)函数使用memcpy()函数将oldmm中的内容复制到新分配的mm结构体中,复制的大小为sizeof(*mm)。

(4)函数调用mm_init()对新的mm结构体进行初始化。

(5)函数调用dup_mmap()函数来复制oldmm中的内存映射表到新的mm结构体中。
dup_mmap就是将父进程的地址空间的页表项复制到子进程的页表项中。

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中,而不是输出给标准输出,打印在屏幕上。
在这里插入图片描述

二、fork 源码解析

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);
}

三、fork之后父子进程谁先运行

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后,子进程优先调度,如下:
在这里插入图片描述

参考资料

Linux 4.10.0

  • 26
    点赞
  • 40
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值