进程切换与抢占

进程切换

概念

进程切换,又称上下文切换,分为

  • 自愿(voluntary)切换:进程本身无法继续运行
    • 例如需要等待 IO 完成(TASK_UNINTERRUPTABLE),或等待某个资源或事件的就绪(TASK_INTERRUPTIBLE),或正在被 debug/trace(TASK_STOPPED/TASK_TRACED
  • 强制(involuntary)切换:进程还可以运行,但内核不让它运行了
    • 例如进程时间片用尽,或有优先级更高的其他进程
    • 又例如进程自己决定主动让出 CPU(注意:这不属于自愿切换,因为进程仍处于 TASK_RUNNING 状态)
      • 用户态程序通过 sched_yield 系统调用
      • 内核使用 cond_resched()yield()

区分自愿与强制切换,主要在于当前进程是否处于运行状态,以及是否被抢占。一般而言,进程处于运行状态(TASK_RUNNING)时的切换一定为强制切换;而当进程处于其他状态时发生的切换为自愿切换。但严格来说,后者还要考虑到被抢占的情况:

注:实际情况更复杂一些,由于 Linux 内核支持抢占,Kernel Preemption 有可能发生在自愿切换的过程之中,比如进程正进入休眠,本来如果顺利完成的话就属于自愿切换,但休眠的过程并不是原子操作,进程状态先被置成 TASK_INTERRUPTIBLE,然后进程切换,如果 Kernel Preemption 恰好发生在两者之间,那就打断了休眠过程,自愿切换尚未完成,转而进入了强制切换的过程(虽然是强制切换,但此时的进程状态已经不是运行状态了),下一次进程恢复运行之后会继续完成休眠的过程。所以判断进程切换属于自愿还是强制的算法要考虑进程在切换时是否正处于被抢占(preempt)的过程中,……

参考以下内核代码1

 1static void __sched notrace __schedule(unsigned int sched_mode)
 2{
 3    // ...
 4    switch_count = &prev->nivcsw;
 5    // ...
 6    if (!(sched_mode & SM_MASK_PREEMPT) && prev_state) {
 7        // ...
 8        switch_count = &prev->nvcsw;
 9    }
10    // ...
11}

应用

一个进程在单位时间内发生自愿/强制切换的次数,可用于反映该进程对 CPU 资源的需求是否得到满足。

  • 如果大多数发生的是自愿切换,说明进程对 CPU 资源的需求基本得到满足;
  • 若反之,即进程总是在运行过程中被切走,则说明进程对 CPU 资源的需求较大。这里需要排除进程频繁调用 sched_yield 使得强制切换次数增多的情况。

关于一个进程的自愿/强制切换的次数统计在 /proc/<pid>/status 中:

1$ grep ctxt /proc/514/status
2voluntary_ctxt_switches:        338
3nonvoluntary_ctxt_switches:     7

也可用 pidstat -w 查看进程切换的频率(次/秒)。

抢占(Preemption)

抢占是指内核强行切换正在 CPU 上运行的进程,在抢占的过程中并不需要得到被抢占进程的配合;在随后的某个时刻,被抢占的进程还可以恢复运行。发生抢占的主要原因包括:

  • 进程的时间片用完了;
  • 有更高优先级的进程来争夺 CPU。

抢占的过程分为触发抢占执行抢占两个步骤,两者之间不一定是连续的,有些情况下可能会间隔相当长的时间:

  1. 触发抢占:给正在 CPU 上运行的当前进程设置 TIF_NEED_RESCHED,即请求重新调度的标志。注意此时进程并未切换。
  2. 执行抢占:在随后某个时刻,当内核执行到特定场景时,会检查 TIF_NEED_RESCHED 标志。当检查到后,便调用 schedule() 执行抢占。

两者都只会在各自的特定时机下触发,这是由内核代码所决定的。

触发抢占的时机

  • 周期性的时钟中断

    时钟中断处理函数会调用*调度器核心层(scheduler core)的函数 scheduler_tick(),其通过调度类(scheduling class)*的 task_tick() 方法检查进程时间片是否耗尽,若是则触发抢占:

    1void scheduler_tick(void)
    2{
    3	// ...
    4	curr->sched_class->task_tick(rq, curr, 0);
    5	// ...
    6}
    

    Linux 的进程调度是模块化的,不同的调度策略(如 CFS、RT)被封装成不同的调度类,每个调度类都可以实现自己的 task_tick() 方法(如 CFS 的 task_tick_fair()、RT 的 task_tick_rt())。

  • 进程被唤醒

    若该进程优先级高于 CPU 上的当前进程,就会触发抢占。参考:

    1try_to_wake_up
    2  ttwu_runnable
    3    rq = __task_rq_lock(p, ...)
    4      rq = task_rq(p)
    5    ttwu_do_wakeup(rq, p, ...)
    6      check_preempt_curr(rq, p, ...)
    
  • 新进程创建的时候

    若新创建出来的进程的优先级高于 CPU 上的当前进程,则会触发抢占。参考 sched_fork()task_fork()

  • 进程修改 nice 值

    若某进程的 nice 值被修改,导致其优先级高于 CPU 上的当前进程,也会触发抢占。参考 set_user_nice()

  • 调度器做负载均衡

执行抢占的时机

触发抢占通过设置进程的 TIF_NEED_RESCHED 标志告诉调度器需要进行抢占操作了,但要真正执行抢占还得等到内核发现了这个标志才行。而内核代码只在特定的几个点上检查 TIF_NEED_RESCHED 标志,这也就是执行抢占的时机。

若抢占发生在进程处于用户态之时,我们称之为用户态抢占(User Preemption);若发生在进程处于内核态的时候,则称之为内核态抢占(Kernel Preemption)。

执行用户态抢占的时机

  1. 从系统调用(syscall)返回用户态时;
  2. 从中断处理返回用户态时;
  3. 从异常处理返回用户态时。

user_mode_preempt

ARM64 下可参考 arch/arm64/kernel/entry-common.c 中的如下调用路径:

1el0_*
2  exit_to_user_mode
3    prepare_exit_to_user_mode
4      do_notify_resume
5        schedule

执行内核态抢占的时机

Linux 内核在 2.6 版本之后支持内核态抢占,但是否开启还取决于几个编译选项:

  • CONFIG_PREEMPT_NONE=y:不允许内核抢占。
  • CONFIG_PREEMPT_VOLUNTARY=y:允许内核主动调用 cond_resched() 让出 CPU。
  • CONFIG_PREEMPT=y:完全允许内核抢占。

kernel_preempt

上述三个编译选项为互斥的关系,三者只能选其一。

  • CONFIG_PREEMPT=y 的前提下,内核态抢占的时机为:

    1. 中断处理程序返回内核空间的时候。

      参考 preempt_schedule_irq() 的调用路径,例如:

      1el1h_64_irq/fiq_handler
      2  el1_interrupt
      3    __el1_irq
      4      arm64_preempt_schedule_irq
      5        preempt_schedule_irq
      6          __schedule(SM_PREEMPT)
      
    2. 内核从禁止抢占(non-preemptible)状态向允许抢占(preemptible)转变的时候。

      参考 preempt_enable()__preempt_schedule()__schedule() 的调用路径。

      1preempt_enable
      2  __preempt_schedule
      3    preempt_schedule
      4      preempt_schedule_common
      5        __schedule(SM_PREEMPT)
      
    3. 而此时 cond_resched() 不起作用:

      1cond_resched
      2  _cond_resched
      3    // ifdef CONFIG_PREEMPTION
      4    { return 0; }
      
  • CONFIG_PREEMPT_VOLUNTARY=y 时,调用 cond_resched()might_sleep() 都会触发内核态抢占:

     1cond_resched
     2  _cond_resched
     3    // if !CONFIG_PREEMPTION
     4    __cond_resched
     5      preempt_schedule_common
     6        __schedule(SM_PREEMPT)
     7
     8might_sleep
     9  might_resched
    10    // ifdef CONFIG_PREEMPT_VOLUNTARY_BUILD
    11    __cond_resched()
    
  • CONFIG_PREEMPT_NONE=y 时,仅 cond_resched() 会触发内核态抢占。

引申问题:关闭中断后是否有必要再关闭抢占?

首先,内核文档 中有提到:

But keep in mind that ‘irqs disabled’ is a fundamentally unsafe way of disabling preemption - any cond_resched() or cond_resched_lock() might trigger a reschedule if the preempt count is 0. A simple printk() might trigger a reschedule.

另外,我认为抢占和中断是两个不同维度上的概念,两者虽有关联但不可混为一谈:

参考资料

  1. Linux Performance
    1. 进程切换:自愿(voluntary)与强制(involuntary)
    2. 抢占(preemption)是如何发生的
  2. The Linux Kernel documentation
    1. Proper Locking Under a Preemptible Kernel: Keeping Kernel Code Preempt-Safe
    2. Unreliable Guide To Locking
  3. 【原创】(三)Linux进程调度器-进程切换 - LoyenWang - 博客园 (cnblogs.com)
  4. 关抢占 自旋锁_spinlock与中断、抢占的关系_weixin_39526185的博客-CSDN博客
  5. Linux进程调度:调度时机 - 知乎 (zhihu.com)

  1. 推测 nivcsw 为 “Number of InVoluntary Context SWitch” 的缩写,而 nvscw 则为 “… Voluntary …”。 ↩︎


关于一个神奇的 Linux 目录权限问题的探究
Linux 脚本执行:由一个补丁分析说起