Linux 操作系统:调度

1 调度策略与调度类

在 Linux 里面,进程大概可以分为两种

一种称为实时进程,也就是需要尽快返回结果的那种。

另一种是普通进程,大部分的进程其实就是这种。

task_struct 中,有一个成员变量,我们叫做调度策略

unsigned int policy;

它有以下几个定义:


#define SCHED_NORMAL    0
#define SCHED_FIFO    1
#define SCHED_RR    2
#define SCHED_BATCH    3
#define SCHED_IDLE    5
#define SCHED_DEADLINE    6

配合调度策略的,还有优先级的概念,同一策略有不同的优先级,也在 task_struct 中。

int prio, static_prio, normal_prio;
unsigned int rt_priority;

优先级其实就是一个数值,对于实时进程,优先级的范围是 0~99;对于普通进程,优先级的范围是 100-139。数值越小,优先级越高。

1.1 实时调度策略

对于调度策略,其中 SCHED_FIFO、SCHED_RR、SCHED_DEADLINE是实时进程的调度策略。

对于 SCHED_FIFO 高优先级的进程可以抢占低优先级的进程,而相同优先级的进程,就遵循先来先执行。

SCHED_RR 轮流调度算法,采用时间片,相同优先级的任务当用完时间片会被放到队列尾部,以保证公平性,而高优先级的任务也是可以抢占低优先级的任务。

另一种 SCHED_DEADLINE 是按照 deadline 进行调度的。当产生一个调度点的时候,DL 总是选择其 deadline 距离当前时间点最近的那个任务,并调度它执行。

1.2 普通调度策略

对于普通调度策略有,SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE。

SCHED_NORMAL 就是普通的进程。

SCHED_BATCH 是后台进程,几乎不需要与前端进行交互。

SCHED_IDLE 是特别空闲时才跑的进程

上面无论是调度策略 policy 还是优先级 priority,都仅仅只是设置了一个变量,变量仅仅表示应该这样做,但具体的执行是由 struct_task 中的成员变量 sched_class 干的:

const struct sched_class *sched_class;

调度策略的执行逻辑就封装在这里面,它是真正干活的那个。

sched_class 有几种实现:

  • stop_sched_class 优先级最高的任务会使用这种策略,会中断其他所有线程,且不会被其他任务打断;
  • dl_sched_class 就对应上面的 deadline 调度策略;
  • rt_sched_class 就对应 RR 算法挥着 FIFO 算法的调度策略,具体调度策略由 task_struct->policy 指定;
  • fair_sched_class 就是普通进程的调度策略;
  • idle_sched_class 就是空闲进程的调度策略;

完全公平调度算法

普通进程使用的策略是 fair_sched_class

在 Linux 里面,实现了一个基于 CFS(Completely Fair Scheduing) 的调度算法,叫完全公平调度。

它的原理如下:
首先需要记录下进程的运行时间。CPU会提供一个时钟,过一段时间就触发一个时钟中断 Tick。CFS 会为每一个进程安排一个虚拟运行时间 vruntime。如果一个进程在运行,随着时间的增长,也就是一个个 tick 的到来,进程的 vruntime 将不断增大。没有得到执行的进程 vruntime 不变。对于 vruntime 少的就优先运行这样的进程。

但由于还存在着优先级的不同,高优先级理应得到更多执行时间。这样就不能按照每个进程得到的绝对执行时间来考量,而应该按优先级设置不同比例来分配时间,高优先级与低优先级执行时间限定在一个比例。

在更新进程运行统计量的时候,就可以看出这样的逻辑


/*
 * Update the current task's runtime statistics.
 */
static void update_curr(struct cfs_rq *cfs_rq)
{
  struct sched_entity *curr = cfs_rq->curr;
  u64 now = rq_clock_task(rq_of(cfs_rq));
  u64 delta_exec;
......
  delta_exec = now - curr->exec_start;
......
  curr->exec_start = now;
......
  curr->sum_exec_runtime += delta_exec;
......
  curr->vruntime += calc_delta_fair(delta_exec, curr);
  update_min_vruntime(cfs_rq);
......
}


/*
 * delta /= w
 */
static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
  if (unlikely(se->load.weight != NICE_0_LOAD))
        /* delta_exec * weight / lw.weight */
    delta = __calc_delta(delta, NICE_0_LOAD, &se->load);
  return delta;
}

在这里得到当前的时间,以及这次的时间片开始的时间,两者相减就是这次运行的时间 delta_exec, 但是得到的这个时间其实是实际运行的时间,需要做一定的转化才能作为虚拟运行时间 vruntime。转化方式如下:

虚拟运行时间 vruntime += 实际运行时间 delta_exec * NICE_0_LOAD/权重

也就是说,同样的实际运行时间,给高权重的算少了,低权重的算多了,但是当选取下一个运行进程的时候,还是按照最小的 vruntime 来的, 这样高权重获得的实际运行时间就自然多了。

调度队列与调度实体

CFS 需要一个数据结构来对 vruntime 进行排序,找出最小的那个。这个排序的数据结构不但需要查询的时候,能够快速找到最小的,更新的时候也需要能够快速地调整排序。

能够平衡查询和更新快速的是树,这里使用的是红黑树。

红黑树的节点是应该包括 vruntime 的, 称为调度实体 entity。

在 task_struct 中有这样的成员变量:

struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl; 

这里有实时调度实体 sched_rt_entity, Deadline 调度实体 sched_dl_entity,以及完全公平算法调度实体 sched_entity。

看来不光 CFS 调度策略需要有这样一个数据结构进行排序,其他的调度策略也同样有自己的数据结构进行排序,因为任何一个策略做调度的时候,都是要区分谁先运行谁后运行。

而进程根据自己是实时的,还是普通的类型,通过 policy 这个成员变量, 将自己挂在某一个数据结构里面,和其他的进程排序,等待被调度。如果这个进程是普通进程,则通过 sched_entity, 将自己挂在这颗红黑树上。

对于普通进程的调度实体如下,这里面包含了vruntime 和权重 load_weight,以及对于运行时间的统计。


struct sched_entity {
  struct load_weight    load;
  struct rb_node      run_node;
  struct list_head    group_node;
  unsigned int      on_rq;
  u64        exec_start;
  u64        sum_exec_runtime;
  u64        vruntime;
  u64        prev_sum_exec_runtime;
  u64        nr_migrations;
  struct sched_statistics    statistics;
......
};

下图是一个红黑树的例子
在这里插入图片描述
所有可运行的进程通过不断地插入操作最终都存储在以 vruntime 时间为顺序的红黑树中,vruntime 最小的在树的左边, vruntime 最多的在树的右侧。CFS 调度策略会选择红黑树最左边的叶子节点作为下一个将获得 CPU 的任务。

这颗红黑树放在哪里呢?

每个 CPU 都有自己的 struct rp 结构,其用于在此 CPU 上运行的所有进程,其包括一个实时进程队列 rt_rq 和一个 CFS 运行队列 cfs_rq, 在调度时,调度器首先会先去实时进程队列找是否有实时进程需要运行,如果没有才会去 CFS 运行队列找是否有进程需要运行。


struct rq {
  /* runqueue lock: */
  raw_spinlock_t lock;
  unsigned int nr_running;
  unsigned long cpu_load[CPU_LOAD_IDX_MAX];
......
  struct load_weight load;
  unsigned long nr_load_updates;
  u64 nr_switches;


  struct cfs_rq cfs;
  struct rt_rq rt;
  struct dl_rq dl;
......
  struct task_struct *curr, *idle, *stop;
......
};

对于普通进程公平队列 cfs_rq, 定义如下:


/* CFS-related fields in a runqueue */
struct cfs_rq {
  struct load_weight load;
  unsigned int nr_running, h_nr_running;


  u64 exec_clock;
  u64 min_vruntime;
#ifndef CONFIG_64BIT
  u64 min_vruntime_copy;
#endif
  struct rb_root tasks_timeline;
  struct rb_node *rb_leftmost;


  struct sched_entity *curr, *next, *last, *skip;
......
};

这里面 rb_root 指向的就是红黑树的根节点,这个红黑树在 CPU 看起来就是一个队列,不断地取下一个运行地进程。rb_leftmost 指向的就是最左边的节点。

上面介绍的数据结构的关系如图:
在这里插入图片描述

1.3 调度类工作模式

调度类的定义如下:


struct sched_class {
  const struct sched_class *next;

  void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);
  void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
  void (*yield_task) (struct rq *rq);
  bool (*yield_to_task) (struct rq *rq, struct task_struct *p, bool preempt);

  void (*check_preempt_curr) (struct rq *rq, struct task_struct *p, int flags);

  struct task_struct * (*pick_next_task) (struct rq *rq,
            struct task_struct *prev,
            struct rq_flags *rf);
  void (*put_prev_task) (struct rq *rq, struct task_struct *p);

  void (*set_curr_task) (struct rq *rq);
  void (*task_tick) (struct rq *rq, struct task_struct *p, int queued);
  void (*task_fork) (struct task_struct *p);
  void (*task_dead) (struct task_struct *p);

  void (*switched_from) (struct rq *this_rq, struct task_struct *task);
  void (*switched_to) (struct rq *this_rq, struct task_struct *task);
  void (*prio_changed) (struct rq *this_rq, struct task_struct *task, int oldprio);
  unsigned int (*get_rr_interval) (struct rq *rq,
           struct task_struct *task);
  void (*update_curr) (struct rq *rq)

这个结构定义了很多种方法,用于在队列上操作任务。这里有一个成员变量,是一个指针,指向新一个调度类。
上面讲过,调度类分为下面几种:


extern const struct sched_class stop_sched_class;
extern const struct sched_class dl_sched_class;
extern const struct sched_class rt_sched_class;
extern const struct sched_class fair_sched_class;
extern const struct sched_class idle_sched_class;

它们其实是放在一个链表上的。这里我们以调度最常见的操作,取下一个任务为例,这里面有一个 for_each_class 循环,沿着上面的顺序,依次调用每个调度类的方法。


/*
 * Pick up the highest-prio 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;
......
  for_each_class(class) {
    p = class->pick_next_task(rq, prev, rf);
    if (p) {
      if (unlikely(p == RETRY_TASK))
        goto again;
      return p;
    }
  }
}

这就说明,调度的时候是从优先级最高的调度类到优先级低的调度类,依次执行。而对于每种调度类,有自己的实现,例如:CFS 就有 fair_sched_class。


const struct sched_class fair_sched_class = {
  .next      = &idle_sched_class,
  .enqueue_task    = enqueue_task_fair,
  .dequeue_task    = dequeue_task_fair,
  .yield_task    = yield_task_fair,
  .yield_to_task    = yield_to_task_fair,
  .check_preempt_curr  = check_preempt_wakeup,
  .pick_next_task    = pick_next_task_fair,
  .put_prev_task    = put_prev_task_fair,
  .set_curr_task          = set_curr_task_fair,
  .task_tick    = task_tick_fair,
  .task_fork    = task_fork_fair,
  .prio_changed    = prio_changed_fair,
  .switched_from    = switched_from_fair,
  .switched_to    = switched_to_fair,
  .get_rr_interval  = get_rr_interval_fair,
  .update_curr    = update_curr_fair,
};

对于用同样的 pick_next_task 选取下一个要运行任务,不同的调度类有自己的实现。fair_sched_class 的实现是 pick_next_task_fair, rt_sched_class 的实现是 pick_next_task_rt。

我们会发现这两个函数是操作不同的队列, pick_next_task_rt 操作的是 rt_rq, pick_next_task_fair 操作的是 cfs_rq。


static struct task_struct *
pick_next_task_rt(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
  struct task_struct *p;
  struct rt_rq *rt_rq = &rq->rt;
......
}


static struct task_struct *
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
  struct cfs_rq *cfs_rq = &rq->cfs;
  struct sched_entity *se;
  struct task_struct *p;
......
}

这样整个运行场景就可以串起来了,在每个 CPU 上都有一个队列 rq, 这个队列里面包含多个子队列, 例如 rt_rq 和 cfs_rq, 不同的队列有不同的实现方式, cfs_rq 就是用红黑树实现的

当某一时刻,某个 CPU 需要找下一个任务执行的时候,会按照优先级依次调用调度类,不同的调度类操作不同的队列。当然 rt_sched_class 先被调用,它会在 rt_rq上找下一个任务,只有找不到的时候,才轮到 fair_sched_class 被调用,它会在 cfs_rq 上找下一个任务。这样保证了实时任务的优先级永远大于普通任务。
在这里插入图片描述

可以看一下 sched_class 定义的与调度有关的函数。

  • enqueue_task 向就绪队列中添加一个进程,当某个进程进入可运行状态时,调用这个函数;
  • dequeue_task 将一个进程从就绪队列中删除;
  • pick_next_task 选择接下来要运行的进程;
  • put_prev_task 用另一个进程代替当前运行的进程;
  • set_curr_task 用于修改调度策略
  • task_tick 每次周期性时钟到的时候,这个函数被调用,可能触发调度。

在这里,重点看 fair_sched_class 对于 pick_next_task 的实现 pick_next_task_fair, 获取下一个进程。调用路径如下: pick_next_task_fair -> pick_next_entity -> __pick_first_entity 。


struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq)
{
  struct rb_node *left = rb_first_cached(&cfs_rq->tasks_timeline);


  if (!left)
    return NULL;


  return rb_entry(left, struct sched_entity, run_node);

从函数实现可以看出,就是从红黑树里面取最左边的节点。

2 主动调度

某个进程碰到 sleep 或是等待 I/O 则会主动让出 CPU。

典型的如。Btrfs, 等待一个写入, 这个片段可以看作写入的一个典型场景。写入需要一段时间,这段时间用不上 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);
}

另一个则是,从 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() 函数。

schedule 函数的调用过程如下:


asmlinkage __visible void __sched schedule(void)
{
  struct task_struct *tsk = current;


  sched_submit_work(tsk);
  do {
    preempt_disable();
    __schedule(false);
    sched_preempt_enable_no_resched();
  } while (need_resched());
}

这段代码的逻辑是在 __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);
  prev = rq->curr;
......

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

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

接下来代码如下:


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。

根据上一章对于 fair_sched_class 的定义,它调用的就是 pick_next_task_fair, 代码如下:


static struct task_struct *
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
  struct cfs_rq *cfs_rq = &rq->cfs;
  struct sched_entity *se;
  struct task_struct *p;
  int new_tasks;

对于 CFS 调度类, 取出相应的队列 cfs_rq,这就是上一章的那颗红黑树


    struct sched_entity *curr = cfs_rq->curr;
    if (curr) {
      if (curr->on_rq)
        update_curr(cfs_rq);
      else
        curr = NULL;
......
    }
    se = pick_next_entity(cfs_rq, curr);

取出当前正在运行的 curr, 如果依然是可运行的状态,也即处理进程就绪状态,则调用 update_curr 更新 vruntime。 update_curr 上一章就见过,它会根据实际运行时间算出 vruntime 来。

接着, pick_next_entity 从红黑树里面,取最左边的一个节点。这个函数的实现:


  p = task_of(se);


  if (prev != p) {
    struct sched_entity *pse = &prev->se;
......
    put_prev_entity(cfs_rq, pse);
    set_next_entity(cfs_rq, se);
  }


  return p

task_of 得到下一个调度实体对应的 task_struct, 如果发现继任和前任不一样,这就说明有一个更需要运行的进程了,就需要更新红黑树了。前任的 vruntime 更新过了, put_prev_entity 放回红黑树,会找到相应的位置,人后 set_next_entity 将继任者设为当前任务。

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


if (likely(prev != next)) {
    rq->nr_switches++;
    rq->curr = next;
    ++*switch_count;
......
    rq = context_switch(rq, prev, next, &rf);

进程上下文切换

上下文切换主要干两件事情,一是切换进程空间,也即虚拟内存;二是切换寄存器和 CPU 上下文。

context_switch 的实现。


/*
 * 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)

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


/*
 * %rdi: prev task
 * %rsi: next task
 */
ENTRY(__switch_to_asm)
......
  /* switch stack */
  movq  %rsp, TASK_threadsp(%rdi)
  movq  TASK_threadsp(%rsi), %rsp
......
  jmp  __switch_to
END(__switch_to_asm)

最终,都返回了 __switch_to 这个函数。这个函数对于 32 位和 64 位操作系统虽然有不同的实现,但里面做的事情是差不多的,以 64 位操作系统为例。


__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;
}

这里面有一个 Per CPU 的结构体 tss, 这是什么呢?

在 x86 体系结构中,提供了一种以硬件的方式进行进程切换的模式,对于每个进程, x86 希望在内存里维护一个 TSS(Task State Segment, 任务状态段)结构,这里面有所有的寄存器。

另外,还有一个特殊的寄存器 TR(Task Register, 任务寄存器), 指向某个进程的 TSS。 更改 TR 的值,就会触发硬件保存 CPU 所有寄存器的值到当前进程的 TSS 中, 然后从新进程的 TSS 中读出所有寄存器值,加载到 CPU 对应的寄存器中。

下面是 32 位的 TSS 结构。
在这里插入图片描述
但是这有个缺点。做进程切换的时候,没必要每个寄存器都切换,这样每个进程一个 TSS, 就需要全量保存,全量切换,动作太大了。

于是,Linux 操作系统想了一个办法。在系统初始化的时候会调用 cpu_init ,这里面会给每一个 CPU 关联一个 TSS, 然后将 TR 指向这个 TSS, 然后在操作系统的运行过程中,TR 就不切换了,永远指向这个 TSS。 TSS 用数据结构 tss_struct 表示, 在 x86_hw_tss 中可以看到和上图相应的结构。


void cpu_init(void)
{
  int cpu = smp_processor_id();
  struct task_struct *curr = current;
  struct tss_struct *t = &per_cpu(cpu_tss, cpu);
    ......
    load_sp0(t, thread);
  set_tss_desc(cpu, t);
  load_TR_desc();
    ......
}


struct tss_struct {
  /*
   * The hardware state:
   */
  struct x86_hw_tss  x86_tss;
  unsigned long    io_bitmap[IO_BITMAP_LONGS + 1];
} 

在 Linux 中,真的参与进程切换的寄存器很少,主要的就是栈顶寄存器。

于是,在 task_struct 里面,还有一个我们原来没有注意的变量, thread 。这里面保留了要切换进程的时候需要修改的寄存器。


/* CPU-specific state of this task: */
  struct thread_struct    thread;

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

例如 __switch_to 中的 load_sp0, 就是将下一个进程的 thread_struct 的 sp0 值加载到 tss_struct 里面去。

指令指针的保存与恢复

这样就完成了整个切换,这里我们盘点一下。

从进程 A 切换到进程 B, 用户栈要不要切换呢?当然要,起始早就切换了,就在切换内存空间的时候。每个进程的用户栈都是独立的,都在内存空间里面。

那内核栈呢?已经在 __switch_to 里面切换了,也就是将 current_task 指向当前的 task_struct。里面的 void *stack 指针,指向的就是当前的内核栈。

内核栈的栈顶指针呢?在 __switch_to_asm 里面已经切换了栈顶指针,并且将栈顶指针在 __switch_to 加载到TSS 里面。

用户栈的栈顶指针呢?如果当前在内核里面的话,它当然是内核栈顶部的 pt_regs 结构里面呀。当从内核返回用户态运行的时候, pt_regs 里面有所有当时在用户态的时候运行的上下文信息,就可以开始运行了。

唯一让人不解的就是指令指针寄存器,它应该指向下一条指令的,那它是如何切换的呢?

这里先明确一点,进程的调度最终会用到 __schedule 函数。姑且叫 “进程调度第一定量”

用前面的写文件为例。本来一个进程 A 在用户态是要写一个文件的,写文件的操作用户态没法完成,就要通过系统调用到内核态。在这个切换的过程中,用户态的指令寄存器是保存在 pt_regs 里面的,到了内核态,就开始沿着写文件的逻辑一步一步执行,结果发现需要等待, 于是就调用 __schedule 函数。

这个时候,进程 A 在内核态的指令指针是指向 __schedule 了。这里记住, A 进程的内核栈会保存这个 __schedule 的调用,而且知道这是从 btrfs_wait_for_no_snapshoting_writes 这个函数里面进去的。

__schedule 里面经过上面的层层调用,到达了 context_switch 的最后三行指令(其中 barrier 语句是一个编译指令, 用于保证 switch_to 和 finish_task_switch 的顺序执行)


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 了。

这样合理吗?那怎么知道进程 B 被切换下去的时候执行到哪里了呢?这时候就要用到 “进程调度第一定律”了。

当时 B 进程被别人切换走的时候,也是调用 __schedule, 也是调用到 switch_to ,被切换成为 C 进程的,所以,B 进程当时的下一条指令也是 finish_task_switch, 这就说明指令指针指到这里是没有错的。

接下来,要从 finish_task_switch 完毕后,返回 __schedule 的调用了。返回的哪里呢?按照函数返回的原理, 当然是从内核栈里面去找,这时就从 B 进程的内核里面找。

假设, B 就是前面例子里调用 tap_do_read 读网卡的进程,它当时调用 __schedule 的时候,是从 tap_do_read 这个函数调用进去的。

当然,B 进程的内核栈里存放的是 tap_do_read。于是,从 __schedule 返回之后,当然是接着 tap_do_read 运行, 然后再内核运行完毕之后,返回用户态。这个时候,B 进程内核栈的 pt_regs 也保存了用户态的指令指针寄存器,就接着用户态的下一条指令开始运行就可以了。

章2总结

在这里插入图片描述

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。 顾名思义就是,检查是否是时候被抢占了。


static void
check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
  unsigned long ideal_runtime, delta_exec;
  struct sched_entity *se;
  s64 delta;


  ideal_runtime = sched_slice(cfs_rq, curr);
  delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
  if (delta_exec > ideal_runtime) {
    resched_curr(rq_of(cfs_rq));
    return;
  }
......
  se = __pick_first_entity(cfs_rq);
  delta = curr->vruntime - se->vruntime;
  if (delta < 0)
    return;
  if (delta > ideal_runtime)
    resched_curr(rq_of(cfs_rq));
}

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_task_need_resched, 标记进程应该被抢占,但是此时此刻,并不真的被抢占,而是打上一个标签 TIF_NEED_RESCHED。


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

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

上一章说过,当一个进程在等待一个 I/O 的时候,会主动放弃 CPU。但是当 I/O 到来的时候,进程往往会被唤醒。这个时候是一个时机。当被唤醒的进程优先级高于 CPU 上的当前进程,就会触发抢占。try_to_wake_up() 调用 ttwu_queue 将这个唤醒的任务添加到队列当中。ttwu_queue 再调用 ttwu_do_activate 激活这个任务。 ttwu_do_activate 调用 ttwu_do_wake_up。这里面调用了 check_preempt_curr 检查是否应该发生抢占。如果应该发生抢占,也不是直接踢走该进程,而是将当前进程标记为应该被抢占。


static void ttwu_do_wakeup(struct rq *rq, struct task_struct *p, int wake_flags,
         struct rq_flags *rf)
{
  check_preempt_curr(rq, p, wake_flags);
  p->state = TASK_RUNNING;
  trace_sched_wakeup(p);

到这里,你会发现,抢占问题只做完了一半。就是标识当前运行中的进程应该被抢占了,但是真正的抢占动作并没有发生。

3.1 抢占的时机

真正的抢占还需要时机,也就是需要那么一个时刻,让正在运行中的进程有机会调用一下 __schedule。

可以想象,不可能某个线程运行着,突然要去调用 __schedule, 代码里面不可能这么写,所以一定要规划几个时机,这个时机分为用户态和内核态。

3.1.1 用户态的抢占时机

对于用户态的进程来说,从系统调用中返回的那个时刻,是一个被抢占的时机。

前面讲系统调用的时候,64 位的系统调用用的链路位 do_syscall_64 -> syscall_return_slowpath->prepare_exit_to_usermode->exit_to_usermode_loop 这个函数,现在我们来看一下。


static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)
{
  while (true) {
    /* We have work to do. */
    local_irq_enable();


    if (cached_flags & _TIF_NEED_RESCHED)
      schedule();
......
  }
}

在 exit_to_usermode_loop 函数中,上面打的标签起了作用,如果被打了 _TIF_NEED_RESCHED, 调用 schedule 进程调度, 调用的过程和上一节解析的一样,会选择一个进程让出 CPU, 做上下文切换。

对于用户态的进程来讲,从中断中返回的那个时刻,也是一个被抢占的时机。

在 arch/x86/entry/entry_64.S 中有中断的处理过程。是一段汇编代码


common_interrupt:
        ASM_CLAC
        addq    $-0x80, (%rsp) 
        interrupt do_IRQ
ret_from_intr:
        popq    %rsp
        testb   $3, CS(%rsp)
        jz      retint_kernel
/* Interrupt came from user space */
GLOBAL(retint_user)
        mov     %rsp,%rdi
        call    prepare_exit_to_usermode
        TRACE_IRQS_IRETQ
        SWAPGS
        jmp     restore_regs_and_iret
/* Returning to kernel space */
retint_kernel:
#ifdef CONFIG_PREEMPT
        bt      $9, EFLAGS(%rsp)  
        jnc     1f
0:      cmpl    $0, PER_CPU_VAR(__preempt_count)
        jnz     1f
        call    preempt_schedule_irq
        jmp     0b

中断处理调用的是 do_IRQ 函数, 中断完毕后分为两种情况,一个是返回用户态,一个是返回内核态。

返回用户态这一部分, retint_user 会调用 prepare_exit_to_usermode, 最终调用 exit_to_usermode_loop, 和上面的逻辑一样,发现有标记则调用 schedule()。

3.1.2 内核态的抢占时机

对内核态的执行中,被抢占的时机一般会发生在 preempt_enable() 中。

在内核态的执行中,有的操作时不能被中断的,所以在进行这些操作之前,总是先调用 preempt_disable() 关闭抢占,当再次打开的时候,就是一次内核态代码被抢占的机会。

就像下面代码中展示的一样,preempt_enable() 会调用 preempt_count_dec_and_test(), 判断 preempt_count 和 TIF_NEED_RESCHED 是否可以被抢占。如果可以,就调用 preempt_schedule -> preempt_schedule_common -> __schedule 进行调度。这还是满足进程调度第一定律的。


#define preempt_enable() \
do { \
  if (unlikely(preempt_count_dec_and_test())) \
    __preempt_schedule(); \
} while (0)


#define preempt_count_dec_and_test() \
  ({ preempt_count_sub(1); should_resched(0); })


static __always_inline bool should_resched(int preempt_offset)
{
  return unlikely(preempt_count() == preempt_offset &&
      tif_need_resched());
}


#define tif_need_resched() test_thread_flag(TIF_NEED_RESCHED)


static void __sched notrace preempt_schedule_common(void)
{
  do {
......
    __schedule(true);
......
  } while (need_resched())

在内核态也会遇到中断的情况,当中断返回的时候,返回的仍然时内核态。这个时候也是一个执行抢占的时机,现在再来看上面中断返回的的代码中返回内核的那部分代码,调用的是 preempt_schedule_irq。


asmlinkage __visible void __sched preempt_schedule_irq(void)
{
......
  do {
    preempt_disable();
    local_irq_enable();
    __schedule(true);
    local_irq_disable();
    sched_preempt_enable_no_resched();
  } while (need_resched());
......
}

preempt_schedule_irp 调用 __schedule 进行调度,还是满足进程调度第一定律的。

3.2 总结

在这里插入图片描述

参考:
Linux 如何管理和度量时间
趣谈 Linux 操作系统

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值