Linux内核分析(八):Linux进程调度的时机和进程切换

何天杨+ 原创作品转载请注明出处 + 《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000

一、进程调度简析

   Linux的调度基于分时技术:所谓的分时就是指将时间划分成很小很小的片段,然后每个片段都相对公平的分给这一时刻需要执行的任务。这就我们所说的时间片调度。比如现在有是10个任务需要同时进行,每个任务都必须在1s内得到回馈,这样我么可以把1s分成10份,每过0.1s切换一个任务执行,这样宏观上看就是大家在同时的推进,当然实际上操作系统的时间片要小的多,比如1纳秒之类的。
   进入正题,我们知道进程调度是操作系统的核心功能。调度器只是是调度过程中的一部分,进程调度是非常复杂的过程,需要多个系统协同工作完成。本文所关注的仅为调度器,它的主要工作是在所有 RUNNING 进程中选择最合适的一个。作为一个通用操作系统,Linux 调度器将进程分为三类:首先是交互式进程,此类进程有大量的人机交互,因此进程不断地处于睡眠状态,等待用户输入。典型的应用比如编辑器 vi。此类进程对系统响应时间要求比较高,否则用户会感觉系统反应迟缓。第二种就是批处理任务,此类进程不需要人机交互,在后台运行,需要占用大量的系统资源。但是能够忍受响应延迟。比如编译器。最后一种就是实时进程实时对调度延迟的要求最高,这些进程往往执行非常重要的操作,要求立即响应并执行。比如视频播放软件或飞机飞行控制系统,很明显这类程序不能容忍长时间的调度延迟,轻则影响电影放映效果,重则机毁人亡。根据进程的不同分类 Linux 采用不同的调度策略。对于实时进程,采用 FIFO 或者 Round Robin 的调度策略。对于普通进程,则需要区分交互式和批处理式的不同。传统 Linux 调度器提高交互式应用的优先级,使得它们能更快地被调度。而 CFS 和 RSDL 等新的调度器的核心思想“完全公平”。这个设计理念不仅大大简化了调度器的代码复杂度,还对各种调度需求的提供了更完美的支持。

1.1进程调度相关的数据结构

   task_struct是进程在内核中对应的数据结构,它标识了进程的状态等各项信息。其中有一项thread_struct结构的变量thread,记录了CPU相关的进程状态信息,如内核控制的断点和栈指针等。在内核中获得当前进程task_struct结构使用宏current,该宏读取变量current_task得到指针。
thread_union 和 thread_info
  thread_union用于表示一个进程的内核态堆栈,当进程进入内核态时就会使用该进程对应的内核态堆栈。thread_union是一个联合体,由内核堆栈stack和thread_info(线程描述符, 但其实在Linux的内核中,进程是进行调度的最小单位,并没有真正的对线程的支持,可以说线程只是用户空间的概念,在内核中只能用进程来实现线程)两项组成。
   在内核中得到当前thread_info用current_thread_info函数得到,它通过对当前栈指针进行计算得到thread_info的指针。
sched_class
  sched_class是Linux2.6中调度算法对外的统一界面。Linux使用这个概念将进程调度的具体策略和进程切换的过程隔离开,使得组织有序且可以实现对不同类进程采用不同的调度策略。sched_class有fair_sched_class,rt_sched_class和idle_sched_class三个实例,分别组织在kernel下的sched_fair.c、sched_rt.c、sched_idletask.c中。这三个实例在初始化时被串成了一个链表,依次为:rt,fair,idle
  sched_entity 和sched_rt_entity
内核中对sched_entity的解释为“CFS stats for a schedulable entity (task, task-group etc)”,sched_rt_entity可类似解释。从这里可以看出这个结构的作用是存储一些调度算法相关的进程的状态。
  rq
  rq是当前CPU上就绪进程所组织成的队列。这个结构体记录了每个队列的状态。rq结构体中有cfs_rq和rt_rq两个子结构,分别描述了该CPU上fair类型和rt类型进程的信息。

1.2进程调度相关信息

1.不同类型的进程有不同的调度需求,需要不同的进程调度策略。进程可以有以下几种分类:
  I/O-bound和CPU-bound
  批处理进程、实时进程、交互式进程。

2.调度策略:一组规则,它们决定什么时候以怎样的方式选择一个新进程运行。

3.Linux的进程根据优先级排队。
  根据特定的算法计算出进程的优先级,用一个值表示。这个值表示把进程如何适当的分配给CPU。

4.Linux中的优先级是动态的。
  调度程序会根据进程的行为周期性的调整进程的优先级。
   —— 较长时间未分配到CPU的进程,通常提升优先级
—— 已经在CPU上运行了较长时间的进程,通常降低优先级

5.进程调度的时机
  中断处理过程(包括时钟中断、I/O中断、系统调用和异常)中,直接调用schedule(),或者返回用户态时根据need_resched标记调用schedule();
  内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度;(内核线程是只有内核态没有用户态的特殊进程)
  用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。(用户态进程只能被动调度)
  schedule函数:
schedule函数实现调度
目的:在运行队列中找到一个进程,把COU分配给它
调用方法:
– 直接调用schedule()
– 松散调用,根据need_resched标记

1.3linux系统的一般执行过程分析

最一般的情况:正在运行的用户态进程X切换到运行用户态进程Y的过程

1、正在运行的用户态进程x
2、发生中断——savecs:eip/esp/eflags(current) to kernel stack(x的内核堆栈),then load cs:eip(中断服务程序的起点)(entry of a specific ISR) and ss:esp(内核堆栈)(point to kernel stack).
3、SAVE_ALL //保存现场
4、(执行中断服务)中断处理过程中或中断返回前(如果有进程调度的时机)调用了schedule(),其中的switch_to做了关键的进程上下文切换
5、标号1之后开始运行用户态进程Y(的内核部分)(这里Y曾经通过以上步骤被切换出去过因此可以从标号1继续执行)
6、(从Y进程的中断中)restore_all //恢复现场
7、iret - pop cs:eip/ss:esp/eflags from (Y进程)kernel stack
8、继续运行用户态进程Y

关键点:中断和中断返回中有CPU上下文的切换,进程调度的过程中有进程上下文的切换,此时切换了两个进程间的堆栈。

linux系统执行过程中的几个特殊情况(针对内核线程)

1、通过中断处理过程中的调度时机,用户态进程与内核线程之间互相切换和内核线程之间互相切换,与最一般的情况非常类似,只是内核线程运行过程中发生中断没有进程用户态和内核态的转换;

2、内核线程主动调用schedule(),只有进程上下文的切换,没有发生中断上下文的切换,与最一般的情况略简略;

3、创建子进程的系统调用在子进程中的执行起点及返回用户态,如fork;(如果下一个进程是尚未被调用过的进程,它的执行的起点是ret_from_fork,nexp_ip=ret_from_fork)

4、加载一个新的可执行程序后返回到用户态的情况,如execve;(修改中断时保存的信息)

二、进程上下文切换相关代码分析

2.1进程切换

 1.为了控制进程的执行,内核必须有能力挂起正在CPU上执行的进程,并恢复以前挂起的某个进程的执行,这叫做进程切换、任务切换、上下文切换;
 2.挂起正在CPU上执行的进程,与中断时保存现场是不同的,中断前后是在同一个进程上下文中,只是由用户态转向内核态执行;
 3.进程上下文包含了进程执行需要的所有信息
 4.用户地址空间:包括程序代码,数据,用户堆栈等
 5.控制信息:进程描述符,内核堆栈等
 6.硬件上下文(注意中断也要保存硬件上下文只是保存的方法不同)
 
  schedule()函数选择一个新的进程来运行,并调用context_switch进行上下文的切换,这个宏调用switch_to来进行关键上下文切换

/linux-3.18.6/kernel/sched/core.c
asmlinkage __visible void __sched schedule(void)
{
   struct task_struct *tsk = current;        
   sched_submit_work(tsk);
   __schedule();
}

next = pick_next_task(rq, prev);//进程调度算法都封装这个函数内部

/linux-3.18.6/kernel/sched/core.c
   next = pick_next_task(rq, prev);
   /*挑选一个优先级最高的任务排进队列*/
   clear_tsk_need_resched(prev);
   //清除prev的TIF_NEED_RESCHED标志
   clear_preempt_need_resched();
   rq->skip_clock_update = 0;

context_switch(rq, prev, next);//进程上下文切换

/linux-3.18.6/kernel/sched/core.c
 static inline void
 context_switch(struct rq *rq, struct task_struct *prev, struct task_struct *next)
{
   struct mm_struct *mm, *oldmm;
   //初始化进程地址管理结构体mm和oldmm
   prepare_task_switch(rq, prev, next);//提前准备
   mm = next->mm;
   oldmm = prev->active_mm;
    /*完成mm_struct的切换*/
   //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(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:
    spin_release(&rq->lock.dep_map, 1, _THIS_IP_);
    context_tracking_task_switch(prev, next);
 /* Here we just switch the register state and the stack. */
     switch_to(prev, next, prev);//切换寄存器状态,进程切换核心代码
     barrier();
 /* this_rq must be evaluated again because prev may have moved CPUs since it called schedule(), thus the 'rq' on its stack frame will be invalid. */
     finish_task_switch(this_rq(), prev);

我们看到在context_switch中使用switch_to(prev,next,prev)来切换进程。我们查看一下switch_to的代码

#define switch_to(prev, next, last)                   
do {                               
  //Context-switching clobbers all registers, so we clobber them explicitly, via unused output variables.(EAX and EBP is not listed because EBP is saved/restored explicitly for wchan access and EAX is the return value of __switch_to()) 
 unsigned long ebx, ecx, edx, esi, edi;                
 asm volatile("pushfl\n\t"  //保存标志位              
         "pushl %%ebp\n\t"  
         //把当前进程的堆栈基址压栈
         "movl %%esp,%[prev_sp]\n\t"  
         //把当前的栈顶保存起来
         "movl %[next_sp],%%esp\n\t"  
         //重新写入esp指针,从堆栈来看这两步完成内核堆栈的切换,以后都是操作下一个进程的内核堆栈中
         "movl $1f,%[prev_ip]\n\t"   
          //保存当前进程的EIP,next_ip一般是$1f,对于新创建的子进程是ret_from_fork     
          "pushl %[next_ip]\n\t"   //下一个进程的起点压栈,下一个进程的栈顶就是起点
          __switch_canary             
          "jmp __switch_to\n"  
          //通过寄存器传递参数,返回1f
          "1:\t" //执行下一进程的第一条指令
          "popl %%ebp\n\t"  //ebp出栈 
          "popfl\n"      //restore flags 
          /* output parameters */                
          : [prev_sp] "=m" (prev->thread.sp),  //内核堆栈的栈顶
            [prev_ip] "=m" (prev->thread.ip),   //当前进程的EIP
             "=a" (last),                 
      /* clobbered output registers: */                  
           "=b" (ebx), "=c" (ecx), 
           "=d" (edx), "=S" (esi), 
           "=D" (edi)       
            __switch_canary_oparam                                   
            /* input parameters: */               
            : [next_sp]  "m" (next->thread.sp),       
             //下一个进程的内核堆栈的栈顶
              [next_ip]  "m" (next->thread.ip),      //下一个进程执行的起点

 //regparm parameters for __switch_to(): 
               [prev]     "a" (prev),                       
               [next]     "d" (next)               
                __switch_canary_iparam                  
        : //reloaded segment registers 
          "memory");               
} while (0)

  switch_to是一个宏定义,完成进程从prev到next的切换,首先保存flags,然后保存当前进程的ebp,然后把当前进程的esp保存到prev->thread.sp中,然后把标号1:的地址保存到prev->thread.ip中
  然后把next->thread.ip压入堆栈。这里,如果之前B也被switch_to出去过,那么next->thread.ip里存的就是下面这个1f的标号,但如果next进程刚刚被创建,之前没有被switch_to出去过,那么next->thread.ip里存的将是ret_ftom_fork__switch_canqry应该是现代操作系统防止栈溢出攻击的金丝雀技术
  jmp __switch_to使用regparm call, 参数不是压入堆栈,而是使用寄存器传值,来调用__switch_to
  eax存放prev,edx存放next。这里为什么不用call __switch_to而用jmp,因为call会导致自动把下面这句话的地址(也就是1:)压栈,然后__switch_to()就必然只能ret到这里,而无法根据需要ret到ret_from_fork
  当一个进程再次被调度时,会从1:开始执行,把ebp弹出,然后把flags弹出。
  switch_to利用了prev和next两个参数:prev指向当前进程,next指向被调度的进程

三、实验截图

1.在schedule处设置断点,运行,并用list展开函数
这里写图片描述

2.单步运行,直至__schedule(),按“s”进入函数内部
这里写图片描述

3.在pick_next_task处设立断点,执行
这里写图片描述

4.在context_switch处设立断点,执行
这里写图片描述

四、总结

  首先简单提一下这个宏和函数的被调用关系:
  schedule() –> context_switch() –> switch_to –> __switch_to()
  这里面,schedule是主调度函数,涉及到一些调度算法,这里不讨论。当schedule()需要暂停A进程的执行而继续B进程的执行时,就发生了进程之间的切换。进程切换主要有两部分:1、切换全局页表项;2、切换内核堆栈和硬件上下文。这个切换工作由context_switch()完成。其中switch_to和__switch_to()主要完成第二部分。更详细的,__switch_to()主要完成硬件上下文切换,switch_to主要完成内核堆栈切换。
   阅读switch_to时请注意:这是一个宏,不是函数,它的参数prev, next, last不是值拷贝,而是它的调用者context_switch()的局部变量。局部变量是通过%ebp寄存器来索引的,也就是通过n(%ebp),n是编译时决定的,在不同的进程的同一段代码中,同一局部变量的n是相同的。在switch_to中,发生了堆栈的切换,即ebp发生了改变,所以要格外留意在任一时刻的局部变量属于哪一个进程。关于__switch_to()这个函数的调用,并不是通过普通的call来实现,而是直接jmp,函数参数也并不是通过堆栈来传递,而是通过寄存器来传递。

  上面的四个步骤中,有三个是在switch_to宏中完成,硬件上下文切换由__switch_to()函数完成。
  下面来具体看switch_to从A进程切换到B进程的步骤。
step1:复制两个变量到寄存器:
[prev] “a” (prev)
[next] “d” (next)
即:

eax <== prev_A 或 eax <==%p(%ebp_A)
edx <== next_A 或 edx <==%n(%ebp_A)

这里prev和next都是A进程的局部变量。
现在eax中保存prev,ebx中保存next。其中eax中始终都保持prev,最后把该值交给 last

step2:保存进程A的ebp和eflags

pushfl
pushl %ebp
注意,因为现在esp还在A的堆栈中,所以这两个东西被保存到A进程的内核堆栈中。

step3:保存当前esp到A进程内核描述符中:

"movl %%esp,%[prev_sp]\n\t"    /* save    ESP   */
可以表示成: prev_A->thread.sp <== esp_A

在调用switch_to时,prev是指向A进程自己的进程描述符的。

step4:从next(进程B)的描述符中取出之前从B切换出去时保存的esp_B。

"movl %[next_sp],%%esp\n\t" /* restore ESP */
可以表示成:esp_B <== next_A->thread.sp

注意,在A进程中的next是指向B的进程描述符的。
   从这个时候开始,CPU当前执行的进程已经是B进程了,因为esp已经指向B的内核堆栈。但是,现在的ebp仍然指向A进程的内核堆栈,所以所有局部变量仍然是A中的局部变量,比如next实质上是%n(%ebp_A),也就是next_A,即指向B的进程描述符。

step5:把标号为1的指令地址保存到A进程描述符的ip域:

"movl $1f,%[prev_ip]\n\t"    /* save    EIP   */
可以表示成:prev_A->thread.ip <== %1f,当A进程下次被switch_to回来时,会从这条指令开始执行。具体方法看后面被切换回来的B的下一条指令。

step6:将返回地址保存到堆栈,然后调用__switch_to()函数,__switch_to()函数完成硬件上下文切换。

"pushl %[next_ip]\n\t"    /* restore EIP   */
"jmp __switch_to\n"    /* regparm call  */

   这里,如果之前B也被switch_to出去过,那么[next_ip]里存的就是下面这个1f的标号,但如果进程B刚刚被创建,之前没有被switch_to出去过,那么[next_ip]里存的将是ret_ftom_fork(参看copy_thread()函数)。这就是这里为什么不用call __switch_to而用jmp,因为call会导致自动把下面这句话的地址(也就是1:)压栈,然后__switch_to()就必然只能ret到这里,而无法根据需要ret到ret_from_fork。  
   另外请注意,这里__switch_to()返回时,将返回值prev_A又写入了%eax,这就使得在switch_to宏里面eax寄存器始终保存的是prev_A的内容,或者,更准确的说,是指向A进程描述符的“指针”。这是有用的,下面step8中将会看到。

step7:从__switch_to()返回后继续从1:标号后面开始执行,修改ebp到B的内核堆栈,恢复B的eflags:

"popl %%ebp\n\t"        /* restore EBP   */   
"popfl\n"            /* restore flags */

  如果从__switch_to()返回后从这里继续运行,那么说明在此之前B肯定被switch_to调出过,因此此前肯定备份了ebp_B和flags_B,这里执行恢复操作。
  注意,这时候ebp已经指向了B的内核堆栈,所以上面的prev,next等局部变量已经不是A进程堆栈中的了,而是B进程堆栈中的(B上次被切换出去之前也有这两个变量,所以代表着B堆栈中prev、next的值了),因为prev == %p(%ebp_B),而在B上次被切换出去之前,该位置保存的是B进程的描述符地址。如果这个时候就结束switch_to的话,在后面的代码中(即 context_switch()函数中switch_to之后的代码)的prev变量是指向B进程的,因此,进程B就不知道是从哪个进程切换回来。而从context_switch()中switch_to之后的代码中,我们看到finish_task_switch(this_rq(), prev)中需要知道之前是从哪个进程切换过来的,因此,我们必须想办法保存A进程的描述符到B的堆栈中,这就是last的作用。

step8:将eax写入last,以在B的堆栈中保存正确的prev信息。

"=a" (last)  即  last_B <== %eax
而从context_switch()中看到的调用switch_to的方法是:
switch_to(prev, next, prev);

  所以,这里面的last实质上就是prev,因此在switch_to宏执行完之后,prev_B就是正确的A的进程描述符了。
这里,last的作用相当于把进程A堆栈中的A进程描述符地址复制到了进程B的堆栈中。
  至此,switch_to已经执行完成,A停止运行,而开始了B。在以后,可能在某一次调度中,进程A得到调度,就会出现switch_to(C, A)这样的调用,这时,A再次得到调度,得到调度后,A进程从context_switch()中switch_to后面的代码开始执行,这时候,它看到的prev_A将指向C的进程描述符。
 注意,这里有两个堆栈,在这个过程中,有一个时期esp和ebp并不在同一个堆栈上,要格外注意这个时期里所有涉及堆栈的操作分别是在哪个堆栈上进行的。记住一个简单的原则即可,pop/push这样的操作,都是对esp所指向的堆栈进行的,这些操作同时也会改变esp本身,除此之外,其它关于变量的引用,都是对ebp所指向的堆栈进行的。
 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值