您的位置 首页 golang

剖析Linux内核—fork背后隐藏的技术细节

linux服务器 开发相关视频解析:

1、前言

全文分为两部分讲解: fork 的内存管理部分和进程管理部分, 内存管理 主要讲解子进程如何构建自己的内存管理相关基础设施,父子进程如何共享地址空间的,写时复制如何发生,页表层面为我们做了哪些事情等等。而 进程管理 主要讲解,子进程如何构建自己的进程管理相关基础设施,如何加入到cpu的运行队列,第一次运行时如何执行等等。

实际上,除了0号进程,其他的所有进程无论是内核线程还是普通的用户进程和线程都是fork出来的,而创建进程是内核所做的事情,要么在内核空间直接创建出所谓的内核线程,要么是通过fork,clone这样的系统调用陷入内核空间来创建。对于内核线程没有异常级别的切换,构建好调度相关基础数据结构时就可以在第一次参与调度的时候执行他的执行函数,任务切换的时候也不需要进行地址空间切换。而对于用户任务来说,需要异常级别的切换(也是一种上下文切换),任务切换的时候甚至还需要切换地址空间。

说明:我们将参与调度的实体称为 任务 ,包括用户进程,用户线程,内核线程。

2.fork的内存管理

2.1 内存相关基础设施构建

我们移步到如下调用路径(当前处于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的一种优化)。

【文章福利】需要C/C++ Linux服务器架构师学习资料加群812855908(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL, Redis ,fastdfs, MongoDB ZK 流媒体 CDN ,P2P,K8S, Docker ,TCP/IP,协程,DPDK, ffmpeg 等)

好了,讲完了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都无法调用。

2.2 内存基础设施的使用之–写实复制的发生

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时对私有可写的页面做写保护的准备,在父子进程有一方发生写操作时触发了处理器的访问权限缺页异常,异常处理程序重新分配了新的页面给了发起写操作的进程,父子进程对应这个页面的引用就此分道扬镳。

2.3 内存基础设施的使用之–各级页表创建

我们知道,对于用户进程来说,内核并不是马上满足进程对于物理页的请求,而仅仅是为他分配虚拟页,内核采用一种惰性的内存分配的方式,知道访问的最后一刻才为进程分配物理页,这既是所谓的内核的按需分配/掉页机制。进程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页则创建  

可以看的缺页异常处理中按需创建了所需要的各级页表。

2.4 内存基础设施的使用之–进程调度地址空间的切换

进程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和页表的拷贝,而这种拷贝看似多余但又不可或缺。

3.fork的进程管理

3.1 进程相关基础设施构建

我们移步到如下调用路径(当前处于sched_fork函数中):

 kernel_clone   //kernel/fork.c
->copy_process
    /* Perform scheduler related setup. Assign this task to a CPU. */ 
    ->sched_fork  

正如源代码中的注释一样,在这里进程调度相关的设置,以及分配cpu给进程,但是请记住:分配完cpu后进程并没有参与调度执行。

首先需要说明的一点是,进程的task_struct是资源封装和管理的结构,如管理进程的虚拟内存mm_struct,进程的打开文件files_struct等,而进程参与调度使用的是调度实体去管理调度(对于普通的进程是sched_entity)。

所以在sched_fork函数中调用__sched_fork先来初始化,基本上都是一些清零操作:

 sched_fork
->__sched_fork  
            p->on_rq                        = 0;                                           
            p->se.on_rq                     = 0;     
            p->se.exec_start                = 0;     
            p->se.sum_exec_runtime          = 0;     
            p->se.prev_sum_exec_runtime     = 0;     
            p->se.nr_migrations             = 0;     
            p->se.vruntime                  = 0;     
            INIT_LIST_HEAD(&p->se.group_node);       
            ...  

然后设置了一些比较重要的一些属性:

 sched_fork
-> p->state = TASK_NEW;   //设置进程初始化状态
  p->prio = current->normal_prio;  //进程的动态优先级设置
   /*                                                                            
 ¦* Revert to default priority/policy on fork if requested.                   
 ¦*/                                                                          
 if (unlikely(p->sched_reset_on_fork)) {                                      
         if (task_has_dl_policy(p) || task_has_rt_policy(p)) {                
                 p->policy = SCHED_NORMAL;                //调度策略                    
                 p->static_prio = NICE_TO_PRIO(0);                  //静态优先级设置              
                 p->rt_priority = 0;                                          
         } else if (PRIO_TO_NICE(p->static_prio) < 0)                         
                 p->static_prio = NICE_TO_PRIO(0);                            
                                                                              
         p->prio = p->normal_prio = __normal_prio(p);                         
         set_load_weight(p, false);    //设置进程权重                                        
           ...                             
 }                                                                            
 if (dl_prio(p->prio))                                                        
         return -EAGAIN;                                                      
 else if (rt_prio(p->prio))                                                   
         p->sched_class = &rt_sched_class;                                    
 else                                                                         
         p->sched_class = &fair_sched_class;     //设置调度类为cfs                             
 __set_task_cpu(p, smp_processor_id());       //设置 进程运行的cpu为当前cpu
 if (p->sched_class->task_fork)               
         p->sched_class->task_fork(p);        //执行调度类的task_fork方法即是task_fork_fair
 
#if defined(CONFIG_SMP)                 
        p->on_cpu = 0;                  
#endif                                  
        init_task_preempt_count(p);     //初始化抢占计数器  

可以看出这里主要设置了一些调度相关的属性:如调度优先级(一般设置为nice为0),调度策略为SCHED_NORMAL,调度类为公平调度类,进程权重信息等。

然后设置新的进程在当前cpu上。

接下来就调用了调度类的task_fork进行设置虚拟运行时间等( 注意在task_fork_fair中会将设置的vruntime减去当前cpu运行cfs队列的最小min_vruntime,唤醒的时候会加上所在cpu运行队列的min_vruntime )。

3.2 修改异常上下文和调度上下文信息

上面构建好调度基础设施之后,接下来需要设置异常返回时的现场以及调度现场信息,使得进程能够返回正确的位置执行:

 sched_fork
  ->copy_thread  

copy_thread这个函数对于进程调度来说至关重要,决定进程第一次被调度的时候执行哪个代码,决定fork调用的返回值。写到这里不得不提到两个相关重要的两个结构体:pt_regs和cpu_context,他俩都是处理器架构相关的结构。

pt_regs描述的发生异常的时候保存的现场信息,主要是一些通用寄存器,我们这里称为异常现场:

 struct pt_regs {                                                           
        union {                                                            
                struct user_pt_regs user_regs;                             
                struct {                                                   
                        u64 regs[31];      //通用寄存器                                
                        u64 sp;                                            
                        u64 pc;                                            
                        u64 pstate;                                        
                };                                                         
        };                                                                 
        u64 orig_x0;                                                       
    ....             
};  

当异常发生时,异常的现场(通用寄存器的内容,如发生异常时的x0-x30,sp, pc, pstate)会被压到内核栈,通过pt_regs结构来描述,而当异常处理结束的时候,会需要恢复现场,将这些保存的值恢复到通用寄存器中。

cpu_context描述的是进程调度的时候需要保存的进程上下文,我们这里成为调度现场:

 arch/arm64/include/asm/processor.h
 
tsk->thread.cpu_context
        struct cpu_context {
                unsigned long x19;
                unsigned long x20;
                unsigned long x21;
                unsigned long x22;
                unsigned long x23;
                unsigned long x24;
                unsigned long x25;
                unsigned long x26;
                unsigned long x27;
                unsigned long x28;     
                unsigned long fp;
                unsigned long sp;
                unsigned long pc;
        };  

当进程切换的时候,会将处理器的当前需要保存的寄存器保存到前一个进程的tsk的thread.cpu_context中,并将后一个即将要调度的进程的上下文从tsk的thread.cpu_context中恢复到相应的寄存器,就完成了处理器状态的切换(如前一个进程的pc和sp的位置被保存起来,后一个进程的pc和sp的位置恢复到相关寄存器);

介绍完了这俩结构体,就可以在这两个结构体上做手脚,但是我们需要明确的是:

1.pt_regs和cpu_context都是处理器架构相关的结构。

2.pt_regs是发生异常时(当然包括中断)保存的处理器现场,用于异常处理完后来恢复现场,就好像没有发生异常一样,它 保存在进程内核栈中

3.cpu_context是发生进程切换时,保存当前进程的上下文, 保存在当前进程的进程描述符 中。

4.pt_regs表征发生异常时处理器现场,cpu_context发生调度时当前进程的处理器现场。

ok,下面就可以在fork中做一些手脚:首先先将p->thread.cpu_context清零,然后对于用户进程和内核线程有不同的处理:

  if (likely(!(p->flags & PF_KTHREAD))) {          //对于用户进程
         *childregs = *current_pt_regs();        //拷贝父进程的pt_regs                            
         childregs->regs[0] = 0;             //        regs[0] 为异常返回用户空间时恢复到x0的值(fork的返回值),这里设置为0   表明是子进程返回!!!!
                                                                              
         /*                                                                   
         ¦* Read the current TLS pointer from tpidr_el0 as it may be          
         ¦* out-of-sync with the saved value.                                 
         ¦*/                                                                  
         *task_user_tls(p) = read_sysreg(tpidr_el0);                          
                                                                              
         if (stack_start) {                                                   
                 if (is_compat_thread(task_thread_info(p)))                   
                         childregs->compat_sp = stack_start;                  
                 else                                                         
                         childregs->sp = stack_start;   //创建线程时设置用户栈起始地址                      
         }                                                                    
                                                                              
         /*                                                                   
         ¦* If a TLS pointer was passed to clone, use it for the new          
         ¦* thread.                                                           
         ¦*/                                                                  
         if (clone_flags & CLONE_SETTLS)                                      
                 p->thread.uw.tp_value = tls;                                 
 } else {                                     //对于内核线程
         /*                                                                   
         ¦* A kthread has no context to ERET to, so ensure any buggy          
         ¦* ERET is treated as an illegal exception return.                   
         ¦*                                                                   
         ¦* When a user task is created from a kthread, childregs will        
         ¦* be initialized by start_thread() or start_compat_thread().        
         ¦*/                                                                  
         memset(childregs, 0, sizeof(struct pt_regs));     //清0pt_regs                   
         childregs->pstate = PSR_MODE_EL1h | PSR_IL_BIT;       //设置子进程的处理器状态为   PSR_MODE_EL1h ,异常等级为el1使用sp_el1             
                                                                              
         p->thread.cpu_context.x19 = stack_start;    //设置内核线程执行函数地址                          
         p->thread.cpu_context.x20 = stk_sz;         //设置传递给函数的参数                
 }                                                                            
 p->thread.cpu_context.pc = (unsigned long)ret_from_fork;         //进程第一次被切换后的pc    
 p->thread.cpu_context.sp = (unsigned long)childregs;              //进程第一次被切换后的sp  

上面以及做了注释,需要说明的是:

  1. 我们没有看到当创建用户任务的时候,异常返回后处理器的状态,实际上不需要设置,因为我们是通过fork系统调用的方式陷入内核,发生svc异常的时候,处理器的状态已经保存好了,已经是el0(PSR_MODE_EL0t)。
  2. childregs->regs[0] = 0;的设置保证了,子进程被调度返回用户空间的时候,fork的返回值为0,这就是为何fork返回值为0表示是子进程的原因。
  3. 如果创建的是子进程,那么就直接和父进程写时复制方式共享用户栈,而栈不需要在进行设置,直接使用父进程的。
  4. 最后两句,来设置的是进程切换时,子进程的pc和sp,当子进程第一次被调度的时候,从ret_from_fork开始执行指令,栈指针指向childregs,即为设置后pt_regs。

3.3 子进程被唤醒

前面已经为子进程的调度做好了一些数据结构的准备,但是子进程并没有被调度执行,那么 何时开始被唤醒呢 ?我们回退到kernel_clone中,copy_process做了一些资源的复制之后,开始唤醒子进程:

 kernel_clone
->copy_process
    ->wake_up_new_task
            -> p->state = TASK_RUNNING;    //设置进程状态为TASK_RUNNING;
                __set_task_cpu(p, select_task_rq(p, task_cpu(p), WF_FORK))  //为子进程选择空闲的cpu
                activate_task(rq, p, ENQUEUE_NOCLOCK)  //子进程加入到cpu的运行队列
                check_preempt_curr(rq, p, WF_FORK) //检查是否可以抢占所在cpu的当前进程  

这里面做了几步非常重要的操作:

  1. 设置进程状态为TASK_RUNNING。
  2. 通过__set_task_cpu为子进程选择空闲的cpu,有可能不是当前的cpu(进程创建的时候是做负载均衡最好的时机,这个时候进程在cpu的cache还没有数据)。
  3. activate_task来将进程加入到选择的cpu的运行队列,这里加入到选择cpu的红黑树。
  4. check_preempt_curr就会检查是否能够抢占所在cpu的当前进程,这是创建进程时发生抢占的一个时机。

wake_up_new_task执行完之后,子进程就已经在所选择的cpu的运行队列了,也已经是TASK_RUNNING状态,等待调度器在合适的调度时机选择他。

其实,在这里我们也能看的,唤醒的实质是:将进程的状态设置TASK_RUNNING(调度器只选择TASK_RUNNING的进程),加入到cpu的运行队列(根据调度类加入到cpu的不同的调度队列,这里只是一种形象的说法,实际上不一定是队列,如:cfs类进程加入到红黑树),然后做唤醒抢占检查。

3.3 子进程被选择调度

走到这里,子进程已经被放置到了cpu的运行队列,已经具备调度条件,万事具备只欠东风,这个东风就是在何时的时候调度器选择这个子进程,几次上下文切换,子进程处在了红黑树最左边的那个节点上(这是有可能的,由于进程运行过程中,虚拟运行时间单调递增,向红黑树右侧移动,子进程就会逐渐移动到红黑树最左边),假如在某一时刻,子进程所在的cpu的运行队列上一个进程被tick中断打断,然后走到scheduler_tick中执行如下路径:

 sscheduler_tick   //kernel/sched/core.c
    ->task_tick_fair
        ->entity_tick
            ->if (cfs_rq->nr_running > 1)               
                      check_preempt_tick(cfs_rq, curr); 
                        ->ideal_runtime = sched_slice(cfs_rq, curr);    //获得当前进程的真实运行时间
                            se = __pick_first_entity(cfs_rq);              //获得红黑树最左边的那个调度实体
                           delta = curr->vruntime - se->vruntime;         //计算当前进程的虚拟运行时间 和 红黑树最左边的那个调度实体的虚拟运行时机的差值
 
                           if (delta < 0)       //   差值小于0说明  当前进程的  vruntime 更小更需要调度
                                   return;                                
 
                           if (delta > ideal_runtime)    //当差值  大于   当前进程的真实运行时间         
                                   resched_curr(rq_of(cfs_rq));       //设置重新调度标志  

假如子进程刚好满足delta > ideal_runtime的条件,然后当前进程就被设置了重新调度标志,当tick中断返回的时候,发生抢占时调度:

 tick中断发生
->
    vectors   //arch/arm64/kernel/entry.S
    ->el0_irq
        ->irq_handler  //处理中断
                ->...
                    ->scheduler_tick  
        ->b       ret_to_user   //中断返回用户空间
            ->work_pending
                ->do_notify_resume
                    -> if (thread_flags & _TIF_NEED_RESCHED) {
                             schedule()   //发生调度  

schedule的代码就不在分析,大致说明一下:

schedule实现中会选择一个合适的进程来调度,对于cfs调度类,选择红黑树最左边的那个调度实体所对应的进程,当前场景也就是渴望调度的子进程,然后进行进程的上下文切换,包括地址空间切换到子进程(见上篇),处理器状态切换,这里就切换了cpu_context到相应的寄存器。

这时,子进程就欢快的运行了。

3.4 子进程开始执行

进程上下文切换之后,子进程于是就获得了cpu,开始执行,那么最重要的两步就是pc和sp,当然上面我们知道fork的时候已经做了设置:

于是cpu就开始从ret_from_fork下面开始取指令执行,所处的上下文为子进程:

 /*
 * This is how we return from a fork.
 */SYM_CODE_START(ret_from_fork)   //arch/arm64/kernel/entry.S
        bl      schedule_tail
        cbz     x19, 1f                         // not a kernel thread
        mov     x0, x20   //赋值内核线程函数的参数
        blr     x19      //执行内核线程函数
1:      get_current_task tsk
        b       ret_to_user     //返回用户空间
SYM_CODE_END(ret_from_fork)  

ret_from_fork首先跳转到schedule_tail(会raw_spin_unlock_irq打开中断和自旋锁以及一些对前一个进程做回收等操作)中执行,然后对于内核线程直接调用之前设置的内核执行的函数,对于用户任务通过 ret_to_user 返回用户空间。

3.5 父子进程返回用户空间

上面我们知道,当子进程被调度执行的时候从ret_from_fork开始执行,sp指向子进程内核栈的pt_regs, 最终执行 ret_to_user 来返回用户空间:

  ret_to_user   //arch/arm64/kernel/entry.S
    -> kernel_exit 0
        ->  msr     elr_el1, x21                    // set up the return data
            msr     spsr_el1, x22
            ldp     x0, x1, [sp, #16 * 0]
            ldp     x2, x3, [sp, #16 * 1]
            ldp     x4, x5, [sp, #16 * 2]
            ldp     x6, x7, [sp, #16 * 3]
            ldp     x8, x9, [sp, #16 * 4]
            ldp     x10, x11, [sp, #16 * 5]
            ldp     x12, x13, [sp, #16 * 6]
            ldp     x14, x15, [sp, #16 * 7]
            ldp     x16, x17, [sp, #16 * 8]
            ldp     x18, x19, [sp, #16 * 9]
            ldp     x20, x21, [sp, #16 * 10]
            ldp     x22, x23, [sp, #16 * 11]
            ldp     x24, x25, [sp, #16 * 12]
            ldp     x26, x27, [sp, #16 * 13]
            ldp     x28, x29, [sp, #16 * 14]
            ldr     lr, [sp, #S_LR]
            add     sp, sp, #PT_REGS_SIZE           // restore sp
            ...
            eret  

可以看的,子进程将自己内核栈中的pt_regs恢复到相应的寄存器中,完成了异常的恢复,最终调用eret,从异常中返回,这个时候硬件自动将 elr_el1设置到pc, spsr_el1设置到pstate, sp使用了sp_el0。

这里需要说明一下,以便更好的理解:

  1. elr_el1的值是原来父进程复制过来的,还记得copy_thread中的*childregs = *current_pt_regs()吗?,由于我们原来是fork系统调用,所以这里是执行svc系统调用的下一条指令。
  2. spsr_el1 是之前fork系统调用时保存的处理器的状态,现在恢复这个状态,当然原来在el0,现在也是el0。
  3. sp 改变为了sp_el0,共享父进程的用户栈(对于创建子进程来说)。
  4. 子进程返回的时候,由于负载均衡,不一定和父进程在一个cpu上,所以父子进程可以并发执行。
  5. 父进程创建完子进程,并唤醒子进程后,也会沿着原来的svc调用路径一路返回到 ret_to_user ,然后恢复上下文,和子进程经历同样的过程,也会svc系统调用的下一条指令,继续使用原来的用户栈指针,好像什么都没发生一起,但是他却孕育了新的进程在当前cpu或者其他cpu上活跃着。
  6. 父子进程返回用户空间后都会从fork返回,fork函数调用一次却返回两次,这是由于是两个不同的进程参与调度,而且他们写实复制方式共享相同的地址空间,对于共享的私有数据,如堆栈会通过写实复制方式为写者分配新的页并作拷贝和映射操作(见上篇)。

写到这里来总结一下,发生fork的时候进程管理做的事情:

首先是调用sched_fork为新创建的进程构建调度相关的基础组件,如设置优先级、调度类计算虚拟运行时间等属性信息,为参与最终的调度做准备,然后调用copy_thread来设置异常返回的上下文和调度上下文这是为调度子进程后处理器状态做准备,最后通过wake_up_new_task来唤醒子进程将它放置到合适cpu的运行队列,来等待合适的调度时机参与进程调度,来获得cpu资源。

下面给出精心绘制的创建子进程后调度相关的图示:

4. 总结

写到这里,Linux内核进程创建也就讲完了,当然fork的实现涉及到很多内容,这里只是从内存管理和进程调度的两个维度来看进程的创建过程,阅读完这两篇文章希望能帮助大家理解fork的时候背后隐藏的一些技术细节,真正理解到fork的时候创建的页表如何被使用的,进程又是如何参与到调度的,从fork系统调用到最后的返回用户空间整个过程有所了解,感谢阅读。

文章来源:智云一二三科技

文章标题:剖析Linux内核—fork背后隐藏的技术细节

文章地址:https://www.zhihuclub.com/100876.shtml

关于作者: 智云科技

热门文章

网站地图