带你玩转Linux内核进程创建-fork背后隐藏的技术细节(二)
上一篇带你玩转Linux内核进程创建-fork背后隐藏的技术细节(一)文章我们讲到fork的时候内存管理相关的内容,时间大概隔了快一周了,发布下篇文章,写文章确实费时费力,需要仔细推敲,原创不易,希望大家多多支持吧。本文讲解fork的时候进程管理相关的内容,主要讲解fork的时候进程如何组装调用相关的基础设施组件,以及如何加入运行队列的,调度执行的时候究竟会发生什么。
注:这里只讲解cfs调度类,主要关注用户任务
二、fork的进程管理
2.1进程相关基础设施构建
我们移步到如下调用路径(当前处于sched_fork函数中):
正如源代码中的注释一样,在这里进程调度相关的设置,以及分配cpu给进程,但是请记住:分配完cpu后进程并没有参与调度执行。
首先需要说明的一点是,进程的task_struct是资源封装和管理的结构,如管理进程的虚拟内存mm_struct,进程的打开文件files_struct等,而进程参与调度使用的是调度实体去管理调度(对于普通的进程是sched_entity)。
所以在sched_fork函数中调用__sched_fork先来初始化,基本上都是一些清零操作:
然后设置了一些比较重要的一些属性:
可以看出这里主要设置了一些调度相关的属性:如调度优先级(一般设置为nice为0),调度策略为SCHED_NORMAL,调度类为公平调度类,进程权重信息等。
然后设置新的进程在当前cpu上。
接下来就调用了调度类的task_fork进行设置虚拟运行时间等(注意在task_fork_fair中会将设置的vruntime减去当前cpu运行cfs队列的最小min_vruntime,唤醒的时候会加上所在cpu运行队列的min_vruntime)。
【文章福利】小编推荐自己的Linux内核技术交流群:【891587639】整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!前100名进群领取,额外赠送一份价值699的内核资料包(含视频教程、电子书、实战项目及代码)


上面构建好调度基础设施之后,接下来需要设置异常返回时的现场以及调度现场信息,使得进程能够返回正确的位置执行:
copy_thread这个函数对于进程调度来说至关重要,决定进程第一次被调度的时候执行哪个代码,决定fork调用的返回值。写到这里不得不提到两个相关重要的两个结构体:pt_regs和cpu_context,他俩都是处理器架构相关的结构。
pt_regs描述的发生异常的时候保存的现场信息,主要是一些通用寄存器,我们这里称为异常现场:
当异常发生时,异常的现场(通用寄存器的内容,如发生异常时的x0-x30,sp, pc, pstate)会被压到内核栈,通过pt_regs结构来描述,而当异常处理结束的时候,会需要恢复现场,将这些保存的值恢复到通用寄存器中。
cpu_context描述的是进程调度的时候需要保存的进程上下文,我们这里成为调度现场:
当进程切换的时候,会将处理器的当前需要保存的寄存器保存到前一个进程的tsk的thread.cpu_context中,并将后一个即将要调度的进程的上下文从tsk的thread.cpu_context中恢复到相应的寄存器,就完成了处理器状态的切换(如前一个进程的pc和sp的位置被保存起来,后一个进程的pc和sp的位置恢复到相关寄存器);
介绍完了这俩结构体,就可以在这两个结构体上做手脚,但是我们需要明确的是:
pt_regs和cpu_context都是处理器架构相关的结构。
pt_regs是发生异常时(当然包括中断)保存的处理器现场,用于异常处理完后来恢复现场,就好像没有发生异常一样,它保存在进程内核栈中。
cpu_context是发生进程切换时,保存当前进程的上下文,保存在当前进程的进程描述符中。
pt_regs表征发生异常时处理器现场,cpu_context发生调度时当前进程的处理器现场。
ok,下面就可以在fork中做一些手脚:首先先将p->thread.cpu_context清零,然后对于用户进程和内核线程有不同的处理:
上面以及做了注释,需要说明的是:
我们没有看到当创建用户任务的时候,异常返回后处理器的状态,实际上不需要设置,因为我们是通过fork系统调用的方式陷入内核,发生svc异常的时候,处理器的状态已经保存好了,已经是el0(PSR_MODE_EL0t)。
childregs->regs[0] = 0;的设置保证了,子进程被调度返回用户空间的时候,fork的返回值为0,这就是为何fork返回值为0表示是子进程的原因。
如果创建的是子进程,那么就直接和父进程写时复制方式共享用户栈,而栈不需要在进行设置,直接使用父进程的。
最后两句,来设置的是进程切换时,子进程的pc和sp,当子进程第一次被调度的时候,从ret_from_fork开始执行指令,栈指针指向childregs,即为设置后pt_regs。
2.3子进程被唤醒
前面已经为子进程的调度做好了一些数据结构的准备,但是子进程并没有被调度执行,那么何时开始被唤醒呢?我们回退到kernel_clone中,copy_process做了一些资源的复制之后,开始唤醒子进程:
这里面做了几步非常重要的操作:
设置进程状态为TASK_RUNNING。
通过__set_task_cpu为子进程选择空闲的cpu,有可能不是当前的cpu(进程创建的时候是做负载均衡最好的时机,这个时候进程在cpu的cache还没有数据)。
activate_task来将进程加入到选择的cpu的运行队列,这里加入到选择cpu的红黑树。
check_preempt_curr就会检查是否能够抢占所在cpu的当前进程,这是创建进程时发生抢占的一个时机。
wake_up_new_task执行完之后,子进程就已经在所选择的cpu的运行队列了,也已经是TASK_RUNNING状态,等待调度器在合适的调度时机选择他。
其实,在这里我们也能看的,唤醒的实质是:将进程的状态设置TASK_RUNNING(调度器只选择TASK_RUNNING的进程),加入到cpu的运行队列(根据调度类加入到cpu的不同的调度队列,这里只是一种形象的说法,实际上不一定是队列,如:cfs类进程加入到红黑树),然后做唤醒抢占检查。
2.4子进程被选择调度
走到这里,子进程已经被放置到了cpu的运行队列,已经具备调度条件,万事具备只欠东风,这个东风就是在何时的时候调度器选择这个子进程,几次上下文切换,子进程处在了红黑树最左边的那个节点上(这是有可能的,由于进程运行过程中,虚拟运行时间单调递增,向红黑树右侧移动,子进程就会逐渐移动到红黑树最左边),假如在某一时刻,子进程所在的cpu的运行队列上一个进程被tick中断打断,然后走到scheduler_tick中执行如下路径:
假如子进程刚好满足delta > ideal_runtime的条件,然后当前进程就被设置了重新调度标志,当tick中断返回的时候,发生抢占时调度:
schedule的代码就不在分析,大致说明一下:
schedule实现中会选择一个合适的进程来调度,对于cfs调度类,选择红黑树最左边的那个调度实体所对应的进程,当前场景也就是渴望调度的子进程,然后进行进程的上下文切换,包括地址空间切换到子进程(见上篇),处理器状态切换,这里就切换了cpu_context到相应的寄存器。
这时,子进程就欢快的运行了。
2.5子进程开始执行
进程上下文切换之后,子进程于是就获得了cpu,开始执行,那么最重要的两步就是pc和sp,当然上面我们知道fork的时候已经做了设置:
于是cpu就开始从ret_from_fork下面开始取指令执行,所处的上下文为子进程:
ret_from_fork首先跳转到schedule_tail(会raw_spin_unlock_irq打开中断和自旋锁以及一些对前一个进程做回收等操作)中执行,然后对于内核线程直接调用之前设置的内核执行的函数,对于用户任务通过 ret_to_user 返回用户空间。
2.6父子进程返回用户空间
上面我们知道,当子进程被调度执行的时候从ret_from_fork开始执行,sp指向子进程内核栈的pt_regs, 最终执行 ret_to_user 来返回用户空间:
可以看的,子进程将自己内核栈中的pt_regs恢复到相应的寄存器中,完成了异常的恢复,最终调用eret,从异常中返回,这个时候硬件自动将 elr_el1设置到pc, spsr_el1设置到pstate, sp使用了sp_el0。
这里需要说明一下,以便更好的理解:
elr_el1的值是原来父进程复制过来的,还记得copy_thread中的*childregs = *current_pt_regs()吗?,由于我们原来是fork系统调用,所以这里是执行svc系统调用的下一条指令。
spsr_el1 是之前fork系统调用时保存的处理器的状态,现在恢复这个状态,当然原来在el0,现在也是el0。
sp 改变为了sp_el0,共享父进程的用户栈(对于创建子进程来说)。
子进程返回的时候,由于负载均衡,不一定和父进程在一个cpu上,所以父子进程可以并发执行。
父进程创建完子进程,并唤醒子进程后,也会沿着原来的svc调用路径一路返回到 ret_to_user ,然后恢复上下文,和子进程经历同样的过程,也会svc系统调用的下一条指令,继续使用原来的用户栈指针,好像什么都没发生一起,但是他却孕育了新的进程在当前cpu或者其他cpu上活跃着。
父子进程返回用户空间后都会从fork返回,fork函数调用一次却返回两次,这是由于是两个不同的进程参与调度,而且他们写实复制方式共享相同的地址空间,对于共享的私有数据,如堆栈会通过写实复制方式为写者分配新的页并作拷贝和映射操作(见上篇)。
写到这里来总结一下,发生fork的时候进程管理做的事情:
首先是调用sched_fork为新创建的进程构建调度相关的基础组件,如设置优先级、调度类计算虚拟运行时间等属性信息,为参与最终的调度做准备,然后调用copy_thread来设置异常返回的上下文和调度上下文这是为调度子进程后处理器状态做准备,最后通过wake_up_new_task来唤醒子进程将它放置到合适cpu的运行队列,来等待合适的调度时机参与进程调度,来获得cpu资源。
下面给出精心绘制的创建子进程后调度相关的图示:

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