温暖的电波  

1 背景

  说起抢占,需要关注服务器上Linux内核中的CONFIG_PREEMPT_xxx采用的何种模式,下面是几个比较常见系统的配置方式

  • 例如REHL以及centos7使用的是CONFIG_PREEMPT_VOLUNTARY
  • 又例如SLES以及龙蜥OS使用的是CONFIG_PREEMPT_NONE

  咱们这里要分析的就是在CONFIG_PREEMPT_VOLUNTARY或者CONFIG_PREEMPT_NONE的情况下,如果OS中有一个内核线程一直死循环运行,可以被其他高优先级内核线程(worker,甚至是softlockup的watchdog线程)抢占吗?

2 分析

  要回答上面的问题首先要知道抢占是如何发生的,下面就以centos为例进行分析。

  在Linux内核中抢占的发生分为2步:

  • 标记抢占
  • 执行抢占

2.1 标记抢占

  标记抢占是指为当前任务标记"TIF_NEED_RESCHED"标志,有了这个标志后,系统会在某个合适的时间点"执行抢占",抢占当前任务。

  系统中有多个时机来标志抢占,一般情况下有如下时机来进行标记。

2.1.1 周期性时钟中断

  系统周期性的产生时钟中断,在时钟中断中会对CPU上的当前任务进行时间片统计;不同的调度类有不同的统计方式,他们都是用回调函数sched_class->task_tick来实施。

void scheduler_tick(void)       
{       
        int cpu = smp_processor_id();
        struct rq *rq = cpu_rq(cpu);
        struct task_struct *curr = rq->curr;
        ......
        curr->sched_class->task_tick(rq, curr, 0);
        ......
}

  以fair class调度类为例,sched_class->task_tick()最终会调用task_tick_fair()来实施,这个流程中会检查当前任务ra->curr的时间片是否超限,如果超限则为curr标记TIF_NEED_RESCHED:

static void
check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
        ......
        ideal_runtime = sched_slice(cfs_rq, curr);
        delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
        if (delta_exec > ideal_runtime) {  //如果超限
                resched_curr(rq_of(cfs_rq));    //则标记抢占
        ......
}

2.1.2 任务唤醒和创建

  任务唤醒和创建也是一个比较常见的发生抢占的时机。试想一下,如果有唤醒的任务或者新建的任务加入到就绪队列,它们的"优先级"可能要高于current;此时需要提供一个时机让"新"任务可以有机会优先得到运行。

  新创建任务的情况:

void wake_up_new_task(struct task_struct *p)
{
    ......
    activate_task(rq, p, ENQUEUE_NOCLOCK);    //新任务加入就绪队列
    ......
    check_preempt_curr(rq, p, WF_FORK);        //检查是否要抢占curr,如果条件满足则标记current需要抢占

  再来看看任务唤醒的情况:

/* 唤醒的调用流程
try_to_wake_up()-->
    ttwu_queue(p, cpu, wake_flags)-->
        ttwu_do_activate(rq, p, wake_flags, &rf)-->
            ttwu_do_wakeup(rq, p, wake_flags, rf)
*/

static void ttwu_do_wakeup(struct rq *rq, struct task_struct *p, int wake_flags,
                           struct rq_flags *rf)
{
        check_preempt_curr(rq, p, wake_flags);    //检查是否需要抢占,如果条件满足则标记当前任务需要抢占
        ......
}

  上面两种场景种,都要调用check_preempt_curr函数,这个函数会根据不同的调度class来做决策是否需要抢占当前任务(class相同,class不同的场景都会分开考虑);对于fair class而言调用的是check_preempt_wakeup。

2.1.3 其他场景

  除了上述场景外,还有其他场景可能导致curr被抢占,例如任务发生迁移(load balance,numa balance),任务调度策略发生变化(任务的优先级改变)等情况。这些情况都有一个共同特点,当前CPU上的运行策略可能发生改变,即CPU上可能有优先级更高的任务产生,导致current任务需要尽快腾出CPU资源给其他任务运行。此时就会是一个标记抢占的时机,待到一个合适的时间点执行真正的抢占。

2.2 执行抢占

  在2.1中咱们讨论了任务标记了"TIF_NEED_RESCHED"标志,表示这个任务在合适的时机放弃CPU资源,被其他任务抢占。这个执行抢占的时机有用户态抢占和内核态抢占两类,下面依次分析。

2.2.1 用户态抢占的情况

  用户态抢占是指current任务从内核态退出到用户态时发生抢占。为什么这里是一个执行抢占的时机呢?

  因为current被标记抢占一定是发生在内核态,所以在从内核态退出到用户态时是一个绝佳的执行抢占时机。咱们从代码层面看看这个用户态抢占是如何发生的。这里以用户态被中断打断的场景为例进行讲解。

2.2.1.1 arm64架构情形

  阶段1:中断处理函数汇编部分

/*el0_irq是用户态发生中断的中断处理函数 */
SYM_CODE_START_LOCAL_NOALIGN(el0_irq)
        kernel_entry 0
el0_irq_naked:
        el0_interrupt_handler handle_arch_irq
        b       ret_to_user   //返回用户态
SYM_CODE_END(el0_irq)

/* go on */
ret_to_user:
        disable_daif
        ldr     x1, [tsk, #TSK_TI_FLAGS]
        and     x2, x1, #_TIF_WORK_MASK
        cbnz    x2, work_pending      //这里会去检查是否需要抢占
finish_ret_to_user:
        enable_step_tsk x1, x2
#ifdef CONFIG_GCC_PLUGIN_STACKLEAK
        bl      stackleak_erase
#endif
        kernel_exit 0
ENDPROC(ret_to_user)

/* 有需要处理的work,例如:执行抢占 */
work_pending:
        mov     x0, sp                          // 'regs'
        bl      do_notify_resume    //最终是这个函数里面执行抢占
        .....

  阶段2:中断处理函数返回用户态的检查

asmlinkage void do_notify_resume(struct pt_regs *regs,
                                 unsigned long thread_flags)
{
        ......
        do {
                /* Check valid user FS if needed */
                addr_limit_user_check();

                if (thread_flags & _TIF_NEED_RESCHED) {  //检查抢占标志
                        /* Unmask Debug and SError for the next task */
                        local_daif_restore(DAIF_PROCCTX_NOIRQ);

                        schedule();  //调度,切换
                } else {

2.2.1..2 x86_64架构情形

  阶段1:中断处理函数中汇编部分

common_interrupt:
        addq    $-0x80, (%rsp)                  /* Adjust vector to [-256, -1] range */
        call    interrupt_entry
        UNWIND_HINT_REGS indirect=1
        call    do_IRQ  /* rdi points to pt_regs */   //调用中断处理函数
        /* 0(%rsp): old RSP */
ret_from_intr:
        DISABLE_INTERRUPTS(CLBR_ANY)
        TRACE_IRQS_OFF

        LEAVE_IRQ_STACK

        testb   $3, CS(%rsp)
        jz      retint_kernel

        /* Interrupt came from user space */
GLOBAL(retint_user)
        mov     %rsp,%rdi
        call    prepare_exit_to_usermode   //中断直接返回到用户态的情况   
        TRACE_IRQS_IRETQ

  阶段2:中断处理函数返回用户态的处理

__visible inline void prepare_exit_to_usermode(struct pt_regs *regs)
{
       ......
        cached_flags = READ_ONCE(ti->flags);

        if (unlikely(cached_flags & EXIT_TO_USERMODE_LOOP_FLAGS))  //这里的标志是多种组合,_TIF_NEED_RESCHED是其中之一
                exit_to_usermode_loop(regs, cached_flags);
        ......  
}

static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)
{
        while (true) {
                /* We have work to do. */
                local_irq_enable();

                if (cached_flags & _TIF_NEED_RESCHED)  //如果需要抢占则放弃CPU资源
                        schedule();
       ......}
}

2.2.2 内核态抢占的情况

  在2.2.1中我们通过中断直接返回用户态来说明用户态抢占的情形。这里我们再以中断返回内核态为例来说明内核抢占的情况。

  在开讲之前,说明一下中断返回内核态是一种什么样的情形,例如:程序A在用户态调用调用系统调用syscall_X然后陷入内核态执行这个系统调用,就在syscall_X在正在内核态执行的过程中突然被一个中断打断,CPU执行中断处理函数;待中断处理函数处理完毕、退出中断,此时退出到前面系统调用在内核态执行的上下文---这时的上下文是在内核态,在这个上下文发生抢占就是内核态抢占。

  需要说明的是,要有内核态的抢占发生,内核必须要有CONFIG_PREEMPT=y配置;对于REHL&centos、SLES&龙蜥来说内核使能的分别是CONFIG_PREEMPT_VOLUNTARY和CONFIG_PREEMPT_NONE,这些OS都无法在内核抢占。因此在这些系统中如果一个内核线程一直运行,不主动放弃CPU资源的情况下是无法被其他任务抢占的,即使优先级最高的stop class任务也不行。

  下面我们看看内核抢占的情形。

2.2.2.1 arm64架构情形

el1_irq:
        ......
        irq_handler      //中断处理函数

/* 只有使能了CONFIG_PREEMPT才有机会发生内核抢占 */
#ifdef CONFIG_PREEMPT
        ldr     w24, [tsk, #TSK_TI_PREEMPT]     // get preempt count
        cbnz    w24, 1f                         // preempt count != 0
        ldr     x0, [tsk, #TSK_TI_FLAGS]        // get flags
        tbz     x0, #TIF_NEED_RESCHED, 1f       // needs rescheduling?
        bl      el1_preempt    //内核抢占检查函数,就不展开了
1:
#endif

2.2.2.1 x86_64架构情形  

ret_from_intr:
       ......
       /* 检查中断发生的上下文 */
        testb   $3, CS(%rsp)
        jz      retint_kernel    /* 中断发生在内核态上下文 */



retint_kernel:
/* 和arm64一样,也是只有在内核使能了CONFIG_PREEMPT配置才会发生抢占 */
#ifdef CONFIG_PREEMPT
        .......
0:      cmpl    $0, PER_CPU_VAR(__preempt_count)
        jnz     1f
        call    preempt_schedule_irq    /* 内核态抢占 */
        jmp     0b
1:
#endif

3 结论

  REHL、centos、SLES一级龙蜥等操作系统内核态不会被其他内核线程抢占,这些系统只有在返回用户态时才会发生抢占。因此内核中的softlockup检测机制就是利用这一特点,在内核中为每个CPU创建一个优先级最高的任务watdog/N,N是cpu编号;内核期望这个watchdog线程会定期更新时间戳,一旦这个时间戳未按时更新,说明watchdog线程没有及时得到调度;由于watchdog线程的优先级很高,正常情况下watchdog线程是一定是有机会得到运行的,除非内核长时间没有达到执行抢占的时机,例如长时间在内核态运行不退出到用户态。

 

  题外话再扩展一下CONFIG_PREEMPT_VOLUNTARY和CONFIG_PREEMPT_NONE的区别:

  REHL以及centos7使用的是CONFIG_PREEMPT_VOLUNTARY,这种情况下,内核态可以通过调用might_sleep()中调用一次schedule()来主动放弃CPU触发抢占,但是仍然不能被动抢占

  SLES以及龙蜥OS使用的是CONFIG_PREEMPT_NONE,表示不允许在内核态发生抢占。

 

posted on 2023-07-21 17:38  温暖的电波  阅读(707)  评论(2编辑  收藏  举报