Linux源码学习之调度方式与上下文切换

本文深入探讨了Linux系统的进程调度,包括主动调度和抢占式调度。主动调度涉及进程因I/O等待或长时间运行而让出CPU,而抢占式调度则在进程超过时间片或需要响应更高优先级任务时发生。调度流程中,调度类根据优先级选取下一个进程,然后进行内存空间和上下文的切换。此外,文章还提到了进程上下文切换的关键步骤,如保存和恢复寄存器及栈信息。最后,讨论了调度的时机,如系统调用和中断返回时。
摘要由CSDN通过智能技术生成

1.调度分类

所谓进程调度,其实就是一个人在做 A 项目,在某个时刻,换成做 B 项目去了。发生这种情况,主要有两种方式。

方式一:A 项目做着做着,发现里面有一条指令 sleep,也就是要休息一下,或者在等待某个 I/O 事件。那没办法了,就要主动让出 CPU,然后可以开始做 B 项目。

方式二:A 项目做着做着,旷日持久,实在受不了了。项目经理介入了,说这个项目 A 先停停,B 项目也要做一下,要不然 B 项目该投诉了。

上面两种对应着主动调度和 抢占式调度

主动调度的场景:

场景1:

写入块设备的一个典型场景。写入需要一段时间,这段时间用不上 CPU,还不如主动让给其他进程。

static void btrfs_wait_for_no_snapshoting_writes(struct btrfs_root *root)
{
......
  do {
    prepare_to_wait(&root->subv_writers->wait, &wait,
        TASK_UNINTERRUPTIBLE);
    writers = percpu_counter_sum(&root->subv_writers->counter);
    if (writers)
      schedule();//这里让出
    finish_wait(&root->subv_writers->wait, &wait);
  } while (writers);
}

场景2:

Tap 网络设备等待一个读取。Tap 网络设备是虚拟机使用的网络设备。当没有数据到来的时候,它也需要等待,所以也会选择把 CPU 让给其他进程。

static ssize_t tap_do_read(struct tap_queue *q,
         struct iov_iter *to,
         int noblock, struct sk_buff *skb)
{
......
  while (1) {
    if (!noblock)
      prepare_to_wait(sk_sleep(&q->sk), &wait,
          TASK_INTERRUPTIBLE);
......
    /* Nothing to read, let's sleep */
    schedule();//这里让出
  }
......
}

你应该知道,计算机主要处理计算、网络、存储三个方面。计算主要是 CPU 和内存的合作;网络和存储则多是和外部设备的合作;在操作外部设备的时候,往往需要让出 CPU,就像上面两段代码一样,选择调用 schedule() 函数。

2.主动调度

这段代码的主要逻辑是在 __schedule 函数中实现的。这个函数比较复杂,我们分几个部分来讲解。

static void __sched notrace __schedule(bool preempt)
{
  struct task_struct *prev, *next;
  unsigned long *switch_count;
  struct rq_flags rf;
  struct rq *rq;
  int cpu;


  cpu = smp_processor_id();
  rq = cpu_rq(cpu);//取出任务队列rq
  prev = rq->curr;//已经成为前任了
......

首先,在当前的 CPU 上,我们取出任务队列 rq。

task_struct *prev 指向这个 CPU 的任务队列上面正在运行的那个进程 curr。为啥是 prev?因为一旦将来它被切换下来,那它就成了前任了。

接下来代码如下:

next = pick_next_task(rq, prev, &rf);//获取下一个
clear_tsk_need_resched(prev);
clear_preempt_need_resched();

第二步,获取下一个任务,task_struct *next 指向下一个任务,这就是继任。pick_next_task 的实现如下:

static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
  const struct sched_class *class;
  struct task_struct *p;
  /*
   * Optimization: we know that if all tasks are in the fair class we can call that function directly, but only if the @prev task wasn't of a higher scheduling class, because otherwise those loose the opportunity to pull in more work from other CPUs.
   */
  if (likely((prev->sched_class == &idle_sched_class ||
        prev->sched_class == &fair_sched_class) &&
       rq->nr_running == rq->cfs.h_nr_running)) {
    p = fair_sched_class.pick_next_task(rq, prev, rf);
    if (unlikely(p == RETRY_TASK))
      goto again;
    /* Assumes fair_sched_class->next == idle_sched_class */
    if (unlikely(!p))
      p = idle_sched_class.pick_next_task(rq, prev, rf);
    return p;
  }
again://依次去调度 其他调度队列
  for_each_class(class) {
    p = class->pick_next_task(rq, prev, rf);
    if (p) {
      if (unlikely(p == RETRY_TASK))
        goto again;
      return p;
    }
  }
}

我们来看 again 这里,就是咱们上一节讲的依次调用调度类。但是这里有了一个优化,因为大部分进程是普通进程,所以大部分情况下会调用上面的逻辑,调用的就是 fair_sched_class.pick_next_task。

我们来看 again 这里,就是咱们上一节讲的依次调用调度类。但是这里有了一个优化,因为大部分进程是普通进程,所以大部分情况下会调用上面的逻辑,调用的就是 fair_sched_class.pick_next_task。

后面就是CFS取出一个节点 的逻辑了

第三步,当选出的继任者和前任不同,就要进行上下文切换,继任者进程正式进入运行。

if (likely(prev != next)) {
    rq->nr_switches++;
    rq->curr = next;
    ++*switch_count;
......
    rq = context_switch(rq, prev, next, &rf);//切换上下文
2.1进程上下文切换

上下文切换主要包含两个部分

1.切换进程空间,即虚拟内存(这里也就切换了TLB)

2.切换寄存器和CPU上下文

context_swtich实现

/*
 * context_switch - switch to the new MM and the new thread's register state.
 */
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
         struct task_struct *next, struct rq_flags *rf)
{
  struct mm_struct *mm, *oldmm;
......
  mm = next->mm;
  oldmm = prev->active_mm;
......
  switch_mm_irqs_off(oldmm, mm, next);
......
  /* Here we just switch the register state and the stack. */
  switch_to(prev, next, prev);
  barrier();
  return finish_task_switch(prev);
}

入参是等待队列 前一个任务 和 当前待调度的任务

首先是内存空间的切换。

接下来,我们看 switch_to。它就是**寄存器和栈的切换,它调用到了 __switch_to_asm。**这是一段汇编代码,主要用于栈的切换。

对于 32 位操作系统来讲,切换的是栈顶指针 esp。

/*
 * %eax: prev task
 * %edx: next task
 */
ENTRY(__switch_to_asm)
......
  /* switch stack */
  movl  %esp, TASK_threadsp(%eax)
  movl  TASK_threadsp(%edx), %esp
......
  jmp  __switch_to
END(__switch_to_asm)

返回了 __switch_to 这个函数

__visible __notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
  struct thread_struct *prev = &prev_p->thread;
  struct thread_struct *next = &next_p->thread;
......
  int cpu = smp_processor_id();
  struct tss_struct *tss = &per_cpu(cpu_tss, cpu);
......
  load_TLS(next, cpu);
......
  this_cpu_write(current_task, next_p);


  /* Reload esp0 and ss1.  This changes current_thread_info(). */
  load_sp0(tss, next);
......
  return prev_p;
}

X86系统提供一种以硬件模式的进程上下文切换模式

具体如下:

1、每个进程都在内存中维护一个tss,里面保存着所有寄存器的值,如栈、程序计数器信息;

img

​ TSS寄存器

2、tr指向tss(特殊的寄存器 TR(Task Register,任务寄存器));

3、当tr值发生改变时,会触发硬件将当前寄存器信息保存至tss,并将新的进程的tss信息加载至CPU对应的寄存器中(其实就是进程上下文切换过程)

所谓的进程切换,就是将某个进程的 thread_struct 里面的寄存器的值,写入到 CPU 的 TR 指向的 tss_struct,对于 CPU 来讲,这就算是完成了切换。

再说的简单一点:

就是保存CPU寄存器进堆栈(tss_struct),然后用新的进程的堆栈信息去恢复CPU寄存器

在进程切换时,内核栈运行时的寄存器保存在thread_struct中,用户态的寄存器保存在内核栈里的pt_regs中

指令指针寄存器什么时候切换的?

是在schedule()的最后这里

switch_to(prev, next, prev);
barrier();
return finish_task_switch(prev);

当进程 A 在内核里面执行 switch_to 的时候,内核态的指令指针也是指向这一行的。但是在 switch_to 里面,将寄存器和栈都切换到成了进程 B 的,唯一没有变的就是指令指针寄存器。当 switch_to 返回的时候,指令指针寄存器指向了下一条语句 finish_task_switch。但这个时候的 finish_task_switch 已经不是进程 A 的 finish_task_switch 了,而是进程 B 的 finish_task_switch 了。

A进程切换B进程,执行完switch_to,之后,内核栈已经切换成B的。虽然后面的finish_task_switch是接着switch_to执行,但其实这里是执行B的后续逻辑

img

3.抢占式调度

时间片到了,或者一个进程使用时间太长了,是时候切换到另一个进程了。那怎么衡量一个进程的运行时间呢?在计算机里面有一个时钟,会过一段时间触发一次时钟中断,通知操作系统,时间又过去一个时钟周期,这是个很好的方式,可以查看是否是需要抢占的时间点。

时钟中断处理函数会调用 scheduler_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);
  cpu_load_update_active(rq);
  calc_global_load_tick(rq);
......
}

这个函数先取出当前 CPU 的运行队列,然后得到这个队列上当前正在运行中的进程的 task_struct,然后调用这个 task_struct 的调度类的 task_tick 函数,顾名思义这个函数就是来处理时钟事件的。

如果当前运行的进程是普通进程,调度类为 fair_sched_class,调用的处理时钟的函数为 task_tick_fair。我们来看一下它的实现。

static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
{
  struct cfs_rq *cfs_rq;
  struct sched_entity *se = &curr->se;


  for_each_sched_entity(se) {
    cfs_rq = cfs_rq_of(se);
    entity_tick(cfs_rq, se, queued);
  }
......
}

根据当前进程的 task_struct,找到对应的调度实体 sched_entity 和 cfs_rq 队列,调用 entity_tick。

static void
entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr, int queued)
{
  update_curr(cfs_rq);
  update_load_avg(curr, UPDATE_TG);
  update_cfs_shares(curr);
.....
  if (cfs_rq->nr_running > 1)
    check_preempt_tick(cfs_rq, curr);
}

在 entity_tick 里面,我们又见到了熟悉的 update_curr。它会更新当前进程的 vruntime,然后调用 check_preempt_tick。顾名思义就是,检查是否是时候被抢占了。

check_preempt_tick 先是调用 sched_slice 函数计算出的 ideal_runtime。ideal_runtime 是一个调度周期中,该进程运行的实际时间。sum_exec_runtime 指进程总共执行的实际时间,prev_sum_exec_runtime 指上次该进程被调度时已经占用的实际时间。每次在调度一个新的进程时都会把它的 se->prev_sum_exec_runtime = se->sum_exec_runtime,所以 sum_exec_runtime-prev_sum_exec_runtime 就是这次调度占用实际时间。如果这个时间大于 ideal_runtime,则应该被抢占了。

简单的说发现实际运行时间已经大于分配给它的时间就需要调度出去

除了这个条件之外,还会通过 __pick_first_entity 取出红黑树中最小的进程。如果当前进程的 vruntime 大于红黑树中最小的进程的 vruntime,且差值大于 ideal_runtime,也应该被抢占了。

当发现当前进程应该被抢占,不能直接把它踢下来,而是把它标记为应该被抢占。为什么呢?因为进程调度第一定律呀,一定要等待正在运行的进程调用 __schedule 才行啊,所以这里只能先标记一下。

标记一个进程应该被抢占,都是调用 resched_curr,它会调用 set_tsk_need_resched,标记进程应该被抢占,但是此时此刻,并不真的抢占,而是打上一个标签 TIF_NEED_RESCHED

static inline void set_tsk_need_resched(struct task_struct *tsk)
{
  set_tsk_thread_flag(tsk,TIF_NEED_RESCHED);
}

另外一个可能抢占的场景是当一个进程被唤醒的时候。

抢占的时机

真正的抢占还需要时机,也就是需要那么一个时刻,让正在运行中的进程有机会调用一下 __schedule。你可以想象,不可能某个进程代码运行着,突然要去调用 __schedule,代码里面不可能这么写,所以一定要规划几个时机,这个时机分为用户态和内核态。

用户态的抢占时机

时机-1: 从系统调用中返回, 返回过程中会调用 exit_to_usermode_loop, 检查 _TIF_NEED_RESCHED, 若打了标记, 则调用 schedule()

时机-2: 从中断中返回, 中断返回分为返回用户态和内核态(汇编代码: arch/x86/entry/entry_64.S), 返回用户态过程中会调用 exit_to_usermode_loop()->shcedule()

内核态的抢占时机

时机-1: 发生在 preempt_enable() 中, 内核态进程有的操作不能被中断, 会调用 preempt_disable(), 在开启时(调用 preempt_enable) 时是一个抢占时机, 会调用 preempt_count_dec_and_test(), 检测 preempt_count 和标记, 若可抢占则最终调用 __schedule

-时机-2: 发生在中断返回, 也会调用 __schedule

img

4.思考与总结:

1.总结:

进程调度完整流程:

进程调度有两种场景:

1.主动调度

进程需要执行长时间等待操作自己释放CPU

场景:

  1. 写文件时间过长

  2. 等待网络数据包

进程自己调用__schedule()方法

2.抢占式调度

进程执行时间过长,或者时间片用完了,不会立即执行调度,而是打标记为可调度,真正发生调度的时候还是执行__schedule()的时候

执行__schdule()调度的时机?

是从系统调用和中断返回用户态的时候

以及内核态打开可抢占以及 从中断返回内核态的时候。

执行__scehdule()方法的时候

执行__schdule()方式后,调度类开始工作,会先取出当前CPU的等待队列,会按照优先级依次调用调度类,不同的调度类操作不同的队列。当然 rt_sched_class 先被调用,它会在 rt_rq 上找下一个任务,只有找不到的时候,才轮到 fair_sched_class 被调用,取下下一个任务,如果下一个任务与当前任务不同,那么就要发生进程切换,

进程切换主要是两部分,首先切换内存空间,然后切换寄存器和CPU上下文。

切换完成返回用户态就完成一整个调度流程了

2.Linux 内核是如何管理和度量时间的吗?

linux内核依靠硬件定时电路特定时钟频率,tick rate,触发时钟中断,通过中断处理,实现系统时间更新, 定时器设置,延时处理

3.java线程是如何调度的?

JVM的线程调度实现(抢占式调度)

java使用的线程调使用抢占式调度,Java中线程会按优先级分配CPU时间片运行,且优先级越高越优先执行,但优先级高并不代表能独自占用执行时间片,可能是优先级高得到越多的执行时间片,反之,优先级低的分到的执行时间少但不会分配不到执行时间。

线程让出cpu的情况:

  1. 当前运行线程主动放弃CPU,JVM暂时放弃CPU操作(基于时间片轮转调度的JVM操作系统不会让线程永久放弃CPU,或者说放弃本次时间片的执行权),例如调用yield()方法。

  2. 当前运行线程因为某些原因进入阻塞状态,例如阻塞在I/O上。

  3. 当前运行线程结束,即运行完run()方法里面的任务。

线程的执行并不是按照指定的顺序来,比如我依次开启线程1、2、3,但实际的执行结果并不受我们的控制,而是由cpu调度器随机调度执行的!因此我们需要格外注意线程安全问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值