1.开场白
本文主要从内存管理和进程管理两个维度来窥探一下fork背后隐藏的技术细节,希望能够通过本文让大家站在一个高度去看进程创建。
全文分为两部分讲解:fork的内存管理部分和进程管理部分,内存管理主要讲解子进程如何构建自己的内存管理相关基础设施,父子进程如何共享地址空间的,写时复制如何发生,页表层面为我们做了哪些事情等等。而进程管理主要讲解,子进程如何构建自己的进程管理相关基础设施,如何加入到cpu的运行队列,第一次运行时如何执行等等。
实际上,除了0号进程,其他的所有进程无论是内核线程还是普通的用户进程和线程都是fork出来的,而创建进程是内核所做的事情,要么在内核空间直接创建出所谓的内核线程,要么是通过fork,clone这样的系统调用陷入内核空间来创建。对于内核线程没有异常级别的切换,构建好调度相关基础数据结构时就可以在第一次参与调度的时候执行他的执行函数,任务切换的时候也不需要进行地址空间切换。而对于用户任务来说,需要异常级别的切换(也是一种上下文切换),任务切换的时候甚至还需要切换地址空间。
说明:我们将参与调度的实体称为任务,包括用户进程,用户线程,内核线程。
我们移步到如下调用路径(当前处于copy_mm函数中):
kernel_clone //kernel/fork.c
->copy_process
->copy_mm
首先,任务在创建的时候根据传递的fork的参数clone_flags来决定是否需要创建一个mm_struct结构来管理任务的地址空间,如果传递过来的clone_flags带有CLONE_VM标志,则不需要创建,直接和父进程共享地址空间即可,如内核线程和用户线程。
if (clone_flags & CLONE_VM) {
mmget(oldmm);
mm = oldmm;
goto good_mm;
}
mm = dup_mm(tsk, current->mm);
...
good_mm:
tsk->mm = mm;
tsk->active_mm = mm;
return 0;
而当传递过来的clone_flags不带CLONE_VM标志,则需要为子进程创建新的地址空间,如创建子进程,就调用到dup_mm中。为了看的清晰贴出了如下代码:
static struct mm_struct *dup_mm(struct task_struct *tsk,
struct mm_struct *oldmm)
{
struct mm_struct *mm;
int err;
mm = allocate_mm();
if (!mm)
goto fail_nomem;
memcpy(mm, oldmm, sizeof(*mm));
if (!mm_init(mm, tsk, mm->user_ns))
goto fail_nomem;
err = dup_mmap(mm, oldmm);
if (err)
goto free_pt;
....
}
这里需要注意的地方有三个地方:1.通过allocate_mm分配属于进程自己的mm_struct结构来管理自己的地址空间;2.通过mm_init来初始化mm_struct中相关成员;3.通过dup_mmap来复制父进程的地址空间(实际上后面我们会看到是复制父进程的vma以及页表)。
分配mm_struct结构就不需要赘述,我们先看下mm_init,调用链如下:
mm_init
->
mm->mmap = NULL;
mm->mm_rb = RB_ROOT;
mm->vmacache_seqnum = 0;
...
if (mm_alloc_pgd(mm))
goto fail_nopgd;
if (init_new_context(p, mm))
goto fail_nocontext;
这里有两个地方暗藏玄机,首先是mm_alloc_pgd,对于像amr64这种处理器架构来说,只不过是分配一个进程私有pge页而已,当va->pa转换的时候,查找属于当前进程的pgd表项。
mm_alloc_pgd //arch/arm64/mm/pgd.c
->mm->pgd = pgd_alloc(mm);
->__get_free_page
但是,当我们看其他处理器架构mm_alloc_pgd的实现时会发现,除了会分配pge页还会做主内核页表的内核空间的pge表项的同步工作,如riscv,x86。下面是riscv的实现:
static inline pgd_t *pgd_alloc(struct mm_struct *mm) //arch/riscv/include/asm/pgalloc.h
{
pgd_t *pgd;
pgd = (pgd_t *)__get_free_page(GFP_KERNEL); //分配进程私有的pge页
if (likely(pgd != NULL)) {
memset(pgd, 0, USER_PTRS_PER_PGD * sizeof(pgd_t));
/* Copy kernel mappings */
memcpy(pgd + USER_PTRS_PER_PGD,
init_mm.pgd + USER_PTRS_PER_PGD,
(PTRS_PER_PGD - USER_PTRS_PER_PGD) * sizeof(pgd_t)); //同步主内核页表的内核空间的pge表项到进程的pgd页中
}
return pgd;
}
这些处理器为什么要这样多此一举呢?原因是这样的:当内核初始化完成转换,进程切换的时候都是使用tsk->mm->pgd指向的页表作为base来进程页表遍历(walk),对于arm64架构来说,他有两个页表基址寄存器ttbr0_el1和ttbr1_el1(只考虑阶段1的el0和el1的地址转换),内核在初始化的时候会将主内核页表swapper_pg_dir的地址存放在ttbr1_el1,进程切换的时候将进程tsk->mm->pgd存放在ttbr0_el1,当进行va->pa的转换的时候,mmu会判断地址是属于用户空间地址还是内核空间,如果是用户空间就使用ttbr0_el1作为base来进行页表walk,当地址属于内核空间地址就使用ttbr1_el1作为base来进行页表walk。所有不需要同步内核空间的pgd表项,在访问内核地址空间的内容的时候没有任何问题。
但是,像x86这样的处理器架构就不一样了,只有一个页表基址寄存器如cr3,所有fork子进程的时候就需要同步主内核页表的内核相关部分的pgd表项,这样通过一个页表基址寄存器就可以找到内核空间的各级表项。
接下来我们看一下,mm_init中的另一个比较重要的初始化:
mm_init
->init_new_context
->atomic64_set(&mm->context.id, 0)
可以看的最后设置了mm->context.id为0,这点很重要,当进程调度的时候进行地址空间切换,如果mm->context.id为0就为进程分配新的ASID(ASID技术为了在进程地址空间切换的时候防止频繁刷tlb的一种优化)。
好了,讲完了mm_init相关的一些隐藏的技术细节,我们在返回dup_mm中来看看dup_mmap的实现:
dup_mm
->dup_mmap
-> for (mpnt = oldmm->mmap; mpnt; mpnt = mpnt->vm_next) {
...
tmp = vm_area_dup(mpnt); //分配拷贝父进程的vma
copy_page_range //进程页表复制
这里注意看两个地方,分别是:vm_area_dup和copy_page_range(这是fork的主要内存开销)。
vm_area_dup主要是拷贝父进程的vma,代码实现很简单,我们重点来看copy_page_range。
对于每一个vma都调用copy_page_range,此函数会遍历vma中每一个虚拟页,然后拷贝父进程的页表到子进程(虚拟页对应的页表存在的话),这里主要是页表遍历的代码,从pgd->pud->pmd->pte。我们不关注页表拷贝过程,我们把目光聚集到对私有页面的处理上来:
copy_page_range
->is_cow = is_cow_mapping(src_vma->vm_flags) //判断当前vma是否为私有可写的属性
->copy_p4d_range
->copy_pud_range
->copy_pmd_range
->copy_pte_range
->copy_present_pte
-> /*
¦* If it's a COW mapping, write protect it both
¦* in the parent and the child
¦*/
if (is_cow_mapping(vm_flags) && pte_write(pte)) { //写保护处理
ptep_set_wrprotect(src_mm, addr, src_pte);
pte = pte_wrprotect(pte);
}
最终,我们看的在copy_present_pte函数中,对父子进程的写保护处理,也就是当发现父进程的vma的属性为私有可写的时候,就设置父进程和子进程的相关的页表项为只读。这点很重要,因为这样既保证了父子进程的地址空间的共享(读的时候),又保证了他们有独立的地址空间(写的时候)。
总结来说:fork中构建了内存管理相关的基础设施如mm_struct ,vma,pgd页等,以及拷贝父进程的vma和拷贝父进程的页表来达到和父进程共享地址空间的目的,可以看的处理这种共享并不是像共享内存那种纯粹意义上的共享,而是让子进程能够使用父进程的内存资源,而且在写的时候能够让父子进程开来创造了条件(写保护)。当然这种方式并没有拷贝父进程的任何物理页,只是通过页表来共享而已,当然这种内存开销也是很大的,如果子进程fork之后立马进程exec加载自己的程序,这这种写时复制意义并不大,但是试想,如果不通过页表共享,则子进程寸步难行,甚至连exec都无法调用。
fork创建完子进程后,通过复制父进程的页表来共享父进程的地址空间,我们知道对于私有的可写的页,设置了父子进程的相应页表为为只读,这样就为写实复制创造了页表层面上的条件。当父进程或者子进程,写写保护的页时触发访问权限异常:
处理器架构捕获异常后,进入通用的缺页异常处理路径:
... //处理器架构处理
do_page_fault // arch/arm64/mm/fault.c
-> __do_page_fault
-> handle_mm_fault
-> handle_pte_fault //mm/memory.c
-> if (vmf->flags & FAULT_FLAG_WRITE) {
if (!pte_write(entry))
return do_wp_page(vmf);
entry = pte_mkdirty(entry);
}
在匿名页缺页异常处理路径中,判断这个页错误是写保护错误(也就是判断虚拟页可写可是对应的页表为只读)时,就会调用do_wp_page做写实复制处理:
do_wp_page
->wp_page_copy
->new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, vmf->address); //分配新的页面
->cow_user_page(new_page, old_page, vmf) //拷贝原理共享的页面到新页面
->entry = mk_pte(new_page, vma->vm_page_prot);
entry = pte_sw_mkyoung(entry);
entry = maybe_mkwrite(pte_mkdirty(entry), vma); //设置为可写
->set_pte_at_notify(mm, vmf->address, vmf->pte, entry) //页表属性设置到进程对应的页表项中
可以看的出来,fork时对私有可写的页面做写保护的准备,在父子进程有一方发生写操作时触发了处理器的访问权限缺页异常,异常处理程序重新分配了新的页面给了发起写操作的进程,父子进程对应这个页面的引用就此分道扬镳。
我们知道,对于用户进程来说,内核并不是马上满足进程对于物理页的请求,而仅仅是为他分配虚拟页,内核采用一种惰性的内存分配的方式,知道访问的最后一刻才为进程分配物理页,这既是所谓的内核的按需分配/掉页机制。进程fork的时候,仅仅分配了一级页表页也就是私有的pgd页,其他的各级页表并没有分配,当进程第一次访问虚拟页时,发生缺页异常来分配:缺页异常中分配各级页表路径如下:
handle_mm_fault
->__handle_mm_fault
->pgd = pgd_offset(mm, address) //根据发生缺页的地址和mm->pgd计算出pgd表项
->p4d = p4d_alloc(mm, pgd, address) //获得p4d表项 arm64没有使用p4d 直接(p4d_t *)pgd
-> vmf.pud = pud_alloc(mm, p4d, address) //获得pud项 没有pud页则创建
-> vmf.pmd = pmd_alloc(mm, vmf.pud, address) //获得pm项 没有pm页则创建
->handle_pte_fault
->do_anonymous_page //匿名映射缺页异常为例
->pte_alloc(vma->vm_mm, vmf->pmd) //获得pte 没有pte页则创建
可以看的缺页异常处理中按需创建了所需要的各级页表。
进程fork之后最终会参与系统调度,系统为其分配一定的cpu时间,在进程切换的时候,对于用户进程来说,处理要切换处理器状态(如pc,sp等),最重要的就是切换地址空间,这样进程运行的时候访问的才是自己地址空间的东西,也达到了虚拟地址空间隔离的效果。
现在我们移步到进程调度相关代码,主要来看下进程地址空间切换部分:
... //主动调度或者抢占式调度
__schedule
->next = pick_next_task(rq, prev, &rf) //选择合适的进程调度
->context_switch //上下文切换
if (!next->mm) { //对于内核线程
next->active_mm = prev->active_mm; //引用前一个进程的active_mm
} else { //对于用户任务
switch_mm_irqs_off //切换地址空间
}
可以看的对于内核线程,它不需要切换地址空间,其实这里的地址空间指得是用户虚拟地址空间,因为它只使用内核空间(所有进程共享),但是他做了一步比较重要的操作,即是next->active_mm = prev->active_mm,这样做的目的是:内核线程运行过程中也会不断的发生va->pa的转换,而转化需要页表,就借用上一个用户进程的页表作为base(页表walk的时候从prev->active_mm->pgd开始)。
说完了内核线程我们来看看用户任务是如何切换地址空间的。
switch_mm_irqs_off
->switch_mm // arch/arm64/include/asm/mmu_context.h
-> if (prev != next)
__switch_mm(next)
这里依然有我们需要注意的地方,那就是当发现prev != next,即是前一个任务和即将要切换的任务的地址空间不相等的时候才会执行__switch_mm做地址空间切换,如果相等就不需要切换,大家可能已经知道了,如果是两个属于同一进程的不同线程之间(也有可能是同一进程)不需要切换地址空间(他们共享地址空间,但是调度是独立调度)。
接下来,当发现是两个不同的进程直接切换,那么需要切换地址空间了。
switch_mm
->__switch_mm
->check_and_switch_context(next) //next为下一个进程的进程描述符 arch/arm64/mm/context.c
-> ... //ASID分配相关若干代码
->cpu_switch_mm(mm->pgd, mm)
->cpu_do_switch_mm(virt_to_phys(pgd),mm) //virt_to_phys(pgd)将进程的mm->pgd转化为了物理地址
-> unsigned long ttbr1 = read_sysreg(ttbr1_el1); //读取 ttbr1_el1寄存器
unsigned long asid = ASID(mm); //获得进程的ASID
unsigned long ttbr0 = phys_to_ttbr(pgd_phys); //取pgd地址
/* Skip CNP for the reserved ASID */
if (system_supports_cnp() && asid)
ttbr0 |= TTBR_CNP_BIT;
/* SW PAN needs a copy of the ASID in TTBR0 for entry */
if (IS_ENABLED(CONFIG_ARM64_SW_TTBR0_PAN))
ttbr0 |= FIELD_PREP(TTBR_ASID_MASK, asid);
/* Set ASID in TTBR1 since TCR.A1 is set */
ttbr1 &= ~TTBR_ASID_MASK;
ttbr1 |= FIELD_PREP(TTBR_ASID_MASK, asid); //ASID设置到 ttbr1
write_sysreg(ttbr1, ttbr1_el1); //设置ttbr1_el1
isb();
write_sysreg(ttbr0, ttbr0_el1);//ttbr0_el1
isb();
post_ttbr_update_workaround();
实际上,完成了上面的操作也就完成了进程地址空间的切换。这里需要设置两个页表基址寄存器:ttbr0_el1 和 ttbr1_el1 。内核将mm->pgd的虚拟地址转化为物理地址之后设置到了ttbr0_el1,将为进程分配的ASID设置到了ttbr1_el1(其实ttbr0_el1和ttbr1_el1都有ASID域,究竟设置到那个寄存器由TCR_EL1的A1, bit [22]来决定,为1设置到ttbr1_el1,内核初始化的时候就是设置为1)。
那么问题来了,为什么说我设置了ttbr0_el1 和 ttbr1_el1就完成了进程地址空间切换呢?待我一一到来(假如地址合法)。1)访问用户空间虚拟地址 当第一次访问一个虚拟地址的时候,则mmu会在tlb中查找对应的表项,显然查找不到,则这个时候就需要遍历多级页表,那么这个时候就需要有一个base地址开始遍历,判断地址属于用户空间地址,那么就从ttbr0_el1中获取这个地址,然后就会根据ttbr0_el1找到属于当前进程在fork时创建的pgd页,然后结合虚拟地址就可以遍历各级页表表项(当然会由缺页异常来分配各级页表并填充相应表项),最终将叶子表项(即是最后一级页表表项)填充到tlb中,并返回物理地址。第二次再访问的时候,就直接可以在tlb中找到,不需要遍历多级页表。2)访问内核空间虚拟地址 访问内核空间虚拟地址,也会首先从tlb中查找对应的表项,找不到就会从ttbr1_el1开始遍历各级页表,然后最终将叶子表项(即是最后一级页表表项)填充到tlb中,并返回物理地址。
可以看到每一次做va->pa的地址翻译的时候首先在tlb中查找,上面忘记说了一点,那就是对于用户空间虚拟地址tlb的查找需要根据va和ASID共同查找(内核空间虚拟地址所有进程共享不需要ASID), tlb没有找到就要接受系统惩罚,需要遍历多级页表项然后获得所需要的表项从表项中获得物理地址,这个过程呢需要根据是用户空间虚拟地址还是内核空间虚拟地址,从ttbr0_el1或 ttbr1_el1开始遍历多级页表,然后将表项填入到tlb。这里就使用了fork时创建的基础设施,mm->pgd已经相应的ASID结构,在缺页异常时填充各级表项,进程切换时来使用他们。
下面给出了一个用户进程的内存组织图(有fork时创建以及缺页异常时创建和填充)
讲到这里,我们的fork时的第一个维度内存管理部分讲解完了,下面给出大致总结:fork的时候会创建内核管理的一些基础设施:如mm_struct, vma等用于描述进程自己的地址空间,然后会创建出进程私有的pgd页,用于页表遍历时填充页表,然后还会拷贝父进程所有的vma,然后就是对于每个vma做页表的拷贝和写保护操作。后面的pud pmd的其他各级页表的创建和填充工作由缺页异常处理来完成,可以看的fork的主要开销为vma和页表的拷贝,而这种拷贝看似多余但又不可或缺。
接下来介绍fork的第二个维度-进程管理相关的隐藏技术细节,由于本文章篇幅较长,将放在下一篇文章中,敬请期待,感谢阅读!