传统 Unix 操作系统的调度必须实现几个冲突的目标:进程响应时间尽可能快,后台作业的吞吐量尽可能高,尽可能避免进程的饥饿现象,低优先级和高优先级的进程需要尽可能调和等等。
调度策略:决定什么时候以怎样的方式为一个新进程选择运行的规则。
Linux 的调度基于分时技术。
调度策略也根据进程的优先级对它们进行分类。Linux 中,进程的优先级是动态的。
进程分类方式一:
进程分类方式二:
一个批处理进程可能是 I/O 受限的(如数据库服务器),或 CPU 受限的(如图像绘制程序)。
Linux 中,调度程序可确认实时程序,通过基于进程过去行为的启发式算法(平均睡眠时间)区分交互式程序和批处理程序。
如果进程进入 TASK_RUNNING 状态,内核检查到它的动态优先级大于当前正在运行进程的优先级,current 的执行被中断,调度程序选择另一个进程运行(通常为刚刚变为可运行的进程)。
进程当前的时间片到期也可以被抢占。此时,当前进程 thread_info 结构中的 TIF_NEED_RESCHED 标志被设置,以便时钟中断处理程序终止时调度程序被调用。
被抢占的进程没有被挂起,因为还处于 TASK_RUNNING 状态,只不过不再使用 CPU。
如果平均时间片太短,进程切换引起的系统额外开销就变得非常高。
如果平均时间片太长,进程看起来就不再是并发执行,也会降低系统的响应能力。
Linux 单凭经验的方法,即选择尽可能长、同时能保持良好响应时机的一个时间片。
调度算法Linux 2.6 的调度算法特点:
swapper 进程的 PID 为 0,只有在没有其他进程执行时运行。
每个 Linux 进程总是按照如下调度类型被调度:
普通进程和实时进程的调度算法区别很大。
每个普通进程都有静态优先级:100(最高)~ 139(最低)。
新进程继承父进程的静态优先级,但可通过将某些“nice 值”传递给 nice() 和 setpriority() 改变。
基本时间片
动态优先级和平均睡眠时间
动态优先级 = max(100, min( 静态优先级 - bonus + 5, 139))
bonus 的值与进程的平均睡眠时间相关。范围 0~10,小于 5 表示降低动态优先级以示惩罚,大于 5 表示增加动态优先级以示奖赏。
粗略地将,平均睡眠时间是进程在睡眠状态所消耗的平均纳秒数,不会大于 1s。
平均睡眠时间也被调度程序用来判断一个给定进程是交互进程还是批处理进程。如果一个进程满足下式,则被看作交互式进程:
活动和过期进程
当一个较高优先级的进程用完其时间片,应该被还没有用完时间片的低优先级进程取代,为此,调度程序维持两个不相交的可运行进程的集合。
活动进程:还没有用完时间片的进程,允许运行。
过期进程:用完了时间片,被禁止运行,直到所有活动进程过期。
总体方案要稍复杂一些:
每个实时进程都有实时优先级,范围 1(最高)~ 99(最低)。用户可通过 sched_setparam() 和 sched_setscheduler() 改变进程的实时优先级。
调度程序总是让优先级高的进程运行。实时进程总是被当成活动进程。
只有如下事情发生,实时进程才会被另一个进程取代:
进程链表链接所有的进程描述符。
运行队列链表链接所有的可运行进程(处于 TASK_RUNNING 状态的进程)的进程描述符,swapper 进程(idle 进程)除外。
runqueue 结构存放在 runqueues 每 CPU 变量中。宏 this_rq() 产生本地 CPU 运行队列的地址,宏 cpu_rq(n) 产生索引为 n 的 CPU 运行队列的地址。
runqueue:
- spinlock_t lock
- unsigned long nr_running
- unsigned long cpu_load
- unsigned long nr_switches
- unsigned long nr_uninterruptible
- unsigned long expired_timestamp
- unsigned long long timestamp_last_tick
- task_t * curr
- task_t * idle
- struct mm_struct * prev_mm
- prio_array_t * active 对应于包含活动进程的可运行进程的集合
- prio_array_t * expired 对应于包含过期进程的可运行进程的集合
- prio_array_t[2] arrays 每个元素表示一个可运行进程的集合
- int best_expired_prio
- atomic_t nr_iowait
- struct sd sched_domain*
- int active_balance
- int push_cpu
- task_t * migration_thread
- struct list_head migration_queue
arrays 中两个数据结构的作用会发生周期性的变化:活动进程突然变成过期进程,过期进程变为活动进程。调度程序简单地交换运行队列的 active 和 expired 字段的内容完成这种变化。
每个进程描述符都包含几个与调度相关的字段。
如:
新进程被创建时,cop_process() 调用 sched_fork() 用下述方法设置 current 父进程和 p 子进程的 time_slice 字段:
p->time_slice = (current->time_slice + 1) >> 1;
current->time_slice >> = 1;
将父进程剩余的节拍数被划分成两等份:一份给自己,另一份给子进程。避免因为创建过多子进程而占用太多时间片。
如果父进程的时间片只剩下一个时钟节拍,则 current->time_slice 置为 0。copy_process() 把 current-time_slice 重新置为 1,然后调用 scheduler_tick() 递减该字段。
copy_process() 也初始化子进程描述符中与进程调度相关的字段:
p->first_time_slice = 1; // 因为子进程没有用完它的时间片,所以设置为 1
p->timestamp = sched_clock(); // 返回被转换成纳秒的 64 位仅存去 TSC 的内容
维持当前最新的 time_slice 计数器。
更新实时进程的时间片
对于先进先出的实时进程,scheduler_tick() 什么也不做,因为 current 不可能被其他低优先级或等优先级的进程抢占,维持最新时间片计数器没有意义。
对于基于时间片轮转的实时进程,scheduler_tick() 递减其时间片计数器,如果时间片被用完,执行一系列操作以达到抢占当前进程的目的:
if(current->policy == SCHED_RR && !--current->time_slice){current->time_slice = task_timeslice(current); // task_timeslice() 根据进程的静态优先级返回相应的基本时间片current->first_time_slice = 0; // 该标志被 fork() 中的 copy_process() 设置,并在进程的第一个时间片刚一用完立即清 0set_tsk_need_resched(current); // 设置进程的 TIF_NEED_RESCHED 标志,强制调用 schedule() 函数,使 current 被另一个具有相同或更高优先级的实时进程取代list_del(¤t->run_list); // 基于时间片轮转:先把进程从运行队列的活动链表中删除list_add_tail(¤t->run_list, this_rq()->active->queue+current->prio); // 然后把进程重新插入到同一个活动链表的尾部
}
更新普通进程的时间片
scheduler_tick() 执行如下操作:
// 如果进程是一个非交互式进程,TASK_INTERACTIVE 宏产生值 0
// EXPIRED_STARVING 宏检查到运行队列中的第一个过期进程的等待时间已经超过 1000 个时钟节拍乘以运行队列中的可运行进程数加1,产生值 1
// 如果当前进程的静态优先级大于一个过期进程的静态优先级,EXPIRED_STARVING 宏也产生值 1
if(!TASK_INTERACTIVE(current) || EXPIRED_STARVING(this_rq()){ enqueue_task(current, this_rq()->expired); // 插入过期进程集合if(current->static_prio
}
elseenqueue_task(current, this_rq()->active); // 插入活动进程集合
// bonus = TIMESLICE_GRANULARIT 宏产生的 CPU 的数量 * 比例常量 的乘积
// 具有高静态优先级的交互式进程,其时间片被分成大小为 TIMESLICE_GRANULARITY 的几个片段,以使这些进程不会独占 CPU
if(TASK_INTERACTIVE(p) && !((task_timeslice(p) - p->time_slice) % TIMESLICE_GRANULARIT(p)) && (p->time_slice >= TIMESLICE_GRANULARITY(p)) && (p->array == rq->active)) {list_del(¤t->run_list);list_add_tail(¤t->run_list, this_rq()->active->queue+current->prio);set_tsk_need_resched(p);
}
通过将进程状态设置为 TASK_RUNNING,并把该进程插入本地 CPU 的运行队列来唤醒睡眠或停止的进程。
参数:
执行下列操作:
更新进程的平均睡眠时间 p->sleep_avg 和动态优先级 p->prio。
参数:
执行下述操作:
实现调度程序。从运行队列链表中找到一个进程,并随后将 CPU 分配给这个进程。schedule() 可由几个内核控制路径调用,可采取直接调用或延迟调用的方式。
直接调用
如果 current 进程未获得必需的资源而阻塞,就直接调用调度程序。要阻塞的内核路径执行下述步骤:
延迟调用
也可以把 current 进程的 TIF_NEED_RESCHED 标志置为 1,而以延迟方式调用调度程序。每次在恢复用户进程的执行之前会检查该标志。
延迟调用调度程序的例子:
进程切换前 schedule() 所执行的操作
next 变量指向被选中的进程,以取代 current 进程。
如果系统中没有优先级高于 current 进程的可运行进程,那么 next 与 current 相等,不发生进程切换。
禁用内核抢占,初始化一些局部变量:
need_resched:
preempt_disable();
prev = current;
rq = this_rq(); // 把与本地 CPU 对应的运行队列赋 rq
保证 prev 不占用大内核锁:
if(prev->lock_depth >= 0)up(&kernel_sem);
调用 sched_clock() 获取 TSC,将其值转换为纳秒,存放在 now 中。然后计算 prev 所用的 CPU 时间片长度:
now = sched_clock();
run_time = now - prev->timestamp;
if(run_time > 1000000000)run_time = 1000000000; // 限制在 1s
优待有较长平均睡眠时间的进程:
run_time /= (CURRENT_BONUS(pre) ? : 1); // CURRENT_BONUS 返回 0~10 之间的值,它与进程的平均睡眠时间成正比
寻找可运行进程前,必须关掉本地中断,并获得所要保护的运行队列的自旋锁:
spin_lock_irq(&rq->lock);
prev 可能是一个正在被终止的进程,通过检查 PF_EDAD 标志验证:
if(prev->flags & PF_DEAD)prev->state = EXIT_DEAD;
接下来检查 prev 的状态。
如果不是可运行状态,且没有在内核态被抢占,就从运行队列删除 prev 进程。
但是,如果它有非阻塞挂起信号,且状态为 TASK_INTERRUPTIBLE,将该进程的状态设置为 TASK_RUNNING,并插入运行队列,给 prev 一次被选中执行的机会:
if(prev->state != TASK_RUNNING && !(preempt_count() & PREEMPT_ACTIVE))
{if(prev->state == TASK_INTERRUPTIBLE && signal_pending(prev))prev->state = TASK_RUNNING;else{if(prev->state == TASK_UNINTERRUPTIBLE)rq->nr_uninterruptible++;deactivate_task(prev, rq);}
}
deactivate_task() 从运行队列中删除该进程:
rq->nr_running--;
dequeue_task(p, p->array);
p->array = NULL;
现在,检查运行队列中剩余的可运行进程数。
如果有可运行的进程,就调用 dependent_sleeper(),绝大多数情况下,该函数立即返回 0。
但是,如果内核支持超线程技术,如果被选中执行的进程优先级比已经在相同物理 CPU 的某个逻辑 CPU 上运行的兄弟进程低,则拒绝选择该进程,而执行 swapper 进程:
if(rq->nr_running)
{if(dependent_sleeper(smp_processor_id(), rq)){next = rq->idle;goto switch_tasks;}
}
如果运行队列中没有可运行的进程,则调用 idle_balance() 从另外一个运行队列迁移一些可运行进程过来。
如果没有迁移成功,当内核支持超线程技术时,wake_sleeping_dependent() 重新调度空闲 CPU 的可运行进程。
然而,在单处理器系统中,迁移失败时,将 swapper 进程作为 next 进程。
if(!rq->nr_running)
{idle_balance(smp_processor_id(), rq);if(!rq->nr_running){next = rq->idle;rq->expired_timestamp = 0;wake_sleeping_dependent(smp_processor_id(), rq);if(!rq->nr_running)goto switch_tasks;}
}
假设运行队列中有可运行进程,现在检查这些可运行进程中是否至少有一个进程是活动的。如果没有,则交换运行队列结构的 active 和 expired 字段。
array = rq->active;
if(!array->nr_active)
{rq->active = rq->expired;rq->expired = array;array = rq->active;rq->expired_timestamp = 0;rq->best_expired_prio = 140;
}
现在可在活动 prio_array_t 中搜索一个可运行的进程了。
首先搜索进程集合掩码的第一个非 0 位,其下标对应包含最佳运行进程的链表。
随后,返回该链表的第一个进程描述符:
idx = sched_find_first_bit(array->bitmap); // 基于 bsfl 汇编指令,返回 32 位数组中被设置为 1 的最低位的位下标
next = list_entry(array->queue[idx].next, task_t, run_list); // 存放将取代 prev 的进程描述符指针
检查 next->activated 字段,表示进程被唤醒时的状态。
如果 next 是一个普通进程,正在从 TASK_INTERRUPTIBLE 或 TASK_STOPPED 状态被唤醒,调度程序就把自从进程插入运行队列开始所经过的纳秒数加到进程的平均睡眠时间中。
if(next->prio >= 100 && next->activated > 0)
{unsigned long long delta = now - next->timestamp;if(next->activated == 1)delta = (delta * 38) / 128;array = next->array;dequeue_task(next, array);recalc_task_prio(next, next->timestamp + delta);enqueue_task(next, array);
}
next->activated = 0;
总结:禁止内核抢占,更新 prev 的运行时间,根据 prev 的状态进行相应处理,对运行队列处理以使获得 next,更新 next 的平均睡眠时间。
schedule() 进行进程切换时所执行的操作
next 进程已运行,内核将立刻访问 next 进程的 thread_info,其地址存放在 next 进程描述符接近顶部的位置。
switch_tasks:
prefetch(next); // 提示 CPU 把 next 进程的描述符第一部分字段装入硬件高速缓存
在替代 prev 前,调度程序应该完成一些管理的工作:
clear_tsk_need_resched(prev); // 清除 prev 的 TIF_NEED_RESCHED 标志
rcu_qsctr_inc(prev->thread_info->cpu); // CPU 正在经历静止状态
减少 prev 的平均睡眠时间,并把它补充给进程所使用的 CPU 时间片,随后更新进程的时间戳:
prev->sleep_avg -= run_time;
if((long)prev->sleep_avg <&#61; 0)prev->sleep_avg &#61; 0;
prev->timestamp &#61; prev->last_ran &#61; now;
prev 和 next 可能是同一个进程&#xff0c;这时函数不作进程切换&#xff1a;
if(prev &#61;&#61; next)
{spin_unlock_irq(&rq->lock);goto finish_schedule;
}
否则&#xff0c;进程切换&#xff1a;
next->timestamp &#61; now;
rq->nr_switches&#43;&#43;;
rq->curr &#61; next;
prev &#61; context_switch(rq, prev, next); // context_switch() 建立 next 的地址空间
进程描述符的 active_mm 字段指向进程所使用的内存描述符&#xff0c;而 mm 字段指向进程所拥有的内存描述符。
对于一般进程&#xff0c;这两个字段地址相同&#xff1b;
而内核线程没有自己的地址空间&#xff0c;mm 总是被置为 NULL。
context_switch() 确保&#xff0c;如果 next 是一个内核线程&#xff0c;使用 prev 所使用的地址空间&#xff1a;
if(!next->mm) // 内核线程
{next->active_mm &#61; prev->active_mm;atomic_inc(&prev->active_mm->mm_count);enter_lazy_tlb(prev->active_mm, next);
}
如果内核线程都有自己的地址空间&#xff0c;当调度程序选择一个新进程运行时&#xff0c;需改变页表&#xff0c;因为内核线程仅使用线性地址空间的第 4 个 GB&#xff0c;该空间的映射对系统的所有进程都是相同的。
甚至在最坏情况下&#xff0c;写 cr3 寄存器会使所有 TLB 表项无效&#xff0c;导致极大的性能损失。
现在的 Linux 中&#xff0c;如果 next 是内核线程&#xff0c;就不触及页表&#xff0c;进一步优化&#xff0c;将进程设置为懒惰 TLB 模式。
而如果 next 是一个普通进程&#xff0c;context_switch() 用 next 的地址空间替换 prev 的地址空间&#xff1a;
if(next->mm) // 普通进程switch_mm(prev->active_mm, next->mm, next);
如果 prev 是内核线程或正在退出的进程&#xff0c;context_switch() 把 prev 内存描述符的指针保存到运行队列的 prev_mm 字段中&#xff1a;
if(!prev->mm)
{rq->prev_mm &#61; prev->active_mm;prev->active_mm &#61; NULL;
}
现在&#xff0c;context_switch() 可调用 switch_to() 执行 prev 和 next 之间的进程切换了&#xff1a;
switch_to(prev, next, prev);
return prev;
总结&#xff1a;更新 prev 的时间片、时间戳&#xff0c;根据 prev 是内核线程还是普通线程&#xff0c;进行相应的内存描述符替换。
进程切换后 schedule() 所执行的动作
switch_to 宏后紧接着的指令不是让 next 进程立即执行&#xff0c;而是如果稍后调度程序又选择 prev 时则执行 prev。
到那时&#xff0c;prev 不指向 schedule() 开始时所替换出的进程&#xff0c;而是指向被调度时被 prev 替换出的进程。
进程切换后的第一部分指令&#xff1a;
barrier(); // 代码优化屏障
finish_task_switch(prev);
finish_task_switch()&#xff1a;
mm &#61; this_rq()->prev_mm; // 如果 prev 是一个内核线程&#xff0c;运行队列的 prev_mm 存放 prev 的内存描述符地址
this_rq()->prev_mm &#61; NULL;
prev_task_flags &#61; prev->flags;
spin_unlock_irq(&this_rq()->lock); // 释放运行队列的自旋锁
if(mm) // prev 是内核线程mmdrop(mm); // 减少内存描述符使用计数&#xff0c;如果等于 0&#xff0c;是否与页表相关的所有描述符和虚拟存储区
if(prev_task_flags & PF_DEAD) // 如果 prev 是一个正在从系统中被删除的僵死进程put_task_struct(prev); // 释放进程描述符的引用计数&#xff0c;并撤销所有启用对该进程的引用
schedule() 的最后一部分指令&#xff1a;
finish_schedule:prev &#61; current;
if(prev->lock_depth >&#61; 0)__reacquire_kernel_lock(); // 重新获得大内核锁
preempt_enable_no_resched(); // 重新启用内核抢占
if(test_bit(TIF_NEED_RESCHED, ¤t_thread_info()->flags) // 如果设置了当前进程的 TIF_NEED_RESCHED 标志goto need_resched; // schedule() 重新开始执行
return;
多处理器系统中运行队列的平衡
3 中不同类型的多处理机器&#xff1a;
内核应周期性地检查运行队列的工作量是否平衡&#xff0c;需要时&#xff0c;把一些进程从一个运行队列迁移到另一个运行队列。
为适应各种已有的多处理器体系结构&#xff0c;Linux 提出一种基于“调度域”概念的复杂的运行队列平衡算法。
调度域实际上是一个 CPU 集合&#xff0c;它们的工作量由内核保持平衡。
调度域采取分层组织的形式&#xff1a;最上层的调度域包括多个子调度域&#xff0c;每个子调度域包括一个 CPU 子集。
每个调度域被划分为一个或多个组&#xff0c;工作量的平衡在调度域的组之间完成。
每个调度域由 sched_domain 描述符表示&#xff0c;调度域中的每个组由 sched_group 描述符表示。
sched_domain 的 groups 字段指向组描述符链表中的第一个元素。
sched_domain 的 parent 字段指向父调度域的描述符。
物理 CPU 的 sched_domain 描述符存放在每 CPU 变量 phys_domains 中。
如果内核不支持超线程技术&#xff0c;这些域在域层次结构的最底层&#xff0c;运行队列描述符的 sd 字段指向它们。
如果内核支持超线程技术&#xff0c;底层调度域存放在每 CPU 变量 cpu_domains 中。
保持系统中运行队列的平衡&#xff0c;每经过一次时钟节拍&#xff0c;被 scheduler_tick() 调用。
参数&#xff1a;
首先&#xff0c;访问运行队列描述符的 nr_running 和 cpu_load 字段&#xff0c;确定运行队列中的进程数&#xff0c;更新运行队列的平均工作量。
然后&#xff0c;从基本域到最上层的调度域循环&#xff0c;每次循环确定是否已到调用 load_balance() 的时间&#xff0c;从而在调度域上指向重新平衡的操作。
sched_domain 的 idle 值决定调用 load_balance() 的频率。
如果 idle 等于 SCHED_IDLE&#xff0c;那么运行队列为空&#xff0c;load_balance() 被调用频率高。
反之&#xff0c;如果 idle 等于 NOT_IDLE&#xff0c;load_balance() 被调用频率低。
检查调度域是否处于严重的不平衡状态&#xff0c;如果是&#xff0c;从最繁忙的的组中迁移一些进程到本地 CPU 的运行队列。
参数&#xff1a;
把进程从源运行队列迁移到本地运行队列。
参数&#xff1a;
从优先级高的进程开始分析 busiest 运行队列的过期进程。
扫描完所有的过期进程后&#xff0c;扫描 busiest 运行队列的活动进程&#xff0c;对所有后续进程调用 can_migrate_task()&#xff0c;如果下列条件都满足&#xff0c;can_migrate_task() 返回 1&#xff1a;
如果 can_migrate_task 返回 1&#xff0c;调用 pull_task() 将后续进程迁移到本地运行队列。
pull_task() 先执行 dequeue_task() 从远程运行队列删除进程&#xff0c;然后执行 enqueue_task() 把进程插入本地运行队列&#xff0c;如果刚被迁移的进程比当前进程拥有更高的动态优先级&#xff0c;
调用 resched_task() 抢占本地 CPU 的当前进程。
nice() 允许进程改变自己的基本优先级。
increment 参数用来修改进程描述符的 nice 字段。
nice() 已被 setpriority() 取代。
nice() 只影响调用它的进程&#xff0c;而 getpriority() 和 setpriority() 作用于给定组中所有进程的基本优先级。
getpriority() 返回 20 减去给定组中所有进程中最低 nice 字段的值&#xff0c;即最高优先级。
setpriority() 把给定组中所有进程的基本优先级设置为一个给定的值。
分别返回和设置 CPU 进程亲和力掩码&#xff0c;即允许执行进程的 CPU 的位掩码。该掩码存放在进程描述符的 cpus_allowed 字段中。
sched_getscheduler() 和 sched_setscheduler()
sched_getscheduler() 查询参数 pid 表示的进程当前使用的调度策略。如果 pid 等于 0&#xff0c;检索调用进程的策略。如果成功&#xff0c;为进程返回策略&#xff1a;
SCHED_FIFO、SCHED_RR 或 SCHED_NORMAL。
sched_setscheduler() 既设置调度策略&#xff0c;也设置由参数 pid 表示的进程的相关参数。如果 pid 等于 0&#xff0c;调用进程的调度程序参数将被设置。
sched_getparam() 和 sched_setparam()
sched_getparam() 检索参数 pid 表示的进程的调度参数。如果 pid 是 0&#xff0c;current 进程的参数被检索。
sched_setparam() 类似于 sched_setscheduler()&#xff0c;不同之处在于不让调用者设置 policy 字段。
sched_yield()
允许进程在不被挂起的情况下自愿放弃 CPU&#xff0c;进程仍然处于 TASK_RUNNING 状态&#xff0c;但调度程序把它放在运行队列的过期进程集合中&#xff0c;或运行队列链表的末尾。
sched_get_priority_min() 和 sched_get_priority_max()
分别返回最小和最大实时静态优先级的值&#xff0c;该值由 policy 参数标识的调度策略使用。
sched_rr_get_interval()
把参数 pid 标识的实时进程的轮转时间片写入用户地址空间的一个结构中。如果 pid 等于 0&#xff0c;系统调用就写当前进程的时间片。