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¢os、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,表示不允许在内核态发生抢占。