Linux内核进程创建-fork背后隐藏的技术细节(下)
上一篇文章我们讲到fork的时候内存管理相关的内容,时间大概隔了快一周了,发布下篇文章,写文章确实费时费力,需要仔细推敲,原创不易,希望大家多多支持吧。本文讲解fork的时候进程管理相关的内容,主要讲解fork的时候进程如何组装调用相关的基础设施组件,以及如何加入运行队列的,调度执行的时候究竟会发生什么。
注:这里只讲解cfs调度类,主要关注用户任务
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
上面以及做了注释,需要说明的是:
我们没有看到当创建用户任务的时候,异常返回后处理器的状态,实际上不需要设置,因为我们是通过fork系统调用的方式陷入内核,发生svc异常的时候,处理器的状态已经保存好了,已经是el0(PSR_MODE_EL0t)。
childregs->regs[0] = 0;的设置保证了,子进程被调度返回用户空间的时候,fork的返回值为0,这就是为何fork返回值为0表示是子进程的原因。
如果创建的是子进程,那么就直接和父进程写时复制方式共享用户栈,而栈不需要在进行设置,直接使用父进程的。
最后两句,来设置的是进程切换时,子进程的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的当前进程
这里面做了几步非常重要的操作:
设置进程状态为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类进程加入到红黑树),然后做唤醒抢占检查。
3.3 子进程被选择调度
走到这里,子进程已经被放置到了cpu的运行队列,已经具备调度条件,万事具备只欠东风,这个东风就是在何时的时候调度器选择这个子进程,几次上下文切换,子进程处在了红黑树最左边的那个节点上(这是有可能的,由于进程运行过程中,虚拟运行时间单调递增,向红黑树右侧移动,子进程就会逐渐移动到红黑树最左边),假如在某一时刻,子进程所在的cpu的运行队列上一个进程被tick中断打断,然后走到scheduler_tick中执行如下路径:
scheduler_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的代码就不在分析,大致说明一下:
这时,子进程就欢快的运行了。
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。
这里需要说明一下,以便更好的理解:
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的时候进程管理做的事情:
首先是调用sched_fork为新创建的进程构建调度相关的基础组件,如设置优先级、调度类计算虚拟运行时间等属性信息,为参与最终的调度做准备,然后调用copy_thread来设置异常返回的上下文和调度上下文这是为调度子进程后处理器状态做准备,最后通过wake_up_new_task来唤醒子进程将它放置到合适cpu的运行队列,来等待合适的调度时机参与进程调度,来获得cpu资源。
下面给出精心绘制的创建子进程后调度相关的图示:
4. 总结
写到这里,Linux内核进程创建也就讲完了,当然fork的实现涉及到很多内容,这里只是从内存管理和进程调度的两个维度来看进程的创建过程,阅读完这两篇文章希望能帮助大家理解fork的时候背后隐藏的一些技术细节,真正理解到fork的时候创建的页表如何被使用的,进程又是如何参与到调度的,从fork系统调用到最后的返回用户空间整个过程有所了解,感谢阅读。