<返回更多

Linux 进程管理之进程调度与切换

2023-03-03  今日头条  Linux码农
加入收藏

进程调度相关内核结构

我们知道,进程运行需要各种各样的系统资源,如内存、文件、打印机和最宝贵的 CPU 等,所以说,调度的实质就是资源的分配。系统通过不同的调度算法(Scheduling Algorithm)来实现这种资源的分配。通常来说,选择什么样的调度算法取决于资源分配的策略(Scheduling Policy)。

有关调度相关的结构保存在 task_struct 中,如下:

struct task_struct {
/*
task_struct 采用了如下3个成员表示进程的优先级,prio、normal_prio表示动态优先级
static_prio 为静态优先级,静态优先级是进程启动时分配的优先级,它可以使用nice和sched_setscheduler系统调用修改,
否则在进程运行期间会一直保持恒定
*/
int prio, static_prio, normal_prio;
/*
sched_clas表示该进程所属的调度器类
2.6.24版本中有3中调度类:
实时调度器 : rt_sched_class
完全公平调度器:fair_sched_class
空闲调度器: idle_sched_class
*/
const struct sched_class *sched_clas;
//调度实体
struct sched_entity se;
/*
policy 保存了对该进程应用的调度策略。
进程的调度策略有6种
SCHED_NORMAL SCHED_FIFO SCHED_RR SCHED_BATCH SCHED_IDLE
普通进程调度策略: SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE,这些都是通过完全公平调度器来处理的
实时进程调度策略: SCHED_RR、SCHED_FIFO 这些都是通过实时调度器来处理的
*/
unsigned int policy;
/*
除了内核线程(Kernel Thread),每个进程都拥有自己的地址空间(也叫虚拟空间),用mm_struct 来描述。active_mm是为内核线程而引入的。因为内核线程没有自己的地址空间,
为了让内核线程与普通进程具有统一的上下文切换方式,当内核线程进行上下文切换时,让切换进来的线程的active_mm
指向刚被调度出去的进程的active_mm(如果进程的mm 域不为空,则其active_mm 域与mm 域相同)
*/
struct mm_struct *mm, *active_mm;
//表示实时进程的优先级,最低的优先级为0,最高为99,值越大,优先级越高
unsigned int rt_priority;
...
};

每个进程都拥有自己的地址空间(也叫虚拟空间),用 mm_struct 来描述。

active_mm 是为内核线程而引入的,因为内核线程没有自己的地址空间,为了让内核线程与普通进程具有统一的上下文切换方式,当内核线程进行上下文切换时,让切换进来的线程的 active_mm 指向刚被调度出去的进程的 active_mm(如果进程的mm 域不为空,则其 active_mm 域与 mm 域相同)。

linux 2.6 中 sched_class 表示该进程所属的调度器类有3种:

进程的调度策略有5种,用户可以调用调度器里不同的调度策略:

在每个 CPU 中都有一个自身的运行队列 rq,每个活动进程只出现在一个运行队列中,在多个 CPU 上同时运行一个进程是不可能的。

运行队列是使用如下结构实现的:

struct rq {
//运行队列中进程的数目
unsigned long nr_running;
//进程切换总数
u64 nr_switches;
//用于完全公平调度器的就绪队列
struct cfs_rq cfs;
//用于实时调度器的就绪队列
struct rt_rq rt;
//记录本cpu尚处于TASK_UNINTERRUPTIBLE状态的进程数,和负载信息有关
unsigned long nr_uninterruptible;
//curr指向当前运行的进程实例,idle 指向空闲进程的实例
struct task_struct *curr, *idle;
//上一次调度时的mm结构
struct mm_struct *prev_mm;
...
};

每个运行队列中有2个调度队列:CFS 调度队列 和 RT 调度队列。

tast 作为调度实体加入到 CPU 中的调度队列中。

系统中所有的运行队列都在 runqueues 数组中,该数组的每个元素分别对应于系统中的一个 CPU。在单处理器系统中,由于只需要一个就绪队列,因此数组只有一个元素。

static DEFINE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues);

内核也定义了一下便利的宏,其含义很明显。

#define cpu_rq(cpu) (&per_cpu(runqueues, (cpu)))
#define this_rq() (&__get_cpu_var(runqueues))
#define task_rq(p) cpu_rq(task_cpu(p))
#define cpu_curr(cpu) (cpu_rq(cpu)->curr)

进程调度与切换

在分析调度流程之前,我们先来看在什么情况下要执行调度程序,我们把这种情况叫做调度时机。

Linux 调度时机主要有。

  1. 进程状态转换的时刻:进程终止、进程睡眠;
  2. 当前进程的时间片用完时(current->counter=0);
  3. 设备驱动程序;
  4. 进程从中断、异常及系统调用返回到用户态时。

时机1,进程要调用 sleep() 或 exit() 等函数进行状态转换,这些函数会主动调用调度程序进行进程调度。

时机2,由于进程的时间片是由时钟中断来更新的,因此,这种情况和时机4 是一样的。

时机3,当设备驱动程序执行长而重复的任务时,直接调用调度程序。在每次反复循环中,驱动程序都检查 need_resched 的值,如果必要,则调用调度程序 schedule() 主动放弃 CPU。

时机4 , 如前所述, 不管是从中断、异常还是系统调用返回, 最终都调用 ret_from_sys_call(),由这个函数进行调度标志的检测,如果必要,则调用调用调度程序。那么,为什么从系统调用返回时要调用调度程序呢?这当然是从效率考虑。从系统调用返回意味着要离开内核态而返回到用户态,而状态的转换要花费一定的时间,因此,在返回到用户态前,系统把在内核态该处理的事全部做完。

Linux 的调度程序是一个叫 Schedule() 的函数,这个函数来决定是否要进行进程的切换,如果要切换的话,切换到哪个进程等。

Schedule 的实现

asmlinkage void __sched schedule(void)
{
/*prev 表示调度之前的进程, next 表示调度之后的进程 */
struct task_struct *prev, *next;
long *switch_count;
struct rq *rq;
int cpu;
need_resched:
preempt_disable(); //关闭内核抢占
cpu = smp_processor_id(); //获取所在的cpu
rq = cpu_rq(cpu); //获取cpu对应的运行队列
rcu_qsctr_inc(cpu);
prev = rq->curr; /*让prev 成为当前进程 */
switch_count = &prev->nivcsw;
/释放全局内核锁,并开this_cpu 的中断/
release_kernel_lock(prev);
need_resched_nonpreemptible:
__update_rq_clock(rq); //更新运行队列的时钟值
...
if (unlikely(!rq->nr_running))
idle_balance(cpu, rq);
// 对应到CFS,则为 put_prev_task_fair
prev->sched_class->put_prev_task(rq, prev); //通知调度器类当前运行进程要被另一个进程取代
/pick_next_task以优先级从高到底依次检查每个调度类,从最高优先级的调度类中选择最高优先级的进程作为
下一个应执行进程(若其余都睡眠,则只有当前进程可运行,就跳过下面了)/
next = pick_next_task(rq, prev); //选择需要进行切换的task
//进程prev和进程next切换前更新各自的sched_info
sched_info_switch(prev, next);
if (likely(prev != next)) {
rq->nr_switches++;
rq->curr = next;
++switch_count;
//完成进程切换(上下文切换)
context_switch(rq, prev, next); / unlocks the rq */
} else
spin_unlock_irq(&rq->lock);
if (unlikely(reacquire_kernel_lock(current) < 0)) {
cpu = smp_processor_id();
rq = cpu_rq(cpu);
goto need_resched_nonpreemptible;
}
preempt_enable_no_resched();
if (unlikely(test_thread_flag(TIF_NEED_RESCHED)))
goto need_resched;
}

从代码分析来看,Schedule 主要完成了2个功能:

进程上下文切换

static inline void
context_switch(struct rq *rq, struct task_struct *prev, struct task_struct *next)
{
struct mm_struct *mm, *oldmm;
prepare_task_switch(rq, prev, next);
mm = next->mm; //获取要执行进程的mm字段
oldmm = prev->active_mm; //被切换出去进程的 active_mm 字段
//mm为空,说明是一个内核线程
if (unlikely(!mm)) {
//内核线程共享上一个运行进程的mm
next->active_mm = oldmm; //借用切换出去进程的 mm_struct
//增加引用计数
atomic_inc(&oldmm->mm_count);
//惰性TLB,因为内核线程没有虚拟地址空间的用户空间部分,告诉底层体系结构无须切换
enter_lazy_tlb(oldmm, next);
} else
//进程切换包括进程的执行环境切换和运行空间的切换。运行空间的切换是有switch_mm完成的。若是用户进程,则切换运行空间
switch_mm(oldmm, mm, next);
//若上一个运行进程是内核线程
if (unlikely(!prev->mm)) {
prev->active_mm = NULL; //断开内核线程与之前借用的地址空间联系
//更新运行队列的prev_mm成员,为之后归还借用的mm_struct做准备
rq->prev_mm = oldmm;
}
/* Here we just switch the register state and the stack. */
//切换进程的执行环境
switch_to(prev, next, prev);
barrier();
//进程切换之后的处理工作
finish_task_switch(this_rq(), prev);
}

进程上下文切换包括进程的地址空间的切换和执行环境的切换。

//进程切换包括进程的执行环境切换和运行空间的切换。运行空间的切换是有switch_mm完成的
static inline void switch_mm(struct mm_struct *prev,
struct mm_struct *next,
struct task_struct tsk)
{
//得到当前进程运行的cpu
int cpu = smp_processor_id();
//若要切换的prev != next,执行切换过程
if (likely(prev != next)) {
/ stop flush ipis for the previous mm */
//清除prev的cpu_vm_mask,标志prev已经弃用了当前cpu
cpu_clear(cpu, prev->cpu_vm_mask);
#ifdef CONFIG_SMP
//在smp系统中,更新cpu_tlbstate
per_cpu(cpu_tlbstate, cpu).state = TLBSTATE_OK;
per_cpu(cpu_tlbstate, cpu).active_mm = next;
#endif
//设置cpu_vm_mask,表示next占用的当前的cpu
cpu_set(cpu, next->cpu_vm_mask);
/* Re-load page tables */
//加载CR3
load_cr3(next->pgd);
/*
• load the LDT, if the LDT is different:
*/
//若ldt不相同,还要加载ldt
if (unlikely(prev->context.ldt != next->context.ldt))
load_LDT_nolock(&next->context);
}
#ifdef CONFIG_SMP
else {
per_cpu(cpu_tlbstate, cpu).state = TLBSTATE_OK;
//prev == next 那当前cpu中的active_mm就是prev,也即是next
BUG_ON(per_cpu(cpu_tlbstate, cpu).active_mm != next);
/*
在smp系统中,虽然mm时一样的,但需要加载CR3
执行cpu_test_and_set来判断next是否正运行在此cpu上,这里是判断在切换next是否运行
在当前的cpu中,
假设cpu为1,一个进程在1上执行时候,被调度出来,再次调度的时候,
又发生在cpu1上
*/
if (!cpu_test_and_set(cpu, next->cpu_vm_mask)) {
/* We were in lazy tlb mode and leave_mm disabled
• tlb flush IPI delivery. We must reload %cr3.
*/
load_cr3(next->pgd);
load_LDT_nolock(&next->context);
}
}
#endif
}

对于 switch_mm 处理,关键的一步就是它将新进程页面目录的起始物理地址装入到寄存器 CR3 中。CR3 寄存器总是指向当前进程的页面目录。

#define switch_to(prev,next,last) do { 
unsigned long esi,edi; 
/*分别保存了eflags、ebp、esp,flags和 ebp他们是被保存在prev进程(其实就是要被切换出去的进程)的内核堆栈中的*/
asm volatile("pushflnt" /* Save flags */ 
"pushl %%ebpnt" 

//%0为 prev->thread.esp, %1 为 prev->thread.eip
"movl %%esp,%0nt" /* save ESP */ 

//%5为 next->thread.esp,%6 为 next->thread.eip
"movl %5,%%espnt" /* restore ESP */ 

/*将标号为1所在的地址,也即是"popl %%ebpnt" 指令所在的地址保存在prev->thread.eip中,作为prev进程
下一次被调度运行而切入时的返回地址,因此可以知道,每个进程调离时都要执行"movl $1f,%1nt",所以
这就决定了每个进程在受到调度恢复运行时都会从标号1处也即是"popl %%ebpnt"开始执行。
但是有一个例外,那就是新创建的进程,新创建的进程并没有在上一次调离时执行上面的指令,所以一来要将其
task_struct中的thread.eip事先设置好,二来所设置的返回地址也未必是这里的标号1所在的地址,这取决于系统空间
堆栈的设置。事实上可以下fork()中可以看到,这个地址在copy_thread()中设置为ret_from_fork,其代码在entry.S中,
也就是对于新创建的进程,在调用schedule_tail()后直接转到了ret_from_sys_call, 也即是返回到了用户空间去了
*/
"movl $1f,%1nt" /* save EIP */ 

/*将next->thread.eip压入栈中,这里的next->thread.eip正是next进程上一次被调离在"movl $1f,%1nt"保存的,也即是
指向标号为1的地方,也即是"popl %%ebpnt"指令所在的地址*/
"pushl %6nt" /* restore EIP */ 

/*需要注意的是 __switch_to 是经过regparm(3)来修饰的,这个是gcc的一个扩展语法。
即从eax,ebx,ecx寄存器取函数的参数。这样,__switch_to函数的参数就是从寄存器中取的。
并是不向普通函数那样从堆栈中取的。在__switch_to之前,将next->thread.eip压栈了,这样从函数返回后
,它的下一条运行指令就是 next->thread.eip了。

对于新创建的进程。我们设置了p->thread.eip = ret_from_fork.这样子进程被切换进来之后,就会通过
ret_from_fork返回到用户空间了。
对于已经运行的进程,我们这里可以看到,在进程被切换出去的时候,prev->thread.eip被设置了标号1的地址,
即是从标号1的地址开始运行的。
标号1的操作:
恢复ebp(popl %%ebp)
恢复flags(popf1)
这样就恢复了进程的执行环境。

从代码可以看到,在进程切换时,只保留了flags esp和ebp寄存器,显示的用到了eax和edx,
那其他寄存器怎么保存的呢?
实际上过程切换只是发生在内核态,对于内核态的寄存器来说,它的段寄存器都是一样的,所以不需要保存

*/
/*通过jmp指令转入到函数__switch_to中,由于上一行"pushl %6nt"把next->thread.eip,也即是标号为1的地址压到栈中,所以
跳入__switch_to执行完后,执行标记为1的地方,也即是"popl %%ebpnt" 指令*/
"jmp __switch_ton" 
"1:t" 
"popl %%ebpnt" 
"popfl" 
:"=m" (prev->thread.esp),"=m" (prev->thread.eip), 
"=a" (last),"=S" (esi),"=D" (edi) 
:"m" (next->thread.esp),"m" (next->thread.eip), 
"2" (prev), "d" (next)); 
} while (0)

switch_to 把寄存器中的值比如esp等存放到进程thread结构中,保存现场一边后续恢复,同时调用 __switch_to 完成了堆栈的切换。

在进程的 task_struct 结构中有个重要的成分 thread,它本身是一个数据结构 thread_struct, 里面记录着进程在切换时的(系统空间)堆栈指针,取指令地址(也就是“返回地址”)等关键性的信息。

/*
__switch_to 处理的主要逻辑是TSS,其核心就是load_esp0将TSS中的内核空间(0级)堆栈指针换成next->esp0. 这是因为
cpu在穿越中断门或者陷阱门时要根据新的运行级别从TSS中取得进程在系统空间的堆栈指针,其次,段寄存器fs和gs的内容也做了
相应的切换。同时cpu中为debug而设计的一些寄存器以及说明进程I/O 操作权限的位图。
*/
struct task_struct fastcall * __switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
struct thread_struct *prev = &prev_p->thread,
*next = &next_p->thread;
int cpu = smp_processor_id();
struct tss_struct *tss = &per_cpu(init_tss, cpu);

...

/* 将TSS 中的内核级(0 级)堆栈指针换成next->esp0,这就是next 进程在内核栈的指针*/
load_esp0(tss, next);


//为prev保存gs
savesegment(gs, prev->gs);


//从next的tls_array缓存中加载线程的Thread-Local Storage描述符
load_TLS(next, cpu);


/*若当前特权级别是0且prev->iopl != next->iopl,则恢复IOPL设置set_iopl_mask
*/
if (get_kernel_rpl() && unlikely(prev->iopl != next->iopl))
set_iopl_mask(next->iopl);

/*根据thread_info的TIF标志_TIF_WORK_CTXSW_PREV和 _TIF_WORK_CTXSW_NEXT 判断是否需要处理
debug寄存器和IO位图*/
if (unlikely(task_thread_info(prev_p)->flags & _TIF_WORK_CTXSW_PREV ||
task_thread_info(next_p)->flags & _TIF_WORK_CTXSW_NEXT))
__switch_to_xtra(prev_p, next_p, tss);

//设置cpu的lazy模式
arch_leave_lazy_cpu_mode();

//若fpu_counter > 5 则恢复next_p 的FPU寄存器
if (next_p->fpu_counter > 5)
math_state_restore();

//若需要,恢复gs寄存器
if (prev->gs | next->gs)
loadsegment(gs, next->gs);

x86_write_percpu(current_task, next_p);

return prev_p;
}

关于__switch_to 的工作就是处理 TSS (任务状态段)。

TSS 全称task state segment,是指在操作系统进程管理的过程中,任务(进程)切换时的任务现场信息。

linux 为每一个 CPU 提供一个 TSS 段,并且在 TR 寄存器中保存该段。

linux 中之所以为每一个 CPU 提供一个 TSS 段,而不是为每个进程提供一个TSS 段,主要原因是 TR 寄存器永远指向它,在任务切换的适合不必切换 TR 寄存器,从而减小开销。

在从用户态切换到内核态时,可以通过获取 TSS 段中的 esp0 来获取当前进程的内核栈 栈顶指针,从而可以保存用户态的 cs,esp,eip 等上下文。

TSS 在任务切换过程中起着重要作用,通过它实现任务的挂起和恢复。所谓任务切换是指,挂起当前正在执行的任务,恢复或启动另一任务的执行。

在任务切换过程中,首先,处理器中各寄存器的当前值被自动保存到 TR(任务寄存器)所指定的任务的 TSS 中;然后,下一任务的 TSS 被装入 TR;最后,从 TR 所指定的 TSS 中取出各寄存器的值送到处理器的各寄存器中。由此可见,通过在 TSS 中保存任务现场各寄存器状态的完整映象,实现任务的切换。

因此,__switch_to 核心内容就是将 TSS 中的内核空间(0级)堆栈指针换成 next->esp0。这是因为 CPU 在穿越中断门或者陷阱门时要根据新的运行级别从TSS中取得进程在系统空间的堆栈指针。

thread_struct.esp0 指向进程的系统空间堆栈的顶端。当一个进程被调度运行时,内核会将这个变量写入 TSS 的 esp0 字段,表示这个进程进入0级运行时其堆栈的位置。换句话说,进程的 thread_struct 结构中的 esp0 保存着其系统空间堆栈指针。当进程穿过中断门、陷阱门或者调用门进入系统空间时,处理器会从这里恢复期系统空间栈。

由于栈中变量的访问依赖的是段、页、和 esp、ebp 等这些寄存器,所以当段、页、寄存器切换完以后,栈中的变量就可以被访问了。

因此 switch_to 完成了进程堆栈的切换,由于被切进的进程各个寄存器的信息已完成切换,因此 next 进程得以执行指令运行。

由于 A 进程在调用 switch_to 完成了与 B 进程堆栈的切换,也即是寄存器中的值都是 B 的,所以 A 进程在 switch_to 执行完后,A停止运行,B开始运行,当过一段时间又把 A 进程切进去后,A 开始从 switch_to 后面的代码开始执行。

schedule 的调用流程如下:

声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多资讯 >>>