进程调度案例分析(为何不能调度())

愿君学长松,慎勿作桃李。这篇文章主要讲述进程调度案例分析:为何不能调度?相关的知识,希望能为你提供帮助。

微信公众号:奔跑吧linux社区
本文节选自《奔跑吧linux内核》第二版卷1第9.3.5章


现在JD有半价抢购活动,如果您觉得本文还可以,千万不要错过哟!
?https://item.jd.com/13051268.html?
1. 问题引入
假设Linux内核只有3个内核线程(见图9.15),0号线程创建了内核线程1和内核线程2,它们永远不会退出。当系统时钟中断到来时,时钟中断处理函数会检查是否有进程需要调度。当有进程需要调度时,调度器会选择运行线程1或者线程2。

假设0号线程先运行,那么在这个场景下会发生什么情况?
这是一个有意思的问题,涉及调度器的实现机制、中断处理、内核抢占、新建进程如何被调度、进程切换等知识点。我们只有把这些知识点都弄明白了,才能真正搞明白这个问题。
2.场景分析
这个场景中的主要操作步骤如下。
(1)start_kernel()运行在0号线程里。0号线程创建了内核线程1和内核线程2。函数调用关系是start_kernel()→kernel_thread()→_do_fork()。在_do_fork()函数会创建新线程,并且把新线程添加到调度器的就绪队列中。0号线程创建内核线程1和内核线程2后,进入while死循环,0号线程不会退出,它正在等待被调度出去。
(2)产生时钟中断。处理器采用时钟定时器来周期性地提供系统脉搏。时钟中断是普通外设中断的一种。调度器利用时钟中断来定时检测当前正在运行的线程是否需要调度。
(3)当时钟中断检测到当前线程需要调度时,设置need_resched标志位。
(4)当时钟中断返回时,根据Linux内核是否支持内核抢占来确定是否需要调度,下面分两种情况来讨论。
? 支持内核抢占的内核:发生在内核态的中断返回时,检查当前线程的need_resched标志位是否置位,如果置位,说明当前线程需要调度。
? 不支持内核抢占的内核:发生在内核态的中断在中断返回时不会检查是否需要调度。
不支持内核抢占的内核在不支持内核抢占功能的Linux内核(见图9.16)里,即使0号线程的need_resched标志位置位了,Linux内核也不会调度内核线程1或者内核线程2来运行。只有发生在用户态的中断返回或者系统调用返回用户空间时,才会检查是否需要调度。处理流程如下所示。

(1)发生时钟中断。触发时钟中断时当前进程(线程)有可能在用户态执行,也可能在内核态执行。如果进程运行在用户态时发生了中断,那么会进入异常向量表的el0_irq汇编函数;如果进程运行在内核态时发生了中断,那么会进入异常向量表的el1_irq汇编函数中。在本场景中,因为3个线程都是内核线程,所以时钟中断只能跳转到el1_irq汇编函数里。当进入中断时,CPU会自动关闭中断。
(2)在el1_irq汇编函数里,首先会保存中断现场(也称为中断上下文)到当前进程的栈中,Linux内核使用pt_regs数据结构来实现一个栈框,用来保存中断现场(本节称为pt_regs栈帧)。
(3)中断处理过程包括切换到Linux内核的中断栈、硬件中断号的查询、中断服务程序的处理等,详细分析可以参考本书卷2的2.4节以及2.5节。
(4)当确定当前中断源是时钟中断后,scheduler_tick()函数会取检查当前进程的是否需要调度。如果需要调度,则设置当前进程的need_resched标志位(thread_info中的TIF_NEED_ RESCHED标志位),详细分析请参考8.1.7节。
(5)中断返回。这里需要给中断控制器返回一个中断结束(End Of Interrupt, EOI)信号。
(6)在el1_irq汇编函数直接恢复中断现场,这里会使用0号线程的pt_regs栈框来恢复中断现场。在不支持内核抢占的系统里,el1_irq汇编函数不会检查是否需要调度。在中断返回时,CPU打开中断,然后从中断的地方开始继续执行0号进程。
支持内核抢占的内核在支持内核抢占功能的Linux内核中,中断返回时会检查当前进程是否设置了need_resched标志位置位。如果置位,那么调用preempt_schedule_irq()函数以调度其他进程(线程)并运行。如图9.17所示,在支持内核抢占的Linux内核中,中断与调度的流程和图9.16略有不一样。在el1_irq汇编函数即将返回中断现场时,判断当前进程是否需要调度。如果需要调度,调度器会选择下一个进程,并且进行进程的切换。如果选择了内核线程1,则从内核线程1的pt_regs栈框中恢复中断现场并打开中断,然后继续执行内核线程1的代码。

3.如何让新进程执行
可能读者对图9.17会有如下疑问:
  1. 如果内核线程1是新创建的进程,它的栈应该是空的,那它第一次运行时如何恢复中断现场呢?
  2. 如果不能从内核线程1的栈中恢复中断现场,那是不是内核线程1一直在关闭中断的状态下运行?
    对于内核线程来说,在创建时会对如下两部分内容进行设置与保存。
  3. 进程的硬件上下文。它是保存在进程中的cpu_context数据结构,进程硬件上下文包括X19~X28寄存器、FP寄存器、SP寄存器以及PC寄存器,详见8.1.6节。对于ARM64处理器来说,设置Pc寄存器为ret_from_fork,即指向ret_from_fork汇编函数。设置SP寄存器指向栈的pt_regs栈框。
  4. pt_regs栈框。
上述内存的设置与保存是在copy_thread()函数里实现的。
< arch/arm64/kernel/process.c>

int copy_thread( )

childregs-> pstate = PSR_MODE_EL1h;
p-> thread.cpu_context.x19 = stack_start;
p-> thread.cpu_context.x20 = stk_sz;
p-> thread.cpu_context.pc = (unsigned long)ret_from_fork;
p-> thread.cpu_context.sp = (unsigned long)childregs;


stack_start指向内核线程的回调函数,而x20指向回调函数的参数。
在进程切换时,switch_to()函数会完成进程硬件上下文的切换,即把下一个进程(next进程)的cpu_context数据结构保存的内容恢复到处理器的寄存器中,从而完成进程的切换。此时,处理器开始运行next进程了。根据PC寄存器的值,处理器会从ret_from_fork汇编函数里开始执行,新进程的执行过程如图9.18所示。

ret_from_fork汇编函数实现在arch/arm64/kernel/entry.S文件中。
1 ENTRY(ret_from_fork)
2blschedule_tail
3cbz x19, 1f// 不是一个内核线程
4mov x0, x20
5blr x19
6 1:get_thread_info tsk
7bret_to_user

在第2行中,调用schedule_tail()函数来对prev进程做收尾工作。在finish_lock_switch()函数里会调用raw_spin_unlock_irq()函数来打开本地中断。因此,next进程是运行在打开中断的环境下的。
在第3行中,判断next线程是否为内核线程。如果next进程是内核线程,在创建时会设置X19寄存器指向stack_start。如果X19的值寄存器为0,说明这个next进程是用户进程,直接跳转到第6行,调用ret_to_user汇编函数,返回用户空间。
在第4~5行中,如果next进程是内核线程,那么直接跳转到内核线程的回调函数里。
综上所述,当处理器切换到内核线程1时,它从ret_from_fork汇编函数开始执行,schedule_tail()函数会打开中断,因此,不用担心内核线程1在关闭中断的状态下运行。另外,此时的内核线程1不会从中断现场返回,因为到目前为止,内核线程1还没有触发任何一个中断。那么,对于0号线程触发的中断现场怎么办呢?中断现场是保存在中断进程的栈里,只有当调度器再一次调度该进程时,它才会从栈中恢复中断现场,然后继续运行该进程。
4.调度的本质
下面是一个常见的思考题。
raw_local_irq_disable() //关闭本地中断

schedule()//调用schedule()函数来切换进程

raw_local_irq_enable()//打开本地中断

有读者这么认为,假设进程A在关闭本地中断的情况下切换到进程B来运行,进程B会在关闭中断的情况下运行,如果进程B一直占用CPU,那么系统会一直没有办法响应时钟中断,系统就处于瘫痪状态。
显然,上述分析是不正确的。因为进程B切换执行时会打开本地中断,以防止系统瘫痪。我们接下来详细分析这个问题。
调度与中断密不可分,而调度的本质是选择下一个进程来运行。理解调度有如下几个关键点。
  1. 调度的时机,即什么情况下会触发调度。
  2. 如何合理和高效选择下一个进程?
  3. 如何切换到下一个进程来执行?
  4. 下一个进程如何返回上一次暂停的地方?
我们以一个场景为例,假设系统中只有一个用户进程A和一个内核线程B,在不考虑自愿调度和系统调用的情况下,请描述这两个进程(线程)是如何相互切换并运行的。
如图9.19所示,用户进程A切换到内核线程B的过程如下。
(1)假设在T0时刻之前,用户进程A正在用户空间运行。
(2)在T0时刻,时钟中断发生。
(3)CPU打断正在运行的用户进程A,处于异常模式。CPU会跳转到异常向量表中的el0_irq里。在el0_irq汇编函数里,首先把中断现场保存到进程A的pt_regs栈框中。
(4)处理中断。
(5)调度滴答处理函数。在调度滴答处理中,检查当前进程是否需要调度。如果需要调度,则设置当前进程的need_resched标志位(thread_info中的TIF_NEED_RESCHED标志位)。
(6)中断处理完成之后,返回el0_irq汇编函数里。在即将返回中断现场前,ret_to_user汇编函数会检查当前进程是否需要调度。
(7)若当前进程序需要调度,则调用schedule()函数来选择下一个进程并进行进程切换。
(8)在switch_to()函数里进行进程切换。
(9)T1时刻,switch_to()函数返回时,CPU开始运行内核线程B了。
(10)CPU沿着内核线程B保存的栈帧回溯,一直返回。返回路径为finish_task_switch() →el1_preempt()→el1_irq。
(11)在el1_irq汇编函数里把上一次发生中断时保存在栈里的中断现场进行恢复,最后从上一次中断的地方开始执行内核线程B的代码。

从栈帧的角度来观察,进程调度的栈帧变化情况如图9.20所示。

首先,对于用户进程A,从中断触发到进程切换这段时间内,内核栈的变化情况如图9.20左边视图所示,栈的最高地址位于pt_regs栈框,用来保存中断现场。
然后,依次保存el0_irq汇编函数、ret_to_user汇编函数、_schedule()函数、context_switch()函数以及switch_to()函数的栈帧,此时SP寄存器指向switch_to()函数栈帧,这个过程称为压栈。
接下来,切换进程。
switch_to()函数返回之后,即完成了进程切换。此时,CPU的SP寄存器指向了内核线程B的内核栈中的switch_to()函数栈帧。CPU沿着栈帧一直返回,并且恢复了上一次保存在pt_regs栈框的中断现场,最后跳转到内核线程B中断的地方并开始执行,这个过程称为出栈。
综上所述,上述过程有几个比较难理解的地方。
  1. 刚切换到CPU运行的进程(next进程),它需要沿着上一次调度时保留在栈中的踪迹一直返回,并且从栈中恢复上一次的中断现场。我们假设只考虑中断导致的调度,对于主动发生调度的情况以及系统调用返回时发生调度的情况,留给读者思考。
  2. next进程需要为刚调度出去的进程(prev进程)做一些收尾工作,比如,调用raw_spin_unlock_irq()来释放锁并打开本地中断,见finish_task_switch()函数。
  3. switch_to()函数是进程切换的场所,对于系统中所有的进程,不管是运行在用户态的用户进程,还是运行在内核态的内核线程,都必须在switch_to()函数里进行进程切换。对于用户进程来说,它必须借助中断或者系统调用陷入内核,才能有机会从switch_to()函数里把自己调度出去,这个过程必然会在栈中留下踪迹。当用户进程需要重新调度执行时,它也必须根据帧栈的回溯返回用户态,才能继续执行进程本身的代码。
  4. 以时钟中断驱动的进程切换涉及两种上下文(一个是中断上下文,一个是进程上下文)的保存和恢复。中断上下文保存在中断进程的栈(即pt_regs栈框)中。进程上下文保存在进程的task_struct数据结构里。
    最后留给读者一个有意思的思考题:在中断处理函数中能不能调用schedule()函数?有兴趣的读者可以参考本书卷2的2.5.3节。


新书预告
《奔跑吧linux内核》第二版卷1已经上架了。现在JD上有半价抢购活动哟!千万不要错过了,全球首本最有深度和广度的Linux 5.x内核分析书籍,融入笨叔十多年的工作经验。点击“阅读原文”进入JD购买。
https://item.jd.com/13051268.html



《奔跑吧linux内核》第二版卷2预计春节后上架!



金色年华,流金岁月,奔二入门篇预计春节后上架!

【进程调度案例分析(为何不能调度())】

    推荐阅读