Linux进程调度

背景

进程主要分为两部分:

1)进程管理,见Linux进程管理
2)进程调度,见Linux进程调度

进程调度程序,也称调度程序,是内核的组成部分,负责选择下一个要运行的进程。

意义
只有合理的调度,系统资源才能最大限度地发挥作用,多进程才会有并发执行效果。

[======]

调度策略

调度程序原则

最大限度利用处理器时间,只要有可执行的进程,那么总会有进程正在执行。只要进程数目比处理器个数多,就有进程在指定时刻不能执行,处于等待执行状态(就绪态)。在一组可运行状态的进程中,选择一个来执行,是调度程序所需完成的基本工作。

相关概念

多任务OS指能同时并发交互执行的多个进程的OS,分为两类:非抢占式多任务(cooperative multitasking),抢占式多任务(preemptive multitasking)。
1)在抢占式多任务模式下,由调度程序来决定什么时候停止一个进程的运行,以便其他进程能得到执行机会。该强制的挂起动作叫抢占(preemption)。进程在被抢占前能运行的时间是预先设置好的,叫进程的时间片(timeslice)。时间片实际上是分配给每个可运行进程的处理器时间段。有效管理时间片能使调度程序从系统全局角度做出调度决定,避免个别进程独占系统资源。
Linux调度程序采用动态方法计算时间片。

2)在非抢占多任务模式下,除非进程主动停止,否则一直运行。进程主动挂起OS称为让步(yielding)。缺点:调度程序无法对每个进程该执行多长时间做出统一规定,进程独占的处理器时间可能超出用户预料。
Linux调度程序采用抢占式的多任务。

Linux 2.5内核,开始采用O(1)调度程序。

I/O消耗型和处理器消耗型的进程

进程可分为I/O消耗型、处理器消耗型。I/O消耗型:进程大部分时间用来提交或等待I/O请求,进程经常阻塞在I/O操作,如键盘I/O、磁盘I/O、网络I/O。
处理器消耗型:进程把时间大多用在执行代码上。除非被抢占,否则通常都一直不停运行,因为没有太多I/O需求。

调度策略通常要在2个矛盾的目标间找平衡:进程响应迅速(响应时间短),最大系统利用率(高吞吐量)。为满足上述需求,调度程序通常采用一套非常复杂的算法来决定最值得运行的进程投入运行,但往往不保证低优先级进程被公平对待。因为交互式程序都是I/O消耗型,所以调度程序向这种进程倾斜会缩短系统响应时间。
Linux为保证交互式应用,对进程的响应做了优化,更倾向于优先调度I/O消耗型进程。

进程的优先级

调度算法最基本的一类是基于优先级的调度。根据进程的价值和其对处理器时间的需求,来对进程分级的想法。优先级高的进程先运行,低的后运行,相同的按轮转方式调度(一个接一个,重复运行)。调度程序总是选择时间片未用尽,而且优先级最高的进程运行。

Linux根据以上思想实现了一种基于动态优先级的调度方法。一开始,该方法先设置基本的优先级,然而它允许调度程序根据需要来加、减优先级。

Linux提供两组独立的优先级范围:
1)nice值,范围-20~+19,默认值0。nice值越大,优先级越低。nice值表示你正为其他进程做好事(being nice)。
nice值小的进程先运行;nice值也用来决定分配给进程的时间片的长短。

2)实时优先级,值可配置,默认范围0~99。
任何实时进程的优先级高于普通进程。Linux提供对POSIX实时优先级的支持。

时间片

时间片(timeslice)是一个数值,也称量子(quantum)或处理器片(processor slice),表明进程在被抢占前能持续运行的时间。调度策略必须规定一个默认的时间片
时间片过长 => 系统交互响应变差;时间片过短 => 明显增大进程切换带来的处理器耗时。

Linux调度程序提高交互式程序的优先级,为其提供较长的默认时间片。还能根据进程的优先级动态调整分配给它的时间片(长短)。从而保证优先级高的进程。

时间片范围:

进程不一定非要一次性用完所有时间片,可以分几次使用,从而保证尽可能长时间处于可运行状态。如,拥有100ms的时间片的进程,可以重复调度,分5次每次20ms用完这些时间片。

当一个进程的时间片耗尽了,就认为到期了。没有时间片的进程不会再投入运行,除非等到其他所有的进程都耗尽了时间片(即时间片剩余时间0)。此时,所有进程的时间片都被重新计算。

进程抢占

什么时候发生进程抢占?
当一个进程进入TASK_RUNNING,内核会检查其优先级是否高于当前正在执行进程。如果是,调度重新会被唤醒,抢占当前正在运行的进程并运行新的可运行进程。此外,当一个进程的时间片变为0时,会被抢占,调度程序被唤醒以选择一个新进程。

Linux调度算法

调度程序定义于kernel/sched.c。25.内核重写了调度程序,实现了下列目标:

  • 充分实现O(1)调度。不管有多少进程,新调度程序采用的每个算法都能在恒定时间内完成。
  • 全面实现SMP的可扩展性。每个处理器都拥有自己的锁和可执行队列。
  • 强化SMP的亲和力。尽量将相关一组任务分配给一个CPU进行连续的执行。只有在需要平衡任务队列的大小时,才在CPU之间移动进程。
  • 加强交互性能。即使在系统处于相当负载的情况下,也能保证系统的响应,并立即调度交互式进程。
  • 保证公平。在合理设定的时间范围内,没有进程会处于饥饿状态。同样,也没有进程能显失公平地得到大量时间片。
  • 虽然最常见的优化情况是,系统中只有1~2个可运行进程,但优化也完全有能力扩展到具体多处理器且每个处理器运行多个进程的系统中。

可执行队列

调度程序中最基本的数据结构:运行队列(runqueue),定义于kernel/sched.c。由runqueue结构表示,是给定处理器上可执行进程的链表,每个处理器一个。每个可投入运行的进程都唯一地属于一个可执行队列。
此外,可执行队列中还包含每个处理器的调度信息。

runqueue结构体定义:

/* 运行队列, 每个处理器一个这样的链表 */
struct runqueue {
    spinlock_t lock;                            /* 保护运行队列的自旋锁 */
    unsigned long nr_running;                   /* 可运行任务数目 */
    unsigned long nr_switches;                  /* 上下文切换数目 */
    unsigned long expired_timestamp;            /* 队列最后被换出时间 */
    unsigned long nr_uninterruptible;           /* 处于不可中断睡眠状态的任务数目 */
    unsinged long long timestamp_last_tick;     /* 最后一个调度程序的节拍 */
    struct task_struct *curr;                   /* 当前运行任务 */
    struct task_struct *idle;                   /* 该处理器的空任务 */
    struct mm_struct *prev_mm;                  /* 最后运行任务的mm_struct结构体 */
    struct prio_array *active;                  /* 活动优先级队列 */
    struct prio_array *expired;                 /* 超时优先级队列 */
    struct prio_array arrays[2];                /* 实际优先级数组 */
    struct task_struct *migration_thread;       /* 移出线程 */
    struct list_head *migration_queue;          /* 移出队列 */
    atomic_t nr_iowait;                         /* 等待I/O操作的任务数目 */
};

可执行队列是调度程序的核心数据结构体,有一组宏定义用于获取与给定处理器或进程相关的可执行队列。

  • cpu_rq(processor)宏:返回给定处理器可执行队列的指针。
  • this_rq()宏:返回当前处理器的可执行队列。
  • task_rq(task)宏:返回给定任务所在的队列指针。

对可执行队列进行操作以前,应先锁住它。常见情况是,发生在你向锁的运行队列上恰好有一个特定的任务正在运行。可以用task_rq_lock()和task_rq_unlock():

struct runqueue *rq;
unsigned long flags;
rq = task_rq_lock(task, &flags); /* 锁住运行队列task */
/* 对任务队列rq进行操作 */
task_rq_unlock(rq, &flags);       /* 解锁运行队列 */

可以用this_rq_lock()锁住当前的可执行队列,用rq_unlock(struct runqueue* rq)释放给定队列上的锁。

struct runqueue *rq;

rq = this_rq_lock();      /* 锁住运行队列 */
/* 操作这个进程的当前运行队列rq */
rq_unlock(rq);                          /* 解锁运行队列 */

为避免死锁,锁住多个运行队列的代码必须总是按同样顺序获取这些锁:按可执行地址队列地址从低向高的顺序。例如:

/* 锁定 ... */
if (rq1 == rq2)
    spinlock(&rq1->lock);
else {
    if (rq1 < rq2) { /* 先对地址低的运行队列加锁 */
        spin_lock(&rq1->lock);
        spin_lock(&rq2->lock);
    }
    else {
        spin_lock(&rq2->lock);
        spin_lock(&rq1->lock);
    }
}

/* 操作2个运行队列rq1, rq2 ... */

/* 释放锁... */
spin_unlock(&rq1->lock);
if (rq1 != rq2)
    spin_unlock(&rq2->lock);

可通过double_rq_lock(), double_rq_unlock()自动完成,以简化步骤:

double_rq_lock(rq1, rq2);

/* 操作两个运行队列 ... */

double_rq_unlock(rq1, rq2);

嵌套锁必须以相同顺序操作。

自旋锁(spinlock_t)用于防止多个任务同时对可执行队列进行操作。CPU空转,一直等待锁,没有上下文切换,比较适合锁被短时间持有,且现场不希望在重新调度上花费太多成本的应用场景,如中断处理程序。

优先级数组

每个运行队列都有两个优先级数组,一个活跃的和一个过期的。优先级数组在kernel/sched.c中被定义,是prio_array类型结构体。优先级数组提供O(1)算法复杂度。

优先级数组拥有一个优先级位图,当需要查找当前系统内拥有最高优先级的可执行进程时,它可以帮助提高效率。

/* O(1)算法时间复杂度, 帮助查找下一个(优先级最高的)就绪进程 */

struct prio_array { /* 优先级数组 */
    int nr_active;  /* 可执行进程的任务数目 */
    unsigned long bitmap[BITMAP_SIZE]; /* 优先级位图 */
    struct lsit_head queue[MAX_PRIO];  /* 优先级队列 */
};

MAX_PRIO 定义了系统拥有的优先级个数,默认值140。每个优先级都有一个struct list_head结构体。BITMAP_SIZE是优先级位图数组的大小,类型unsigned long长整型(32bit),每个bit位代表一个优先级,共用到5个长整型(合计160bit,20bit保留)。

每个优先级数组都要包含一个这样的位图成员,至少为每个优先级准备一位。一开始所有位被置为0。当某个拥有一定优先级的进程开始准备执行时(TASK_RUNNING),位图中相应的位就会被置为1。如,如果一个优先级7的进程变成可执行,低7位就被置为1。这样,查找系统中最高优先级就变成了查找位图中被设置的第一个位。因为优先级个数是定值,所以查找时间恒定,不受系统有多少个可执行进程影响。
此外,Linux对它支持的每种体系结构都提供了对应的快速查找算法,以保证对位图的快速查找,提供该功能的函数是sched_find_first_bit()。很多体系结构提供find-first-set指令,对指定的字操作。在这些系统上,找到第一个要设置的位所划分的时间之多是执行这条指令的两倍,时间很小。

每个优先级数组还包含一个叫struct list_head的队列,其中每个元素都是一个struct list_head类型的队列。每个链表与一个给定的优先级相对应。事实上,每个链表都包含该处理器队列上相应优先级的全部可运行进程,所以要找到下一个要运行的任务非常简单。

重新计算时间片

老版Linux
在所有进程时间片用完时,采用一种显式的办法重新计算每个进程的时间片。典型实现是循环访问每个进程:

for (系统中每个任务) {
    重新计算优先级
    重新计算时间片
}

在决定新时间片长短时,会用到进程的优先级及其他属性。但存在弊端:

  • 可能很耗时。最坏情况,n个进程的系统复杂度O(n)。
  • 重算时必须靠锁的形式来保护任务队列和每个进程描述符(task_struct)。加剧锁的争用。
  • 重新计算时间片的时机不确定,会给时间确定性要求很高的实时程序带来麻烦。
  • 实现很粗糙。

新版Linux(2.6以后)
调度程序减少了对循环的依赖,取而代之的是它位每个处理器维护两个优先级数组:活动数组,过期数组。
活动数组内可执行队列上的进程都还有时间片剩余;过期数组内的可执行队列上的进程都耗尽了时间片。当一个进程的时间片耗尽时,它会被移至它会被移至过期数组,但在此之前,时间片已经重新计算好了。重新计算时间片现在很简单,只要在活动和过期数组之间来回切换即可,通过交互指针实现交换时间。

动作由schedule()完成:

struct prio_array* arrary = rq->active; /* 活动数组 */
if (!arrary->nr_active) { /* 可执行的任务数为0 */
    rq->active = rq->expired;
    rq->expired = array;
}

schedule()

选定下一个进程并切换到它去执行,是通过schedule()实现。当内核代码想休眠时,会直接调用该函数。另外,如果哪个进程被抢占,那么该函数也会被唤起执行。schedule()独立于每个处理器运行,每个CPU都要对下一次该运行哪个进程做出判断。

下面代码判断谁是优先级最高的进程,即下一个要调度的进程:

struct task_struct *prev, *next;
struct list_head *queue;
struct prio_array *array;
int idx;

prev = current;
array = rq->active; /* 活动的优先级数组 */
idx = sched_find_first_bit(array->bitmap); /* 优先级位图数组中第一个被设置的位(索引), 对应优先级最高可执行进程 */
queue = array->queue + idx; /* 找到指定优先级的可运行任务链表 */
next = list_entry(queue->next, struct task_struct, run_list); /* 找到该链表的头一个进程 */

首先,要在活动优先级数组中找到第一个被设置的位,该位对应着优先级最高的可执行进程。
然后,调度重新选择这个级别链表里的头一个进程。

计算优先级和时间片

之前已经提及策略:如何利用优先级和时间片来影响调度程序做出决定;I/O消耗型和CPU消耗型进程,为什么提高I/O消耗型进程的优先级有好处。
这里介绍实际代码如何实现这些设计:

进程有一个初始优先级,叫nice值,变化范围-20~19,默认10。19优先级最底,-20最高。进程task_struct.static_prio存放该值。起名静态优先级(static priority),是因为它从一开始由用户指定,不能改变。而调度程序要用到的动态优先级存放在prio域里。动态优先级通过一个关于静态优先级的和进程交互性的函数关系计算而来。

effective_prio()函数可以返回一个进程的动态优先级。该函数以nice值为基数,再加上-5到+5之间的进程交互性的奖励或罚分。举例来说,一个交互性很强的进程,即时nice值10,其动态优先级最终也可能得到5。相反,一个温和的处理器吞噬者,虽然本来nice值一样10,最后的动态优先级可能12。交互性不强不弱的进程:位于I/O消耗型与处理器消耗型进程之间,不会得到优先级的奖励,同样不会被罚分,所以它的动态优先级和它的nice值相等。

调度程序如何了解一个进程的交互性强不强?
它通过一些推断,主要是进程的休眠时间长短。如果一个进程的大部分时间都在休眠,那么它是I/O消耗型的;如果一个进程执行时间比休眠时间长,那它是处理器消耗型的。

为了支持这种推断机制,Linux记录一个进程用于休眠和用于执行的时间。该值存放在task_struct.sleep_avg中,范围0~MAX_SLEEP_AVG,默认值10ms。当一个进程从休眠状态恢复到执行状态时,sleep_avg会根据它的休眠时间长短而增长,直到达到MAX_SLEEP_AVG为止。相反,进程每运行一个时钟节拍,sleep_avg就做相应的递减,到0为止。

这种推断不仅基于休眠时间有多次,而且运行时间长短也要被计算。因此,尽管一个进程休眠了不少时间,但它如果总是把自己的时间片用光,那么它就不会得到大额的优先级奖励。也就是说,这种推断机制不仅会奖励交互性强的进程,还会惩罚处理器耗费最大的进程,且不会滥用奖惩手段。如果一个进程发生了变化,开始大量占用处理器时间,那么它很快失去曾经得到的优先级提升。由于奖励和罚分都加在作为基数的nice值上,所以用户还是可以通过改变nice值来对调度程序施加影响。

另一方面,重新计算时间片相对简单。它只要以静态优先级为基础即可。在一个进程创建时,新建的子进程和父进程均分父进程剩余的进程时间片。这样的分配很公平而且防止用户通过不断创建新进程来赚取时间片。task_timeslice()为给定任务返回一个新的时间片。时间片的计算只需要把优先级按比例缩放,使其符合时间片的数值范围要求即可。进程的优先级越高,它每次执行得到的时间片就越长。优先级最高的进程(nice值-20)能获得的最大时间片长度(MAX_TIMESLICE)是800ms。而优先级最底的进程(nice值+19)获得的最短时间片长度(MIN_TIMESLICE)是5ms或一个时钟嘀嗒。默认优先级的进程得到的时间片长度100ms。

调度程序时间片:

进程类型 nice值 时间片长度
初始创建的进程 父进程的值 父进程的一半
优先级最底的进程 +19 5ms(MIN_TIMESLICE)
默认优先级的进程 0 100ms(DEF_TIMESLICE)
优先级最高的进程 -20 800ms(MAX_TIMESLICE)

调度程序还提供另一种机制支持交互进程:如果一个进程的交互性非常强,那么当它的时间片用完后,它会被再放置到活动数组而不是过期数组中。之前提到过,重新计算时间片是通过活动数组与过期数组之间的切换来进行的。一般进程在用尽时间片后,都会被移至过期数组,当活动数组中没有剩余进程时,这两个数组就会被交换;获得数组编程过期数组,过期数组替代活动数组。这种操作提供了时间复杂度O(1)的时间片重新计算。但这种交换发生之前,交互性很强的一个进程有可能已经处于过期数组中了,当它需要交互的时候,确无法执行,因为必须等到数组交换发生为止才可执行。将交互式的进程重新插入到活动数组可以避免这种问题。但该进程不会被立即执行,它会和优先级相同的进程轮流着被调度和执行。该逻辑在scheduler_tick()中实现,该函数会被定时器中断调用:

struct task_struct *task;
struct runqueue *rq;

task =  current;
rq = this_rq();

if (!--task->time_slice) { /* 减少时间片值, 再判断是否为0 */
    if (!TASK_INTERACTIVE(task) || EXPIRED_STARVING(rq)) /* 判断是否为交互型进程, 或者饥饿状态 */
        enqueue_task(task, rq->expired); /* 插入过期数组 */
    else
        enqueue_task(task, rq->active);  /* 插入活动数组 */
}

首先,这段代码减小进程时间片的值,再看它是否为0。如果是,说明进程的时间片耗尽,需要把它插入到一个数组中,所以该代码先通过TASK_INTERACTIVE() 宏查看该进程是不是交互型的进程。该宏主要基于进程的nice值来判定它是不是一个“交互性十足”的进程。nice值越小(优先级越高),说明进程交互性越高。

接着,EXPIRED_STARVING()宏负责检查过期数组内的进程是否处于饥饿状态----是否已经有相对较长的时间没有发生数组切换了。如果最近一直没有发生切换,那么再把当前的进程放置到活动数组会进一步拖延切换过期数组内的进程会越来越饿。只要不发生这种情况,进程就会被重新放置在获得数组里;否则,进程会被放入过期数组里,也是最普通的一种操作。

睡眠和唤醒

休眠(被阻塞)的进程处于一种特殊的不可执行状态。如果没有这种特殊状态,调度程序可能选出一个本不愿意被执行的进程,更糟糕的是,休眠必须以轮询方式实现。进程休眠有各种原因,但肯定都是为了等待一些事件。

等待导致休眠的可能事件:
事件可能是一段时间、从文件I/O读取更多数据,或某个硬件事件。一个进程还有可能在尝试获取一个已被占用的内核信号量时被迫进入休眠。休眠的一个常见原因是文件I/O,如进程对文件执行read操作,需要从磁盘读取数据。还有,进程从键盘读取数据。

休眠、唤醒操作:
休眠,对应内核的操作都相同:1)修改休眠状态;2)可执行队列(移出) => 等待队列(移入);3)调用schedule()调度下一个进程。
唤醒,过程刚好相反:1)修改可执行状态;2)等待队列 => 可执行队列。

休眠状态:
休眠有两种相关的进程状态:TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE。唯一区别是:TASK_UNINTERRUPTIBLE状态进程会忽略信号,而TASK_UNINTERRUPTIBLE状态进程如果接收到一个信号会被提前唤醒并响应该信号。两种状态的进程位于同一个等待队列,等待某些事件,不能够运行。

休眠通过等待队列进行处理。等待队列是由等待某些事件发生的进程组成的简单链表。内核用wake_queue_head_t来代表等待队列,等待队列可通过DECLARE_WAITQUEUE()静态创建,也可以由init_waitqueue_head()动态创建。进程把自己放入等待队列并设置成不可执行状态。当与等待队列相关的事件发生时,队列上的进程会被唤醒。为避免产生竞争条件,休眠和唤醒的实现不能有纰漏。

/* 'q' 是我们希望睡眠的等待队列 */
DECLARE_WAITQUEUE(wait, current);

add_wait_queue(q, &wait);
while (!condition) { /* 'condition' 是我们在等待的事件, 防止虚假唤醒. wake_up在其他地方调用 */
    set_current_state(TASK_INTERRUPTIBLE); /* for TASK_UNINTERRUPTIBLE */
    if (signal_pending(current))
        /* 处理信号 */
        schedule();
}

set_current_state(TASK_RUNNING);
remove_wait_queue(q, &wait);

进程通过执行下面几个步骤将自己加入一个等待队列:
1)调用DECLARE_WAITQUEUE() 创建一个等待队列的项。
2)调用add_wait_queue()把自己加入到队列中。该队列会在进程等待的条件满足时唤醒它,当然我们必须在其他地方撰写相关代码,在事件发生时,对等待队列执行wake_up()操作。
3)将进程的状态变更为TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE。
4)如果状态被置为TASK_INTERRUPTIBLE,则信号唤醒进程。这就是所谓伪唤醒(虚假唤醒,唤醒不是因为事件的发生),因此检查并处理信号)。
5)检查条件是否为真:如果是,就没必要休眠。如果不是,调用schedule(),调度下一个进程。
6)当进程被唤醒时,它会再次检查条件是否为真。如果是,它就退出循环;如果不是,再次调用schedule()并一直重复这步操作。
7)当条件满足后,进程将自己设置为TASK_RUNNING,并调用remove_wait_queue()把自己移出等待队列。

如果在进程开始休眠前,条件已经达成,那么循环会退出,进程不存在错误进入休眠的倾向。唤醒操作通过wake_up()进行,会唤醒指定的等待队列上所有进程。它调用函数try_to_wake_up(),该函数负责将进程设置为TASK_RUNNING状态,调用activate_task()将此进程放入可执行队列,如果被唤醒的进程优先级比当前正在执行的进程优先级高,还要设置need_resched标志。通常哪段代码促使等待条件达成,它就要负责随后调用wake_up()。

负载平衡程序

前面提到过,Linux的调度程序为对称多处理系统的每个处理器准备了单独的可执行队列和锁。i.e. 每个处理器都有一个自己的进程链表,而它只对属于自己的这些进程进行调度。处于效率考虑,整个调度系统从每个处理器来看都是独立的。那么,可执行队列间出现负载不均衡的情况时,比如一个处理器队列有5个进程,另一个只有1个进程,那么该怎么办?
这些问题由负载平衡程序解决,它负责保证可执行队列间的负载处于均衡状态,理想目标:每个队列上的进程数目应该相等。目标难以企及,但负载平衡程序做得已经非常接近了。

负载平衡程序由load_balance()实现(位于<kernel/sched.c>),它有两种调用方法:
1)在schedule()执行时,只要当前的可执行队列欸空,它就会被调用。
2)被定时器调用,系统空闲时,每隔1ms调用一次或其他情况下每隔200ms调用一次。
注意:单处理器系统中,load_balance()不会被调用,也不会被编译进内核。因为只有一个可执行队列。

负载平衡程序调用时,需要锁住当前处理器的可执行队列并且屏蔽中断,以避免可执行队列被并发地访问。在执行schedule()时调用load_balance(),这种情况下,要做的工作很明显,因为当前的可执行队列为空,所以要找到一些进程并且插入到这个队列里,好处显而易见。然而,在定时器中调用load_balance()时,要做工作不那么明显:它需要解决所有运行队列间所有的失衡,使它们大致持平。

load_balance()又大又复杂,但操作步骤理解起来不难:

1)首先,load_balance()调用find_busiest_queue(),找到最繁忙的可执行队列。i.e. 该队列中的进程数目最多。如果没有哪个可执行队列中进程的数目比当前队列中的数目多25%(或以上),find_busiest_queue()返回NULL,并且load_balance也返回;否则,最繁忙的可执行队列就被返回。

2)其次,load_balance() 从最繁忙的运行队列中选择一个优先级数组以便抽取进程,最好是过期数组,因为那里面的进程已经有相对较长一段时间没有运行,很可能不在处理器的高速缓存中;如果过期数组为空,那只能选活动数组。

3)接着,load_balance()找到含有进程并且优先级最高(值最小)的链表,因为把优先级高的进程平均分散开来才是最重要的。

4)分析找到的所有这些优先级相同的进程,选择一个不是正在执行,也不会因为处理器相关性而不可移动,并且不在高速缓存中的进程。如果有进程满足这些条件,调用pull_task()将其从最繁忙的队列中抽取到当前队列。

5)只要可执行队列之间仍然不平衡,就重复上面两个步骤,继续从繁忙的队列中抽取进程到当前队列。这最终会消除不平衡,此时解除对当前运行队列进行的锁定,从load_balance()返回。

抢占和上下文切换

上下文切换:从一个可执行进程切换到另一个可执行进程,由context_switch()负载处理(位于<kernel/sched.c>)。

每当一个新的进程被选出来准备投入运行时,schedule()就会调用该函数。它完成两项基本工作:

  • 调用定义在<asm/mmu_contex.h>中的switch_mm(),函数负责把虚拟内存从上一个进程映射到新进程中。
  • 调用定义在<asm/system.h>中的switch_to(),函数负责从上一个进程的处理器状态切换到新进程的处理状态。这包括保存、恢复栈信息和寄存器信息。

内核必须知道在什么时候调用schedule()。内核提供一个need_resched标志来表明是否需要重新执行一次调度。当某个进程耗尽其时间片时,scheduler_tick()就会设置该标志;当一个优先级高的进程进入可执行的状态时,try_to_wake_up()也会设置该标志。

用于访问和操作need_resched标志的函数:

函数 目的
set_tsk_need_resched() 设置指定进程中的need_resched标志
clear_tsk_need_resched() 清除进程中的need_resched标志
need_resched() 检查need_resched标志的值;如果被设置就返回真,否则返回假

再返回用户空间以及从中断返回时,内核会检查need_resched标志。如果已被设置,内核会在继续执行前调用调度程序。

每个进程都包含一个need_resched标志,因为访问进程描述符内的数值比访问一个全局变量快,因为current宏速度很快并且描述符通常都在高速缓存中。
2.2以前内核中,该标志曾经是一个全局变量。
2.2~2.4内核中,它在task_struct中。
2.6内核中,它被移动到thread_info结构体里,用一个特别的标志变量中的一位来表示。

用户抢占

内核即将返回用户空间的时候,如果need_resched标志被设置,会导致schedule()被调用,此时就会发生用户抢占。在内核返回用户空间时,它知道自己是安全的,因为既然它可以继续去执行当前进程,那么它当然可以再去选择一个新的进程执行。所以,内核无论是在从中断处理程序,还是系统调用后返回,都会检查need_resched标志。如果它被设置了,那么,内核会选择一个其他(更合适的)进程投入运行。从中断处理程序或系统调用返回的代码都是跟体系结构相关的,在entry.S(此文件不仅包含内核入口部分的程序,内核退出部分的相关代码也在其中)文件中通过汇编语言来实现。

简而言之,用户抢占在以下情况时产生:

  • 从系统调用返回用户空间。
  • 从中断处理程序返回用户空间。
    即,内核空间返回用户空间时。

内核抢占

2.6内核引入抢占能力,只要重新调度是安全的,那么内核就可以在任何时间抢占正在执行的任务。

那么,什么时候重新调度才是安全的呢?
只要没有持有锁,内核就可以进行抢占。锁是非抢占区域的标志。由于内核支持SMP,所以如果没有持有锁,那么正在执行代码就是可重新导入的,也就是可以抢占的。

如何确保抢占是安全的?
为了支持抢占,为了每个进程的thread_info引入preempt_count计数器:初值0,每当使用锁的时+1,释放锁时-1。当值为0时,内核就可以执行抢占。
从中断返回内核空间时,内核会检查need_resched和preempt_count的值。如果need_resched被设置,并且preempt_count为0的话,这说明有个更为重要的任务需要执行并且可以安全地抢占,此时,调度程序就会被调用。
如果preempt_count不为0,说明当前任务持有锁,所以抢占是不安全的。这时,就会像通常那样直接从中断返回当前执行进程。
如果当前进程持有的所有锁都被释放了,那么preempt_count就会为0。此时,释放锁的代码会检查need_resched是否被设置。如果是,就会调用调度程序。
当然,有些内核代码需要允许或者禁止内核抢占。

x86中,thread_info结构定义:

#include <asm/thread_info.h>

struct thread_info {
    struct task_struct *task;
    struct exec_domain *exec_domain;
    unsigned long flags;
    unsigned long status;
    __u32 cpu;
    __s32 preempt_count; /* 可抢占计数器, > 0 => 不可抢占(non-preemptable), 0 => preemptable, < 0 => bug */
    mm_segment_t addr_limit;
    struct restart_block restart_block;
    unsigned long previous_esp;
    _u8 supervisor_stack[0];
};

什么时候发生内核抢占?
如果内核中的进程被阻塞了,或它显式地调用了schedule(),内核抢占也会显式地发生。这种形式的内核抢占从来都是受支持的,因为根本无需额外的逻辑来保证内核可以安全地被抢占。

具体来说,内核抢占会发生在:

  • 当从中断处理程序返回内核空间之前。
  • 当内核代码再次具有可抢占性的时候。
  • 如果内核中的任务显式的调用schedule()。
  • 如果内核中的任务阻塞(同样也会导致调用schedule())。

实时

Linux提供两种实时调度策略:SCHED_FIFO,SCHED_RR。普通的、非实时调度策略是SCHED_NORMAL。
SCHED_FIFO:
SCHED_FIFO实现了一种简单的、先入先出的调度算法,不使用时间片。SCHED_FIFO级的进程会比任何SCHED_NORMAL级的进程都先得到调度。一旦一个SCHED_FIFO级进程处于可执行状态,就会一直执行,直到它自己阻塞或显式是否处理器为止;它不基于时间片,可以一直执行下去。只有较高优先级的SCHED_FIFO或SCHED_RR任务才能抢占SCHED_FIFO任务。如果有2个或更多SCHED_FIFO级进程,它们会轮流执行,但是在它们愿意让出处理机时,会再次让出。只要有SCHED_FIFO级进程在执行,其他级别较低的进程就只能等待它结束后才有机会执行。

SCHED_RR:
SCHED_RR与SCHED_FIFO大体相同,只是SCHED_RR级进程在耗尽事先分配的时间后,不能再接着执行。i.e. SCHED_RR是带有时间片的SCHED_FIFO -- 一种实时轮流调度算法。当SCHED_RR任务耗尽它的时间片,在同一优先级的其他实时进程被轮流调度。时间片只用来重新调度同一优先级的进程。对于SCHED_FIFO进程,高优先级总是立即抢占低优先级;低优先级进程不能抢占SCHED_RR进程,即使时间片耗尽。

这两种实时算法都是静态优先级,内核不为实时进程计算动态优先级,这能保证给定优先级别的实时进程总能抢占优先级比它低的进程。

Linux实时调度算法提供了一种软实时工作方式。软实时含义:内核调度进程,尽力使进程在它的限定时间到来前运行,但内核不保证总能满足这些进程的要求。相反,硬实时系统保证在一定条件下,可以满足任何调度的要求。Linux对于实时任务的调度不做任何保证。

实时优先级范围0MAX_RT_PRIO-1,MAX_RT_PRIO默认值100,因此默认的实时优先级范围099。SCHED_NORMAL级进程的nice值共享了该取值空间:范围MAX_RT_PRIO ~ MAX_RT_PRIO+40。i.e. nice值[-20, +19]对应[100, 139]实时优先级范围。

[======]

与调度相关的系统调用

Linux提供了一族系统调用,用于管理与调度程序相关的参数。这些系统调用可以用来操作和处理进程优先级、调度策略及处理器,同时还提供显式将处理器交给其他进程的机制。

系统调用 描述
nice() 设置进程的nice值
sched_setscheduler() 设置进程的调度策略
sched_getscheduler() 获取进程的调度策略
sched_setparam() 设置进程的实时优先级
sched_getparam() 获取进程的实时优先级
sched_get_priority_max() 获取实时优先级的最大值
sched_get_priority_min() 获取实时优先级的最小值
sched_rr_get_interval() 获取进程的时间片值
sched_setaffinity() 设置进程的处理器的亲和力
sched_getaffinity() 获取进程的处理器的亲和力
sched_yield() 暂时让出处理器

与调度策略和优先级相关的系统调用

sched_setscheduler(),sched_getscheduler()分别用于设置和获取进程的调度策略和实时优先级。

与其他的系统调用相似,它们的实现也是由许多参数检查、初始化和清理构成的。其实最重要的工作在于读取或改写进程task_struct的policy和rt_priority的值。

sched_setparam()和sched_getparam()分别用于设置和获取进程的实时优先级。这两个系统调用获取封装在sche_param结构中的rt_priority。
sched_get_priority_max()和sched_get_priority_min()分别用于返回给定调度策略的最大和最小优先级。实时调度策略的最大优先级MAX_USER_RT_PRIO-1,最小优先级1。

对于一个普通进程,nice()函数可以将给定进程的静态优先级增加一个给定的量。只有超级用户才能在调用它时,使用负值,从而提高进程的优先级。nice()函数会调用内核的set_user_nice(),设置进程的task_struct的static_prio和prio值。

与处理器绑定有关的系统调用

sched_setaffinity/sched_getaffinity可哟关于,设置、查询强制的处理器绑定(processor affinity)。调度程序尽力通过一种软的或自然的亲和性,试图使进程尽量在同一个处理器上运行,但也允许用户强制指定进程在指定处理器上运行。这种强制的亲和性保存在进程task_struct.cpus_allowed位掩码标志中。该标志的每一位对应一个系统可用的处理器。默认情况下,所有的位都被设置,进程可以在系统中所有可用的处理器上执行。用户可以通过sched_setaffinity()设置一个不同的位掩码,调用sched_getaffinity()则返回当前的cpus_allowed位掩码。

内核提供的强制处理器绑定的方法很简单:
1)当处理进行第一次创建,它继承了其父进程的相关掩码(cpus_allowed):父进程运行在指定处理器,子进程也运行在相应处理器。
2)当处理器绑定关系改变时(sched_setaffinity()),内核会采用”移植线程“把任务推到合法的处理器上。
3)加载平衡器(load_balance())只把任务拉到允许的处理上。因此,进程只允许在指定处理器上,对处理器的指定是由该进程描述符的cpus_allowed设置的。

放弃处理器时间

sched_yield()系统调用,让进程显式地将处理器时间让给其他等待执行进程,它将进程从活动队列中(因为进程正在执行)=> 过期队列。

特殊情况:实时进程(SCHED_FIFO/SCHED_RR进程)不会过期,调用sched_yield()不会移入过期队列,而是移到活动队列末尾。

内核代码直接调用yield(),先确定给给定进程确实处于可执行状态,然后再调用sched_yield();用户空间应用程序直接调用sched_yield()。

[======]

调度程序小结

满足进程调度的各种需要不容易,很难找到”一刀切“算法,需要既适合众多可运行进程,又具有可伸缩性,还能在调度周期和吞吐量之间求得平衡,同时还满足各种负载的需求。

Linux内核的调度程序尽量满足各方面需求,并以较完善的可伸缩性和外在魅力为所有的情况提供最佳解决方案。

本章考察了进程调度所遵循的基本原理、具体实现、调度算法,以及目前Linux内核所使用的接口。

posted @ 2022-06-06 16:52  明明1109  阅读(1516)  评论(0编辑  收藏  举报