《深入理解Linux内核中文第三版》学习笔记——第7章 进程调度

文章目录

  • 第7章 进程调度
    • 7.1. 调度策略
      • 7.1.1. 进程的抢占
      • 7.1.2. 一个时间片必须持续多长?
    • 7.2. 调度算法
      • 7.2.1. 普通进程的调度
        • 基本时间片
        • 动态优先级和平均睡眠时间
        • 活动和过期进程
      • 7.2.2. 实时进程的调度
    • 7.3. 调度程序所使用的数据结构
      • 7.3.1. 数据结构runqueue
      • 7.3.2. 进程描述符
    • 7.4. 调度程序所使用的函数
      • 7.4.1. scheduler_tick()函数
        • 更新实时进程的时间片
        • 更新普通进程的时间片
      • 7.4.2. try_to_wake_up()函数
      • 7.4.3. recalc_task_prio()函数
      • 7.4.4. schedule()函数
        • 直接调用
        • 延迟调用
        • 进程切换前schedule()所执行的操作
        • schedule()完成进程切换时所执行的操作
        • 进程切换后schedule()所执行的操作
      • 7.4.5. 多处理器系统中运行队列的平衡
        • 调度域
        • rebalance_tick()函数
        • load_balance()函数
        • move_tasks()函数
      • 7.4.6. 与调度相关的系统调用
        • nice()系统调用
        • getpriority()和setpriority()系统调用
        • sched_getaffinity()和sched_setaffinity()系统调用
        • 与实时进程相关的系统调用
          • sched_getscheduler()和sched_setscheduler()系统调用
          • sched_getparam()和sched _setparam()系统调用
          • sched_yield()系统调用
          • sched_get_priority_min()和sched_get_priority_ max()系统调用
          • sched_rr_get_interval()系统调用

第7章 进程调度

此章以80x86体系结构为例。假定系统采用统一内存访问模型,系统时钟设定为1ms。

7.1. 调度策略

传统Unix 操作系统的调度策略须实现几个互相冲突的目标:

  • 进程响应时间尽可能快;
  • 后台作业的吞吐量尽可能高;
  • 尽可能避免进程的饥饿现象;
  • 低优先级和高优先级进程的需要尽可能调和等等。

Linux 的调度基于分时 ( time sharing ) 技术:多个进程以“时间多路复用 ”方式运行,因为CPU的时间被分成 “片 ( slice) " , 给每个可运行进程分配一 片。如果当前运行进程的时间片或时限(quantum ) 到期时,该进程还没有运行完毕,进程切换就可以发生。

调度策略根据进程优先级对其分类。在Linux中,进程优先级是动态的。调度程序跟踪进程运行并周期性调整期优先级。

进程分类

传统上把进程分为以下两类:

  • “I/O受限(I/O-bound)”:频繁使用I/O设备,花费很多时间等待I/O操作完成;
  • “CPU受限(CPU-bound)”:需大量CPU时间的数值计算。

另一饭分类法把进程分为以下三类:

  • 交互式进程(interactive process):与用户交互,花大量时间等待键盘、鼠标操作。得到输入后,需快速唤醒,平均延迟须在50~150ms间。典型交互式进程:命令shell、文本编辑程序、图形应用程序。
  • 批处理程序(batch process):无用户交互,后台运行,不必快速响应。典型批处理进程:编译程序,数据库搜索引擎、科学计算。
  • 实时进程(real-time process):有很强的调度需要。进程不会被低优先级进程阻塞,要求有短且变化小的响应时间。典型实时进程:音视频应用程序、机器人控制程序、传感器采集程序。

两种分类法相互独立。Linux中,调度算法可确认所有实时程序的身份,但无法区分交互式程序和批处理程序。Linux2.6调度陈旭实现了基于进程过去行为的启发式算法,以区分交互是进程和批处理进程。

表7-1列举可改变调度优先级的系统调用

系统调用 说明
nice() 改变一个将通进程的静态优先级
getpriority() 获得一组将通进程的最大静态优先级
setpriority() 设置一组普通进程的静态优先级
sched_getscheduler() 获得一个迸程的调度策略
sched_setscheduler() 设置一个进程的调度策略和实时优先级
sched_getparam() 获得一个进程的实时优先级
sched_setpararn() 设置一个进程的实时优先级
sched_yield() 自愿放弃处理器而不阻塞
sched_get_ priority_min() 获得一种镶略的最小实时优先级
sched_get_ priority_max() 获得-种锁赂的般大实时优先级
sched_rr_get_intcrval() 获得时间片轮转策略的时间片值
sched_setaffinity() 设置进程的CPU 亲和力掩码
sched_getaffinity() 获得进程的CPU 亲和力掩码

7.1.1. 进程的抢占

Linux进程是抢占式的。

  • 若进程A进入TASK_RUNNING状态,内核检查其动态优先级是否大于当前正在运行的进程。如是,正在运行的进程被中断,调用调度函数选择进程A。
  • 进程在时间片到期时也快被抢占。此时,进程thread_info结构中的TIF_NEED_RESCHED标志被置位,以便时钟中断处理程序终止时调度程序被调用。

注意:

  • 被抢占进程没有被挂起,仍处于TASK_RUNNING状态,只是不再使用CPU。
  • Linux2.6内核是抢占式的,意味着进程无论出于内核态还是用户态,都可能被抢占,详见第五章“内核抢占”一节。

7.1.2. 一个时间片必须持续多长?

时间片不能太长也不能太短。

  • 时间片太短:进程切换引起的系统额外开销非常高。
  • 时间片太长:进程看起来不是并发执行。

对时间片大小的选择始终是一种折衷。Linux 采取单凭经验的方撞,即选择尽可能长、同时能保持良好响应时间的时间片。

7.2. 调度算法

Linux2.6调度算法相比早期Linux版本的优点:

  • 固定时间内选中要运行进程,与可运行进程数量无关。
  • 很好地处理与处理器数量地比例关系,因为每个CPU都有自己的可运行进程队列。
  • 解决了区分交互式进程和批处理进程的问题。

每个Linux进程总按照下面调度类型被调度:

  • SCHED_FIFO:先进先出的实时进程。 当调度程序把CPU 分配给进程的时候,在把该进程描述符保留在运行队列链表的当前位置。如果没有其他可运行的更高优先级实时进程,进程就继续使用CPU,想用多久就用多久,即使还有其他具有相同优先级的实时进程处于可运行状态。
  • SCHED_RR:时间片轮转的实时进程。 当调度程序把CPU 分配给进程的时候,它把该进程的描述符放在运行队列链表的末尾。这种策略保证对所有具有相同优先级的SCHED_RR实时进程公平地分配CPU时间。
  • SCHED_NORMAL:普通的分时进程。

7.2.1. 普通进程的调度

每个普通进程都有它自己的静态优先级,调度程序使用静态优先级来估价系统中这个进程与其他普通进程之间调度的程度。内核用从100 (最高优先级)到139 (最低优先级)的数表示普通进程的静态优先级。
新进程继承其父进程的静态优先级。不过,通过把某些“nice 值”传递给系统调用nice()和setpriority()(参见本章稍后“与调度相关的系统调用” 一节),用户可改变自己拥有的进程的静态优先级。

基本时间片

静态优先级决定了进程基本事件片,公式如下:
基本时间片(单位 m s ) = { ( 140 − 静态优先级 ) × 20 若静态优先级 < 120 ( 140 − 静态优先级 ) × 20 若静态优先级 ≥ 120 (1) 基本时间片(单位ms)= \begin{cases} (140-静态优先级) \times 20 & 若静态优先级<120\\ (140-静态优先级) \times 20 & 若静态优先级\geq120 \end{cases} \tag{1} 基本时间片(单位ms={(140静态优先级)×20(140静态优先级)×20若静态优先级<120若静态优先级120(1)
可见,静态优先级较高的进程获得更长CPU时间。表7-2说明拥有最高、默认和最低静态优先级的普通进程,其静态优先级、基本时间片及nice值(表中还列出了交互式的δ值和睡暇时间的极限值,在本章稍后给予说明)。

说明 静态优先级 nice值 基本时间片 交互式的δ值 睡眠时间的极限值
最高静态优先级 100 -20 800ms -3 299ms
高静态优先级 110 -10 600ms -1 499ms
缺省静态优先级 120 0 100ms +2 799ms
低静态优先级 130 +10 50ms +4 999ms
最低静态优先级 139 +19 5ms +6 1199ms

动态优先级和平均睡眠时间

动态优先级取值100(最高优先级)~139(最低优先级)。动态优先级是调度程序选择新进程来运行时使用的数。其与静态优先级关系如下:
动态优先级 = m a x ( 100 , m i n ( 静态优先级 − b o n u s + 5 , 139 ) ) (2) 动态优先级=max(100, min(静态优先级 - bonus + 5, 139)) \tag{2} 动态优先级=max(100,min(静态优先级bonus+5,139))(2)
bonus取值0~10,取值依赖于进程平均睡眠时间。

  • bonus<5:降低动态优先级;
  • bonus>5: 增加动态优先级。

平均睡眠时间是进程在睡眠状态消耗的平均纳秒数。进程运行中平均睡眠时间递减,最终平均睡眠时间用于小于1s。表7-3说明平均睡眠时间和bonus的关系(表中时间片粒度在稍后讨论)。

平均睡眠时间 bonus 粒度
[0,100ms) 0 5120
[100ms, 200ms) 1 2560
[200ms, 300ms) 2 1280
[300ms, 400ms) 3 640
[400ms, 500ms) 4 320
[500ms, 600ms) 5 160
[600ms, 700ms) 6 80
[700ms, 800ms) 7 40
[800ms, 900ms) 8 20
[900ms, 1000ms) 9 10
1s 10 10

调度程序使用平均睡眠时间区分交互式进程和批处理进程。满足以下公式,被视为交互式进程:
动态优先级 ≤ 3 × 静态优先级 / 4 + 28 (3) 动态优先级\leq3\times静态优先级/4+28 \tag{3} 动态优先级3×静态优先级/4+28(3)
相当于
b o n u s − 5 ≥ 静态优先级 / 4 − 28 bonus-5\geq静态优先级/4-28 bonus5静态优先级/428
”静态优先级/4-28“为交互式的δ,见表7-2。

活动和过期进程

为了避免进程饥饿,当一个进程用完他的时间片是,应被还没用完时间片的低优先级进程取代。为了实现此机制,调度程序维持两个不想交的可运行进程的集合。

  • 活动进程:没用完时间片,允许运行。
  • 过期进程:已用完时间片,被禁止运行,直至所有进程都过期。

调度程序试图提升交互是进程的性能。原则如下:

  1. 用完时间片的活动批处理进程变为过期进程。
  2. 用完时间片的交互式进程通常仍是活动进程,调度程序重填其时间片并留在活动进程集合中。
  3. 若最老的过期进程已等待很长时间,或过期进程比交互式进程的静态优先级高,将用完时间片的交互式进程移到过期进程集合中。
  4. 结果,活动进程集合变为空,过期进程有机会运行。

7.2.2. 实时进程的调度

实时进程与实时优先级相关,取值1(最高优先级)~99(最低优先级)。调度程序总让优先级高的进程运行,实时进程总是被当成活动进程。
若多个可运行的实时进程具有相同最高优先级,调度程序选择第一个出现在与本地CPU运行队列响应链表的进程(参见第三章“TAS K_RUNNI NG状态的进程链表”)。只有下述事件之一发生时,实时进程会被另一进程取代:

  • 进程被另一个有更高实时优先级的实时进程抢占;
  • 进程执行了阻塞操作并进入睡眠(TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE);
  • 进程停止(TASK_STOPPED或TASK_TRACED)或被杀死(EXIT_ZOMBIE或EXIE_DEAD);
  • 进程调用sched_yield()自愿放弃CPU。
  • 进程是基于时间片轮转的实时进程(SCHED_RR),且用完时间片。

当系统调用nice()和setpriority()用于基于时间片轮转的实时进程时,不改变实时进程的优先级而会改变其基本时间片的长度。实际上.基于时间片轮转的实时进程的基本时间片的长度与实时进程的优先级无关,而依赖于进程的静态优先级, 关系见参见“普通进程的调度”一节中的公式(1)。

7.3. 调度程序所使用的数据结构

进程链表链接所有的进程描述符,而运行队列链表链接所有的可运行进程(也就是处于TASK_RUNNING 状态的进程)的进程描述符,swapper 进程(idle进程)除外。

7.3.1. 数据结构runqueue

系统中的每个CPU都有它自己的运行队列,所有的runqueue结构存放在runqueues 每CPU变量中。
宏this_rq()产生本地CPU运行队列的地趾,而宏cpu_rq(n)产生索引为n的CPU的运行队列的地址。
表7-4 列出了runqueue数据结构所包括的字段。

类型 名称 说明
spinlock_t lock 保护进程链表的自旋锁
unsigned long nr_running 运行队列链表中可运行进程的数量
unsigned long cpu_load 基于运行队列中进程的平均数量的CPU负载因子
unsigned long nr_switches CPU执行进程切换的次数
unsigned long nr_uninterruptible 先前在运行队列链表中而现在睡眠在
TASK_UNINTERRUPTIBLE状态的
进程的数量(对所有运行队列来说,
只有这些字段的总数才是有意义的)
unsigned long expired_timestamp 过期队列中最老的进程被插入队列的时间
unsigned long long timestamp_last_tick 最近一次定时器中断的时间戳的值
task_t * curr 当前正在运行进程的进程描述符指针
(对本地CPU,它与current相同)
task_t * idle 当前CPU(this CPU)上swapper进程的进程描述符指针
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 先前在运行队列的链表中而现在正等
待磁盘I/O操作结束的进程的数量
struct sched_domain * sd 指向当前CPU的基本调度域
int active_balance 如果要把一些进程从本地运行队列迁
移到另外的运行队列(平衡运行队
列),就设置这个标志
int push_cpu 未使用
task_t * migration_thread 迁移内核线程的进程描述符指针
struct list_head migration_queue 从运行队列中被删除的进程的链表
  • 系统中每个可运行进程属于且只属于一个运行队列。
  • 只要可运行进程保持在同一个队列中,它就只可能在拥有该运行队列的CPU上执行。
  • 可运行进程会从一个运行队列迁移到另一个运行队列。

下图7-1表示runqueue中最重要的字段,即与可运行进程的链表相关的字段。prio_array_t参见第三章的表3-2。
《深入理解Linux内核中文第三版》学习笔记——第7章 进程调度_第1张图片

  • runqueue中active字段指向arrays中两个prio_array_t数据结构之一:包含活动进程的可运行进程的集合;
  • runqueue中expired字段指向数组中的另一个prio_array_t数据结构:包含过期进程的可运行进程的集合。

调度程序通过交换active和expired字段内容以实现活动进程与过期进程的周期性切换。

7.3.2. 进程描述符

表7-5列举与调度程序相关的进程描述符字段

类型 名称 说明
unsigned long thread_info->flags 存放TIF_NEED_RESCHED 标志,如果必须调用调度
程序,则设置该标志(见第四章“从中断和异常返回” 一节)
unsigned int thread_info->cpu 可运行进程所在运行队列的CPU逻辑号
unsigned long state 进程的当前状态(见第三章“进程状态”一节)
int prio 进程的动态优先级
int static_prio 进程的静态优先级
struct list_head run_list 指向进程所属的运行队列链表中的下一个和前一个元素
prio_array_t * array 指向包含进程的运行队列的集合prio_array_t
unsigned long sleep_avg 进程的平均睡眠时间
unsigned long long timestamp 进程最近插入运行队列的时间,或涉及本进程的最近一次
进程切换的时间
unsigned long long last_ran 最近一次替换本进程的进程切换时间
int activated 进程被唤醒时所使用的条件代码
unsigned long policy 进程的调度类型(SCHED_NORMAL、SCHED_RR或
SCHED_FIFO)
cpumask_t cpus_allowed 能执行进程的CPU的位掩码
unsigned int time_slice 在进程的时间片中还剩余的时钟节拍数
unsigned int first_time_slice 如果进程肯定不会用完其时间片,就把该标志设置为1
unsigned long rt_priority 进程的实时优先级

当新进程被创建的时候,由于copy_process()调用的函数sched_fork()用下述方法设置current 进程(父进程)和p进程(子进程) 的time_slice 字段:

p->time_slice = (current->time_slice + 1) >> 1;
current->time_slice >>= 1;

父进程剩余的节拍数被划分成两等份: 一份给父进程,另一份给子进程。这样做是为了避免用户通过下述方法获得无限的CPU时间:父进程创建一个运行相同代码的子进程,并随后杀死自己,通过适当地调节创建的速度, 子进程就可以总是在父进程过期之前获得新的时间片。因为内核不奖赏创建,所以这种编程技巧不起作用。
如果父进程的时间片只剩下一个时钟节拍,则划分操作强行把current->time_slice置为0,从而耗尽父进程的时时间片。这种情况下,copy_process()把current->time_slice重新置为1 ,然后调用scheduler_tick()递减该字段(见下一节)。
函数copy_process()也初始化子进程描述符中与进程调度相关的几个字段:

p->first_time_slice = 1;
p->timestamp = sched_clock();

因为子进程没有用完它的时间片(如果一个进程在它的第一个时间片内终止或执行新的程序,就把子进程的剩余时问奖励给父进程),所以first_time_slice标志被置为1。用函数sched_clock()所产生的时间戳的值初始化timestamp字段:实际上,函数sched_clock()返回被转化成纳秒的64位寄存器TSC[见第六章“时间戳计数器(TSC)”一节]的内容。

7.4. 调度程序所使用的函数

调度程序依赖的最重要函数如下

函数名 函数说明
scheduler_tick() 维持当前最新的time_slice计数器
try_to_wake_up() 唤醒睡眠进程
recalc_task_prio() 更新进程的动态优先级
schedule() 选择要被执行的新进程
load_balance() 维持多处理器系统中运行队列的平衡

7.4.1. scheduler_tick()函数

第六章“更新本地CPU统计数”一节中说明:每次时钟节拍到来时,scheduler_tick()如何被调用以执行与调度相关的操作的。它执行步骤如下:

  1. 把转换为纳秒的TSC的当前值存入本地运行队列的timestamp_last_tick 字段。此时间戳从函数sched_clock()获得。
  2. 检查当前进程是否是本地CPU的swapper进程,如果是,执行下面的子步骤:
    • 如果本地运行队列除了swapper 进程外,还包括另外一个可运行的进程,就设置当前进程的TIF_NEED_RESCHED字段,以强迫进行重新调度。本章稍后“schedule()函数一节”将看到,如果内核支持超线程技术(见本章稍后“多处理器系统中运行队列的平衡”一节),那么,只要一个逻辑CPU运行队列中的所有进程都有比另一个逻辑CPU(两个逻辑CPU对应同一个物理CPU)上已经在执行的进程有低得多的优先级,前一个逻辑CPU就可能空闲,即使它的运行队列中有可运行的进程。
    • 跳转到第7 步(不必更新swapper进程的时间片计数器)。
  3. 检查current->array是否指向本地运行队列的活动链表。如果不是,说明进程已经过期但还没有被替换:设置TIF_NEED_RESCHED标志,以强制进行重新调度并跳转到第7步。
  4. 获得this_rq()->lock自旋锁。
  5. 递减当前进程的时间片计数器,并检查是否已经用完时间片。不同调度类型不同,函数所执行的这一步操作有很大的差别,后续讨论。
  6. 释放this_rq()->lock自旋锁。
  7. 调用rebalance_tick()函数,该函数应该保证不同CPU的运行队列包含数量基本相同的可运行进程。详见后续“多处理器系统中运行队列的平衡”一节。

更新实时进程的时间片

若current进程是SCHED_FIFO的实时进程
scheduler_tick()什么都不做。因为,current进程不可能被比起优先级低或相等的进程抢占。

若current进程是SCHED_RR的实时进程
scheduler_tick()递减其时间片计数器并检查时间片是否用完;若发现时间片用完,执行以下操作已达到抢占当前进程的目的。如下:

if (current->policy == SCHED_RR && !--current->time_slice) {
	current->time_slice = task_timeslice(current); //检查进程进程优先级,并根据“普通进程的调度”一节中的公式(1)返回相应的基本时间片
	current->first_time_slice = O; //该标识被fork()系统调用服务例程中的copy_process()设置,并在进程的第一个时间片刚一用完时立刻清0
	set_tsk_need_resched(current); //设置进程TIF_NEED_RESCHED标志
	// 以下两步把进程描述符移到与当前进程优先级相应的运行队列活动链表的尾部
	list_del(&current->run_list);
	list_add_tail(&current->run_list,
				  this_rq()->active->queue + current->prio);
}

更新普通进程的时间片

若current进程是SCHED_NORMAL的普通进程
scheduler_tick()执行以下操作:

  1. 递减时间片计数器(current->time_slice)。
  2. 检查时间片计数器。若时间片用完,执行下列操作:
    • 调用dequeue_task()从可运行进程的this_rq()->active集合中删除current指向的进程。
    • 调用set_tsk_need_resched()设置TIF_NEED_RESCHED 标志。
    • 更新current 指向的进程的动态优先级:
      current->prio = effective_prio(current); //读current的static_prio和sleep_avg字段,根据前面“普通进程的调度”一节的公式(2)计算进程的动态优先级
      
    • 重填进程的时间片:
      current->time_slice = task_timeslice(current);
      current->first_time_slice = 0;  
      
    • 如果本地运行队列数据结构的expired_timestamp字段等于0(即过期进程集合为空),就把当前时钟节拍的值赋给expired_timestamp:
      if (!this_rq()->expired_timestamp)
      	this_ rq()->expired_timestamp = jiffies;
      
    • 把当前进程插入活动进程集合或过期进程集合:
      // TASK_INTERACTIVE宏返回1条件:前面“普通进程的调度” 一节的公式(3)识别进程是一个交互式进程
      // EXPIRED_STARVING宏返回1条件:运行队列中的第一个过期进程的等待时间已经超过1000 个时钟节拍乘以运行队列中的可运行进程数加1
      //                            或当前进程的静态优先级大于一个过期进程的静态优先级
      if (!TASK_INTERACTIVE (current) || EXPIRED_STARVING(this_rq()) {
      	enqueue_task(current, this_rq()->expired);
      	if (current->static_prio < this_rq()->best_expired_prio)
      		this_rq()->best_expired_prio = current ->static_prio;
      } else
      	enqueue_task(current, this_rq()->active) ;
      
  3. 若时间片没有用完(current->time_slice≠0),检查当前进程的剩余时间片是否太长:
    // 宏TIMESLICE_GRANULARITY产生“系统中CPU的数量”与“成比例的常量”的乘积给当前进程的bonus(见表7-3)。
    //具有高静态优先级的交互式进程,其时间片被分成大小为TIMESLICE_GRANULAR ITY的几个片段,以使这些进程不会独占CPU.
    if (TASK_INTERACTIVE(p) && 
    	!((task_timeslice(p) - p->time_slice) % TIMESLICE_GRANULARITY(p)) &&
    	(p->time_slice >= TIMESLICE_GRANULARITY(p)) &&
    	(p->array == rq->activce)) (
    	list_del(&current->run_list);
    	llst_add_tail(&current->run_llst,
    			      this_rq()->active->queue + current->prio);
    	set_tsk_need_resched(p);
    }
    

7.4.2. try_to_wake_up()函数

try_to_wake_up()函数通过把进程状态设置为TASK _RUNNING,并把该进程插入本地CPU的运行队列来唤醒睡眠或停止的进程。参数有:

  • 被唤醒进程的描述符指针( p )
  • 可以被唤醒进程状态掩码( state )
  • 标志( sync ),用来禁止被唤醒的进程抢占本地CPU上正运行的进程

流程如下:

  1. 调用函数task_rq_lock()禁用本地中断,并获得最后执行进程的CPU(它可能不同于本地CPU)所拥有的运行队列rq的锁。CPU的逻辑号存储在p->thread_info->cpu字段。

  2. 检查进程的状态p->state是否属于被当作参数传递给函数的状态掩码state,如果不是,就跳转到第9 步终止函数。

  3. 如果p->array字段不等于NULL,那么进程已经属于某个运行队列,因此跳转到第8 步。

  4. 多处理器系统中,该函数检查要被唤醒的进程是否应该从最近运行的CPU的运行队列迁移到另外一个CPU的运行队列。实际上,函数就是根据一些启发式规则选择一个目标运行队列。例如:

    • 如果系统中某些CPU空闲,就选择空闲CPU的运行队列作为目标。按照优先选择先前正在执行进程的CPU和本地CPU这种顺序来进行。
    • 如果先前执行进程的CPU的工作量远小于本地CPU的工作量,就选择先前的运行队列作为目标。
    • 如果进程最近被执行过,就选择老的运行队列作为目标(可能仍然用这个进程的数据填充硬件高速缓存)。
    • 如果把进程移到本地CPU以缓解CPU之间的不平衡,目标就是本地运行队列(见本章稍后“多处理器系统中运行队列的平衡”一节)。

    执行完这一步, 函数已经确定了目标CPU和对应的目标运行队列rq,前者将执行被唤醒的进程,后者就是进程插入的队列。

  5. 如果进程处于TASK_UNINTERRUPTIBLE状态,函数递减目标运行队列的nr_uninterruptible字段,并把进程描述符的p->activated字段设置为-1。参见后面的“recalc_task_prio()函数”一节对activated 字段的说明。

  6. 调用activate_task()函数,它依次执行下面的子步骤:

    • 调用sched_clock()获取以纳秒为单位的当前时间戳。如果目标CPU不是本地CPU,就要补偿本地时钟中断的偏差,这是通过使用本地CPU和目标CPU上最近一次发生时钟中断的相对时间戳来达到的。
      now = (sched_clock() - this_rq()->timestamp_last_tick)
      	  + rq->timest.amp_last_tick;
      
    • 调用recalc_task_prio(),把进程描述符的指针和上一步计算出的时间戳传递给它,详见下节。
    • 根据后续表7-6设置p->activated字段的值。
    • 使用第6a步中计算的时间戳设置p->timestamp字段。
    • 把进程描述符插入活动进程集合:
      enqueue_task(p, rq->active);
      rq ->nr_running++;
      
  7. 如果目标CPU不是本地CPU,或者没有设置sync标志,就检查可运行的新进程的动态优先级是否比rq 运行队列中当前进程的动态优先级高(p->prio < rq->curr->prio);如果是,就调用resched_task()抢占rq->curr。

    • 单处理器系统中,后面的函数只是执行set_tsk_need_resched()设置rq->curr进程的TIF_NEED_RESCHED标志。
    • 多处理器系统中,resched_task()也检查TIF_NEED_RESCHED的旧值是否为0、目标CPU与本地CPU是否不同、rq->curr进程的TIF_POLLING_NRFLAG标志是否清0(目标CPU没有轮询进程TIF_NEEP_RESCHED标志的值)。如果是, resched_task()调用smp_send_reschedule()产生IPI,并强制目标CPU重新调度(参见第4章“处理器间中断处理”一节)。
  8. 把进程的p->state 字段设置为TASK_RUNNING状态。

  9. 调用task_rq_unlock()来打开rq运行队列的锁并打开本地中断。

  10. 返回1(成功唤醒进程)或0(进程没有被唤醒)。

7.4.3. recalc_task_prio()函数

函数更新进程的平均睡眠时间和动态优先级。接收参数如下:

  • 进程描述符的指针p
  • 函数sched_clock()计算出的当前时间戳now

流程如下:

  1. 把min(now - p->timestamp, 109)的结果赋给局部变量sleep_time。sleep_time存放的是从进程最后一次执行开始,进程消耗在睡眠状态的纳秒数(时长<1s)。
  2. 如果sleep_time 不大于0 ,就不用更新进程的平均睡眠时间,直接跳转到第8 步。
  3. 检查进程是否不是内核钱程、进程是否从TASK_UNINTERRUPTIBLE状态(p->activated 字段等于-1 ,见前节第5 步)被唤醒、进程连续睡眠的时间是否超过给定的睡眠时间极限。如果这三个条件都满足,函数把p->sleep_avg字段设置为相当于900个时钟节拍的值(用最大平均睡眠时间减去一个标准进程的基本时间片长度获得的一个经验值)。然后,跳转到第8 步。
    睡眠时间极限依赖于进程的静态优先级,表7-2说明了它的一些典型值。简而言之,此经验规则的目的是保证已经在不可中断模式上(通常是等待磁盘I/O的操作)睡眠了很长时间的进程获得一个预先确定而且足够长的平均睡眠时间,以使这些进程既能尽快获得服务,又不会因睡眠时间太长而引起其他进程的饥饿。
  4. 执行CURRENT_BONUS宏计算进程原来的平均睡眠时间的bonus值(见表7 -3)。如果(10-bonus)大于0,函数用这个值与sleep_time相乘。因为将要把sleep_time加到进程的平均睡眠肘间上(见下面的第6 步),所以当前平均睡眠时间越短,它增加的就越快。
  5. 如果进程处于TASK_UNINTERRUPTIBLE状态而且不是内核线程, 执行下述子步骤:
    • 检查平均睡眠时间p->sleep_avg是否大子或等于进程的睡眠时间极限(见前表7-2)。如果是,把局部变量sleep_time重新置为0,因此不用调整平均睡眠时间,而直接跳转到第6 步。
    • 如果sleep_time + p->sleep_avg的和大于或等于睡眠时间极限,就把p->sleep_avg字段置为睡眠时间极限并把sleep_time设置为0。
      通过对进程平均睡眠时间的轻微限制,函数不会对睡眠时间很长的批处理进程给予过多的奖赏。
  6. 把sleep_time 加到进程的平均睡眠时间上(p->sleep_avg)。
  7. 检查p->sleep_avg是否超过1000 个时钟节拍(ns),如果是,函数把它减到1000个时钟节拍(ns)。
  8. 更新进程的动态优先级:
    p->prio = effective_prio(p);//函数在本章前面“scheduler_tick()函数"一节讨论过
    

7.4.4. schedule()函数

函数实现调度程序。其任务是从运行队列的连表中找到一个进程,并随后将CPU分配给这个进程。

直接调用

若current进程不能获得必要的资源而要立即被阻塞,可直接调用调度程序。步骤如下:

  1. current进程插入适当的等待队列。
  2. 把current进程状态改为TASK_INTERRUPTIBLE或TASE_UNINTERRUPTIBLE。
  3. 调用schedule()。
  4. 检查资源是否可用,不可用则转到第2步。
  5. 一旦资源可用,从等待队列删除current进程。

内核例程反复检查进程需要的资源是否可用,若不可用,调用schedule()把CPU分配给其他进程。当调度程序再次把CPU分给此进程时,要重新检查资源的可用性。
许多执行长迭代任务的设备驱动程序也直接调用调度程序。每次迭代循环时,驱动程序检查TIF_NEED_RESCHED标志,若需要调用schedule()自动放弃CPU。

延迟调用

可以把current的进程TIF_NEED_RESCHED 标志设置为1,而以延迟方式调用调度程序 。由于总在恢复用户态进程的执行之前检查这个标志的值(见第四章从“中断和异常返回”一 节),所以schedule()将在不久之后的某个时间被明确地调用 。
以下是延迟调用调度程序的典型例子:

  • current进程用完了它的CPU时间片时,由scheduler_tick()函数完成schedule()的延迟调用。
  • 当一个被唤醒进程的优先级比当前进程的优先级高时,由try_to_wake_up()函数完成schedule()的延迟调用 。
  • 当发出系统调用sched_setscheduler()时(见本章稍后“与调度相关的系统调用”一节)。

进程切换前schedule()所执行的操作

函数的任务之一是用另外一个进程来替换当前正在执行的进程。因此,该函数的关键结果是设置next变量,使它指向被选中取代current的进程。如果系统中没有优先级高于current进程的可运行进程,那么最终next与current相等,不发生任何进程切换。步骤如下:

  1. schedule()函数在一开始先禁用内核抢占,并初始化一些局部变量:

    need_resched:
    	preempt_disable();
    	prev = current; 
    	rq = this_rq();
    
  2. schedule()保证prev不占用大内核锁(参见第五章“大内核锁”一节):

    // schedule()不改变lock_depth字段的值;
    // 当prev恢复执行的时候,如果该字段的值不为负数,则prev重新获得kernel_flag自旋锁。
    // 因此,通过进程切换会自动释放和重新获得大内核锁。
    if (prev->lock_depth >= O)
    	up(&kernel_sem);
    
  3. 调用sched_lock()读取TSC,并其转候成纳秒,所获得的时间戳存放在局部变量now 中。然后,schedule()计算prev所用的CPU时间片长度:

    now = sched_clock();
    run_time = now - prev->timestamp;
    if (run_time > 1000000000)
    	run_time = 1000000000;	// 通常使用限制在1s的时间
    

    run_time的值用来限制进程对CPU的使用。不过,鼓励进程有较长的平均睡眠时间:

    run_time /= (CURRENT_BONUS(prev) ? : 1); // CURRENT_BONUS返回0~10之间的值,它与进程平均睡眠时间成正比
    
  4. 开始寻找可运行进程之前, schedule()必须关掉本地中断,并获得所要保护的运行队列的自旋锁:

    spin_lock_irq(&rq->lock);
    

    正如在第三章“进程终止”一节中所描述的,prev可能是一个正在被终止的进程。为确认这个事实,schedule()检查PF_DEAD标志:

    if (prev->flags & PF_DEAD)
    	prev->state = EXIT_DEAD;
    
  5. 接下来schedule()检查prev的状态。

    // 如果prev不是可运行状态,
    // 而且它没有在内核态被抢占(见第四章“从中断和异常返回”一节),
    // 则从运行队列删除prev进程。
    if (prev->state != TASK_RUNNING &&			
    	!(preempt_count() & PREEMPT_ACTIVE)) {
    	// 如果prev是非阻塞挂起信号,而且状态为TASK_INTERRUPTIBLE,
    	// 函数就把该进程的状态设置为TASK_RUNNING,并将它插入运行队列。
    	// 这个操作与把处理器分配给prev是不同的,它只是给prev一次披选中执行的机会。
    	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;
    
  6. schecule()检查运行队列中剩余的可运行进程数。
    若运行队列中有可运行的进程,流程如下:

    if (rq->nr_running) {
    	// dependent_sleeper()函数,绝大多数情况下立即返回0。
    	// 如果内核支持超线程技术(见本章稍后“多处理器系统中运行队列的平衡” 一节),
    	// 函数检查要被选中执行的进程,其优先级是否比已在相同物理CPU的某个逻辑CPU上运行的兄弟进程的优先级低;
    	// 在这种特殊的情况下,schedule()拒绝选择低优先级的进程,而去执行swapper进程。
    	if (dependent_sleeper(smp_processor_id(), rq)) {
    		next = rq->idle ;
    		goto switch_tasks;
    	}
    }
    

    若运行队列中没有可运行的进程,流程如下:

    if (!rq->nr_running) {
    	// idle_balance()从另外一个运行队列迁移一些可运行进程到本地运行队列中,在稍后的“load_balance()函数”一节中对它进行说明
    	idle_balance(smp_processor_id(), rq); 
    	if (!rq->nr_running) { //如果idle_balance()没有成功地把进程迁移到本地运行队列中
    		next = rq->idle;
    		rq->expired_timestamp = 0;
    		// wake_sleeping_dependent()重新调度空闲CPU(每个运行swapper进程的CPU)中的可运行进程
    		wake_sleeping_dependent(smp_processor_id(), rq); 
    		// 在单处理器系统中,或者当把进程迁移到本地运行队列的种种努力都失败的情况下,
    		// 函数就选择swapper进程作为next进程并继续进行下一步骤
    		if (!rq->nr_running)
    			goto switch_tasks;
    	}
    }
    
  7. 假设schedule()函数已经肯定运行队列中有一些可运行的进程,现在它必须检查这些可运行进程中是否至少有一个进程是活动的。流程如下:

    array = rq->active;
    // 检查这些可运行进程中是否至少有一个进程是活动的
    if (!array->nr_active) { // 如果没有,函数就交换运行队列数据结构的active和expired字段的内容
    	rq->active = rq->expired;
    	rq->expired = array;
    	array = rq->active;
    	rq->expired_timestamp = O;
    	rq->best_expired_prio = 140;
    }
    
  8. 现在可以在活动的prio_array_t数据结构中搜索一个可运行进程(参见第三章“标识一个进程”一节)。

    // 搜索活动进程集合位掩码的第一个非0位。
    // 当对应的优先级链表不为空时,就把位掩码的相应位置1。
    // 第一个非0位的下标对应包含最佳运行进程的链表。
    idx = sched_find_first_bit(array->bitmap); // 函数基于bsfl汇编语言指令的,它返回32位字中被设置为1的最低位的位下标。
    // 返回该链表的第一个进程描述符
    next = list_entry(array->queue[idx].next, task_t, run_list); // next 现在存放将取代prev的进程描述符指针
    
  9. schedule()函数检查next->activated字段,该字段的编码值表示进程在被唤醒时的状态。表7-6列举进程描述符中activated字段的含义。

    说明
    0 进程处于TASK_RUNNING状态
    1 进程处于TASK_INTERRUPTIBLE或TASK_STOPPED状态,而且正在被系统
    调用服务例程或内核线程唤醒
    2 进程处于TASK_INTERRUPTIBLE或TASK_STOPPED状态,而且正在被中断
    处理程序或可延迟函数唤醒
    -1 进程处于TASK_UNINTERRUPTIBLE状态而且正在被唤醒

    检查流程如下:

    // 如果next是一个普通进程,而且它正在从TASK_INTERRUPTIBLE或TASK_STOPPED状态被唤醒
    // 调度程序就把自从进程插入运行队列开始所经过的纳秒数加到进程的平均睡眠时间中
    // 换言之,进程的睡眠时间被增加了,以包含进程在运行队列中等待CPU所消耗的时间
    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;
    

schedule()完成进程切换时所执行的操作

现在schedule()要让next进程投入运行。流程如下:

  1. 内核将立刻访问next进程的thread_info数据结构,其地址存放在next进程描述符的接近顶部的位置。

    switch_tasks:
    	prefetch(next); // prefetch宏提示CPU控制单元把next 的进程描述符第一部分字段的内容装入硬件高速缓存。
    	                // 这一点改善了schedule()的性能,因为对于后续指令的执行,数据是并行移动的
    
  2. 在替代prev之前,调度程序应该完成一些管理的工作:

    clear_tsk_need_resched(prev); // 清除prev的TIF_NEED_RESCHED标志,以防以延迟方式调用schedule()
    rcu_qsctr_inc(prev->thread_info->cpu); // 函数记录CPU正在经历静止状态[参见第五章“读-拷贝-更新(RCU)” 一节]
    
  3. schedule()必须减少prev的平均睡眠时间,并把它补充给进程所使用的CPU时间片,随后更新进程时间戳:

    prev->sleep_avg -= run_time;
    if ((long)prev->sleep_avg <= 0)
    	prev->sleep_avg = O;
    prev->timestamp = prev->last_ran = now;
    
  4. 若prev和next是同一进程,函数不做进程切换。

    if (prev == next) {
    	spin_unlock_irq(&rq->lock);
    	goto finish_schedule;
    }
    

    若prev和next是不同的进程,进程切换确实发生了。

    next->timestamp = now;
    rq->nr_switches++;
    rq->curr = next;
    prev = context_switch(rq, prev, next); // 函数建立next的地址空间
    

    如将在第九章“内核钱程的内存描述符”中将要看到的,进程描述符的active_mm字段指向进程所使用的内存描述符,而mm字段指向进程所拥有的内存描述符。对于一般的进程,这两个字段有相同的地址,但是,内核线程没有它自己的地址空间,而且它的mm字段总是被设置为NULL。


    context_switch()函数实现以下切换流程:

    • 如果next是一个内核线程,它使用prev所使用的地址空间:
    if (!next->mm) {
    	next->active_mm = prev->active_mm;
    	atomic_inc(&prev->active_mm->mm_count);
    	enter_lazy_tlb(prev- >active_mm, next);
    }
    
    • 如果next是一个普通进程, context-switch()用next的地址空间替换prev的地址空间:
    if (next->mm)
    	switch_mm(prev->active_mm, next->mm, next);
    
    • 如果prev是内核线程或正在退出的进程,context_switch()函数就把指向prev内存描述符的指针保存到运行队列的prev_mm字段中,然后重新设置prev->active_mm:
    if (!prev->mm) {
    	rq->prev_mm = prev->active_mm;
    	prev->active_mm = NULL;
    }
    

  5. context_switch()终于可以调用switch_to()执行prev和next之间的进程切换了(见第三章“执行进程切换” 一节):

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

进程切换后schedule()所执行的操作

schedule()函数中在switch_to宏调用之后紧接着的指令并不由next进程立即执行,而是稍后当调度程序又选择prev执行时由prev执行。然而,在那个时刻,prev局部变量并不指向我们开始描述schedule()时所替换出去的原来那个进程,而是指向prev被调度时由prev替换出的原来那个进程(如果你被搞糊涂了,请回到第三章阅读“执行进程切换”一节)。

  1. 进程切换后的第一部分指令是:
    barrier(); // 产生一个代码优化屏障(见第五章“优化和内存屏障”一节)
    finish_task_switch(prev); // 说明详见下段代码
    
    // 如果prev是一个内核线程,那么运行队列的prev_mm字段存放借给prev的内存描述符的地址
    mm = this_rq()->prev_mm;
    this_rq()->prev_mm = NULL;
    prev_task_flags = prev->flags;
    spin_unlock_irq(&this_rq()->lock); // 释放运行队列的自旋锁并打开本地中断
    if (mm)
    	mmdrop(mm); // 第九章将要看到mmdrop()减少内存描述符的使用计数器
    	            // 如果该计数器等于O了(可能是因为prev是一个僵死进程,
    	            // 函数还要释放与页表相关的所有描述符和虚拟存储区	
    if (prev_task_flags & PF_DEAD) // 检查prev是否是一个正在从系统中被删除的僵死任务(见第三章”进程终止”一节)
    	put_task_struct(prev); // 若是,释放进程描述符引用计数器,并撤消所有其余对该进程的引用(见第三章“进程删除”一节)。
    
  2. schedule()函数的最后一部分指令是:
    finish_schedule:
    	prev = current;
    	if (prev->lock_depth >= 0)
    		__reacquire_kernel_lock(); // 在需要的时候重新获得大内核锁
    	preempt_enable_no_resched(); // 重新启用内核抢占
    	if (test_bit(TIF_NEED_RESCHED, &current_thread_info()->flags)) // 检查是否一些其他的进程已经设置了当前进程的TIF_NEED_RESCHED标志
    		goto need_resched; // 如果是,整个schedule()函数量新开始执行
    	return; // 否则,函数结束
    

7.4.5. 多处理器系统中运行队列的平衡

多处理器机器具有很多不同的风格,且调度程序的实现随硬件特征的不同而不同。关注下面3种多处理器机器:

  • 标准的多处理器体系结构
    这是多处理器机器最普通的体系结构。这些机器所共有的RAM芯片集被所有CPU共享。
  • 超线程
    超线程芯片是一个立刻执行几个执行线程的微处理器:它包括几个内部寄存器的拷贝,并快速在它们之间切换。
    这种由Intel发明的技术,使得当前线程在访问内存的间隙,处理器可以使用它的机器周期去执行另外一个线程。
    一个超线程的物理CPU可以被Linux看作几个不同的逻辑CPU。
  • NUMA
    把CPU和RAM以本地“节点”为单位分组(通常一个节点包括一个CPU 和几个RAM 芯片)。
    内存仲裁器(一个使系统中的CPU以串型方式访问RAM 的专用电路,见第二章“内存地址”一节)是典型的多处理器系统的性能瓶颈。
    在NUMA体系结构中,当CPU访问与它同在一个节点中的“本地”RAM 芯片时,几乎没有竞争,因此访问通常是非常快的。另一方面,访问其所属节点外的“远程”RAM芯片就非常慢。
    第八章“非一致内存访问(NUMA)”一节将讨论Linux内核内存分配器是如何支持NUMA体系结构的。

由上节可见:

  • 一个指定的CPU只能执行其相应的运行队列中的可运行进程
  • 一个可运行进程总是存放在某一个运行队列中
  • 因此,一个保持可运行状态的进程通常被限制在一个固定的CPU上

针对上述设计:

  • 优点
    对系统性能是有益的。因为,运行队列中的可运行进程所拥有的数据可能填满每个CPU的硬件高速缓存。
  • 缺点
    有些情况下,可能引起严重的性能损失。
    例如,频繁使用CPU的大量批处理进程:如果它们绝大多数都在同一个运行队列中,那么系统中的一个CPU将会超负荷,而其他一些CPU几乎处于空闲状态。

针对上述缺点,内核周期性地检查运行队列的工作量是否平衡,并在需要的时候,把一些进程从一个运行队列迁移到另一个运行队列。但是,为了从多处理器系统获得最佳性能,负载平衡算法应考虑系统CPU的拓扑结构。
从内核2.6.7版本开始,Linux 提出一种基于“调度域”慨念的复杂的运行队列平衡算法。而调度域这一慨念使得这种算法能够容易适应各种已有的多处理器体系结构(甚至那些基于新近出现的“多核”微处理器的体系结构)。

调度域

调度域(scheduling domain)实际上是一个CPU集合,它们的工作量应当自内核保持平衡。调度域采取分层的组织形式,工作量的平衡采用如下有效方式来实现。

  • 每个调度域被依次划分成一个或多个组,每个组代表调度域的一个CPU子集。
  • 工作量的平衡总是在调度域的组之间来完成。
    换言之,只有在某调度域的某个组的总工作量远远低于同一个调度域的另一个组的工作量时,才把进程从一个CPU 迁移到另一个CPU。

图7-2 给出了3个调度域分层实例,对应3 种主要的多处理器机器体系结构。
《深入理解Linux内核中文第三版》学习笔记——第7章 进程调度_第2张图片

  • 图7-2(a) 表示有两个CPU的标准多处理器体系结构中由单个调度域组成的一个层次结构。
    • 该调度域包括两个组,每组一个CPU。
  • 图7-2(b) 表示一个两层的层次结构,用在使用超线程技术、有两个CPU的多处理器结构中。
    • 最上层的调度域包括了系统中所有4个逻辑CPU,它由2个组构成。
    • 上层调度域的每个组对应1个调度域,并包括1个物理CPU。
    • 底层调度域(基本调度域)包括两个组,每个组1个逻辑CPU。
  • 图7-2© 表示有2个节点,每个节点有4个CPU的8-CPUNUMA体系结构上的两层层次结构。
    • 最上层的域由2个组构成,每个组对应1个不同的节点。
    • 每个基本调度域包括一个节点内的CPU,包括4个组,每个组包括1个CPU 。

调度域相关数据结构说明如下:

  • sched_domain描述符表示调度域;sched_group描述符表示调度域中的1个组。
  • 每个sched_domain包含:
    • groups字段:指向组描述符链表的第一个元素;
    • parent字段:指向父调度域的描述符。
  • 系统中所有物理CPU的sched_domain描述符都放在每CPU变量phys_domains中。
    • 如果内核不支持超线程技术,这些域就在域层次结构的最底层,运行队列描述件的sd字段指向它们,即它们是基本调度域。
    • 如果内核不支持超线程技术,则底层调度存放在每CPU变量cpu_domains中。

rebalance_tick()函数

为了保持系统中运行队列的平衡,每次经过一次时钟节拍,scheduler_tick()就调用rebalance_tick()函数。
参数如下:

  • this_cpu:本地CPU的下标
  • this_rq:本地运行队列的地址
  • idle:CPU标志,取值如下:
    • SCHED_IDLE:CPU当前空闲,即current是swapper进程。
    • NOT_IDLE:CPU当前不空闲,即current不是swapper进程。

函数流程如下:

  • 首先确定运行队列中的进程数,并更新运行队列的平均工作量。为了完成此工作,需要访问运行队列描述符的nr_running和cpu_load字段。
  • 开始在所有调度域上的循环,其路径是从基本域(本地队列描述符的sd字段所引用的域)到最上层的域。每次循环中,确定是否已到调用函散load_balance()的时间,从而在调度域上执行重新平衡的操作。由sched_domain描述符中的参数和idle值决定调用load_balance()的频率。规则如下:
    • 如果idle等于SCHED_IDLE,那么运行队列为空,就以很高的频率调用load_balance()(大概每一到两个节拍处理一次对应于逻辑和物理CPU的调度域)。
    • 如果idle等于NOT_IDLE,就以很低的频率调用load_balance()(大慨每10ms处理一次逻辑CPU对应的调度域,每100ms处理一次物理CPU对应的调度域)。

load_balance()函数

load_balance()函数检查是否调度域处于严重的不平衡状态。更确切地说,它检查是否可以通过把最繁忙的组中的一些进程迁移到本地CPU的运行队列来减轻不平衡的状况。如果是,函数尝试实现这个迁移。
参数如下:

  • this_cpu:本地CPU的下标
  • this_rq:本地运行队列的地址
  • sd:指向被检查的调度域的描述符。
  • idle:CPU标志,取值SCHED_IDLE或NOT_IDLE。

函数流程如下:

  1. 获取this_rq->lock自旋锁。

  2. 调用find_busiest_group()函数分析调度域中各组的工作量,返回最繁忙组的sched_group描述符的地址。

    • 若最繁忙组不包括本地CPU,函数还返回为了恢复平衡而被迁移到本地运行队列中的进程数。
    • 若最繁忙组包含本地CPU或所有的组本来就是平衡的,函数返回NULL。

    这个过程试图过滤掉统计工作量中的波动。

  3. 如果find_busiest_group()在调度域中没有找到既不包括本地CPU又非常繁忙的组,就释放this_rq->lock自旋锁,调整调度域描述符的参数,以延迟本地CPU下一次对load_balance()的调度。然后函数终止。

  4. 调用find_busiest_queue()函数以查找在第2步中找到的组中最繁忙的CPU,函数返回相应运行队列的描述符地址busiest。

  5. 获取5中最繁忙CPU的自旋锁busiest->lock自旋锁。为了避免死锁,这一操作必须非常小心:首先释放this_rq->lock,然后通过增加CPU下标获得这两个锁。

  6. 调用move_tasks()函数,尝试从最繁忙的运行队列中把一些进程迁移到本地运行队列this_rq中(见下节)。

  7. 如果函数move_task()没有成功地把某些进程迁移到本地运行队列,那么调度域还是不平衡。执行如下操作:

    • 把busiest->active_balance标志设置为1,并唤醒migration内核线程,它的描述符存放在busiest->migration_thread中。
    • Migration内核线程顺着调度域的链搜索,从最繁忙运行队列的基本域到最上层域,寻找空闲CPU。
    • 如果找到一个空闲CPU,该内核线程就调用move_tasks()把一个进程迁移到空闲运行队列。
  8. 释放busiest->lock和this_rq->lock自旋锁。

  9. 函数结束。

move_tasks()函数

move_tasks()函数把进程从源运行队列迁移到本地运行队列。
参数如下:

  • this_rq
  • this_cpu:本地运行队列描述符和本地CPU下标
  • busiest:源运行队列描述符
  • max_nr_move:被迁移进程的最大数
  • sd:在其中执行平衡操作的调度域的描述符地址
  • idle标志:除了可以被设置为SCHED_IDLE和NOT_IDLE外,在函数被idle_balance()间接调用时,该标识可被设置为NEWLY_IDLE

函数流程如下:

  1. 首先分析busiest运行队列的过期进程,从优先级高的进程开始。

  2. 扫描完所有过期进程后,扫描busiest运行队列的活动进程。

  3. 对所有的候选进程调用can_migrate_task(),如果下列条件都满足,则can_migrate_task()返回1:

    • 进程当前没有在远程CPU上执行。
    • 本地CPU包含在进程描述符的cpus_allowed位掩码中。
    • 至少满足下列条件之一:
      • 本地CPU空闲。如果内核支持超线程技术,则所有本地物理芯片中的逻辑CPU必须空闲。
      • 内核在平衡调度域时因反复进行进程迁移都不成功而陷入困境。
      • 被迁移的进程不是“高速缓存命中”的(最近不曾在远程CPU上执行,因此可以设想远程CPU上的硬件高速缓存中没有该进程的数据)。
  4. 如果can_migrate_task()返回1,move_tasks()就调用pull_task()函数把候选进程迁移到本地运行队列中。pull_task()流程如下:

    • 执行dequeue_task()从远程运行队列删除进程;
    • 执行enqueue_task()把进程插入本地运行队列
    • 如果刚被迁移的进程比当前进程拥有更高的动态优先级,就调用resched_task()抢占本地CPU的当前进程。

7.4.6. 与调度相关的系统调用

作为一般原则,总是允许用户降低其进程的优先级。然而,如果他们想修改属于其他某一用户进程的优先级,或者如果他们想增加自己进程的优先级,那么,他们必须拥有超级用户的特权。

nice()系统调用

nice()系统调用允许进程改变它们的基本优先级。包含在increment参数中的整数值用来修改进程描述符的nice字段。
sys_nice()服务例程处理nice()系统调用。说明如下:

  • increment 参数
    • 可以有任何值,但是大于40 的绝对值会被截为40。
    • 负值相当于请求优先级增加,并请求超级用户特权;正值相当于请求优先级减少。
  • 在increment负增加的情况下,调用capable()核实进程是否有CAP_SYS_NICE权能,并调用security_task_setnice()(详见第二十章)安全勾。
  • 如果用户想用请求的权能来改变优先级,流程如下:
    • 把current-> static_prio转换到nice值的范围,再加上increment的值,并调用set_user_nice()函数。
    • set_user_nice()函数获得本地运行队列锁,更新current进程的静态优先级,调用resched_task()函数以允许其他进程抢占current进程,并释放运行队列锁。

nice()系统调用只维持向后兼容,它已经披下面描述的setpriority()系统调用取代。

getpriority()和setpriority()系统调用

nice()系统调用只影响调用它的进程,而另外两个系统调用getpriority()和setpriority()则作用于给定组中所有进程的基本优先级。

  • getpriority() 返回20减去给定组中所有进程之中最低nice字段的值(即所有进程中的最高优先级)
  • setpriority() 把给定组中所有进程的基本优先级都设置为一个给定的值。

内核对这两个系统调用的实现是通过sys_getpriority()和sys_setpriority()服务例程完成的。这两个服务例程本质上作用于一组相同的参数:

  • which:指定进程组的值。取值如下:
    • PRIO_PROCESS:根据进程的ID选择进程(进程描述符的pid字段)
    • PRIO_PGRP:根据组ID选择进程(进程描述符的pgrp字段)
    • PRIO_USER:根据用户ID选择进程(进程描述符的uid字段)
  • who:用pid、pgrp或uid字段的值选择进程。如果who是0,则把它的值设置为current进程相应字段的值。
  • niceval:新的基本优先级值(仅被sys_setpriority()所需要)。取值范围为-20(最高优先级)~ +19(最小优先级)。

第十将看到,只有当出现了某些错误时,系统调用才返回一个负值。因此,getpriority()不返回-20 ~ +19之间正常的nice值,而是1 ~ 40之间的一个非负值。

sched_getaffinity()和sched_setaffinity()系统调用

sched_getaffinity()和sched_setaffinity()系统调用分别返回和设置CPU进程亲和力掩码,即允许执行进程的CPU的位掩码。该掩码存放在进程描述符的cpus_allowed字段中。

  • sys_sched_getaffinity()
    调用find_task_by_pid()搜索进程描述符,返回的值为相应字段cpus_allowed与可用CPU位图做与运算的结果。
  • sys_sched_setaffinity()
    • 寻找目标进程的描述符并更新cpus_allowed 字段
    • 必须检查进程所属的运行队列,其对应的CPU亲和力掩码是否不再是最新值。
      • 若不是最新值,必须把进程从一个运行队列迁移到另一个运行队列。
      • 为了避免死锁和竞争条件的问题,由migration内核线程(每个CPU 有一个这样的线程)完成这个工作。
      • 一旦必须把进程从运行队列rq1迁移到运行队列rq2,sys_sched_setaffinity()系统调用就唤醒rq1的迁移线程(rq1->migration_thread),该线程从rq1中删除被迁移的进程,然后把它插入rq2。

与实时进程相关的系统调用

后续介绍的一组系统调用允许进程改变自己的调度规则,尤其是可以变为实时进程。进程为了修改任何进程(包括自己)的描述符的rt_priority和policy 字段,必须具有CAP_SYS_NICE权能。

sched_getscheduler()和sched_setscheduler()系统调用
  • sched_ getscheduler()
    • 查询由pid参数所表示的进程当前所用的调度策略。如果pid等于0,将检索调用进程的策略。
    • 如果成功,这个系统调用为进程返回策略:SCHED_FIFO、SCHED_RR或SCHED_NORMAL(后者也称为SCHED_OTHER)。
    • sys_sched_getscheduler()服务例程调用find_task_by_pid(),后一个函数确定给定pid所对应的进程描述符,并返回其policy字段的值。
  • sched_setscheduler()
    • 既设置调度策略,也设置由参数pid所表示进程的相关参数。如果pid等于0,调用进程的调度程序参数将被设置。
    • sys_sched_setscheduler()系统调用服务例程调用do_sched_setscheduler()函数。
      1. 检查由参数policy指定的调度策略和由参数param->sched_priority指定的新优先级是否有效。
      2. 检查进程是否具有CAP_SYS_NICE权能,或进程的拥有者是否有超级用户的仅限。
      3. 如果1&2都满足,就把进程从它的运行队列(如果进程是可运行的)中删除,更新进程的静态优先级、实时优先级和动态优先级,把进程插回到运行队列;最后,在需要的情况下,调用resched_task()函数抢占运行队列的当前进程。
sched_getparam()和sched _setparam()系统调用
  • sched_getparam()
    • 为pid所表示的进程检索调度参数。如果pid是0,则current进程的参数被检索。
    • sys_sched_getparam()服务例程找到与pid相关的进程描述符指针,把它的rt_priority字段存放在类型为sched_param的局部变量中,并调用copy_to_user()把它拷贝到进程地址空间中由param参数指定的地址。
  • sched_setparam()
    • 类似sched_setscheduler(),不同在于sched_setparam()不让调用者设置policy字段的值
    • sys_sched_setparam服务例程用几乎与sys_sched_setscheduler()相同的参数调用do_sched_setscheduler()。
sched_yield()系统调用

sched_ yield()系统调用允许进程在不被挂起的情况下自愿放弃CPU,进程仍然处于TASK_RUNNING状态,但调度程序把它放在运行队列的过期进程集合中(如果进程是普通进程),或放在运行队列链表的末尾(如果进程是实时进程)。随后调用schedule()函数。在这种方式下,具有相同动态优先级的其他进程将有机会运行。这个调用主要由SCHED_FIFO实时迸程使用。

sched_get_priority_min()和sched_get_priority_ max()系统调用

sched_ get_priority_min()和sched_ get_priority_max()系统调用分别返回最小和最大实时静态优先级的值,这个值由policy参数所标识的调度策略来使用。
如果current是实时进程,则sys_sched_get_priority_min()服务例程返回1,否则返回0。
如果current是实时进程,则sys_sched_get_priority_max()服务例程返回99(最高优先级),否则返回0。

sched_rr_get_interval()系统调用

sched_rr_get_interval()系统调把参数pid表示的实时进程的轮转时间片写入用户态地址空间的一个结构中。如果pid等于0,系统调用就写当前进程的时间片。
sys_sched_rr_get_interval()服务例程同样调用find_process_by_pid()检索与pid相关的进程描述符。然后,把所选中进程的基本时间片转换为秒数和纳秒数,并把它们拷贝到用户态的结构中。通常,FIFO实时进程的时间片等于0。

你可能感兴趣的:(linux,进程调度)