0%

Linux进程管理(六)总结

原图

一个例子

下面给出一个综合案例,在一个双核处理器的系统中,在Shell界面下运行test程序。CPUO的就绪队列中有4个进程,而CPU1的就绪缓队列中有1个进程。test程序和这5个进程的nice值都为0。

1
2
3
4
5
6
7
8
9
#include <stdio.h>
int main()
{
unsigned long i = O
while(1){
i++;
};
return O;
}

站在用户空间的角度看问题,我们只能看到test程序被运行了,但是我们看不到新进程是如何创建的、它会添加到哪个CPU里、它是如何运行的,以及CPU0和CPU1之间如何做负载均衡等。

其中的操作步骤如下:

  • 调用系统调用fork()来创建一个新进程
  • 使用do_fork()创建新进程
    • 创建新进程的task_struct数据结构
    • 复制父进程的task_struct数据结构到新进程
    • 复制父进程相关的页表项到新进程
    • 设置新进程的内核栈
  • 父进程调用wake_up_new_task()尝试去唤醒新进程
    • 调用调度类的select_taskirq(),为新进程寻找一个负载最轻的CPU,这里选择CPU1
    • 调用调度类的enqueue_task()把新进程添加到CPU1的就绪队列里
  • CPU1重新选择一个合适的进程来运行
    • 每次时钟节拍到来时,scheduler_tick()会调用调度类的task_tick()检查是否需要重新调度。check_preempt_tick()会做检查,当需要重新调度时会设置当前进程的thread_info中的TIF_NEED_RESCHED标志位。假设这时CPU1准备调度新进程,就会设置当前进程的thread_info 中的TIF_NEED_RESCHED标志位
    • 在中断返回前会检查当前进程是否需要调度。如果需要调度,调用preempt_schedule_irq() 来切换进程运行
    • 调度器的schedule()函数会调用调度类的pick_next_task()来选择下一个最合适运行的进程。在该场景中,选择新进程
    • switch_mm()切换父进程和新进程的页表
    • 在CPU1上,switch_to()切换新进程来运行
  • 运行新进程
    • 新进程第一次运行时会调用ret_from_fork()函数
    • 返回用户空间运行Shell程序
    • Shell程序调用exec()来运行test程序,最终新进程变成了test进程
  • 实现负载均衡
    • 在每个时钟节拍到来时,检查是否需要触发软中断来实现SMP负载均衡,即调用scheduler_tick()-> trigger_load_balance()。下一次实现负载均衡的时间点存放在就绪队列的next_balance 成员里。
    • 触发SCHED_SOFTIRQ软中断
    • 在软中断处理函数run_rebalance_domains()里,从当前CPU开始遍历CPU拓扑关系,从调度域的低层往高层遍历调度域,并寻找有负载不均匀的调度组。本例子中的CPU拓扑关系很简单,只有一层MC层级的调度域
    • CPU0对应的调度域是domain_mc_0,对应的调度组是group_mc_0;CPU1对应的调度域是domain_mc_1,对应的调度组是group_mc_1。CPU0的调度域domain_mc_0管辖CPU0和 CPU1,其中group_mc_0和group_mc_1这两个调度组会被链接到domain_mc_0的一个链表中。同理,CPU1的调度域domain_mc_1管理着group_mc_1和group_mc_0这两个调度组
    • 假设当前运行的CPU是CPU1,也就是说,运行run_rebalance_domains()函数的CPU为CPU1,那么在当前MC的调度域(domain_mc_1)里找哪个调度组是最繁忙的。很容易发现CPU0的调度组(group_mc_0)是最繁忙的,然后计算需要迁移多少负载到CPU1上才能保持两个调度组负载平衡
    • 从CPU0迁移部分进程到CPU1

调度的流程

假设Linux内核只有3个线程,线程0创建了线程1和线程2,它们永远不会退出。当系统时钟中断到来时,时钟中断处理函数会检查是否有进程需要调度。当有进程需要调度时,调度器会选择运行线程1或者线程2。

假设线程0先运行,那么在这个场景下会发生什么情况? 这是一个有意思的问题,涉及调度器的实现机制、中断处理、内核抢占、新建进程如何被调度、进程切换等知识点。我们只有把这些知识点都弄明白了,才能真正搞明白这个问题。

场景分析

这个场景中的主要操作步骤如下:

  • start_kernel()运行在线程0里。线程0创建了线程1和线程2。函数调用关系是start_kernel()->kerne_thread()->_do_fork()。在_do_fortk()函数会创建新线程,并且把新线程添加到调度器的就绪队列中。线程0创建线程1和线程2后,进入while死循环,线程0不会退出,它正在等待被调度出去
  • 产生时钟中断。时钟中断是普通外设中断的一种,调度器利用时钟中断来定时检测当前正在运行的线程是否需要调度
  • 当时钟中断检测到当前线程需要调度时,设置need_resched标志位
  • 当时钟中断返回时,根据Linux内核是否支持内核抢占来确定是否需要调度,下面分两种情况来讨论
    • 支持内核抢占的内核:发生在内核态的中断返回时,检查当前线程的need_resched标志位是否置位,如果置位,说明当前线程需要调度
    • 不支持内核抢占的内核:发生在内核态的中断在返回时不会检查是否需要调度

在不支持内核抢占功能的Linux内核里,即使线程0的need_resched标志位置位置位了,Linux内核也不会调度线程1或者线程2。只有发生在用户态的中断返回或者系统调用返回用户空间时,才会检查是否需要调度。在不支持抢占的Linux内核中,判断与调度的流程如下图所示。

  • 发生时钟中断。触发时钟中断时当前进程(线程)有可能在用户态执行,也可能在内核态执行。如果进程运行在用户态时发生了中断,那么会进入异常向量表的el0_irq汇编函数:如果进程运行在内核态时发生了中断,那么会进入异常向量表的el1_irq汇编函数中。在本场景中,因为3个线程都是内核线程,所以时钟中断只能跳转到el1_irq汇编函数里。当进入中断时 CPU会自动关闭中断
  • 在el1_irq汇编函数里,首先会保存中断现场(也称为中断上下文)到当前进程的栈中,Linux内核使用pt_regs数据结构来实现pt_regs栈框,用来保存中断现场
  • 中断处理过程包括切换到Linux内核的中断栈、硬件中断号的查询、中断服务程序的处理等
  • 当确定当前中断源是时钟中断后,scheduler_tick()函数会去检查当前进程的是否需要调度。如果需要调度,则设置当前进程的need_resched标志位(thread_info中的TIF_NEED_RESCHED标志位)
  • 中断返回。这里需要给中断控制器返回一个中断结束(End Of Interrupt,EOI)信号
  • 在el1_irq汇编函数直接恢复中断现场,这里会使用线程0的pt_regs来恢复中断现场在不支持内核抢占的系统里,el1_irq汇编函数不会检查是否需要调度。在中断返回时,CPU打开中断,然后从中断的地方开始继续执行进程0

在支持内核抢占功能的Linux内核中,中断返回时会检查当前进程是否设置了need_resched标志位置位。如果置位,那么调用preempt schedule_irq()函数以调度其他进程(线程)并运行。如下图所示,在支持内核抢占的Linux内核中,中断与调度的流程和下图略有不一样。在el1_irq汇编函数即将返回中断现场时,判断当前进程是否需要调度。如果需要调度,调度器会选择下一个进程,并且进行进程的切换。如果选择了线程1,则从线程1的pt_regs中恢复中断现场并打开中断,然后继续执行内核线程1的代码。

如何让新进程执行

可能读者会有如下疑问:

  • 如果线程1是新创建的,它的栈应该是空的,那它第一次运行时如何恢复中断现场呢?
  • 如果不能从线程1的栈中恢复中断现场,那是不是线程1一直在关闭中断的状态下运行?

对于内核线程来说,在创建时会对如下两部分内容进行设置与保存。

  • 进程的硬件上下文。它是保存在进程中的cpu_context数据结构,进程硬件上下文包括X19~X28寄存器、FP寄存器、SP寄存器以及PC寄存器。对于ARM64处理器来说,设置PC寄存器为ret_from_fork,即指向ret_from_fork汇编函数。设置SP寄存器指向栈的pt_regs栈框
  • ptregs栈框

上述操作是在copy_thread()函数里实现的。

1
2
3
4
5
6
7
8
9
10
<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_stat指向内核线程的回调函数,而x20指向回调函数的参数。 在进程切换时,switch_to()函数会完成进程硬件上下文的切换,即把下一个进程(next进程)的cpu_context数据结构保存的内容恢复到处理器的寄存器中,从而完成进程的切换。此时,处理器开始运行next进程了。根据PC寄存器的值、外理器会从ret_from_fork汇编函数里开始执行,新进程的执行过程如下图所示。

ret_from_fork汇编函数实现在arch/arm64/kernel/entry.S文件中。

1
2
3
4
5
6
7
8
9
10
11
SYM_CODE_START(ret_from_fork)
bl schedule_tail
cbz x19, 1f // not a kernel thread
mov x0, x20
blr x19
1: get_current_task tsk
mov x0, sp
bl asm_exit_to_user_mode
b ret_to_user
SYM_CODE_END(ret_from_fork)
NOKPROBE(ret_from_fork)

在第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触发的中断现场怎么处理呢?中断现场保存在中断进程的栈里,只有当调度器再、次调度该进程时,它才会从栈中恢复中断现场,然后继续运行该进程。

调度的本质

下面是一个常见的思考题

1
2
3
raw_local_irq_disable()//关闭本地中断
schedule()//调用schedule()函数来切换进程
raw_local_irq_enable() //打开本地中断

有读者这么认为,假设进程A在关闭本地中断的情况下切换到进程B来运行,进程B会在关闭中断的情况下运行,如果进程B一直占用CPU,那么系统会一直没有办法响应时钟中断,系统就处于瘫痪状态。 显然,上述分析是不正确的。因为进程B切换执行时会打开本地中断,以防止系统瘫痪我们接下来详细分析这个问题。 调度与中断密不可分,而调度的本质是选择下一个进程来运行。理解调度有如下几个关键点。

  • 什么情况下会触发调度?
  • 如何合理和高效选择下一个进程?
  • 如何切换到下一个进程来执行?
  • 下一个进程如何返回上一次暂停的地方?

我们以一个场景为例,假设系统中只有一个用户进程A和一个内核线程B,在不考虑自愿调度和系统调用的情况下,请描述这两个进程(线程)是如何相互切换并运行的。 如图所示,用户进程A切换到内核线程B的过程如下。

  • 假设在T0时刻之前,用户进程A正在用户空间运行
  • 在T0时刻,时钟中断发生
  • CPU打断正在运行的用户进程A,处于异常模式。CPU会跳转到异常向量表中的 el0_irq里。在el0_irq汇编函数里,首先把中断现场保存到进程A的pt_regs栈框中
  • 处理中断
  • 调度滴答处理函数。在调度滴答处理中,检查当前进程是否需要调度。如果需要调度,则设置当前进程的need_resched标志位(thread_info中的TIF_NEED_RESCHED标志位)
  • 中断处理完成之后,返回el0_irq汇编函数里。在即将返回中断现场前,ret_to_user汇编函数会检查当前进程是否需要调度
  • 若当前进程序需要调度,则调用schedule()函数来选择下一个进程并进行进程切换
  • 在switch_to()函数里进行进程切换
  • T1时刻,switch_to()函数返回时,CPU开始运行内核线程B了
  • CPU沿着内核线程B保存的栈帧回溯,一直返回。返回路径为finish_task_switch()->el1_preempt()->el1_irq
  • 在el1_irq汇编函数里把上一次发生中断时保存在栈里的中断现场讲行恢复,最后从上一次中断的地方开始执行内核线程B的代码

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

首先,对于用户进程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中断的地方并开始执行,这个过程称为出栈。

参考文献

《奔跑吧Linux内核》
《Linux内核设计与实现》