近期准备分析一下Linux进程调度相关内容,计划从以下几个方面来分析。
- 1)调度点,分析主动切换和被动切换点。
- 2)结合armv7架构分析硬件架构的进程上下文切换
- 3)进程是否需要被调度标记点分析
- 4)分析各个调度器的具体行为逻辑(如果时间不够就只分析公平调度)
Linux进程调度——schedule函数分析
1. 说明
Linux内核版本:4.9.335
架构:armv7
2. 进程调度时机
__schedule
是调度器的核心函数,作用是让调度器选择和切换到一个合适的进程运行。调度的时机可以分为以下几种。
1)阻塞操作:互斥量(mutex)、信号量(semaphore)、等待队列(waitqueue)等。
2)在中断返回用户空间前和系统调用返回用户空间时,会去检查TIF_NEED_RESCHED
标志位以判断是否需要调度。
3)将要被唤醒的进程不会马上被调度,而是会被添加到CFS就绪队列中,并且设置TIF_NEED_RESCHED
标志。那么被唤醒的进程什么时候被调度?这要根据内核是否具有可抢占功能(CONFIG_PREEMPT=y)分为两种情况。
如果内核可抢占,则:
- 如果唤醒动作发生在系统调用或者异常处理上下文中,在下一次调用
preempt_enable
时会检查是否需要抢占调度。 - 如果唤醒动作发生在硬中断处理上下文中,硬件中断处理返回前夕会检查是否需要抢占当前进程。
如果内核不可抢占(抢占内核其实也支持以下几项,多了抢占功能),则:
- 当前进程调用
cond_resched()
时会检查是否需要调度。 - 主动调度调用
schedule
()。 - 系统调用或者异常处理返回用户空间时。
- 中断处理完成返回用户空间时。
3. schedule源码实现
schedule函数定义在kernel/sched/core.c
,源码如下:
asmlinkage __visible void __sched schedule(void)
{
struct task_struct *tsk = current;
sched_submit_work(tsk);
do {
preempt_disable(); (1)
__schedule(false); (2)
sched_preempt_enable_no_resched(); (3)
} while (need_resched()); (4)
}
EXPORT_SYMBOL(schedule);
1)关闭内核抢占,这里就是禁止内核调度。根据是否开启内核抢占CONFIG_PREEMPT
分为以下两种情况
- 非抢占内核:因为当前代码是在内核态执行的,所以就算代码切换过程中发生了中断,中断处理结束返回内核空间时,也不会发生抢占动作,所以这时候的
preempt_disable
函数里面只写了个内存屏障防编译器优化。 - 抢占内核:增加了当前进程
thread_info->preempt_count
的抢占计数,即在调用__schedule
过程中允许发生中断,但是中断返回时禁止切换进程,必须回到当前上下文继续执行进程切换操作。
2)进程切换主函数。
3)非抢占内核sched_preempt_enable_no_resched()
函数是空操作,抢占内核中就是对preempt_count
进行减1操作。
4)进程重新被唤醒时,有可能被设置抢占标识,需要判断当前进程是否被设置了TIF_NEEW_RESCHED
标志(thread_info->flags
中设置该bit)。如果设置了,则需要继续把自己调度出去。
ps:这里有一个问题,很显然抢占内核preempt_disable
()和sched_preempt_enable_no_resched()
需要成对出现。但是进程在调用__schedule
过程中就已经切换到别的进程了。所以这里是否存在调度被关掉的问题?其实不然,判断是否关闭内核抢占使用的是当前进程的thread_info
数据结构中的变量,随着当前进程被调度出去,切换到新的进程上下文时,新的抢占计数就是针对当前进程而言了。
下面展开看__schedule
函数内部实现。
static void __sched notrace __schedule(bool preempt)
{
struct task_struct *prev, *next;
unsigned long *switch_count;
struct pin_cookie cookie;
struct rq *rq;
int cpu;
cpu = smp_processor_id();
rq = cpu_rq(cpu); (1)
prev = rq->curr;
schedule_debug(prev);
if (sched_feat(HRTICK))
hrtick_clear(rq);
local_irq_disable(); (2)
rcu_note_context_switch();
/*
* Make sure that signal_pending_state()->signal_pending() below
* can't be reordered with __set_current_state(TASK_INTERRUPTIBLE)
* done by the caller to avoid the race with signal_wake_up().
*/
smp_mb__before_spinlock();
raw_spin_lock(&rq->lock);
cookie = lockdep_pin_lock(&rq->lock);
rq->clock_skip_update <<= 1; /* promote REQ to ACT */
switch_count = &prev->nivcsw;
if (!preempt && prev->state) { (3)
if (unlikely(signal_pending_state(prev->state, prev))) {
prev->state = TASK_RUNNING;
} else {
deactivate_task(rq, prev, DEQUEUE_SLEEP);
prev->on_rq = 0;
/*
* If a worker went to sleep, notify and ask workqueue
* whether it wants to wake up a task to maintain
* concurrency.
*/
if (prev->flags & PF_WQ_WORKER) {
struct task_struct *to_wakeup;
to_wakeup = wq_worker_sleeping(prev);
if (to_wakeup)
try_to_wake_up_local(to_wakeup, cookie);
}
}
switch_count = &prev->nvcsw;
}
if (task_on_rq_queued(prev))
update_rq_clock(rq);
next = pick_next_task(rq, prev, cookie); (4)
clear_tsk_need_resched(prev);
clear_preempt_need_resched();
rq->clock_skip_update = 0;
if (likely(prev != next)) { (5)
rq->nr_switches++;
rq->curr = next;
++*switch_count;
trace_sched_switch(preempt, prev, next);
rq = context_switch(rq, prev, next, cookie); /* unlocks the rq */(6)
} else {
lockdep_unpin_lock(&rq->lock, cookie);
raw_spin_unlock_irq(&rq->lock);
}
balance_callback(rq);
}
1)获取当前cpu rq数据结构(runqueue data structure
)。
2)关闭当前cpu本地中断,重新打开的地方有两种情况。
- line73,当
prev==next
时,不需要进程切换,解锁rq->lock
并重新打开中断 - line139,进行进程切换时,
next
进程被唤醒后,从switch_to
返回时调用context_switch->finish_task_switch->finish_lock_switch
解锁rq->lock
并重新打开中断。
3)line33-54代码主要作用是,将状态非TASK_RUNNING
的进程剔除自己所在的调度器的就绪队列。这个if判断条件分几种情况来讨论。
-
非抢占情况:即schedule调用__schedule情况,这时候preempt等于0,仅需要判断第二个条件
prev->state
。已知TASK_RUNNING
宏定义为0,所以判断条件的意思是,所有状态不是就绪态的进程都需要进入if代码执行。 -
抢占情况:即
preempt_schedule_irq
等接口调用__schedule
时,preempt
参数传递的是true条件。举个例子,比如DEFINE_WAIT(wait); while (1) { ... set_current_state(TASK_UNINTREEUPTIBEL); if (condition) break; schedule(); } set_current_state(TASK_RUNNING);
进程W在循环判断条件是否满足,满足则退出循环。在执行完第3行的设置进程状态后,发生了一个硬件中断,中断处理完成后,判断
TIF_NEED_RESCHED
标志位,此时如果被标记了,则会调用preempt_schedule_irq
函数进行抢占调度。这时候如果条件本来就是满足的,本来应该用来唤醒W进程的另一个进程P因为条件满足,认为进程W并不会进行睡眠,所以不进行唤醒操作。这时候如果不对__schedule
进入条件(即是否抢占切换)进行判断,则会出现被抢占的进程被剔除就绪队列,再也没办法被唤醒。
继续看33-54行代码,35、36行代码判断是否有需要处理的信号,如果有,当前进程也不应该被踢出就绪队列。
37、38代码调用deactivate_task
来将当前进程踢出当前调度器的就绪队列。
void deactivate_task(struct rq *rq, struct task_struct *p, int flags)
{
if (task_contributes_to_load(p))
rq->nr_uninterruptible++;
dequeue_task(rq, p, flags);
}
static inline void dequeue_task(struct rq *rq, struct task_struct *p, int flags)
{
update_rq_clock(rq);
if (!(flags & DEQUEUE_SAVE))
sched_info_dequeued(rq, p);
p->sched_class->dequeue_task(rq, p, flags);
}
最终调用了调度类(class)的dequeue_task
方法。具体调度类相关接口这里不展开说明。
4)字面意思,挑选出下一个要执行的进程。
/*
* Pick up the highest-prio task:
*/
static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct pin_cookie cookie)
{
const struct sched_class *class = &fair_sched_class;
struct task_struct *p;
/*
* Optimization: we know that if all tasks are in
* the fair class we can call that function directly:
*/
if (likely(prev->sched_class == class &&
rq->nr_running == rq->cfs.h_nr_running)) { (a)
p = fair_sched_class.pick_next_task(rq, prev, cookie);
if (unlikely(p == RETRY_TASK))
goto again;
/* assumes fair_sched_class->next == idle_sched_class */
if (unlikely(!p)) (b)
p = idle_sched_class.pick_next_task(rq, prev, cookie);
return p;
}
again:
for_each_class(class) { (c)
p = class->pick_next_task(rq, prev, cookie);
if (p) {
if (unlikely(p == RETRY_TASK))
goto again;
return p;
}
}
BUG(); /* the idle class will always have a runnable task */
}
a)如果当前进程的调度类是公平调度,且runqueue中的运行态进程数量与cfs调度器中的运行态进程数量一致,意味着当前cpu上没有其他调度类的进程,直接调用公平调度的pick_next_task
回调挑选出下一个要执行的进程即可。
b)如果从cfs调度器中挑选不出下一个要被执行的进程,意味着当前没有需要运行的进程。那么从idle调度器中选出下一个进程。(其实idle调度器只有一个idle进程,这里其实就是挑选idle进程作为下一个进程)
c)遍历所有调度器,挑选出下一个要执行的进程。优先级顺序如下:
stop_sched_class
dl_sched_class
rt_sched_class
fair_sched_class
idle_sched_class
下面继续看__schedule
函数
5)如果next进程不等于prev进程,则进行上下文切换。else情况下,如果是同一个进程,那么也就没必要进行上下文切换了。
6)context_switch
源码如下:
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct pin_cookie cookie)
{
struct mm_struct *mm, *oldmm;
prepare_task_switch(rq, prev, next);
mm = next->mm;
oldmm = prev->active_mm;
/*
* For paravirt, this is coupled with an exit in switch_to to
* combine the page table reload and the switch backend into
* one hypercall.
*/
arch_start_context_switch(prev);
if (!mm) {
next->active_mm = oldmm;
atomic_inc(&oldmm->mm_count);
enter_lazy_tlb(oldmm, next);
} else
switch_mm_irqs_off(oldmm, mm, next);
if (!prev->mm) {
prev->active_mm = NULL;
rq->prev_mm = oldmm;
}
/*
* Since the runqueue lock will be released by the next
* task (which is an invalid locking op but in the case
* of the scheduler it's an obvious special-case), so we
* do an early lockdep release here:
*/
lockdep_unpin_lock(&rq->lock, cookie);
spin_release(&rq->lock.dep_map, 1, _THIS_IP_);
/* Here we just switch the register state and the stack. */
switch_to(prev, next, prev);
barrier();
return finish_task_switch(prev);
}
line7中prepare_task_switch()->prepare_lock_switch()
函数设置next进程的task_struct
结构中的on_cpu
成员为1,表示next进程马上进入执行状态。on_cpu
成员会在mutex和semaphore的自旋等待机制中用到。
line9~10行,变量mm指向next进程的地址空间描述符struct mm_struct
,变量oldmm指向prev进程的正在使用的地址空间描述符(prev->active_mm
)。对于普通进程来说,task_struct
中的mm
和active_mm
都指向进程的地址空间描述符mm_struct
。但是对于内核线程来说是没有独立的地址空间的(mm=NULL
),但是因为进程调度需要用到,所以需要借用一个进程的地址空间,因此有了active_mm
成员。
line18~21,如果next进程的mm为空,表明这是一个内核线程,需要借用prev进程的active_mm
。因为prev也可能是个内核线程,所以一直往前借就完事了。增加oldmm->mm_count
引用计数。保证债主不会释放mm。递减引用计数在line42 finish_task_switch
函数中。
line23,对于普通进程,需要调用switch_mm_irqs_off
来进行地址空间切换。稍后会详细分析。
line25~28,对于prev也是一个内核线程情况,prev进程马上就要被换出,所以设置prev->active_mm
为NULL,另外就绪队列rq数据结构的成员prev_mm
记录了prev->acitive_mm
的值,该值稍后会在finish_task_switch
中用到。
line39,switch_to
函数切换进程,从prev进程上下文切换到next进程上下文中运行。
finish_task_switch
函数中会递减20行中增加的mm_count
的引用计数。
static struct rq *finish_task_switch(struct task_struct *prev)
__releases(rq->lock)
{
struct rq *rq = this_rq();
struct mm_struct *mm = rq->prev_mm;
...
rq->prev_mm = NULL;
...
if (mm)
mmdrop(mm);
...
}
这里有个逻辑,finish_task_switch
其实是由next
进程来进行处理的。prev
进程在switch_to
时就已经被调度出去了,被唤醒时才能执行finish_task_switch
,这个时间点是难以把控的。
但是反过来讲,next
进程被唤醒的时候,从switch_to
返回时,执行的也是自己上下文中的finish_task_switch
函数,这时候把prev
增加的mm_count
引用计数递减。相当于替prev
收拾残局。
4. switch_mm
通常情况下,内核switch_mm_irqs_off
定义为switch_mm
。内核注释翻译一下意思是“如果架构在调用switch_mm
时关心中断状态,可以重写switch_mm_irqs_off
,armv7
没有这个限制,直接实现switch_mm
armv7
的switch_mm
定义在arch/arm/include/asm/mmu_context.h
中。
static inline void
switch_mm(struct mm_struct *prev, struct mm_struct *next,
struct task_struct *tsk)
{
#ifdef CONFIG_MMU
unsigned int cpu = smp_processor_id();
/*
* __sync_icache_dcache doesn't broadcast the I-cache invalidation,
* so check for possible thread migration and invalidate the I-cache
* if we're new to this CPU.
*/
if (cache_ops_need_broadcast() &&
!cpumask_empty(mm_cpumask(next)) &&
!cpumask_test_cpu(cpu, mm_cpumask(next)))
__flush_icache_all();
if (!cpumask_test_and_set_cpu(cpu, mm_cpumask(next)) || prev != next) {
check_and_switch_context(next, tsk);
if (cache_is_vivt())
cpumask_clear_cpu(cpu, mm_cpumask(prev));
}
#endif
}
line18,先把当前CPU设置到下一个进程的cpumask位图中,然后调用check_and_switch_context()
函数来完成ARM体系架构相关的硬件设置,如flush TLB
等,TLB机制非常影响系统性能,所以比较复杂,这里就不展开介绍了。
5. switch_to
switch_to
最终调用架构各自实现的__switch_to
函数
#define switch_to(prev,next,last) \
do { \
__complete_pending_tlbi(); \
last = __switch_to(prev,task_thread_info(prev), task_thread_info(next)); \
} while (0)
此时传递给__switch_to
的r0
是prev
的task_stcuct
指针,r1
是prev
的thread_info
指针,r2
是next
的thread_info
指针(thread_info
架构相关!!!)
armv7
的__switch_to
定义在entry-armv.S
中,源码如下:
ENTRY(__switch_to)
UNWIND(.fnstart )
UNWIND(.cantunwind )
add ip, r1, #TI_CPU_SAVE
ARM( stmia ip!, {r4 - sl, fp, sp, lr} ) @ Store most regs on stack
THUMB( stmia ip!, {r4 - sl, fp} ) @ Store most regs on stack
THUMB( str sp, [ip], #4 )
THUMB( str lr, [ip], #4 )
ldr r4, [r2, #TI_TP_VALUE]
ldr r5, [r2, #TI_TP_VALUE + 4]
#ifdef CONFIG_CPU_USE_DOMAINS
mrc p15, 0, r6, c3, c0, 0 @ Get domain register
str r6, [r1, #TI_CPU_DOMAIN] @ Save old domain register
ldr r6, [r2, #TI_CPU_DOMAIN]
#endif
switch_tls r1, r4, r5, r3, r7
#if defined(CONFIG_CC_STACKPROTECTOR) && !defined(CONFIG_SMP)
ldr r7, [r2, #TI_TASK]
ldr r8, =__stack_chk_guard
ldr r7, [r7, #TSK_STACK_CANARY]
#endif
#ifdef CONFIG_CPU_USE_DOMAINS
mcr p15, 0, r6, c3, c0, 0 @ Set domain register
#endif
mov r5, r0
add r4, r2, #TI_CPU_SAVE
ldr r0, =thread_notify_head
mov r1, #THREAD_NOTIFY_SWITCH
bl atomic_notifier_call_chain
#if defined(CONFIG_CC_STACKPROTECTOR) && !defined(CONFIG_SMP)
str r7, [r8]
#endif
THUMB( mov ip, r4 )
mov r0, r5
ARM( ldmia r4, {r4 - sl, fp, sp, pc} ) @ Load all regs saved previously
THUMB( ldmia ip!, {r4 - sl, fp} ) @ Load all regs saved previously
THUMB( ldr sp, [ip], #4 )
THUMB( ldr pc, [ip] )
UNWIND(.fnend )
ENDPROC(__switch_to)ENTRY(__switch_to)
UNWIND(.fnstart )
UNWIND(.cantunwind )
add ip, r1, #TI_CPU_SAVE
ARM( stmia ip!, {r4 - sl, fp, sp, lr} ) @ Store most regs on stack
switch_tls r1, r4, r5, r3, r7
mov r5, r0
add r4, r2, #TI_CPU_SAVE
ldr r0, =thread_notify_head
mov r1, #THREAD_NOTIFY_SWITCH
bl atomic_notifier_call_chain
mov r0, r5
ARM( ldmia r4, {r4 - sl, fp, sp, pc} ) @ Load all regs saved previously
UNWIND(.fnend )
ENDPROC(__switch_to)
这里把prev
进程相关寄存器上下文保存到该进程的thread_info->cpu_context
结构体中,然后再把next
进程的thread_info->cpu_context
结构体中的值设置到CPU的各个寄存器中,最后的PC
寄存器出栈时,就完成了进程的切换。
此时,CPU就已经在运行next
进程了。