一、CFS调度器-基本原理
首先需要思考的问题是:什么是调度器(scheduler)?调度器的作用是什么?调度器是一个操作系统的核心部分。可以比作是CPU时间的管理员。调度器主要负责选择某些就绪的进程来执行。不同的调度器根据不同的方法挑选出最适合运行的进程。目前Linux支持的调度器就有RT scheduler、Deadline scheduler、CFS scheduler及Idle scheduler等。
1、什么是调度类
从Linux 2.6.23开始,Linux引入scheduling class的概念,目的是将调度器模块化。这样提高了扩展性,添加一个新的调度器也变得简单起来。一个系统中还可以共存多个调度器。在Linux中,将调度器公共的部分抽象,使用struct sched_class结构体描述一个具体的调度类。系统核心调度代码会通过struct sched_class结构体的成员调用具体调度类的核心算法。先简单的介绍下struct sched_class部分成员作用。
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 (*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); /* ... */ };
- next:next成员指向下一个调度类(比自己低一个优先级)。在Linux中,每一个调度类都是有明确的优先级关系,高优先级调度类管理的进程会优先获得cpu使用权。
- enqueue_task:向该调度器管理的runqueue中添加一个进程。我们把这个操作称为入队。
- dequeue_task:向该调度器管理的runqueue中删除一个进程。我们把这个操作称为出队。
- check_preempt_curr:当一个进程被唤醒或者创建的时候,需要检查当前进程是否可以抢占当前cpu上正在运行的进程,如果可以抢占需要标记TIF_NEED_RESCHED flag。
- pick_next_task:从runqueue中选择一个最适合运行的task。这也算是调度器比较核心的一个操作。例如,我们依据什么挑选最适合运行的进程呢?这就是每一个调度器需要关注的问题。
2、Linux中有哪些调度类
Linux中主要包含dl_sched_class、rt_sched_class、fair_sched_class及idle_sched_class等调度类。每一个进程都对应一种调度策略,每一种调度策略又对应一种调度类(每一个调度类可以对应多种调度策略)。例如实时调度器以优先级为导向选择优先级最高的进程运行。每一个进程在创建之后,总是要选择一种调度策略。针对不同的调度策略,选择的调度器也是不一样的。不同的调度策略对应的调度类如下表。
调度类 |
描述 |
调度策略 |
dl_sched_class |
deadline调度器 |
SCHED_DEADLINE |
rt_sched_class |
实时调度器 |
SCHED_FIFO、SCHED_RR |
fair_sched_class |
完全公平调度器 |
SCHED_NORMAL、SCHED_BATCH |
idle_sched_class |
idle task |
SCHED_IDLE |
针对以上调度类,系统中有明确的优先级概念。每一个调度类利用next成员构建单项链表。优先级从高到低示意图如下:
sched_class_highest----->stop_sched_class .next---------->dl_sched_class .next---------->rt_sched_class .next---------->fair_sched_class .next---------->idle_sched_class .next = NULL
Linux スケジューリング コアが次に実行する適切なタスクを選択すると、優先順位に従ってスケジューリング クラスの pick_next_task 関数が実行されます。したがって、SCHED_FIFO スケジューリング ポリシーのリアルタイム プロセスは、常に SCHED_NORMAL スケジューリング ポリシーの通常のプロセスよりも最初に実行されます。pick_next_task 関数もコードに反映されています。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 *クラス; struct task_struct *p; for_each_class(class) { /* すべてのクラスのスケジューリングを優先順位に従って促進し、次のポインターを使用して単一リンク リストを促進します*/ p = クラス->pick_next_task(rq, prev, rf); もし(p) p を返します。 } }
CFS スケジューラの場合、管理対象プロセスはすべて SCHED_NORMAL または SCHED_BATCH ポリシーに属します。以下では主に CFS スケジューラについて説明します。
3. 通常処理の優先順位
CFSとはCompletely Fair Schedulerの略で、完全に公平なスケジューラのことです。CFS の設計コンセプトは、理想的で正確なマルチタスク CPU を実際のハードウェア上に実装することです。CFS スケジューラと以前のスケジューラの違いは、タイム スライスの概念がなく、CPU 使用時間の割合が存在することです。たとえば、同じ優先度を持つ 2 つのプロセスが 1 つの CPU 上で実行されている場合、各プロセスには CPU 実行時間の 50% が割り当てられます。これが公平性というものです。
上記の例は同じ優先順位に基づいています。しかし現実はそうではなく、優先度の高いタスクもあります。では、CFS スケジューラの優先順位はどのように実装されるのでしょうか? まず、プロセスの優先度を表す重みの概念を導入します。CPU 時間は、その重みに比例して各プロセス間に割り当てられます。例: 2 つのプロセス A と B。A の重みは 1024、B の重みは 2048 です。この場合、A が CPU を取得する時間の割合は、1024/(1024+2048) = 33.3% になります。処理Bで得られるCPU時間の割合は、2048/(1024+2048)=66.7%となります。重みが大きいほど、割り当てられる時間の割合が大きくなり、優先度が高くなることがわかります。重みを導入した後、プロセスに割り当てられる時間は次のように計算されます。
プロセスに割り当てられた時間 = 合計 CPU 時間 * プロセスの重み / 準備完了キュー (実行キュー) 内のすべてのプロセスの重みの合計
CFS スケジューラは、優先度に適切な値の概念を提案します。これは、実際には重みと 1 対 1 に対応します。nice 値は特定の数値であり、値の範囲は [-20, 19] です。値が小さいほど優先度が高く、重み値も大きくなります。素敵な値と重みは相互に変換できます。カーネルは、適切な値と重みを変換するためのテーブルを提供します。
const int sched_prio_to_weight[40] = { /* -20 */ 88761, 71755, 56483, 46273, 36291, /* -15 */ 29154, 23254, 18705, 14949, 11916, /* -10 */ 9548, 7620, 6100, 4904, 3906, /* -5 */ 3121, 2501, 1991, 1586, 1277, /* 0 */ 1024, 820, 655, 526, 423, /* 5 */ 335, 272, 215, 172, 137, /* 10 */ 110, 87, 70, 56, 45, /* 15 */ 36, 29, 23, 18, 15, };
数组的值可以看作是公式:weight = 1024 / 1.25nice计算得到。公式中的1.25取值依据是:进程每降低一个nice值,将多获得10% cpu的时间。公式中以1024权重为基准值计算得来,1024权重对应nice值为0,其权重被称为NICE_0_LOAD。默认情况下,大部分进程的权重基本都是NICE_0_LOAD。
4、调度延迟
什么是调度延迟?调度延迟就是保证每一个可运行进程都至少运行一次的时间间隔。例如,每个进程都运行10ms,系统中总共有2个进程,那么调度延迟就是20ms。如果有5个进程,那么调度延迟就是50ms。如果现在保证调度延迟不变,固定是6ms,那么系统中如果有2个进程,那么每个进程运行3ms。如果有6个进程,那么每个进程运行1ms。如果有100个进程,那么每个进程分配到的时间就是0.06ms。随着进程的增加,每个进程分配的时间在减少,进程调度过于频繁,上下文切换时间开销就会变大。因此,CFS调度器的调度延迟时间的设定并不是固定的。当系统处于就绪态的进程少于一个定值(默认值8)的时候,调度延迟也是固定一个值不变(默认值6ms)。当系统就绪态进程个数超过这个值时,我们保证每个进程至少运行一定的时间才让出cpu。这个“至少一定的时间”被称为最小粒度时间。在CFS默认设置中,最小粒度时间是0.75ms。用变量sysctl_sched_min_granularity记录。因此,调度周期是一个动态变化的值。调度周期计算函数是__sched_period()。
static u64 __sched_period(unsigned long nr_running) { if (unlikely(nr_running > sched_nr_latency)) return nr_running * sysctl_sched_min_granularity; else return sysctl_sched_latency; }
nr_running是系统中就绪进程数量,当超过sched_nr_latency时,我们无法保证调度延迟,因此转为保证调度最小粒度。如果nr_running并没有超过sched_nr_latency,那么调度周期就等于调度延迟sysctl_sched_latency(6ms)。
5、虚拟时间(virtual time)
CFS调度器的目标是保证每一个进程的完全公平调度。CFS调度器就像是一个母亲,她有很多个孩子(进程)。但是,手上只有一个玩具(cpu)需要公平的分配给孩子玩。假设有2个孩子,那么一个玩具怎么才可以公平让2个孩子玩呢?简单点的思路就是第一个孩子玩10分钟,然后第二个孩子玩10分钟,以此循环下去。CFS调度器也是这样记录每一个进程的执行时间,保证每个进程获取CPU执行时间的公平。因此,哪个进程运行的时间最少,应该让哪个进程运行。
例如,调度周期是6ms,系统一共2个相同优先级的进程A和B,那么每个进程都将在6ms周期时间内内各运行3ms。如果进程A和B,他们的权重分别是1024和820(nice值分别是0和1)。进程A获得的运行时间是6x1024/(1024+820)=3.3ms,进程B获得的执行时间是6x820/(1024+820)=2.7ms。进程A的cpu使用比例是3.3/6x100%=55%,进程B的cpu使用比例是2.7/6x100%=45%。计算结果也符合上面说的“进程每降低一个nice值,将多获得10% CPU的时间”。很明显,2个进程的实际执行时间是不相等的,但是CFS想保证每个进程运行时间相等。因此CFS引入了虚拟时间的概念,也就是说上面的2.7ms和3.3ms经过一个公式的转换可以得到一样的值,这个转换后的值称作虚拟时间。这样的话,CFS只需要保证每个进程运行的虚拟时间是相等的即可。虚拟时间vriture_runtime和实际时间(wall time)转换公式如下:
NICE_0_LOAD vriture_runtime = wall_time * ---------------- weight
进程A的虚拟时间3.3 * 1024 / 1024 = 3.3ms,我们可以看出nice值为0的进程的虚拟时间和实际时间是相等的。进程B的虚拟时间是2.7 * 1024 / 820 = 3.3ms。我们可以看出尽管A和B进程的权重值不一样,但是计算得到的虚拟时间是一样的。因此CFS主要保证每一个进程获得执行的虚拟时间一致即可。在选择下一个即将运行的进程的时候,只需要找到虚拟时间最小的进程即可。为了避免浮点数运算,因此我们采用先放大再缩小的方法以保证计算精度。内核又对公式做了如下转换。
NICE_0_LOAD vriture_runtime = wall_time * ---------------- weight NICE_0_LOAD * 2^32 = (wall_time * -------------------------) >> 32 weight 2^32 = (wall_time * NICE_0_LOAD * inv_weight) >> 32 (inv_weight = ------------ ) weight
重みの値は計算されて sched_prio_to_weight 配列に保存されており、この配列に基づいて inv_weight の値を簡単に計算できます。sched_prio_to_wmult 配列は、inv_weight の値を保存するためにカーネルで使用されます。計算式は、sched_prio_to_wmult[i] = 232 / sched_prio_to_weight[i] です。
const u32 sched_prio_to_wmult[40] = { /* -20 */ 48388、59856、76040、92818、118348、 /* -15 */ 147320、184698、229616、287308、360437、 /* -10 */ 449829、563644、704093、875809、1099582、 /* -5 */ 1376151、1717300、2157191、2708050、3363326、 /* 0 */ 4194304、5237765、6557202、8165337、10153587、 /* 5 */ 12820798、15790321、19976592、24970740、31350126、 /* 10 */ 39045157、49367440、61356676、76695844、95443717、 /* 15 */ 119304647、148102320、186737708、238609294、286331153、 };
structload_weight 構造体は、プロセスの重み情報を記述するためにシステムで使用されます。Weight はプロセスの重みを表し、inv_weight は 232/weight に等しくなります。
構造体load_weight { 符号なしロングウェイト。 u32 inv_weight; };
実時間を仮想時間に変換する実装関数は calc_delta_fair() です。calc_delta_fair() は __calc_delta() 関数を呼び出します。__calc_delta() の主な機能は、次の式の計算を実装することです。
__calc_delta() = (delta_exec * 重み * lw->inv_weight) >> 32 重み 2^32 = delta_exec * ---------------- (lw->inv_weight = --------------- ) lw->体重 lw->体重
上記の仮想時間の計算式との比較。プロセスの仮想時間を計算する必要がある場合、ここで重みとしてパラメータ NICE_0_LOAD を渡すだけでよく、lw パラメータはプロセスに対応する structload_weight 構造体です。
static u64 __calc_delta(u64 delta_exec, unsigned longweight, structload_weight *lw) { u64 ファクト = スケール_ロード_ダウン(重量); int シフト = 32; __update_inv_weight(lw); if (ありそうもない(事実 >> 32)) { while (事実 >> 32) { 事実 >>= 1; シフト - ; } } 事実 = (u64)(u32) 事実 * lw->inv_weight; while (事実 >> 32) { 事実 >>= 1; シフト - ; } return mul_u64_u32_shr(delta_exec, ファクト, シフト); }
前述の理論によれば、calc_delta_fair() 関数が __calc_delta() を呼び出すときに渡されるweight パラメーターは NICE_0_LOAD であり、lw パラメーターはプロセスに対応する structload_weight 構造体です。
静的インライン u64 calc_delta_fair(u64 デルタ、構造体 sched_entity *se) { if (unlikely(se->load.weight != NICE_0_LOAD)) /* 1 */ デルタ = __calc_delta(デルタ, NICE_0_LOAD, &se->load); /* 2 */ デルタを返します。 }
- 前の理論によれば、nice 値が 0 (重みは NICE_0_LOAD) のプロセスの仮想時間と実際の時間は等しくなります。したがって、プロセスの重みがNICE_0_LOADであれば、プロセスに対応する仮想時間を計算する必要はない。
- __calc_delta() 関数を呼び出します。
Linux では、struct task_struct 構造体を通じて各プロセスを記述します。ただし、スケジューリング クラスの管理とスケジューリングの単位は、task_struct ではなく、スケジューリング エンティティです。グループ スケジューリングがサポートされている場合、グループもタスクではないスケジューリング エンティティに抽象化されます。したがって、struct task_struct 構造には、さまざまなスケジューリング クラスの次のスケジューリング エンティティが見つかります。
構造体タスク_構造体 { 構造体 sched_entity se; 構造体 sched_rt_entity rt; struct sched_dl_entity dl; /* ... */ }
se、rt、dl分别对应CFS调度器、RT调度器、Deadline调度器的调度实体。
struct sched_entity结构体描述调度实体,包括struct load_weight用来记录权重信息。除此以外我们一直关心的时间信息,肯定也要一起记录。struct sched_entity结构体简化后如下:
struct sched_entity { struct load_weight load; struct rb_node run_node; unsigned int on_rq; u64 sum_exec_runtime; u64 vruntime; };
- load:权重信息,在计算虚拟时间的时候会用到inv_weight成员。
- run_node:CFS调度器的每个就绪队列维护了一颗红黑树,上面挂满了就绪等待执行的task,run_node就是挂载点。
- on_rq:调度实体se加入就绪队列后,on_rq置1。从就绪队列删除后,on_rq置0。
- sum_exec_runtime:调度实体已经运行实际时间总合。
- vruntime:调度实体已经运行的虚拟时间总合。
6、就绪队列(runqueue)
系统中每个CPU都会有一个全局的就绪队列(cpu runqueue),使用struct rq结构体描述,它是per-cpu类型,即每个cpu上都会有一个struct rq结构体。每一个调度类也有属于自己管理的就绪队列。例如,struct cfs_rq是CFS调度类的就绪队列,管理就绪态的struct sched_entity调度实体,后续通过pick_next_task接口从就绪队列中选择最适合运行的调度实体(虚拟时间最小的调度实体)。struct rt_rq是实时调度器就绪队列。struct dl_rq是Deadline调度器就绪队列。
struct rq { struct cfs_rq cfs; struct rt_rq rt; struct dl_rq dl; }; struct rb_root_cached { struct rb_root rb_root; struct rb_node *rb_leftmost; }; struct cfs_rq { struct load_weight load; unsigned int nr_running; u64 min_vruntime; struct rb_root_cached tasks_timeline; };
- load:就绪队列权重,就绪队列管理的所有调度实体权重之和。
- nr_running:就绪队列上调度实体的个数。
- min_vruntime:跟踪就绪队列上所有调度实体的最小虚拟时间。
- tasks_timeline:用于跟踪调度实体按虚拟时间大小排序的红黑树的信息(包含红黑树的根以及红黑树中最左边节点)。
CFS维护了一个按照虚拟时间排序的红黑树,所有可运行的调度实体按照p->se.vruntime排序插入红黑树。如下图所示。
CFS选择红黑树最左边的进程运行。随着系统时间的推移,原来左边运行过的进程慢慢的会移动到红黑树的右边,原来右边的进程也会最终跑到最左边。因此红黑树中的每个进程都有机会运行。
现在我们总结一下。Linux中所有的进程使用task_struct描述。task_struct包含很多进程相关的信息(例如,优先级、进程状态以及调度实体等)。但是,每一个调度类并不是直接管理task_struct,而是引入调度实体的概念。CFS调度器使用sched_entity跟踪调度信息。CFS调度器使用cfs_rq跟踪就绪队列信息以及管理就绪态调度实体,并维护一棵按照虚拟时间排序的红黑树。tasks_timeline->rb_root是红黑树的根,tasks_timeline->rb_leftmost指向红黑树中最左边的调度实体,即虚拟时间最小的调度实体(为了更快的选择最适合运行的调度实体,因此rb_leftmost相当于一个缓存)。每个就绪态的调度实体sched_entity包含插入红黑树中使用的节点rb_node,同时vruntime成员记录已经运行的虚拟时间。我们将这几个数据结构简单梳理,如下图所示。
二、CFS调度器-源码解析
1、进程的创建
进程的创建是通过do_fork()函数完成。新进程的诞生,我们调度核心层会通知调度类,调用特别的接口函数初始化新生儿。我们一路尾随do_fork()函数。do_fork()---->_do_fork()---->copy_process()---->sched_fork()。针对sched_fork()函数,删减部分代码如下:
int sched_fork(unsigned long clone_flags, struct task_struct *p) { p->state = TASK_NEW; p->prio = current->normal_prio; p->sched_class = &fair_sched_class; /* 1 */ if (p->sched_class->task_fork) p->sched_class->task_fork(p); /* 2 */ return 0; }
- 我们这里以CFS为例,为task选择调度类。fair_sched_class是CFS调度类。
- 调用调度类中的task_fork函数。task_fork方法主要做fork相关的操作。传递的参数p就是创建的task_struct。
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, #ifdef CONFIG_SMP .select_task_rq = select_task_rq_fair, .migrate_task_rq = migrate_task_rq_fair, .rq_online = rq_online_fair, .rq_offline = rq_offline_fair, .task_dead = task_dead_fair, .set_cpus_allowed = set_cpus_allowed_common, #endif .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_to = スイッチドトゥフェア、 .get_rr_interval = get_rr_interval_fair, .update_curr = update_curr_fair、 #ifdef CONFIG_FAIR_GROUP_SCHED .task_change_group = task_change_group_fair, #endif };
task_fork_fair は次のように実装されます。
static void task_fork_fair(struct task_struct *p) { 構造体 cfs_rq *cfs_rq; struct sched_entity *se = &p->se, *curr; struct rq *rq = this_rq(); 構造体 rq_flags rf; rq_lock(rq, &rf); 更新_rq_クロック(rq); cfs_rq = タスク_cfs_rq(現在); curr = cfs_rq->curr; /* 1 */ if (curr) { update_curr(cfs_rq); /* 2 */ se->vruntime = curr->vruntime; /* 3 */ } place_entity(cfs_rq, se, 1); /* 4 */ se->vruntime -= cfs_rq->min_vruntime; /* 5 */ rq_unlock(rq, &rf); }
- cfs_rq是CFS调度器就绪队列,curr指向当前正在cpu上运行的task的调度实体。
- update_curr()函数是比较重要的函数,在很多地方调用,主要是更新当前正在运行的调度实体的运行时间信息。
- 初始化当前创建的新进程的虚拟时间。
- place_entity()函数在进程创建以及唤醒的时候都会调用,创建进程的时候传递参数initial=1。主要目的是更新调度实体得到虚拟时间(se->vruntime成员)。要和cfs_rq->min_vruntime的值保持差别不大,如果非常小的话,岂不是要上天(疯狂占用cpu运行)。
- 这里为什么要减去cfs_rq->min_vruntime呢?因为现在计算进程的vruntime是基于当前cpu上的cfs_rq,并且现在还没有加入当前cfs_rq的就绪队列上。等到当前进程创建完毕开始唤醒的时候,加入的就绪队列就不一定是现在计算基于的cpu。所以,在加入就绪队列的函数中会根据情况加上当前就绪队列cfs_rq->min_vruntime。为什么要“先减后加”处理呢?假设cpu0上的cfs就绪队列的最小虚拟时间min_vruntime的值是1000000,此时创建进程的时候赋予当前进程虚拟时间是1000500。但是,唤醒此进程加入的就绪队列却是cpu1上CFS就绪队列,cpu1上的cfs就绪队列的最小虚拟时间min_vruntime的值如果是9000000。如果不采用“先减后加”的方法,那么该进程在cpu1上运行肯定是“乐坏”了,疯狂的运行。现在的处理计算得到的调度实体的虚拟时间是1000500 - 1000000 + 9000000 = 9000500,因此事情就不是那么的糟糕。
下面就对update_curr()一探究竟。
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; if (unlikely(!curr)) return; delta_exec = now - curr->exec_start; /* 1 */ if (unlikely((s64)delta_exec <= 0)) return; curr->exec_start = now; curr->sum_exec_runtime += delta_exec; curr->vruntime += calc_delta_fair(delta_exec, curr); /* 2 */ update_min_vruntime(cfs_rq); /* 3 */ }
- delta_exec计算本次更新虚拟时间距离上次更新虚拟时间的差值。
- 更新当前调度实体虚拟时间,calc_delta_fair()函数根据上面说的虚拟时间的计算公式计算虚拟时间(也就是调用__calc_delta()函数)。
- 更新CFS就绪队列的最小虚拟时间min_vruntime。min_vruntime也是不断更新的,主要就是跟踪就绪队列中所有调度实体的最小虚拟时间。如果min_vruntime一直不更新的话,由于min_vruntime太小,导致后面创建的新进程根据这个值来初始化新进程的虚拟时间,岂不是新创建的进程有可能再一次疯狂了。这一次可能就是cpu0创建,在cpu0上面疯狂。
我们就看看update_min_vruntime()是怎么更新min_vruntime的。
static void update_min_vruntime(struct cfs_rq *cfs_rq) { struct sched_entity *curr = cfs_rq->curr; struct rb_node *左端 = rb_first_cached(&cfs_rq->tasks_timeline); u64 vruntime = cfs_rq->min_vruntime; if (curr) { if (curr->on_rq) vruntime = curr->vruntime; それ以外 カーソル = NULL; } if (左端) { /* 空ではないツリー */ struct sched_entity *se; se = rb_entry(左端, struct sched_entity, run_node); if (!curr) vruntime = se->vruntime; それ以外 vruntime = min_vruntime(vruntime, se->vruntime); } /* 後ろに配置されても時間を無駄にしないようにします。*/ cfs_rq->min_vruntime = max_vruntime(cfs_rq->min_vruntime, vruntime); }
レディキュー min_vruntime の最小仮想時間を絞り込みたいので、考えてみてください。最小の仮想時間を持つ場所はどこでしょうか?
- レディキュー自体の cfs_rq->min_vruntime メンバー。
- 現在実行中のプロセスの最も短い仮想時間。CFS スケジューラは、維持されている赤黒ツリー内で最小の仮想時間を持つプロセスを選択することによって、実行に最適なプロセスを選択します。
- 現在のプロセスの実行中にプロセスがレディ キューに参加する場合、赤黒ツリーの一番左のプロセスの仮想時間も最小仮想時間になる可能性があります。
したがって、update_min_vruntime() 関数は、上記の考えられる判断に基づいて最小仮想時間を更新し、レディ キューの最小仮想時間 min_vruntime が単調増加するようにします。
place_entity() 関数を続けましょう。
静的ボイド place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int 初期値) { u64 vruntime = cfs_rq->min_vruntime; /* * 「現在」の期間は現在のタスクにすでに約束されています。 * ただし、新しいタスクの重量が増えると速度が低下します。 * 少し、新しいタスクをスロットに収まるように配置します。 ※最後まで開いたままになります。 */ if (初期値 && sched_feat(START_DEBIT)) vruntime += sched_vslice(cfs_rq, se); /* 1 */ /* 単一レイテンシまでのスリープはカウントされません。*/ if (!initial) { unsigned long thresh = sysctl_sched_latency; /* * 睡眠時間の効果を半分にして、 * スリーパーの穏やかな効果を得るには: */ if (sched_feat(GENTLE_FAIR_SLEEPERS)) しきい値 >>= 1; vruntime -= スレッシュ; /* 2 */ } /* 後ろに配置されても時間を無駄にしないようにします。*/ se->vruntime = max_vruntime(se->vruntime, vruntime); /* 3 */ }
- 関数が作成プロセスによって呼び出される場合、初期パラメータは 1 です。したがって、ここでは作成を処理するプロセスを示します。新しく作成されたプロセスには一定のペナルティが発生します。仮想時間に値を追加するのがペナルティです。結局、仮想時間は小さいほどスケジュールされやすくなります。実行のために。ペナルティ時間は sched_vslice() によって計算されます。
- これは主にウェイクアップするプロセスに当てはまりますが、長時間スリープしていたプロセスについては、スケジュールが設定され、すぐに実行されることが常に期待されます。したがって、ここでは補償として特定の仮想時間が差し引かれます。
- スケジュールされたエンティティの仮想時間を元に戻すことはできないことを保証します。なぜ?考えてみると、プロセスが 1 ミリ秒だけスリープしてから復帰する場合、3 ミリ秒 (仮想時間から 3 ミリ秒を引いたもの) を報酬する必要があり、実際には 2 ミリ秒を獲得します。スケジューラーとして、私たちはお金を失うビジネスをしているわけではありません。100ms スリープすると 3ms が報酬として与えられるので、問題ありません。
新しく作成されたプロセスのペナルティ時間はどれくらいですか?
上記から分かるように、ペナルティ時間計算関数は sched_vslice() 関数です。
静的 u64 sched_vslice(struct cfs_rq *cfs_rq, struct sched_entity *se) { return calc_delta_fair(sched_slice(cfs_rq, se), se); }
calc_delta_fair() 関数は上記で分析されており、実際の実行時間デルタに対応する仮想時間を計算します。ここでのデルタは sched_slice() 関数によって計算されます。
静的 u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se) { u64 スライス = __sched_period(cfs_rq->nr_running + !se->on_rq); /* 1 */ for_each_sched_entity(se) { /* 2 */ structload_weight *load; 構造体load_weight lw; cfs_rq = cfs_rq_of(se); ロード = &cfs_rq->ロード; /* 3 */ if (unlikely(!se->on_rq)) { lw = cfs_rq->ロード; update_load_add(&lw, se->load.weight); 負荷 = &lw; } スライス = __calc_delta(スライス、se->load.weight、load); /* 4 */ } スライスを返します。 }
- 前述したように、__sched_period() は、レディ キュー スケジューリング エンティティの数に基づいてスケジューリング期間を計算します。
- グループ スケジューリングが有効になっていない場合、for_each_sched_entity(se) は for (; se; se = NULL) となり、1 回ループします。
- レディ キューの重みを取得します。これは、レディ キュー上のすべてのスケジューリング エンティティの重みの合計です。
- __calc_delta()函数有两个功能,除了上面说的可以计算进程运行时间转换成虚拟时间以外,还有第二个功能:计算调度实体se的权重占整个就绪队列权重的比例,然后乘以调度周期时间即可得到当前调度实体应该运行的时间(参数weught传递调度实体se权重,参数lw传递就绪队列权重cfs_rq->load)。例如,就绪队列权重是3072,当前调度实体se权重是1024,调度周期是6ms,那么调度实体应该得到的时间是6*1024/3072=2ms。
2、新进程加入就绪队列
经过do_fork()的大部分初始化工作完成之后,我们就可以唤醒新进程准别运行。也就是将新进程加入就绪队列准备调度。唤醒新进程的流程如下图。
do_fork()--->_do_fork()--->wake_up_new_task()--->activate_task()--->enqueue_task()--->enqueue_task_fair() | +------------>check_preempt_curr()--->check_preempt_wakeup()
wake_up_new_task()负责唤醒新创建的进程。简化一下函数如下。
void wake_up_new_task(struct task_struct *p) { struct rq_flags rf; struct rq *rq; p->state = TASK_RUNNING; #ifdef CONFIG_SMP p->recent_used_cpu = task_cpu(p); __set_task_cpu(p, select_task_rq(p, task_cpu(p), SD_BALANCE_FORK, 0)); /* 1 */ #endif rq = __task_rq_lock(p, &rf); activate_task(rq, p, ENQUEUE_NOCLOCK); /* 2 */ p->on_rq = TASK_ON_RQ_QUEUED; check_preempt_curr(rq, p, WF_FORK); /* 3 */ }
- select_task_rq() 関数を呼び出して CPU を再選択し、スケジューリング クラスの select_task_rq メソッドを呼び出してスケジューリング クラスで最もアイドル状態の CPU を選択します。
- スケジューリング クラスの enqueue_task メソッドを呼び出して、プロセスをレディ キューに追加します。
- 新しいプロセスの準備ができたので、新しいプロセスが現在実行中のプロセスをプリエンプトする条件を満たしているかどうかを確認する必要があります。プリエンプション条件が満たされている場合は、TIF_NEED_RESCHED フラグを設定する必要があります。
CFS スケジューリング クラスに対応する enqueue_task メソッド関数は enqueue_task_fair() ですが、グループ スケジューリングに関連するコードの一部を削除しています。
静的ボイド enqueue_task_fair(struct rq *rq, struct task_struct *p, int フラグ) { 構造体 cfs_rq *cfs_rq; struct sched_entity *se = &p->se; for_each_sched_entity(se) { /* 1 */ if (se->on_rq) /* 2 */ 壊す; cfs_rq = cfs_rq_of(se); enqueue_entity(cfs_rq, se, flags); /* 3 */ } もし (!se) add_nr_running(rq, 1); hrtick_update(rq); }
- グループ スケジュールがオフになっている場合、これはループになるため、心配する必要はありません。
- on_rq メンバーは、スケジューリング エンティティがすでに準備完了キューにあるかどうかを表します。値 1 は、レディ キューにあることを意味します。もちろん、レディ キューに追加し続ける必要はありません。
- enqueue_entity は、名前からわかるように、エンキューと呼ばれる準備完了キューにスケジューリング エンティティを追加します。
enqueue_entity()のコードは以下のとおりですが、当面注意する必要のない部分を削除しています。
static void enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags) { bool renorm = !(フラグ & ENQUEUE_WAKEUP) || (フラグ & ENQUEUE_MIGRATED); bool curr = cfs_rq->curr == se; /* * 現在のタスクの場合、呼び出す前に再正規化する必要があります * update_curr()。 */ if (renorm && curr) se->vruntime += cfs_rq->min_vruntime; update_curr(cfs_rq); /* 1 */ if (renorm && !curr) se->vruntime += cfs_rq->min_vruntime; /* 2 */ account_entity_enqueue(cfs_rq, se); /* 3 */ if (フラグ & ENQUEUE_WAKEUP) place_entity(cfs_rq, se, 0); /* 4 */ if (!curr) __enqueue_entity(cfs_rq, se); /* 5 */ se->on_rq = 1; /* 6 */ }
- update_curr() は、現在実行中のスケジューリング エンティティの仮想時間情報を更新します。
- task_fork_fair() 関数の最後に min_vruntime が減算されたことを覚えていますか? 今度はそれを追加し直します。
- レディキューの権限など、レディキューに関する情報を更新します。
- 目覚めたプロセス (フラグに ENQUEUE_WAKEUP マークが付いている) に対しては、状況に応じて一定の補償を提供する必要があります。place_entity() 関数の役割については、以前の 2 つの状況でも説明しました。もちろん、新しいプロセスが初めてレディキューに参加するときにこれを呼び出す必要はありません。
- __enqueue_entity() は、レディキューによって維持される赤黒ツリーに se を追加するもので、すべての ses は vruntime をキーとして使用します。
- すべての操作の完了は、se がレディ キューに参加し、on_rq メンバーが設定されたことも意味します。
account_entity_enqueue() 関数は準備完了キュー内のどのような情報を更新しますか?
static void account_entity_enqueue(struct cfs_rq *cfs_rq, struct sched_entity *se) { update_load_add(&cfs_rq->load, se->load.weight); /* 1 */ if (!parent_entity(se)) update_load_add(&rq_of(cfs_rq)->load, se->load.weight); /* 2 */ #ifdef CONFIG_SMP if (entity_is_task(se)) { struct rq *rq = rq_of(cfs_rq); account_uma_enqueue(rq, task_of(se)); list_add(&se->group_node, &rq->cfs_tasks); /* 3 */ } #endif cfs_rq->nr_running++; /* 4 */ }
- レディキュー重みの更新とは、レディキュー重みに se 重みを加算することです。
- CPU Ready Queue struct rq も重み情報を更新する必要があります。
- スケジューリング エンティティ se をリンク リストに追加します。
- nr_running メンバーは、レディ キュー内のすべてのスケジューリング エンティティの数です。
vruntime がオーバーフローした場合の対処方法
スケジューリング エンティティ se の vruntime メンバーは u64 タイプですが、非常に大きな数値を保存できます。しかし、264ns に達するとオーバーフローします。それでオーバーフローは問題になるのでしょうか?まず、__enqueue_entity() 関数をレディキューに追加するコードを見てみましょう。
static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se) { struct rb_node **link = &cfs_rq->tasks_timeline.rb_root.rb_node; struct rb_node *親 = NULL; struct sched_entity *エントリ; bool 左端 = true; /* * rbtree 内で適切な場所を見つけます。 */ while (*リンク) { 親 = *リンク; エントリ = rb_entry(親, struct sched_entity, run_node); /* ※衝突は気にしません。ノード * the same key stay together. */ if (entity_before(se, entry)) { link = &parent->rb_left; } else { link = &parent->rb_right; leftmost = false; } } rb_link_node(&se->run_node, parent, link); rb_insert_color_cached(&se->run_node, &cfs_rq->tasks_timeline, leftmost); }
我们通过便利红黑树查找符合插入节点的位置。利用entity_before()函数比较两个调度实体se的vruntime值大小,以确定搜索方向。
static inline int entity_before(struct sched_entity *a, struct sched_entity *b) { return (s64)(a->vruntime - b->vruntime) < 0; }
假设要插入a的vruntime是101,b的vruntime是100,那么entity_before()函数返回0。现在假设a的vruntime溢出了,vruntime是5(我们期望是264 + 5,但是很遗憾溢出结果是5),b的vruntime即将溢出,vruntime的值是264 - 2。那么调度实体a的vruntime无论是5还是264 + 5,entity_before()函数都会返回0。因此计算结果保持了一致性,所以溢出是没有任何问题的。要看懂这里的代码,需要对负数在计算机中表示形式有所了解。
同じ C 言語テクニックがレディ キュー min_vruntime メンバーにも適用されており、min_vruntime にも同じ u64 タイプのオーバーフローがあると想像してください。min_vruntime のオーバーフローに問題はありますか? 実際、いいえ、引き続き update_min_vruntime 関数の最後のコードを見てみましょう、 cfs_rq->min_vruntime = max_vruntime(cfs_rq->min_vruntime, vruntime); max_vruntime() 関数も、entity_before() 関数と同様の手法を使用します。したがって、min_vruntime オーバーフローの問題は発生しません。max_vruntime() は引き続き正しい結果を返すことができます。
静的インライン u64 max_vruntime(u64 max_vruntime, u64 vruntime) { s64 デルタ = (s64)(vruntime - max_vruntime); if (デルタ > 0) max_vruntime = vruntime; max_vruntime を返します。 }
現在のプロセス条件をプリエンプトする
新しいプロセスを起動するとき、これはプリエンプションを検出する機会でもあります。目覚めたプロセスの優先順位が高いか、仮想時間が短い可能性があるためです。前のセクションで新しいプロセスを起動した直後に、check_preempt_curr() 関数を呼び出して、プリエンプション条件が満たされているかどうかを確認します。
void check_preempt_curr(struct rq *rq, struct task_struct *p, int flags) { const struct sched_class *クラス; if (p->sched_class == rq->curr->sched_class) { rq->curr->sched_class->check_preempt_curr(rq, p, flags); /* 1 */ } それ以外 { for_each_class(クラス) { /* 2 */ if (クラス == rq->curr->sched_class) 壊す; if (クラス == p->sched_class) { resched_curr(rq); 壊す; } } } }
- 覚醒プロセスと現プロセスは同じスケジューリングクラスに属しているため、スケジューリングクラスのcheck_preempt_currメソッドを直接呼び出してプリエンプション条件を確認してください。結局のところ、スケジューラはプロセス自体を管理し、現在のプロセスをプリエンプトするのが適切かどうかを最もよく知っています。
- 目覚めたプロセスと現在のプロセスが同じスケジューリング クラスに属していない場合は、スケジューリング クラスの優先順位を比較する必要があります。たとえば、現在のプロセスが CFS スケジューリング クラスであり、目覚めたプロセスが RT スケジューリング クラスである場合、当然のことながら、リアルタイム プロセスの方が優先度が高いため、現在のプロセスをプリエンプトする必要があります。
ここで、目覚めたプロセスと現在のプロセスが同じ CFS スケジューリング クラスに属している状況を考えてみましょう。自然な呼び出しは check_preempt_wakeup() 関数です。
static void check_preempt_wakeup(struct rq *rq, struct task_struct *p, int wake_flags) { struct sched_entity *se = &curr->se, *pse = &p->se; struct cfs_rq *cfs_rq = task_cfs_rq(curr); if (wakeup_preempt_entity(se, pse) == 1) /* 1 */ プリエンプトに移動します。 戻る; プリエンプト: resched_curr(rq); /* 2 */ }
- 目覚めたプロセスが現在のプロセスをプリエンプトするための条件を満たしているかどうかを確認します。
- 現在のプロセスをプリエンプトできる場合は、TIF_NEED_RESCHED フラグを設定します。
wakeup_preempt_entity() 関数は次のとおりです。
/* * Should 'se' preempt 'curr'. */ static int wakeup_preempt_entity(struct sched_entity *curr, struct sched_entity *se) { s64 gran, vdiff = curr->vruntime - se->vruntime; if (vdiff <= 0) /* 1 */ return -1; gran = wakeup_gran(se); if (vdiff > gran) /* 2 */ return 1; return 0; }
wakeup_preempt_entity()函数可以返回3种结果。se1、se2、se3及curr调度实体的虚拟时间如下图所示。如果curr虚拟时间比se小,返回-1;如果curr虚拟时间比se大,并且两者差值小于gran,返回0;否则返回1。默认情况下,wakeup_gran()函数返回的值是1ms根据调度实体se的权重计算的虚拟时间。因此,满足抢占的条件就是,唤醒的进程的虚拟时间首先要比正在运行进程的虚拟时间小,并且差值还要大于一定的值才行(这个值是sysctl_sched_wakeup_granularity,称作唤醒抢占粒度)。这样做的目的是避免抢占过于频繁,导致大量上下文切换影响系统性能。
se3 se2 curr se1 ------|---------------|------|-----------|--------> vruntime |<------gran------>| wakeup_preempt_entity(curr, se1) = -1 wakeup_preempt_entity(curr, se2) = 0 wakeup_preempt_entity(curr, se3) = 1
3. 定期的なスケジュール設定
定期的なスケジューリングとは、現在のタスクが現在のプロセスのタイム スライスを使い果たしたかどうか、および現在のプロセスをプリエンプトする必要があるかどうかを Linux が定期的にチェックすることを意味します。一般に、タイマーの割り込み関数では、層ごとに関数呼び出しが行われ、最終的にscheduler_tick()関数に到達します。
void スケジューラ_tick(void) { int cpu = smp_processor_id(); struct rq *rq = cpu_rq(cpu); struct task_struct *curr = rq->curr; 構造体 rq_flags rf; sched_lock_tick(); rq_lock(rq, &rf); 更新_rq_クロック(rq); curr->sched_class->task_tick(rq, curr, 0); /* 1 */ cpu_load_update_active(rq); calc_global_load_tick(rq); rq_unlock(rq, &rf); perf_event_task_tick(); #ifdef CONFIG_SMP rq->idle_balance = idle_cpu(cpu); トリガー_ロード_バランス(rq); /* 2 */ #endif }
- スケジューリング クラスに対応する task_tick メソッドを呼び出します。CFS スケジューリング クラスの場合、この関数は task_tick_fair です。
- 負荷分散のトリガーについては、時間があるときに後で詳しく説明します。
task_tick_fair() 関数は次のとおりです。
static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued) { 構造体 cfs_rq *cfs_rq; struct sched_entity *se = &curr->se; for_each_sched_entity(se) { cfs_rq = cfs_rq_of(se); エンティティ_tick(cfs_rq, se, キューに入れられました); } }
for ループはグループ スケジューリング用であり、グループ スケジューリングがオンになっていない場合、これは 1 レベルのループになります。
entity_tick() がメインのジョブです。
static voidentity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr, int queued) { /* * 「現在」の実行時統計を更新します。 */ update_curr(cfs_rq); /* 1 */ if (cfs_rq->nr_running > 1) check_preempt_tick(cfs_rq, curr); /* 2 */ }
- update_curr() を呼び出して、現在実行中のスケジューリング エンティティの仮想時間およびその他の情報を更新します。
- レディ キュー内のレディ スケジューリング エンティティの数が 1 より大きい場合は、プリエンプション条件が満たされているかどうかを確認する必要があります。プリエンプションが可能であれば、TIF_NEED_RESCHED フラグを設定します。
check_preempt_tick() 関数は次のとおりです。
静的ボイド 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); /* 1 */ delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime; /* 2 */ if (delta_exec > ideal_runtime) { resched_curr(rq_of(cfs_rq)); /* 3 */ clear_buddies(cfs_rq, curr); return; } if (delta_exec < sysctl_sched_min_granularity) /* 4 */ return; se = __pick_first_entity(cfs_rq); /* 5 */ delta = curr->vruntime - se->vruntime; if (delta < 0) /* 6 */ return; if (delta > ideal_runtime) /* 7 */ resched_curr(rq_of(cfs_rq)); }
- sched_slice()函数上面已经分析过,计算curr进程在本次调度周期中应该分配的时间片。时间片用完就应该被抢占。
- delta_exec是当前进程已经运行的实际时间。
- 如果实际运行时间已经超过分配给进程的时间片,自然就需要抢占当前进程。设置TIF_NEED_RESCHED flag。
- 为了防止频繁过度抢占,我们应该保证每个进程运行时间不应该小于最小粒度时间sysctl_sched_min_granularity。因此如果运行时间小于最小粒度时间,不应该抢占。
- 从红黑树中找到虚拟时间最小的调度实体。
- 現在のプロセスの仮想時間が、赤黒ツリーの一番左のスケジューリング エンティティの仮想時間よりもまだ小さい場合、スケジューリングは発生しないはずです。
- ここで仮想時間をリアルタイムと比較するのは奇妙に思えます。バグのような気がします。投稿記録を確認したところ、作成者の意図は次のとおりです。重みの小さいタスクが簡単にプリエンプトされることを願っています。
上記の各周期スケジューリング (スケジューリング ティック) のプロセスは次のように要約できます。
- 現在実行中のプロセスの仮想時間を更新します。
- 現在のプロセスがプリエンプトされる条件を満たしているかどうかを確認します。
-
- (delta_exec > Ideal_runtime) の場合、TIF_NEED_RESCHED を設定します。
- TIF_NEED_RESCHED フラグを確認してください。
-
- 設定されている場合、仮想時間が最小のプロセスが実行可能キューから選択されます。
- 現在占有されているプロセスをレディキューの赤黒ツリーに再度追加します (エンキュータスク)。
- プロセス (デキュータスク) を実行しようとしているノードをレディキュー赤黒ツリーから削除します。
4. 次に実行する適切なプロセスを選択する方法
プロセスに TIF_NEED_RESCHED フラグが設定されている場合、特定の瞬間にシステム スケジューリングがトリガーされるか、プロセスがスケジュール() 関数を呼び出して CPU の使用権を積極的に放棄し、システム スケジューリングをトリガーします。例として、schedule() 関数を見てみましょう。
asmlinkage __visible void __sched スケジュール(void) { struct task_struct *tsk = 現在; sched_submit_work(tsk); する { preempt_disable(); __スケジュール(false); sched_preempt_enable_no_resched(); while (need_resched()); }
主なジョブは依然として __schedule() 関数です。
静的 void __sched notrace __schedule(bool preempt) { struct task_struct *前、*次; 構造体 rq_flags rf; 構造体 rq *rq; 内部CPU; cpu = smp_processor_id(); rq = cpu_rq(cpu); 前 = rq->curr; if (!preempt && prev->state) { if (unlikely(signal_pending_state(prev->state, prev))) { 前->状態 = TASK_RUNNING; } それ以外 { deactivate_task(rq, prev, DEQUEUE_SLEEP | DEQUEUE_NOCLOCK); /* 1 */ 前->on_rq = 0; } } 次 = pick_next_task(rq, prev, &rf); /* 2 */ clear_tsk_need_resched(前); /* 3 */ if (likely(prev != next)) { rq->curr = 次へ; rq = context_switch(rq, 前, 次, &rf); /* 4 */ } バランスコールバック(rq); }
- 積極的に CPU を放棄してスリープ状態になるプロセスの場合は、対応するレディ キューからプロセスを削除する必要があります。
- 実行を開始する次の適切なプロセスを選択してください。この関数は以前に分析されています。
- TIF_NEED_RESCHED フラグをクリアします。
- コンテキストスイッチ、前のプロセスから次のプロセスに切り替えます。
CFS スケジューリング クラスの pick_next_task メソッドは、pick_next_task_fair() 関数です。
静的構造体 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; また: if (!cfs_rq->nr_running) アイドル状態に移行します。 put_prev_task(rq, prev); /* 1 */ する { se = pick_next_entity(cfs_rq, NULL); /* 2 */ set_next_entity(cfs_rq, se); /* 3 */ cfs_rq = グループ_cfs_rq(se); while (cfs_rq); /* 4 */ p = タスクの(se); #ifdef CONFIG_SMP list_move(&p->se.group_node, &rq->cfs_tasks); #endif if (hrtick_enabled(rq)) hrtick_start_fair(rq, p); p を返します。 アイドル: 新しいタスク = アイドルバランス(rq, rf); if (new_tasks < 0) RETRY_TASK を返します。 if (new_tasks > 0) もう一度移動します。 NULL を返します。 }
- この関数は主に前のプロセスの葬儀を処理するために使用され、プロセスが CPU を放棄したときに呼び出されます。
- 実行する最も適切なスケジューリング エンティティを選択します。
- 選択されたスケジューリング エンティティ se は、操作を開始する前に処理する必要があり、set_next_entity() 関数がその処理を担当します。
- グループ スケジューリングが有効になっていない場合、サイクルは一度終了します。
put_prev_task()究竟处理了哪些后事呢?CFS调度类put_prev_task方法的函数是put_prev_task_fair()。
static void put_prev_task_fair(struct rq *rq, struct task_struct *prev) { struct sched_entity *se = &prev->se; struct cfs_rq *cfs_rq; for_each_sched_entity(se) { /* 1 */ cfs_rq = cfs_rq_of(se); put_prev_entity(cfs_rq, se); /* 2 */ } }
- 针对组调度情况,暂不考虑。
- put_prev_entity()是主要干活的部分。
put_prev_entity()函数如下。
static void put_prev_entity(struct cfs_rq *cfs_rq, struct sched_entity *prev) { /* * If still on the runqueue then deactivate_task() * was not called and update_curr() has to be done: */ if (prev->on_rq) /* 1 */ update_curr(cfs_rq); if (prev->on_rq) { /* Put 'current' back into the tree. */ __enqueue_entity(cfs_rq, prev); /* 2 */ /* in !on_rq case, update occurred at dequeue */ update_load_avg(cfs_rq, 前, 0); /* 3 */ } cfs_rq->curr = NULL; /* 4 */ }
- 前のプロセスがまだ準備完了キューにある場合は、前のプロセスがプリエンプトされた可能性があります。CPU を放棄する前に、プロセス仮想時間などの情報を更新する必要があります。前のプロセスが準備完了キューにない場合は、更新を直接スキップできます。前のプロセスはすでに deactivate_task() で update_curr() を呼び出しているため、ここでは省略できます。
- prev プロセスがまだ準備完了キューにある場合は、prev プロセスを赤黒ツリーに再挿入して、スケジューリングを待つ必要があります。
- update_load_avg() は、負荷分散中に使用される前のプロセスの負荷情報を更新します。
- 葬儀の処理が完了すると、レディ キューの curr ポインタも NULL を指すはずです。これは、レディ キューに実行中のプロセスがないことを意味します。
前のプロセスの葬儀は完了し、統合を継承する次のプロセスは set_next_entity() 関数を使用してそれを世界に発表する必要があります。
静的ボイド set_next_entity(struct cfs_rq *cfs_rq, struct sched_entity *se) { /* 'current' はツリー内に保持されません。*/ if (se->on_rq) { __dequeue_entity(cfs_rq, se); /* 1 */ update_load_avg(cfs_rq, se, UPDATE_TG); /* 2 */ } cfs_rq->curr = se; /* 3 */ update_stats_curr_start(cfs_rq, se); /* 4 */ se->prev_sum_exec_runtime = se->sum_exec_runtime; /* 5 */ }
- __dequeue_entity()是将调度实体从红黑树中删除,针对即将运行的进程,我们都会从红黑树中删除当前进程。当进程被强占后,调用put_prev_entity()函数会重新插入红黑树。因此这个地方和put_prev_entity()函数中加入红黑树是个呼应。
- 更新进程的负载信息。负载均衡会使用。
- 更新就绪队列curr成员,昭告天下,“现在我是当前正在运行的进程”。
- update_stats_curr_start()函数就一句话,更新调度实体exec_start成员,为update_curr()函数统计时间做准备。
- check_preempt_tick()函数用到,统计当前进程已经运行的时间,以此判断是否能够被其他进程抢占。
5、进程的睡眠
在__schedule()函数中,如果prev进程主动睡眠。那么会调用deactivate_task()函数。deactivate_task()函数最终会调用调度类dequeue_task方法。CFS调度类对应的函数是dequeue_task_fair(),该函数是enqueue_task_fair()函数反操作。
static void dequeue_task_fair(struct rq *rq, struct task_struct *p, int flags) { struct cfs_rq *cfs_rq; struct sched_entity *se = &p->se; int task_sleep = flags & DEQUEUE_SLEEP; for_each_sched_entity(se) { /* 1 */ cfs_rq = cfs_rq_of(se); dequeue_entity(cfs_rq, se, flags); /* 2 */ } if (!se) sub_nr_running(rq, 1); }
- 针对组调度操作,没有使能组调度情况下,循环仅一次。
- 将调度实体se从对应的就绪队列cfs_rq上删除。
dequeue_entity()函数如下。
static void dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags) { update_curr(cfs_rq); /* 1 */ if (se != cfs_rq->curr) __dequeue_entity(cfs_rq, se); /* 2 */ se->on_rq = 0; /* 3 */ account_entity_dequeue(cfs_rq, se); /* 4 */ if (!(flags & DEQUEUE_SLEEP)) se->vruntime -= cfs_rq->min_vruntime; /* 5 */ }
- 借机更新当前正在运行进程的虚拟时间信息,如果当前dequeue的进程就是当前正在运行的进程的话,那么此次update_curr()就很有必要了。
- 针对当前正在运行的进程来说,其对应的调度实体已经不在红黑树上了,因此不用在调用__dequeue_entity()函数从红黑树上参数对用的节点。
- 调度实体已经从就绪队列的红黑树上删除,因此更新on_rq成员。
- 更新就绪队列相关信息,例如权重信息。稍后介绍。
- プロセスがスリープしていない場合 (たとえば、ある CPU から別の CPU に移行している場合)、前述の理由により、現在のレディ キューに対応する最小仮想時間からプロセスの最小仮想時間を差し引く必要があります。移行後、キューに入れるときに、対応する CFS レディ キューの最小時間が追加されます。
account_entity_dequeue() は、前述の account_entity_enqueue() 操作の逆です。account_entity_dequeue() 関数は次のとおりです。
static void account_entity_dequeue(struct cfs_rq *cfs_rq, struct sched_entity *se) { update_load_sub(&cfs_rq->load, se->load.weight); /* 1 */ if (!parent_entity(se)) update_load_sub(&rq_of(cfs_rq)->load, se->load.weight); #ifdef CONFIG_SMP if (entity_is_task(se)) { account_uma_dequeue(rq_of(cfs_rq), task_of(se)); list_del_init(&se->group_node); /* 2 */ } #endif cfs_rq->nr_running--; /* 3 */ }
- レディキューの重みの合計から現在のデキュースケジューリングエンティティの重みを減算します。
- リンクされたリストからスケジューリング エンティティ se を削除します。
- 準備完了キュー内の実行可能なスケジューリング エンティティの数は 1 減らされます。
3. CFS スケジューラ - グループ スケジューリング
1 はじめに
现在的计算机基本都支持多用户登陆。如果一台计算机被两个用户A和B使用。假设用户A运行9个进程,用户B只运行1个进程。按照之前文章对CFS调度器的讲解,我们认为用户A获得90% CPU时间,用户B只获得10% CPU时间。随着用户A不停的增加运行进程,用户B可使用的CPU时间越来越少。这显然是不公平的。因此,我们引入组调度(Group Scheduling )的概念。我们以用户组作为调度的单位,这样用户A和用户B各获得50% CPU时间。用户A中的每个进程分别获得5.5%(50%/9)CPU时间。而用户B的进程获取50% CPU时间。这也符合我们的预期。本篇文章讲解CFS组调度实现原理。
2、再谈调度实体
通过之前的文章,我们已经介绍了CFS调度器主要管理的是调度实体。每一个进程通过task_struct描述,task_struct包含调度实体sched_entity参与调度。暂且针对这种调度实体,我们称作task se。现在引入组调度的概念,我们使用task_group描述一个组。在这个组中管理组内的所有进程。因为CFS就绪队列管理的单位是调度实体,因此,task_group也脱离不了sched_entity,所以在task_group结构体也包含调度实体sched_entity,我们称这种调度实体为group se。task_group定义在kernel/sched/sched.h文件。
struct task_group { struct cgroup_subsys_state css; #ifdef CONFIG_FAIR_GROUP_SCHED /* schedulable entities of this group on each CPU */ struct sched_entity **se; /* 1 */ /* runqueue "owned" by this group on each CPU */ struct cfs_rq **cfs_rq; /* 2 */ unsigned long shares; /* 3 */ #ifdef CONFIG_SMP atomic_long_t load_avg ____cacheline_aligned; /* 4 */ #endif #endif struct cfs_bandwidth cfs_bandwidth; /* ... */ };
- 指针数组,数组大小等于CPU数量。现在假设只有一个CPU的系统。我们将一个用户组也用一个调度实体代替,插入对应的红黑树。例如,上面用户组A和用户组B就是两个调度实体se,挂在顶层的就绪队列cfs_rq中。用户组A管理9个可运行的进程,这9个调度实体se作为用户组A调度实体的child。通过se->parent成员建立关系。用户组A也维护一个就绪队列cfs_rq,暂且称之为group cfs_rq,管理的9个进程的调度实体挂在group cfs_rq上。当我们选择进程运行的时候,首先从根就绪队列cfs_rq上选择用户组A,再从用户组A的group cfs_rq上选择其中一个进程运行。现在考虑多核CPU的情况,用户组中的进程可以在多个CPU上运行。因此,我们需要CPU个数的调度实体se,分别挂在每个CPU的根cfs_rq上。
- 上面提到的group cfs_rq,同样是指针数组,大小是CPU的数量。因为每一个CPU上都可以运行进程,因此需要维护CPU个数的group cfs_rq。
- 调度实体有权重的概念,以权重的比例分配CPU时间。用户组同样有权重的概念,share就是task_group的权重。
- 整个用户组的负载贡献总和。
如果我们CPU数量等于2,并且只有一个用户组,那么系统中组调度示意图如下。
系统中一共运行8个进程。CPU0上运行3个进程,CPU1上运行5个进程。其中包含一个用户组A,用户组A中包含5个进程。CPU0上group se获得的CPU时间为group se对应的group cfs_rq管理的所有进程获得CPU时间之和。系统启动后默认有一个root_task_group,管理系统中最顶层CFS就绪队列cfs_rq。在2个CPU的系统上,task_group结构体se和cfs_rq成员数组长度是2,每个group se都对应一个group cfs_rq。
3、数据结构之间的关系
假设系统包含4个CPU,组调度的打开的情况下,各种结构体之间的关系如下图。
各 CPU にはグローバル レディ キュー構造体 rq があり、4 CPU システムでは、図の紫色の構造に示すように、4 つのグローバル レディ キューが存在します。デフォルトでは、システムには root_task_group というルート task_group が 1 つだけあります。rq->cfs_rq は、システム ルート CFS レディ キューを指します。ルート CFS レディ キューは赤黒ツリーを維持しており、赤黒ツリー上にはタスク se が 9 つとグループ se が 1 つ(図の青色の se)、合計 10 個のレディ スケジューリング エンティティが存在します。グループ se の my_q メンバーは、独自のレディキューを指します。レディキューの赤黒ツリーには合計 9 個のタスクがあります。親メンバーはグループ se を指します。各グループ se はグループ cfs_rq に対応します。4 つの CPU は 4 つのグループ se およびグループ cfs_rq に対応し、それぞれ task_group 構造体の se メンバーと cfs_rq メンバーに格納されます。se-> Depth メンバーは、se のネストの深さを記録します。最上位の CFS レディ キューの下の se の深さは 0 で、グループ se は層ごとに増加します。cfs_rq->nr_running メンバーは、サブレディ キューを除く、CFS レディ キュー内のすべてのスケジューリング エンティティの数を記録します。cfs_rq->h_nr_running メンバーは、グループ se に対応するグループ cfs_rq 上のスケジューリング エンティティを含む、レディ キュー レベルのすべてのスケジューリング エンティティの数を記録します。たとえば、図の上半分では、nr_running と h_nr_running の値はそれぞれ 10 と 19 に等しく、余分な 9 はグループ cfs_rq の h_nr_running です。グループ cfs_rq にはグループ se がないため、nr_running と h_nr_running の値は両方とも 9 に等しくなります。
4. グループプロセスのスケジューリング
ユーザーグループ内でプロセスをスケジュールするにはどうすればよいですか? 上記の分析を通じて、ルート CFS レディ キューを通じて適切なプロセスをレイヤーごとに簡単に選択できます。たとえば、まずルートレディキューから操作に適したグループ se を選択し、次に対応するグループ cfs_rq を見つけて、グループ cfs_rq からタスク se を選択します。CFS スケジューリング クラスでは、プロセスを選択する関数は pick_next_task_fair() です。
静的構造体 task_struct * pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf) { struct cfs_rq *cfs_rq = &rq->cfs; /* 1 */ struct sched_entity *se; struct task_struct *p; put_prev_task(rq, prev); する { se = pick_next_entity(cfs_rq, NULL); /* 2 */ set_next_entity(cfs_rq, se); cfs_rq = グループ_cfs_rq(se); /* 3 */ while (cfs_rq); /* 4 */ p = タスクの(se); p を返します。 }
- 利便性はルート CFS レディ キューから始まります。
- レディキュー cfs_rq の赤黒ツリーから仮想時間が最小の SE を選択します。
- group_cfs_rq() は se->my_q メンバーを返します。タスク se の場合、group_cfs_rq() は NULL を返します。グループ se の場合、group_cfs_rq() はグループ se に対応するグループ cfs_rq を返します。
- グループ se の場合、グループ cfs_rq の赤黒ツリーから仮想時間が最小の次の se を選択し、一番下のタスク se までループする必要があります。
5. グループプロセスのプリエンプション
定期的なスケジュールでは、task_tick_fair() 関数が呼び出されます。
static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued) { 構造体 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); } }
for_each_sched_entity()是一个宏定义for (; se; se = se->parent),顺着se的parent链表往上走。entity_tick()函数继续调用check_preempt_tick()函数,这部分在之前的文章已经说过了。check_preempt_tick()函数会根据满足抢占当前进程的条件下设置TIF_NEED_RESCHED标志位。满足抢占条件也很简单,只要顺着se->parent这条链表便利下去,如果有一个se运行时间超过分配限额时间就需要重新调度。
6、用户组的权重
每一个进程都会有一个权重,CFS调度器依据权重的大小分配CPU时间。同样task_group也不例外,前面已经提到使用share成员记录。按照前面的举例,系统有2个CPU,task_group中势必包含两个group se和与之对应的group cfs_rq。这2个group se的权重按照比例分配task_group权重。如下图所示。
CPU0上group se下有2个task se,权重和是3072。CPU1上group se下有3个task se,权重和是4096。task_group权重是1024。因此,CPU0上group se权重是439(1024*3072/(3072+4096)),CPU1上group se权重是585(1024-439)。当然这里的计算group se权重的方法是最简单的方式,代码中实际计算公式是考虑每个group cfs_rq的负载贡献比例,而不是简单的考虑权重比例。
7、用户组时间限额分配
各プロセスに割り当てられた時間計算関数は sched_slice() です. 前回の分析はグループスケジューリングを考慮しない場合に基づいていました。では、グループのスケジューリングを考慮する際に、プロセスが割り当てる時間を調整するにはどうすればよいでしょうか? グループ スケジューリングを考慮せずに簡単な例を見てみましょう。シングルコア システムでは、2 つのプロセスの重みは 1024 です。グループ スケジューリングを考慮せずに、スケジューリング エンティティ se によって割り当てられる制限時間は次のように計算されます。
se->load.weight 時間 = sched_period * -------------------------------------- cfs_rq->load.weight
また、CFS レディ キュー全体の重みとスケジューリング サイクル タイムを乗算した se の重みの割合を計算する必要もあります。前回の記事の分析によれば、2 つのプロセスのスケジューリング周期は 6ms であるため、各プロセスに割り当てられる時間は 6ms*1024/(1024+1024)=3ms となります。
次に、グループ スケジュールの場合を考えてみましょう。システムは依然としてシングルコアであり、task_group があり、すべてのプロセスの重みは 1024 です。task_group の重みも 1024 (つまり、シェア値) です。以下に示すように。
グループ cfs_rq でのプロセス割り当て時間の計算式は次のとおりです (gse := group se; gcfs_rq := group cfs_rq)。
se->load.weight gse->load.weight 時間 = sched_period * ------------------------ * -------------------- ---- gcfs_rq->load.weight cfs_rq->load.weight
式に従って、グループ cfs_rq の下のプロセスの割り当て時間を次のように計算します。
1024 1024 時間 = 6ms * --------------- * -------------- = 1.5ms 1024 + 1024 1024 + 1024
上記 2 つの計算式に基づいて、上記の例で各プロセスに割り当てられる時間を次の図のように計算できます。
上記は task_group が 1 階層ネストされている状況を簡単に紹介しましたが、task_group に task_group が引き続き含まれる場合、上記の計算式は 1 階層上の階層の割合を計算する必要があります。この計算式を実現する関数がsched_slice()です。
静的 u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se) { u64 スライス = __sched_period(cfs_rq->nr_running + !se->on_rq); /* 1 */ for_each_sched_entity(se) { /* 2 */ structload_weight *load; 構造体load_weight lw; cfs_rq = cfs_rq_of(se); ロード = &cfs_rq->ロード; /* 3 */ if (unlikely(!se->on_rq)) { lw = cfs_rq->ロード; update_load_add(&lw, se->load.weight); 負荷 = &lw; } スライス = __calc_delta(スライス、se->load.weight、load); /* 4 */ } スライスを返します。 }
- スケジューリング期間は、現在準備が完了しているプロセスの数に基づいて計算されます。デフォルトでは、プロセスが 8 つ以下の場合、スケジューリング期間はデフォルトで 6ms に設定されます。
- for ループは、se->parent リンク リストに基づいて上向きに比率を計算します。
- seに付属するcfs_rqの負荷情報を取得します。
- スライス = スライス * se->load.weight / cfs_rq->load.weight の値を計算します。
8. グループ Se の重み計算
上の例では、グループ se の重み計算が重み比に基づいていると述べました。ただし、実際のコードはそうではありません。タスクをデキューするとき、タスクをエンキューするとき、およびタスク ティックをキューに入れるとき、update_cfs_group() 関数を通じてグループ se の重み情報を更新します。
static void update_cfs_group(struct sched_entity *se) { struct cfs_rq *gcfs_rq = group_cfs_rq(se); /* 1 */ ロングシェア、実行可能。 if (!gcfs_rq) 戻る; シェア = calc_group_shares(gcfs_rq); /* 2 */ runnable = calc_group_runnable(gcfs_rq, 共有); reweight_entity(cfs_rq_of(se), se, 共有, 実行可能); /* 3 */ }
- グループ se に対応するグループ cfs_rq を取得します。
- 新しい重量値を計算します。
- グループ se のウェイト値をシェアに更新します。
calc_group_shares() は、現在のグループ cfs_rq 負荷に基づいて新しい重みを計算します。
static long calc_group_shares(struct cfs_rq *cfs_rq) { 長い tg_weight、tg_shares、load、shares; struct task_group *tg = cfs_rq->tg; tg_shares = READ_ONCE(tg->shares); ロード = max(scale_load_down(cfs_rq->load.weight), cfs_rq->avg.load_avg); tg_weight = atomic_long_read(&tg->load_avg); /* tg_weight >= 負荷であることを確認します */ tg_weight -= cfs_rq->tg_load_avg_contrib; tg_weight += 負荷; シェア = (tg_shares * ロード); if (tg_weight) シェア /= tg_weight; 戻りクランプ_t(ロング、シェア、MIN_SHARES、tg_shares); }
calc_group_shares() 関数によると、次のような重み計算式を取得できます (grq := group cfs_rq)。
tg->shares * ロード ge->load.weight = -------------------------------------- ------ tg->load_avg - grq->tg_load_avg_contrib + ロード 負荷 = max(grq->load.weight, grq->avg.load_avg)
tg->load_avg指所有的group cfs_rq负载贡献和。grq->tg_load_avg_contrib是指该group cfs_rq已经向tg->load_avg贡献的负载。因为tg是一个全局共享变量,多个CPU可能同时访问,为了避免严重的资源抢占。group cfs_rq负载贡献更新的值并不会立刻加到tg->load_avg上,而是等到负载贡献大于tg_load_avg_contrib一定差值后,再加到tg->load_avg上。例如,2个CPU的系统。CPU0上group cfs_rq初始值tg_load_avg_contrib为0,当group cfs_rq每次定时器更新负载的时候并不会访问tg变量,而是等到group cfs_rq的负载grp->avg.load_avg大于tg_load_avg_contrib很多的时候,这个差值达到一个数值(假设是2000),才会更新tg->load_avg为2000。然后,tg_load_avg_contrib的值赋值2000。又经过很多个周期后,grp->avg.load_avg和tg_load_avg_contrib的差值又等于2000,那么再一次更新tg->load_avg的值为4000。这样就避免了频繁访问tg变量。
但是上面的计算公式的依据是什么呢?如何得到的?首先我觉得我们能介绍的计算方法是上一节《用户组的权重》说的方法,计算group cfs_rq的权重占的比例。公式如下。
tg->shares * grq->load.weight ge->load.weight = ------------------------------- (1) \Sum grq->load.weight
\Sum grq->load.weightの合計の計算が高すぎるためです (比較的多数の CPU を備えたシステムでは、他の CPU グループ cfs_rq へのアクセスにより、データ アクセスの激しい競争が発生する可能性があります)。したがって、平均負荷値を使用してプロセスを近似しますが、平均負荷値はゆっくりと変化するため、近似値の計算が容易で安定しています。おおよその加工条件は以下の通りであり、重量と平均荷重は概算となります。
grq->load.weight -> grq->avg.load_avg (2)
近似後、式 (1) は次のように変形されます。
tg->shares * grq->avg.load_avg ge->load.weight = ---------------------------- (3) tg->load_avg ここで: tg->load_avg ~= \Sum grq->avg.load_avg
式 (3) の問題は、平均負荷値の変化が非常にゆっくりであるため (そのように設計されているため)、境界条件中に過渡現象が発生する可能性があることです。具体的には、アイドル グループがプロセスの実行を開始したときです。CPU の grq->avg.load_avg はゆっくりと変化するのに時間がかかり、望ましくない遅延が発生します。この特殊なケース (シングルコア CPU の場合も同様) では、式 (1) は次のように計算されます。
tg->shares * grq->load.weight ge->load.weight = ----------------------------- = tg->shares (4) grq->load.weight
近似式(3)をUPシナリオの式(4)の状況に修正することが目標です。
ge->load.weight = tg->shares * grq->load.weight -------------------------------------------------- - (5) tg->load_avg - grq->avg.load_avg + grq->load.weight
ただし、grq->load.weight は 0 に減らすことができるため、除数は 0 になります。したがって、下限として grq->avg.load_avg を使用する必要があります。これにより、次のようになります。
tg->shares * grq->load.weight ge->load.weight = ------------------------ (6) tg_load_avg' ここで: tg_load_avg' = tg->load_avg - grq->avg.load_avg + max(grq->load.weight, grq->avg.load_avg)
UP システムでは、式 (6) は式 (4) と似ています。通常の状況では、式 (6) は式 (3) と同様です。
正直に言うと、本当に多くの数式があり、さまざまな近似値やパラメータがあります。数式の結果を一度に見るのは常に混乱を招きます。これは、複数の異なる最適化の変更が含まれる可能性があり、その一部は経験の要約であり、一部は実際の環境テストである可能性があるためです。計算式が理解できない場合は、最初にこの関数を追加したときの状態に戻ると、最初のバージョンの方が受け入れられやすいです。次に、各提出レコードをたどって、コードを最適化する理由を段階的に確認します。たとえば、「春の花が咲く海に面している」などです。
4. CFS スケジューラ - PELT (エンティティごとの負荷追跡)
1. なぜPELTが必要なのでしょうか?
スケジューラをよりスマートにするために、私たちはシステムが消費電力を最小限に抑えながら最大のスループットを満たせることを常に望んでいます。多少の矛盾はあるかもしれませんが、現実はいつもこんな感じです。PELT アルゴリズムは Linux 3.8 に組み込まれましたが、その前に PELT アルゴリズムを導入するにはどのような問題があったのでしょうか? Linux 3.8 より前では、CFS は実行キュー (rq) ごとに負荷を追跡していました。しかし、このアプローチでは、現在の負荷の原因を特定できません。同時に、ワークロードが比較的安定している場合でも、rq レベルで負荷を追跡すると、その値に大きな変化が生じる可能性があります。上記の問題を解決するために、PELT アルゴリズムは各スケジューリング エンティティ (スケジューリング エンティティごと) の負荷を追跡します。
2. PELTのやり方
特定の原則については、この記事「エンティティごとの負荷追跡」を参照してください。恥ずかしながら、この記事から抜粋させていただきます。エンティティごとの負荷追跡を実現するために、時間 (仮想時間ではなく物理時間) が 1024 マイクロ秒のシーケンスに分割されます。各 1024 マイクロ秒の期間で、システム負荷に対するエンティティの寄与は、エンティティの実行可能状態 (つまり、 CPU での実行にかかる時間、または CPU の実行がスケジュールされるまでの待機時間) が計算されます。このサイクル中の実行可能時間が x の場合、システム負荷への寄与は (x/1024) です。もちろん、1 回の計算サイクルでのエンティティの負荷が 1024us を超える場合があります。これは、過去のサイクルの負荷を累積するためです。もちろん、計算時に過去の負荷に減衰係数を乗算する必要があります。期間 pi におけるシステム負荷に対するスケジューリング エンティティの寄与を Li で表すとすると、システム負荷に対するスケジューリング エンティティの合計寄与は次のように表すことができます。
L = L0 + L1 * y + L2 * y2 + L3 * y3 + ... + Ln * yn
- y32 = 0.5、y = 0.97857206
上記の計算式を初めて見たとき、「これはどういうことだろう?」と疑問に思った方もいらっしゃるのではないでしょうか?たとえば、SE の負荷寄与を計算する方法などです。初めて rq に参加してから 4096us 実行され、スリープ状態になっているタスクがある場合、1023us、2047us、3071us、4095us、5119us、6143us、7167us、および 8191us の各瞬間の負荷寄与はいくらですか?
1023us: L0 = 1023 2047us: L1 = 1023 + 1024 * y = 1023 + (L0 + 1) * y = 2025 3071us: L2 = 1023 + 1024 * y + 1024 * y2 = 1023 + (L1 + 1) * y = 3005 4095us: L3 = 1023 + 1024 * y + 1024 * y2 + 1024 * y3 = 1023 + (L2 + 1) * y = 3963 5119us: L4 = 0 + 1024 * y + 1024 * y2 + 1024 * y3 + 1024 * y4 = 0 + (L3 + 1) * y = 3877 6143us: L5 = 0 + 0 + 1024 * y2 + 1024 * y3 + 1024 * y4 + 1024 * y5 = 0 + L4 * y = 3792 7167us: L6 = 0 + L5 * y = L4 * y2 = 3709 8191us: L7 = 0 + L6 * y = L5 * y2 = L4 * y3 = 3627
上記の例の後、ルールを見つけるのは難しくありません。現時点での負荷を計算するには、前期間の負荷寄与の合計に減衰係数 y を乗算し、その時点での負荷を加算するだけです。現在の時点。
从上面的计算公式我们也可以看出,经常需要计算val*yn的值,因此内核提供decay_load()函数用于计算第n个周期的衰减值。为了避免浮点数运算,采用移位和乘法运算提高计算速度。decay_load(val, n) = val*yn*232>>32。我们将yn*232的值提前计算出来保存在数组runnable_avg_yN_inv中。
runnable_avg_yN_inv[n] = yn*232, n > 0 && n < 32
runnable_avg_yN_inv的计算可以参考/Documentation/scheduler/sched-pelt.c文件calc_runnable_avg_yN_inv()函数。由于y32=0.5,因此我们只需要计算y*232~y31*232的值保存到数组中即可。当n大于31的时候,为了计算yn*232我们可以借助y32=0.5公式间接计算。例如y33*232=y32*y*232=0.5*y*232=0.5*runnable_avg_yN_inv[1]。calc_runnable_avg_yN_inv()函数简单归纳就是:runnable_avg_yN_inv[i] = ((1UL << 32) - 1) * pow(0.97857206, i),i>=0 && i<32。pow(x, y)是求xy的值。计算得到runnable_avg_yN_inv数组的值如下:
static const u32 runnable_avg_yN_inv[] = { 0xffffffff, 0xfa83b2da, 0xf5257d14, 0xefe4b99a, 0xeac0c6e6, 0xe5b906e6, 0xe0ccdeeb, 0xdbfbb796, 0xd744fcc9, 0xd2a81d91, 0xce248c14, 0xc9b9bd85, 0xc5672a10, 0xc12c4cc9, 0xbd08a39e, 0xb8fbaf46, 0xb504f333, 0xb123f581, 0xad583ee9, 0xa9a15ab4, 0xa5fed6a9, 0xa2704302, 0x9ef5325f, 0x9b8d39b9, 0x9837f050, 0x94f4efa8, 0x91c3d373, 0x8ea4398a, 0x8b95c1e3, 0x88980e80, 0x85aac367, 0x82cd8698, };
根据runnable_avg_yN_inv数组的值,我们就方便实现decay_load()函数。
/* * Approximate: * val * y^n, where y^32 ~= 0.5 (~1 scheduling period) */ static u64 decay_load(u64 val, u64 n) { unsigned int local_n; if (unlikely(n > LOAD_AVG_PERIOD * 63)) /* 1 */ return 0; /* after bounds checking we can collapse to 32-bit */ local_n = n; /* * As y^PERIOD = 1/2, we can combine * y^n = 1/2^(n/PERIOD) * y^(n%PERIOD) * With a look-up table which covers y^n (n<PERIOD) * * To achieve constant time decay_load. */ if (unlikely(local_n >= LOAD_AVG_PERIOD)) { /* 2 */ val >>= local_n / LOAD_AVG_PERIOD; local_n %= LOAD_AVG_PERIOD; } val = mul_u64_u32_shr(val, runnable_avg_yN_inv[local_n], 32); /* 2 */ 戻り値; }
- LOAD_AVG_PERIOD の値は 32 です。2016 サイクル後、減衰値は 0 になると考えられます。つまり、val*yn=0、n > 2016 です。
- nが32以上の場合、y32=0.5の条件に基づいてynの値を計算する必要があります。yn*232 = 1/2n/32 * yn%32*232=1/2n/32 * runnable_avg_yN_inv[n%32]。
3. 現在の負荷寄与の計算方法
经过上面举例,我们可以知道计算当前负载贡献并不需要记录所有历史负载贡献。我们只需要知道上一刻负载贡献就可以计算当前负载贡献,这大大降低了代码实现复杂度。我们继续上面举例问题的思考,我们依然假设一个task开始从0时刻运行,那么1022us后的负载贡献自然就是1022。当task经过10us之后,此时(现在时刻是1032us)的负载贡献又是多少呢?很简单,10us中的2us和之前的1022us可以凑成一个周期1024us。这个1024us需要进行一次衰减,即现在的负载贡献是:(1024 - 1022 + 1022)y + 10 - (1024 - 1022) = 1022y + 2y + 8 = 1010。1022y可以理解成由于经历了一个周期,因此上一时刻的负载需要衰减一次,因此1022需要乘以衰减系数y,2y可以理解成,2us属于上一个负载计算时距离一个周期1024us的差值,由于2是上一个周期的时间,因此也需要衰减一次,8是当前周期时间,不需要衰减。又经过了2124us,此时(现在时刻是3156us)负载贡献又是多少呢?即:(1024 - 8 + 1010)y2 + 1024y + 2124 - 1024 - (1024 - 8) = 1010y2 + 1016y2 + 1024y + 84 = 3024。2124us可以分解成3部分:1016us补齐上一时刻不足1024us部分,凑成一个周期;1024us一个整周期;当前时刻不足一个周期的剩余84us部分。相当于我们经过了2个周期,因此针对上一次的负载贡献需要衰减2次,也就是1010y2部分,1016us是补齐上一次不足一个周期的部分,因此也需要衰减2次,所以公式中还有1016y2 部分。1024us部分相当于距离当前时刻是一个周期,所以需要衰减1次,最后84部分是当前剩余时间,不需要衰减。
上記の例では、より一般的な計算式を得ることができます。最後の瞬間の負荷寄与を u と仮定すると、d 時間後の負荷寄与をどのように計算しますか? 上記の例に基づいて、時間 d を 3 つの部分に分割できます。d1 は現在時刻から最も遠い (不完全な) 期間の残りの部分、d2 は完全な期間の時間、d3 は (不完全な) 期間の残りの部分です。現在の期間の部分。時間 d が p 周期を経過するとします (d=d1+d2+d3、p=1+d2/1024)。d1、d2、d3 の概略図は次のとおりです。
d1 d2 d3 ^ ^ ^ | | | |<->|<----------------->|<--->| |---x---|------| ... |------|-----x (現在) p-1 u' = (u + d1) y^p + 1024 \Sum y^n + d3 y^0 n=1 p-1 = uy^p + d1 y^p + 1024 \Sum y^n + d3 y^0 n=1
上記の例は、上記の式を使用して計算できます。たとえば、最後の負荷寄与率 u=1010 と経過時間 d=2124us は、d1=1016us、d2=1024、および d3=84 の 3 つの部分に分解できます。経験した期間は p=2 です。したがって、電流負荷寄与率 u'=1010y2 + 1016y2 + 1024y + 84 は、上記の計算結果と一致します。
4. 負荷情報の記録方法
Linux では、スケジューリング エンティティ se またはレディ キュー cfs rq の負荷情報を記録するために、struct sched_avg 構造体が使用されます。各スケジューリング エンティティ se および cfs Ready キュー構造には、負荷情報を記録するための struct sched_avg 構造が含まれています。struct sched_avg は次のように定義されます。
構造体 sched_avg { u64 最終更新時刻; u64 ロードサム; u64 runnable_load_sum; u32 util_sum; u32 period_contrib; unsigned longload_avg; unsigned long runnable_load_avg; unsigned long util_avg; };
- last_update_time: 前回のロード更新時刻。時間間隔を計算するために使用されます。
- load_sum: 実行可能時間に基づく負荷寄与の合計。実行可能時間には 2 つの部分が含まれます。1 つは CPU が rq で実行するようにスケジュールされるまでの待機時間、もう 1 つは CPU 上で実行されている時間です。
- util_sum: 実行時間に基づく負荷寄与の合計。実行時間は、スケジューリング エンティティ se が CPU 上で実行されている時間を指します。
- load_avg: 実行可能時間に基づく平均負荷寄与。
- util_avg: 実行時間に基づく平均負荷寄与。
スケジューリング エンティティ se は、タスクまたはグループに属することができます (Linux はグループ スケジューリングをサポートしており、CONFIG_FAIR_GROUP_SCHED で構成する必要があります)。スケジューリングエンティティ se の初期化も、タスク se とグループ se で異なります。スケジューリングエンティティは、struct sched_entity を使用して次のように記述されます。
構造体 sched_entity { 構造体load_weightロード; unsigned long runnable_weight; #ifdef CONFIG_SMP struct sched_avg avg; #endif };
调度实体se初始化函数是init_entity_runnable_average(),代码如下。
void init_entity_runnable_average(struct sched_entity *se) { struct sched_avg *sa = &se->avg; memset(sa, 0, sizeof(*sa)); /* * Tasks are intialized with full load to be seen as heavy tasks until * they get a chance to stabilize to their real load level. * Group entities are intialized with zero load to reflect the fact that * nothing has been attached to the task group yet. */ if (entity_is_task(se)) sa->runnable_load_avg = sa->load_avg = scale_load_down(se->load.weight); se->runnable_weight = se->load.weight; /* when this task enqueue'ed, it will contribute to its cfs_rq's load_avg */ }
タスクseの初期化では、runnable_load_avgとload_avgの値はseの重みと等しくなります(se->load.weight)。また、コメントによれば、その後の負荷計算で runnable_load_avg とload_avg によって累積される最大値が、実際には se の重み値であることもわかります。これは、runnable_load_avg とload_avg の値がタスクの複雑さを間接的に示すことができることを意味します。runnable_weight メンバーは主にグループ se に提案されています。タスク se の場合、runnable_weight は se の重みであり、2 つの値はまったく同じです。
グループseの場合、runnable_load_avgとload_avgの値は0に初期化されます。これは、現在のタスク グループにスケジュールする必要のあるタスクがないことも意味します。runnable_weight は se の重み値に初期化されていますが、runnable_weight の値は後続のコードで継続的に更新されます。runnable_weight はエンティティの重みの一部であり、グループ runqueue の実行可能な部分を表します。
5. 負荷計算コードの実装
上記の情報を理解した後、前のセクションで負荷寄与を計算するための式のソース コード実装の検討を開始できます。
p-1 u' = (u + d1) y^p + 1024 \Sum y^n + d3 y^0 n=1 = uy^p + (ステップ 1) p-1 d1 y^p + 1024 \Sum y^n + d3 y^0 (ステップ 2) n=1
上記の式はコード内の 2 つの部分で実装されており、accumulate_sum() 関数で step1 部分を計算し、次に __accumulate_pelt_segments() 関数を呼び出して step2 部分を計算します。
静的 __always_inline u32 accumulate_sum(u64 デルタ, int cpu, struct sched_avg *sa, 符号なしロングロード、符号なしロング実行可能、int 実行中) { unsigned longscale_freq、scale_cpu; u32 貢献 = (u32)デルタ; /* p == 0 -> デルタ < 1024 */ u64期間。 スケール周波数 = アーチスケール周波数容量 (CPU); スケール_cpu = アーチ_スケール_cpu_容量(NULL, cpu); デルタ += sa->period_contrib; /* 1 */ 周期 = デルタ / 1024; /* 周期は 1024us (~1ms) */ /* 2 */ /* * ステップ 1: 期間の境界を越えた場合は、古い *_sum を減衰させます。 */ if (ピリオド) { sa->load_sum =decay_load(sa->load_sum, period); /* 3 */ sa->runnable_load_sum =decay_load(sa->runnable_load_sum, period); sa->util_sum =decay_load((u64)(sa->util_sum), ピリオド); /* * ステップ2 */ デルタ %= 1024; contrib = __accumulate_pelt_segments(periods, /* 4 */ 1024 - sa->period_contrib, delta); } sa->period_contrib = delta; /* 5 */ contrib = cap_scale(contrib, scale_freq); if (load) sa->load_sum += load * contrib; if (runnable) sa->runnable_load_sum += runnable * contrib; if (running) sa->util_sum += contrib * scale_cpu; return periods; }
- period_contrib记录的是上次更新负载不足1024us周期的时间。delta是经过的时间,为了计算经过的周期个数需要加上period_contrib,然后整除1024。
- 计算周期个数。
- 调用decay_load()函数计算公式中的step1部分。
- __accumulate_pelt_segments()负责计算公式step2部分。
- 更新period_contrib为本次不足1024us部分。
下面分析__accumulate_pelt_segments()函数。
static u32 __accumulate_pelt_segments(u64 periods, u32 d1, u32 d3) { u32 c1, c2, c3 = d3; /* y^0 == 1 */ /* * c1 = d1 y^p */ c1 = decay_load((u64)d1, periods); /* * p-1 * c2 = 1024 \Sum y^n * n=1 * * inf inf * = 1024 ( \Sum y^n - \Sum y^n - y^0 ) * n=0 n=p */ c2 = LOAD_AVG_MAX - decay_load(LOAD_AVG_MAX, periods) - 1024; return c1 + c2 + c3; }
__accumulate_pelt_segments()函数主要的关注点应该是这个c2是如何计算的。本来是一个多项式求和,非常巧妙的变成了一个很简单的计算方法。这个转换过程如下。
p-1 c2 = 1024 \Sum y^n n=1 In terms of our maximum value: inf inf p-1 max = 1024 \Sum y^n = 1024 ( \Sum y^n + \Sum y^n + y^0 ) n=0 n=p n=1 Further note that: inf inf inf ( \Sum y^n ) y^p = \Sum y^(n+p) = \Sum y^n n=0 n=0 n=p Combined that gives us: p-1 c2 = 1024 \Sum y^n n=1 インフインフ = 1024 ( \Sum y^n - \Sum y^n - y^0 ) n=0 n=p = 最大 - (最大 y^p) - 1024
LOAD_AVG_MAX は実際には最大値 1024 (1 + y + y2 + ... + yn) です。計算方法は非常に簡単です。一連の等比数列の和の公式があり、n は正の無限大になる傾向があります。LOAD_AVG_MAX の最終値は 47742 です。もちろん、数学的手法を使用して計算した値はこの値とわずかに異なる場合があり、完全に等しいわけではありません。これは、値 47742 がコードによって計算されるためであり、コンピューターの計算プロセスには浮動小数点演算や丸め演算が含まれるため、誤差が生じるのは正常です。LOAD_AVG_MAXの計算コードは以下のとおりです。
void calc_converged_max(void) { int n = -1; 長い最大 = 1024; 長い最後 = 0、y_inv = ((1UL << 32) - 1) * y; for (; ; n++) { if (n > -1) 最大 = ((最大 * y_inv) >> 32) + 1024; /* ※これは以下と同じです。 * 最大 = 最大*y + 1024; */ if (最後 == 最大) 壊す; 最後 = 最大; } printf("#define LOAD_AVG_MAX %ld\n", max); }
6. エンティティ更新の負荷寄与のスケジュール設定
スケジューリング エンティティの負荷を更新する関数は update_load_avg() です。この関数は以下の状況で呼び出されます。
- レディキューにプロセスを追加することは、CFS の enqueue_entity 操作です。
- 从就绪队列中删除一个进程,在CFS中就是dequeue_entity操作。
- scheduler tick,周期性调用更新负载信息。
static inline void update_load_avg(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags) { u64 now = cfs_rq_clock_task(cfs_rq); struct rq *rq = rq_of(cfs_rq); int cpu = cpu_of(rq); int decayed; /* * Track task load average for carrying it to new CPU after migrated, and * track group sched_entity load average for task_h_load calc in migration */ if (se->avg.last_update_time && !(flags & SKIP_AGE_LOAD)) __update_load_avg_se(now, cpu, cfs_rq, se); /* 1 */ decayed = update_cfs_rq_load_avg(now, cfs_rq); /* 2 */ /* ...... */ }
- __update_load_avg_se()负责更新调度实体se的负载信息。
- 在更新se负载后,顺便更新se attach的cfs就绪队列的负载信息。runqueue的负载就是该runqueue下所有的se负载总和。
__update_load_avg_se()代码如下。
static int __update_load_avg_se(u64 now, int cpu, struct cfs_rq *cfs_rq, struct sched_entity *se) { if (entity_is_task(se)) se->runnable_weight = se->load.weight; /* 1 */ if (___update_load_sum(now, cpu, &se->avg, !!se->on_rq, !!se->on_rq, /* 2 */ cfs_rq->curr == se)) { ___update_load_avg(&se->avg, se_weight(se), se_runnable(se)); /* 3 */ cfs_se_util_change(&se->avg); return 1; } return 0; }
- runnable_weight称作可运行权重,该概念主要针对group se提出。针对task se来说,runnable_weight的值就是和进程权重weight相等。针对group se,runnable_weight的值总是小于等于weight。
- 通过___update_load_sum()函数计算调度实体se的负载总和信息。
- 更新平均负载信息,例如se->load_avg成员。
___update_load_sum()函数实现如下。
static __always_inline int ___update_load_sum(u64 now, int cpu, struct sched_avg *sa, unsigned long load, unsigned long runnable, int running) { u64 delta; デルタ = 今 - sa->last_update_time; デルタ >>= 10; /* 1 */ if (!デルタ) 0を返します。 sa->last_update_time += デルタ << 10; /* 2 */ if (!ロード) 実行可能 = 実行中 = 0; if (!accumulate_sum(delta, cpu, sa,load, runnable, running)) /* 3 */ 0を返します。 1 を返します。 }
- デルタは 2 つの負荷更新間の時間差であり、単位は ns です。1024 を除算すると、ns が us 単位に変換されます。PELT アルゴリズムの最小時間測定単位は us であり、時間差が 1us に満たない場合は、減衰を計算する必要はなく、そのまま値を返します。
- 次回の負荷情報の更新と時差の計算を容易にするために、last_update_time を更新します。
- 負荷の計算は、accumulate_sum() を通じて実行されます。上記の呼び出し場所からわかるように、ここでのパラメータのload、runnable、およびrunningは、0または1のいずれかです。したがって、負荷計算において、se->load_sum と se->runnable_load_sum の最大値は、LOAD_AVG_MAX - 1024 + se->period_contrib であることがわかります。さらに、se->load_sum の値は se->runnable_load_sum と等しくなります。
負荷平均情報がどのように更新されるかを引き続き調査してください。___update_load_avg() 関数は次のとおりです。
静的 __always_inline void ___update_load_avg(struct sched_avg *sa、符号なしロングロード、符号なしロング実行可能) { u32 divider = LOAD_AVG_MAX - 1024 + sa->period_contrib; /* * Step 2: update *_avg. */ sa->load_avg = div_u64(load * sa->load_sum, divider); sa->runnable_load_avg = div_u64(runnable * sa->runnable_load_sum, divider); sa->util_avg = sa->util_sum / divider; }
由上面的代码可知,load是调度实体se的权重weight,runnable是调度实体se的runnable_weight。因此平均负债计算公式如下。针对task se来说,se->load_avg和se->runnable_load_avg的值是相等的(因为,se->load_sum和se->runnable_load_sum相等,并且se->load.weight和se->runnable_weight相等),并且其值是小于等于se->load.weight。
se->load_sum se->load_avg = -------------------------------------------- * se->load.weight LOAD_AVG_MAX - 1024 + sa->period_contrib se->runnable_load_sum se->runnable_load_avg = -------------------------------------------- * se->runnable_weight LOAD_AVG_MAX - 1024 + sa->period_contrib
頻繁に実行されるプロセスの場合、load_avg の値はますます重みに近づきます。たとえば、重み 1024 のプロセスが長時間実行される場合、その負荷寄与曲線は次のようになります。上の表はプロセスの実行時間、下の表は負荷寄与曲線です。
プロセスが実行を開始した瞬間から、負荷の寄与が増加し始めます。これが定期的に実行されるプロセス (毎回 1 ミリ秒実行し、9 ミリ秒スリープ) の場合、負荷寄与曲線はどうなるでしょうか?
負荷寄与率の値は、基本的に最小値と最大値の2つのピーク値の間に維持されます。これも私たちの予想と一致しており、負荷の寄与は反応プロセスの実行頻度であると考えられます。したがって、PELT アルゴリズムに基づいて、負荷分散中にプロセスを他の CPU に移行した場合の影響をより明確に計算できます。
7. Ready Queue による負荷情報の更新
前述したように、レディキューの負荷情報を更新する関数は update_cfs_rq_load_avg() です。
静的インライン整数 update_cfs_rq_load_avg(現在は u64、struct cfs_rq *cfs_rq) { int は減衰しました = 0; 減衰 |= __update_load_avg_cfs_rq(現在、cpu_of(rq_of(cfs_rq)), cfs_rq); 戻りは朽ちた。 }
引き続き __update_load_avg_cfs_rq() を呼び出して、CFS レディ キューの負荷情報を更新します。この関数は、上記の更新スケジューリング エンティティの情報ロード関数と非常によく似ています。
静的整数 __update_load_avg_cfs_rq(u64 の現在、int CPU、struct cfs_rq *cfs_rq) { if (___update_load_sum(now, cpu, &cfs_rq->avg, scale_load_down(cfs_rq->load.weight)、 scale_load_down(cfs_rq->runnable_weight), cfs_rq->curr != NULL)) { ___update_load_avg(&cfs_rq->avg, 1, 1); return 1; } return 0; }
struct cfs_rq结构体内嵌struct sched_avg结构体,用于跟踪就绪队列负载信息。___update_load_sum()函数上面已经分析过,这里和更新调度实体se负载的区别是传递的参数不一样。load和runnable分别传递的是CFS就绪队列的权重以及可运行权重。CFS就绪队列的权重是指CFS就绪队列上所有就绪态调度实体权重之和。CFS就绪队列平均负载贡献是指所有调度实体平均负载之和。在每次更新调度实体负载信息时也会同步更新se依附的CFS就绪队列负载信息。
8、runnable_load_avg和load_avg区别
在介绍struct sched_avg结构体的时候,我们只介绍了load_avg成员而忽略了runnable_load_avg成员。那么他们究竟有什么区别呢?我们知道struct sched_avg结构体会被内嵌在调度实体struct sched_entity和就绪队列struct cfs_rq中,分别用来跟踪调度实体和就绪队列的负载信息。针对task se,runnable_load_avg和load_avg的值是没有差别的。但是对于就绪队列负载来说,二者就有不一样的意义。load_avg代表就绪队列平均负载,其包含睡眠进程的负载贡献。runnable_load_avg只包含就绪队列上所有可运行进程的负载贡献。如何体现区别呢?我们看一下在进程加入就绪队列的处理。又是大家熟悉的enqueue_entity()函数。
static void enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags) { /* * When enqueuing a sched_entity, we must: * - Update loads to have both entity and cfs_rq synced with now. * - Add its load to cfs_rq->runnable_avg * - For group_entity, update its weight to reflect the new share of * its group cfs_rq * - Add its new weight to cfs_rq->load.weight */ update_load_avg(cfs_rq, se, UPDATE_TG | DO_ATTACH); /* 1 */ enqueue_runnable_load_avg(cfs_rq, se); /* 2 */ }
- load_avg成员更新信息,传递flag包含DO_ATTACH。当进程创建第一次调用update_load_avg()函数时,这个flag会用上。
- 更新runnable_load_avg信息。
我们熟悉的update_load_avg()函数如下。
static inline void update_load_avg(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags) { u64 now = cfs_rq_clock_task(cfs_rq); struct rq *rq = rq_of(cfs_rq); int cpu = cpu_of(rq); int decayed; if (!se->avg.last_update_time && (フラグ & DO_ATTACH)) { /* * DO_ATTACH は、enqueue_entity() から来たことを意味します。 * !last_update_time は通過したことを意味します * merge_task_rq_fair() は移行したことを示します。 * * 新しい CPU でタスクをキューに入れています。 */ attach_entity_load_avg(cfs_rq, se, SCHED_CPUFREQ_MIGRATION); /* 1 */ update_tg_load_avg(cfs_rq, 0); } else if (劣化した && (フラグ & UPDATE_TG)) update_tg_load_avg(cfs_rq, 0); }
- プロセスが初めて作成された後、se->avg.last_update_time の値は 0 になります。したがって、今回はattach_entity_load_avg()関数が呼び出されます。
Attach_entity_load_avg() 関数は次のとおりです。
static voidattach_entity_load_avg(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags) { u32 除算器 = LOAD_AVG_MAX - 1024 + cfs_rq->avg.period_contrib; se->avg.last_update_time = cfs_rq->avg.last_update_time; se->avg.period_contrib = cfs_rq->avg.period_contrib; se->avg.util_sum = se->avg.util_avg * divider; se->avg.load_sum = divider; if (se_weight(se)) { se->avg.load_sum = div_u64(se->avg.load_avg * se->avg.load_sum, se_weight(se)); } se->avg.runnable_load_sum = se->avg.load_sum; enqueue_load_avg(cfs_rq, se); cfs_rq->avg.util_avg += se->avg.util_avg; cfs_rq->avg.util_sum += se->avg.util_sum; add_tg_cfs_propagate(cfs_rq, se->avg.load_sum); cfs_rq_util_change(cfs_rq, flags); }
我们可以看到调度室se关于负载的一大堆的初始化。我们现在关注的点是enqueue_load_avg()函数。
enqueue_load_avg()函数如下,很清晰明了直接将调度实体负载信息累加到就绪队列的load_avg成员。
static inline void enqueue_load_avg(struct cfs_rq *cfs_rq, struct sched_entity *se) { cfs_rq->avg.load_avg += se->avg.load_avg; cfs_rq->avg.load_sum += se_weight(se) * se->avg.load_sum; }
プロセスがレディキューから削除された場合、se のロードはレディキューのload_avgから削除されません。したがって、load_avg には、すべてのスケジューリング エンティティの実行可能状態とブロック状態の負荷情報が含まれます。
runnable_load_avg には、実行可能なプロセスの負荷情報のみが含まれます。enqueue_runnable_load_avg() 関数を見てみましょう。非常に明確で、スケジューリングエンティティの負荷情報を runnable_load_avg メンバに直接蓄積します。
静的インラインボイド enqueue_runnable_load_avg(struct cfs_rq *cfs_rq, struct sched_entity *se) { cfs_rq->runnable_weight += se->runnable_weight; cfs_rq->avg.runnable_load_avg += se->avg.runnable_load_avg; cfs_rq->avg.runnable_load_sum += se_runnable(se) * se->avg.runnable_load_sum; }
引き続き dequeue_entity 操作を見てみましょう。
静的ボイド dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int フラグ) { /* * sched_entity をデキューするときは、次のことを行う必要があります。 * - エンティティと cfs_rq の両方が同期されるようにロードを更新します。 * - cfs_rq->runnable_avg から負荷を減算します。 * - cfs_rq->load.weight から以前の重みを減算します。 * - グループ エンティティの場合、新しいシェアを反映するように重みを更新します。 * そのグループ cfs_rq の。 */ update_load_avg(cfs_rq, se, UPDATE_TG); account_entity_dequeue(cfs_rq, se); }
account_entity_dequeue() 関数は、デキューされる予定のスケジューリング エンティティの負荷情報を減算します。account_entity_dequeue() 関数は次のとおりです。
静的インラインボイド dequeue_runnable_load_avg(struct cfs_rq *cfs_rq, struct sched_entity *se) { cfs_rq->runnable_weight -= se->runnable_weight; sub_positive(&cfs_rq->avg.runnable_load_avg, se->avg.runnable_load_avg); sub_positive(&cfs_rq->avg.runnable_load_sum, se_runnable(se) * se->avg.runnable_load_sum); }
ここでは、load_avg メンバーからスケジューリング エンティティの負荷情報を除いたものは確認されず、runnable_load_avg メンバーの変更のみが確認されました。したがって、runnable_load_avg は、エンティティをキューに出入りするようにスケジュールする操作に応じて増減します。したがって、runnable_load_avg には、レディ キュー上のすべての実行可能状態スケジューリング エンティティの負荷情報の合計が含まれます。load_avg は、実行可能なすべてのプロセスとブロックされたプロセスの負荷の合計です。
5. CFS スケジューラ - 帯域幅制御
1 はじめに
帯域幅制御とは何ですか? つまり、ユーザー グループが特定のサイクルで消費できる CPU 時間を制御し、特定のサイクルで消費される CPU 時間が制限を超えると、ユーザー グループ内のタスク スケジューリングが次のサイクルまで制限されます。プロセスの最大 CPU 使用率を制限する必要は本当にありますか? システム内にプロセスが 1 つしかない場合、プロセスの CPU 使用率を最大 50% に制限します。プロセス使用率が 50% に達すると、プロセスの実行が制限され、CPU はアイドル状態になります。意味がないようです。ただし、システム管理者がまさにこれを実行したい場合もあります。これらのプロセスが、一定量の CPU 時間に対してのみ料金を支払う顧客に属している場合、または厳密なリソース プロビジョニングが必要な状況では、プロセス (またはプロセスのグループ) が消費できる CPU 時間の最大シェアを制限する必要がある場合があります。結局のところ、支払った金額と同じだけのサービスが受けられます。この記事では、SCHED_NORMAL プロセスの CPU 帯域幅制御についてのみ説明します。
注: コード分析は Linux 4.18.0 に基づいています。
2. 設計原理
CPU 帯域幅制御を使用する場合は、CONFIG_FAIR_GROUP_SCHED および CONFIG_CFS_BANDWIDTH オプションを構成する必要があります。この機能は、グループが使用する最大 CPU 帯域幅を制限します。2 つの変数quotaとperiodを設定すると、periodは期間を指し、quotaはperiod期間中にグループが使用できるCPU時間制限を指します。グループのプロセスの実行時間がクォータを超えると、プロセスの実行が制限されます。このアクションはスロットルと呼ばれます。次の期間が始まるまで、グループは再スケジュールされます。このプロセスはスロットル解除と呼ばれます。
マルチコア システムでは、ユーザー グループは task_group で記述され、ユーザー グループには CPU スケジューリング エンティティの数と、スケジューリング エンティティに対応するグループ cfs_rq が含まれます。ユーザーグループ内のプロセスを制限するにはどうすればよいですか? ユーザー グループによって管理されているスケジューリング エンティティを対応するレディ キューから単純に削除し、そのスケジューリング エンティティに対応するグループ cfs_rq フラグ ビットをマークするだけです。クォータと期間の値は、tasak_group に埋め込まれている cfs_bandwidth 構造体に保存され、残りのクォータ時間を記録するためのランタイム メンバーも含まれています。ユーザー グループ内のプロセスが一定期間実行されると、対応する実行時間も短縮されます。システムは、周期のサイクル タイムで高精度タイマーを開始します。タイマー時間に達すると、残りのクォータ時間ランタイムがクォータにリセットされ、次のラウンドの時間追跡が開始されます。すべてのユーザー グループ プロセスの実行時間が合計され、合計実行時間がクォータよりも確実に短くなるようにします。各ユーザグループは、CPUレディキューグループcfs_rqの数を管理します。各グループ cfs_rq には、グローバル ユーザー グループ クォータから適用されるクォータ時間もあります。たとえば、期間値は 100 ミリ秒、クォータ値は 50 ミリ秒で、CPU システムが 2 つあるとします。CPU0 上のグループ cfs_rq は、最初にグローバル クォータ時間 (この実際のランタイム値は 45) から 5 ミリ秒間適用され、その後プロセスを実行します。5 ミリ秒の時間がなくなったら、グローバル時間制限クォータから 5 ミリ秒を適用し続けます (この実際の実行時間値は 40)。CPU1 についても同様で、まずクォータからタイム スライスをレディ キュー cfs_rq として適用し、プロセスの実行に使用します。グローバル クォータの残り時間が CPU0 または CPU1 の要求を満たすのに十分でない場合、スロットルに対応する cfs_rq が必要になります。タイマー時間に達したら、すでにスロットルされているすべての cfs_rq のスロットルを解除します。
总结一下就是,cfs_bandwidth就像是一个全局时间池(时间池管理时间,类比内存池管理内存)。每个group cfs_rq如果想让其管理的红黑树上的调度实体调度,必须首先向全局时间池中申请固定的时间片,然后供其进程消耗。当时间片消耗完,继续从全局时间池中申请时间片。终有一刻,时间池中已经没有时间可供申请。此时就是throttle cfs_rq的大好时机。
数据结构
每个task_group都包含cfs_bandwidth结构体,主要记录和管理时间池的时间信息。
struct cfs_bandwidth { #ifdef CONFIG_CFS_BANDWIDTH ktime_t period; /* 1 */ u64 quota; /* 2 */ u64 runtime; /* 3 */ struct hrtimer period_timer; /* 4 */ struct list_head throttled_cfs_rq; /* 5 */ /* ... */ #endif };
- 设定的定时器周期时间。
- 限额时间。
- 剩余可运行时间,在每次定时器回调函数中更新值为quota。
- 上面一直提到的高精度定时器。
- 所有被throttle的cfs_rq挂入此链表,在定时器的回调函数中便利链表执行unthrottle cfs_rq操作。
CFS就绪队列使用cfs_rq结构体描述,和bandwidth相关成员如下:
struct cfs_rq { #ifdef CONFIG_FAIR_GROUP_SCHED struct rq *rq; /* 1 */ struct task_group *tg; /* 2 */ #ifdef CONFIG_CFS_BANDWIDTH int runtime_enabled; /* 3 */ u64 runtime_expires; s64 ランタイム_残り; /* 4 */ u64 スロットルクロック、スロットルクロックタスク; /* 5 */ u64 throttled_lock_task_time; int スロットル、throttle_count; /* 6 */ struct list_head throttled_list; /* 7 */ #endif /* CONFIG_CFS_BANDWIDTH */ #endif /* CONFIG_FAIR_GROUP_SCHED */ };
- cfs_rq が接続されている CPU 実行キュー。各 CPU には rq 実行キューが 1 つだけあります。
- cfs_rq が属する task_group。
- レディキューの帯域幅制限がオンになっているかどうか。デフォルトの帯域幅制限はオフになっています。帯域幅制限が有効な場合、runtime_enabled の値は 1 です。
- グローバル タイム プールから cfs_rq によって適用されるタイム スライスの残り時間。残り時間が 0 以下の場合は、タイム スライスを再度適用する必要があります。
- cfs_rq がスロットルされている場合、スロットルされている時間をカウントすると便利です。また、スロットルが開始された時刻を記録する必要があります。
- throttled: cfs_rq が調整されている場合、調整された変数は 1 に設定されます。調整されていない場合、調整された変数は 0 に設定されます。 throttle_count: task_group はネストをサポートしているため、親 task_group の cfs_rq が調整されている場合、cfs_rq の throttle_count メンバー数そのchaild task_groupの増加に対応します。
- 調整された cfs_rq は、cfs_bandwidth->throttled_cfs_rq リンク リストにハングされます。
帯域幅への貢献
定期的なスケジューリングでは、現在実行中のプロセスの仮想時間を更新するために update_curr() 関数が呼び出されます。この時点で、プロセスの帯域幅の寄与も蓄積されます。プロセスが接続されている cfs_rq の使用可能時間からプロセスの実行時間を差し引き、時間が足りない場合は、グローバル タイム プールから特定のタイム スライスを適用します。update_curr() 関数の account_cfs_rq_runtime() 関数を呼び出して、cfs_rq の残りの実行時間をカウントします。
静的 __always_inline void account_cfs_rq_runtime(struct cfs_rq *cfs_rq, u64 delta_exec) { if (!cfs_bandwidth_used() || !cfs_rq->runtime_enabled) 戻る; __account_cfs_rq_runtime(cfs_rq, delta_exec); }
CFS 帯域幅制御機能が有効な場合、cfs_bandwidth_used() は 1 を返し、cfs_rq->runtime_enabled の値は 1 になります。__account_cfs_rq_runtime() 関数は次のとおりです。
static void __account_cfs_rq_runtime(struct cfs_rq *cfs_rq, u64 delta_exec) { /* クォータが期限切れになる前に delta_exec をドックします (期間をまたぐ可能性があるため) */ cfs_rq->runtime_remaining -= delta_exec; /* 1 */ 期限切れ_cfs_rq_runtime(cfs_rq); if (likely(cfs_rq->runtime_remaining > 0)) /* 2 */ 戻る; /* * if we're unable to extend our runtime we resched so that the active * hierarchy can be throttled */ if (!assign_cfs_rq_runtime(cfs_rq) && likely(cfs_rq->curr)) /* 4 */ resched_curr(rq_of(cfs_rq)); /* 5 */ }
- 进程已经运行delta_exec时间,因此cfs_rq剩余可运行时间减少。
- 如果cfs_rq剩余运行时间还有,那么没必要向全局时间池申请时间片。
- 如果cfs_rq可运行时间不足,assign_cfs_rq_runtime()负责从全局时间池中申请时间片。
- 如果全局时间片时间不够,就需要throttle当前cfs_rq。当然这里是设置TIF_NEED_RESCHED flag。在后面throttle。
assign_cfs_rq_runtime()函数如下:
static int assign_cfs_rq_runtime(struct cfs_rq *cfs_rq) { struct task_group *tg = cfs_rq->tg; struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(tg); u64 amount = 0, min_amount, expires; int expires_seq; /* note: this is a positive sum as runtime_remaining <= 0 */ min_amount = sched_cfs_bandwidth_slice() - cfs_rq->runtime_remaining; /* 1 */ raw_spin_lock(&cfs_b->lock); if (cfs_b->quota == RUNTIME_INF) /* 2 */ amount = min_amount; else { start_cfs_bandwidth(cfs_b); /* 3 */ if (cfs_b->runtime > 0) { amount = min(cfs_b->runtime, min_amount); cfs_b->runtime -= amount; /* 4 */ cfs_b->idle = 0; } } expires_seq = cfs_b->expires_seq; expires = cfs_b->runtime_expires; raw_spin_unlock(&cfs_b->lock); cfs_rq->runtime_remaining += amount; /* 5 */ /* * we may have advanced our local expiration to account for allowed * spread between our sched_clock and the one on which runtime was * issued. */ if (cfs_rq->expires_seq != expires_seq) { cfs_rq->expires_seq = expires_seq; cfs_rq->runtime_expires = expires; } cfs_rq->runtime_remaining > 0 を返します。/* 6 */ }
- グローバル タイム プールから要求されるデフォルトのタイム スライスは 5 ミリ秒です。
- cfs_rq が帯域幅を制限しない場合、クォータ値は RUNTIME_INF になります。帯域幅が制限されていないため、自然なタイム プール時間は無尽蔵であり、タイム スライスの適用は成功する必要があります。
- タイマーがオンになっていることを確認し、オフになっている場合はオンにします。このタイマーは、スケジュールされた時間に達した後、グローバル タイム プールで利用可能な残り時間をリセットします。
- タイム スライスの適用は成功し、グローバル タイム プール内の残りの利用可能な時間が更新されます。
- cfs_rq の残りの使用可能時間が増加します。
- cfs_rq がグローバル タイム プールからタイム スライスを適用できない場合、この関数は 0 を返し、それ以外の場合は 1 を返します。これは、タイム スライスの適用が成功し、スロットルが必要ないことを意味します。
cfs_rq をスロットルする方法
上記の assign_cfs_rq_runtime() 関数が 0 を返し、これはアプリケーション時間が失敗したことを意味すると仮定します。cfs_rq を調整する必要があります。関数が戻った後、TIF_NEED_RESCHED フラグが設定されます。これは、スケジューリングが開始されることを意味します。スケジューラのコア層は、pick_next_task() 関数を通じて実行する必要がある次のプロセスを選択します。CFS スケジューラの pick_next_task インターフェイス関数は pick_next_task_fair() です。CFS スケジューラは、プロセスを選択する前に put_prev_task() を実行します。この関数では、インターフェイス関数 put_prev_task_fair() が呼び出されます。関数は次のとおりです。
static void put_prev_task_fair(struct rq *rq, struct task_struct *prev) { struct sched_entity *se = &prev->se; 構造体 cfs_rq *cfs_rq; for_each_sched_entity(se) { cfs_rq = cfs_rq_of(se); put_prev_entity(cfs_rq, se); } }
prev は、これからスケジュールされるプロセスを指します。put_prev_entity() 関数で check_cfs_rq_runtime() を呼び出して、cfs_rq->runtime_remaining の値が 0 より小さいかどうかを確認します。0 より小さい場合は、絞り込まれた。
static bool check_cfs_rq_runtime(struct cfs_rq *cfs_rq) { if (!cfs_bandwidth_used()) false を返します。 if (likely(!cfs_rq->runtime_enabled || cfs_rq->runtime_remaining > 0)) /* 1 */ false を返します。 if (cfs_rq_throttled(cfs_rq)) /* 2 */ true を返します。 throttle_cfs_rq(cfs_rq); /* 3 */ true を返します。 }
- cfs_rq がスロットリングの条件を満たしているかどうか、および使用可能な実行時間が 0 未満であるかどうかを確認します。
- cfs_rq がすでに調整されている場合、操作を繰り返す必要はありません。
- throttle_cfs_rq() 関数は、実際のスロットル操作であり、スロットルの中核となる関数です。
throttle_cfs_rq() 関数は次のとおりです。
静的 void throttle_cfs_rq(struct cfs_rq *cfs_rq) { struct rq *rq = rq_of(cfs_rq); struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(cfs_rq->tg); struct sched_entity *se; 長いタスクデルタ、デキュー = 1; ブール値が空。 se = cfs_rq->tg->se[cpu_of(rq_of(cfs_rq))]; /* 1 */ /* 調整されている間、階層の実行可能平均をフリーズします */ rcu_read_lock(); walk_tg_tree_from(cfs_rq->tg, tg_throttle_down, tg_nop, (void *)rq); /* 2 */ rcu_read_unlock(); task_delta = cfs_rq->h_nr_running; for_each_sched_entity(se) { struct cfs_rq *qcfs_rq = cfs_rq_of(se); /* スロットルされたエンティティまたは非アクティブ化時のスロットル */ if (!se->on_rq) 壊す; if (デキュー) dequeue_entity(qcfs_rq, se, DEQUEUE_SLEEP); /* 3 */ qcfs_rq->h_nr_running -= task_delta; if (qcfs_rq->load.weight) /* 4 */ デキュー = 0; } もし (!se) sub_nr_running(rq, task_delta); cfs_rq->スロットル = 1; /* 5 */ cfs_rq->throttled_lock = rq_lock(rq); raw_spin_lock(&cfs_b->lock); 空 = list_empty(&cfs_b->throttled_cfs_rq); list_add_rcu(&cfs_rq->throttled_list, &cfs_b->throttled_cfs_rq); /* 6 */ (空の場合) start_cfs_bandwidth(cfs_b); raw_spin_unlock(&cfs_b->lock); }
- スロットルに対応する cfs_rq は、対応するグループ se をそのレディ キューの赤黒ツリーから削除できるため、picking_next_task を選択するときに、ルート cfs_rq の赤黒ツリーをたどると、スロットルされた SE が見つからなくなります。 、つまり、実行する機会がありません。
- task_group は親子関係でネストできます。walk_tg_tree_from() 関数は、cfs_rq->tg に沿って各子 task_group を促進し、各 task_group に対して tg_throttle_down() 関数を呼び出します。tg_throttle_down() は、cfs_rq->throttle_count カウントを増やす役割を果たします。
- cfs_rq に接続されている赤黒ツリーから削除します。
- qcfs_rq を実行しているプロセスにデキューされる SE が 1 つだけある場合は、親 SE もデキューする必要があります。qcfs_rq->load.weight が 0 でない場合、qcfs_rq レディキュー上で複数の se プロセスが実行されていることを意味し、親 se はデキューされるべきではありません。
- スロットルフラグを設定します。
- スロットルの瞬間を記録します。
- スロットルされた cfs_rq は cfs_b リンク リストに追加されるため、後続のスロットル解除操作でこれらのスロットルされた cfs_rq を見つけることができます。
tg_throttle_down() 関数は次のとおりで、主に cfs_rq->throttle_count カウントをインクリメントします。
static int tg_throttle_down(struct task_group *tg, void *data) { struct rq *rq = データ; struct cfs_rq *cfs_rq = tg->cfs_rq[cpu_of(rq)]; /* グループはスロットル状態に入り、停止時間になります */ if (!cfs_rq->throttle_count) cfs_rq->throttled_クロック_タスク = rq_クロック_タスク(rq); cfs_rq->throttle_count++; 0を返します。 }
cfs_rq をスロットルすると、データ構造図は次のようになります。
throttle cfs_rq にアタッチされている task_group の子リストに従い、すべての task_group を見つけて、CPU に対応する cfs_rq->throttle_count メンバーを追加します。
cfs_rq のスロットルを解除する方法
スロットル解除 cfs_rq 操作は、定期タイマーが期限切れになると実行されます。アンスロットル cfs_rq 操作を担当する関数は unthrottle_cfs_rq() で、throttle_cfs_rq() とは逆の操作を行います。機能は次のとおりです。
void unthrottle_cfs_rq(struct cfs_rq *cfs_rq) { struct rq *rq = rq_of(cfs_rq); struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(cfs_rq->tg); struct sched_entity *se; int エンキュー = 1; 長いタスクデルタ; se = cfs_rq->tg->se[cpu_of(rq)]; /* 1 */ cfs_rq->スロットル = 0; /* 2 */ 更新_rq_クロック(rq); raw_spin_lock(&cfs_b->lock); cfs_b->throttled_time += rq_lock(rq) - cfs_rq->throttled_lock; /* 3 */ list_del_rcu(&cfs_rq->throttled_list); /* 4 */ raw_spin_unlock(&cfs_b->lock); /* 階層的なスロットル状態を更新します */ walk_tg_tree_from(cfs_rq->tg, tg_nop, tg_unthrottle_up, (void *)rq); /* 5 */ if (!cfs_rq->load.weight) /* 6 */ 戻る; task_delta = cfs_rq->h_nr_running; for_each_sched_entity(se) { if (se->on_rq) エンキュー = 0; cfs_rq = cfs_rq_of(se); if (エンキュー) enqueue_entity(cfs_rq, se, ENQUEUE_WAKEUP); /* 7 */ cfs_rq->h_nr_running += task_delta; if (cfs_rq_throttled(cfs_rq)) 壊す; } もし (!se) add_nr_running(rq, task_delta); /* 潜在的にアイドル状態の CPU をウェイクアップする必要があるかどうかを決定します: */ if (rq->curr == rq->idle && rq->cfs.nr_running) resched_curr(rq); }
- アンスロットル操作は、cfs_rq に対応するスケジューリング エンティティであり、親 cfs_rq 上でのみ実行される可能性があります。
- スロットルフラグはクリアされます。
- throttled_time は cfs_rq が調整された合計時間を記録し、throttled_ Clock は throttle_cfs_rq() 関数での調整の開始を記録します。
- リンクされたリストから自身を削除します。
- tg_unthrottle_up() 関数は、tg_throttle_down() 関数の逆の操作で、cfs_rq->throttle_count カウントをデクリメントします。
- unthrottle の cfs_rq にプロセスがない場合、エンキュー操作を実行する必要はありません。cfs_rq->load.weight は 0 です。これは、準備完了キューに実行可能なプロセスが存在しないことを意味します。
- スケジューリング エンティティをキューに追加します。ここでの for ループ操作は、throttle_cfs_rq() 関数のデキュー操作に対応します。
tg_unthrottle_up() 関数は次のとおりです。
static int tg_unthrottle_up(struct task_group *tg, void *data) { struct rq *rq = データ; struct cfs_rq *cfs_rq = tg->cfs_rq[cpu_of(rq)]; cfs_rq->throttle_count--; if (!cfs_rq->throttle_count) { /* cfs_rq_lock_task() を調整します */ cfs_rq->throttled_lock_task_time += rq_lock_task(rq) - cfs_rq->throttled_lock_task; } 0を返します。 }
cfs_rq->throttle_count カウントのデクリメントに加えて、throttled_lock_task_time 時間も計算されます。throttled_time とは異なり、throttled_ Clock_task_time 時間には、親 cfs_rq が調整された時間も含まれます。スロットル解除状態ですが、親 cfs_rq はスロットル状態にあるため、単独で実行することはできません。したがって、throttled_lock_task_time は、cfs_rq->throttle_count がゼロ以外から 0 に変化するまでにかかる合計時間をカウントします。
クォータを定期的に更新する
帯域幅制限は task_group に基づいており、各 task_group には組み込みの cfs_bandwidth 構造があります。クォータの定期更新には高精度タイマーが使用され、その周期は一定です。struct hrtimer period_timer は、この目的のために cfs_bandwidth 構造体に埋め込まれています。タイマーの初期化関数は init_cfs_bandwidth() です。
void init_cfs_bandwidth(struct cfs_bandwidth *cfs_b) { raw_spin_lock_init(&cfs_b->lock); cfs_b->ランタイム = 0; cfs_b->クォータ = RUNTIME_INF; cfs_b->period = ns_to_ktime(default_cfs_period()); INIT_LIST_HEAD(&cfs_b->throttled_cfs_rq); hrtimer_init(&cfs_b->period_timer, CLOCK_MONOTONIC, HRTIMER_MODE_ABS_PINNED); cfs_b->period_timer.function = sched_cfs_period_timer; hrtimer_init(&cfs_b->slack_timer, CLOCK_MONOTONIC, HRTIMER_MODE_REL); cfs_b->slack_timer.function = sched_cfs_slack_timer; }
2 つの hrtimer、つまり period_timer とlack_timer を初期化します。period_timer のコールバック関数は sched_cfs_period_timer() です。コールバック関数でクォータを更新し、distribute_cfs_runtime() 関数を呼び出して cfs_rq をスロットル解除します。distribution_cfs_runtime() 関数は次のとおりです。
static u64 distribution_cfs_runtime(struct cfs_bandwidth *cfs_b, u64 は残り、u64 は期限切れになります) { 構造体 cfs_rq *cfs_rq; u64 ランタイム。 u64 starting_runtime = 残り; rcu_read_lock(); list_for_each_entry_rcu(cfs_rq, &cfs_b->throttled_cfs_rq, /* 1 */ スロットルリスト) { struct rq *rq = rq_of(cfs_rq); 構造体 rq_flags rf; rq_lock(rq, &rf); if (!cfs_rq_throttled(cfs_rq)) 次へ進みます。 実行時間 = -cfs_rq->実行時間_残り + 1; if (実行時間 > 残り) 実行時間 = 残り; remaining -= runtime; /* 2 */ cfs_rq->runtime_remaining += runtime; /* 3 */ cfs_rq->runtime_expires = expires; /* we check whether we're throttled above */ if (cfs_rq->runtime_remaining > 0) unthrottle_cfs_rq(cfs_rq); /* 3 */ next: rq_unlock(rq, &rf); if (!remaining) break; } rcu_read_unlock(); return starting_runtime - remaining; }
- 循环便利所有已经throttle cfs_rq,函数参数remaining是全局时间池剩余可运行时间。
- remaining是全局时间池剩余时间,这里借给cfs_rq的时间是runtime。
- 如果从全局时间池借到的时间保证cfs_rq->runtime_remaining的值应该大于0,执行unthrottle操作。
別のslack_timerの役割は何ですか? まず別の質問について考えてみましょう。cfs_rq がグローバル タイム プールから 5ms タイム スライスを適用する場合、cfs_rq にはプロセスが 1 つだけあります。プロセスは 0.5ms 実行後にスリープ状態になります。CFS のコード ロジックによれば、 cfs_rq に対応するグループ se 全体がデキューされます。では、残りの 4.5ms はグローバル タイム プールに戻すべきでしょうか? 戻らない場合は、このプロセスが長期間スリープ状態になっていない可能性があり、他の CPU の cfs_rq が 5 ミリ秒未満のタイム スライスに適用される可能性があり (グローバル タイム プール時間は 4 ミリ秒のままです)、スロットルが発生します。実際の使用可能時間は 8.5ms です。したがって、この場合は、他の CPU で使用できる時間の一部を返します。このステップの関数呼び出し処理は、dequeue_entity()→return_cfs_rq_runtime()→__return_cfs_rq_runtime()となります。
static void __return_cfs_rq_runtime(struct cfs_rq *cfs_rq) { struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(cfs_rq->tg); s64lack_runtime = cfs_rq->runtime_remaining - min_cfs_rq_runtime; /* 1 */ if (slack_runtime <= 0) 戻る; raw_spin_lock(&cfs_b->lock); if (cfs_b->quota != RUNTIME_INF && cfs_rq->runtime_expires == cfs_b->runtime_expires) { cfs_b->ランタイム += スラック_ランタイム; /* 2 */ /* we are under rq->lock, defer unthrottling using a timer */ if (cfs_b->runtime > sched_cfs_bandwidth_slice() && !list_empty(&cfs_b->throttled_cfs_rq)) start_cfs_slack_bandwidth(cfs_b); /* 3 */ } raw_spin_unlock(&cfs_b->lock); /* even if it's not valid for return we don't want to try again */ cfs_rq->runtime_remaining -= slack_runtime; /* 4 */ }
- min_cfs_rq_runtime的值是1ms,我们选择至少保留min_cfs_rq_runtime时间给自己,剩下的时间归返全局时间池。全部归返的做法也是不明智的,有可能该cfs_rq上很快就会有进程运行。如果全部归返,进程运行的时候需要立刻去全局时间池申请,效率低。
- 归返全局时间池slack_runtime时间。
- 开启slack_timer定时器条件有2个(从注释可以得知,使用定时器的原因是当前持有rq->lock锁)。
-
- 全局时间池的时间大于5ms,这样才有可能供其他cfs_rq申请时间片(最小申请时间片大小是5ms)。
- 已经存在throttle的cfs_rq,现在开启slack_timer,在回调函数中尝试分配时间片,并unthrottle cfs_rq。
- cfs_rq剩余可用时间减少。
lack_timer タイマーのコールバック関数は sched_cfs_slack_timer() です。sched_cfs_slack_timer() は do_sched_cfs_slack_timer() を呼び出してメイン ロジックを処理します。
static void do_sched_cfs_slack_timer(struct cfs_bandwidth *cfs_b) { u64 ランタイム = 0、スライス = sched_cfs_bandwidth_slice(); u64 の有効期限が切れます。 /* まだ更新境界にいないことを確認します */ raw_spin_lock(&cfs_b->lock); if (runtime_refresh_within(cfs_b, min_bandwidth_expiration)) { /* 1 */ raw_spin_unlock(&cfs_b->lock); 戻る; } if (cfs_b->quota != RUNTIME_INF && cfs_b->runtime > スライス) /* 2 */ ランタイム = cfs_b->ランタイム; 期限切れ = cfs_b->runtime_expires; raw_spin_unlock(&cfs_b->lock); if (!ランタイム) 戻る; runtime = distribution_cfs_runtime(cfs_b, runtime, 期限切れ); /* 3 */ raw_spin_lock(&cfs_b->lock); if (expires == cfs_b->runtime_expires) cfs_b->runtime -= min(runtime, cfs_b->runtime); raw_spin_unlock(&cfs_b->lock); }
- period_timer 時間がもうすぐ到来するかどうかを確認します。period_timer 時間が経過すると、グローバル タイム プールが更新されます。したがって、period_timer を使用して cfs_rq のスロットルを解除できます。period_timer にしばらく時間がかかる必要がある場合は、この時点で現在の関数 unthrottle cfs_rq を使用する必要があります。
- cfs_rq アプリケーションのタイム スライスの単位は 5 ミリ秒であるため、グローバル タイム プールの残りの実行可能時間はスライス (デフォルトは 5 ミリ秒) より大きくなければなりません。
- Distribution_cfs_runtime() 関数が分析され、渡されたパラメータ ランタイムに従って、スロットルを解除できる cfs_rq の数が計算され、いくつかの cfs_rq のスロットルを解除するだけで、最善を尽くします。
3. ユーザースペースの使い方
CFS 帯域幅制御によって提供されるインターフェイスは、cgroupfs の形式で提供されます。以下の 3 つのファイルが提供されます。
- cpu.cfs_quota_us: サイクル内のクォータ時間は、記事で説明されているクォータです。
- cpu.cfs_period_us: サイクル時間。記事で言及されている期間です。
- cpu.stat: 帯域幅制限ステータス情報
デフォルトでは、cpu.cfs_quota_us=-1、cpu.cfs_period_us=100ms です。クォータの値は -1 で、帯域幅制限がないことを意味します。帯域幅を制限したい場合は、これら 2 つのファイルに正当な値を書き込むことができます。クォータと期間の正当な値の範囲は 1ms ~ 1000ms です。さらに、階層関係も考慮する必要があります。cpu.cfs_quota_us に負の値を書き込んでも帯域幅は制限されません。
前述のように、cfs_rq はグローバル タイム プールからの固定サイズのタイム スライスに適用されます。デフォルトの固定サイズは 5ms ですが、もちろん、この値は変更することもできます。ファイルパスは次のとおりです。
/proc/sys/kernel/sched_cfs_bandwidth_slice_us
cpu.statファイルには以下の3点の情報が出力されます。
- nr_periods: これまでに経過した期間の数
- nr_throttled: ユーザー グループに対して帯域幅調整が発生した回数
- throttled_time: ユーザー グループ内のスケジューリング エンティティの合計制限時間の合計
ユーザーグループレベルの制限
cpu.cfs_quota_us インターフェイスと cpu.cfs_period_us インターフェイスは、max(c_i) <= C で task_group の帯域幅を制御できます (C は親 task_group の帯域幅を表し、c_i はその子 task_group を表します)。すべての子 task_group の最大帯域幅は、親 task_group の帯域幅を超えることはできません。ただし、すべての子 task_group の合計帯域幅は、親 task_group の帯域幅よりも大きくすることができます。つまり、 \Sum (c_i) >= C です。したがって、task_group が調整される理由は 2 つ考えられます。
- task_group は 1 サイクル内で独自のクォータを消費します
- 親の task_group は、1 サイクル内で独自のクォータを消費します。
2 番目のケースでは、子 task_group にはまだクォータが残っており、消費されていませんが、子 task_group も、親 task_group の次のサイクル タイムが到着するまで待機する必要があります。
使用例
- task_group の帯域幅を期間 100% に、クォータを 250 ミリ秒に設定します。これは、1 CPU の帯域幅リソースを task_group に提供するのと同等で、合計 CPU 使用率は 100% になります。
echo 250000 > cpu.cfs_quota_us /* クォータ = 250ms */ echo 250000 > cpu.cfs_period_us /* 期間 = 250ms */
- task_group の帯域幅を 200% (マルチコア システム)、500 ミリ秒の周期と 1000 ミリ秒のクォータ設定で設定すると、task_group に 2 CPU の帯域幅リソースを提供することと同等になり、合計 CPU 使用率は 200% になります。
echo 1000000 > cpu.cfs_quota_us /* クォータ = 1000ms */ echo 500000 > cpu.cfs_period_us /* 期間 = 500ms */
期間を長くすると、task_group のスループットが向上します。
- task_group 帯域幅を 20% 未満に設定すると、CPU 帯域幅リソースの 20% が使用される可能性があります。
echo 10000 > cpu.cfs_quota_us /* クォータ = 10ms */ echo 50000 > cpu.cfs_period_us /* 期間 = 50ms */
より短い周期を使用する場合、周期が短いほど、対応する遅延は小さくなります。
6. CFS スケジューラの概要
これまでの一連の記事を説明したので、CFS スケジューラについてはすでにある程度理解できました。したがって、この記事は要約と考察として役立ちます。CFS スケジューラについてのことを思い出してください。CFS スケジューラ設計の原則を質問形式で確認してみましょう。ここで問題が生じます。
1. CFS スケジューラはなぜ仮想タイム スライス vruntime を導入するのですか?
すべてのプロセスに優先順位の区別がないのであれば、vruntime の概念を導入する必要はまったくないと思います。すべてのプロセスの実行に必要な実際の時間は同じであり、誰もが完全に公平です。スケジューラを設計すれば、当然のことながら、各プロセスの実際の実行時間を記録できます。スケジュールが次のプロセスを選択するたびに、最も短い時間実行されているプロセスを選択できます。もちろん、プロセスと重大度の間には関係があります。ユーザーが重要だと考えるプロセスは、より長く実行される必要があります。このとき、プロセスごとに優先順位により実行時間は異なります。優先順位がない場合と同じ方法で次の実行プロセスを選択するとなると、当然少し難しくなります。異なるプロセスの実行時間は異なるはずなので、どのプロセスの実行時間が最も短いかをどのように判断すればよいでしょうか。そこで、仮想時間の概念を導入します。ここで、優先度によって割り当てられた物理時間 (この値を仮想時間と呼びます) に基づいた式を通じて、異なるプロセスが同じ値を計算できることを期待します。実行中の各プロセスの仮想時間を記録します。次に実行中のプロセスを選択する必要がある場合は、仮想時間が最小のプロセスを見つけるだけです。
2. 新しく作成したプロセスの vruntime の初期値は 0 ですか?
まず別の質問を考えてみましょう、fork() によって作成された新しいプロセスの vruntime が 0 の場合はどうなるのでしょうか? レディキュー上のすべてのプロセスの vruntime は、すでに非常に大きな値になっています。CFS スケジューラの pick_next_task_fair() ロジックに従って、新しいプロセスの vruntime 値が 0 の場合、準備完了キュー内の他のプロセスの vruntime に追いつくために、新しいプロセスを選択し、さらに実行し続ける傾向があります。0 にすることはできないので、より適切な初期値は何でしょうか? もちろん、これはレディキュー上のすべてのプロセスの vruntime 値と同様です。操作方法は次の質問で明らかになります。
3. レディキューに記録される min_vruntime の役割は何ですか?
まず、min_vruntime が何を記録するかを理解する必要があります。min_vruntime は、レディキューによって管理されるすべてのプロセスの最小仮想時間を記録します。理論的には、すべてのプロセスの仮想時間は min_vruntime より大きくなります。今回は録画って何の役に立つの?大きく分けて3つの効果があると思います。
- 新しいプロセスを fork() するとき、仮想時間に 0 の値を割り当ててはなりません。それ以外の場合、新しく作成されたプロセスは、準備完了キュー上の他のプロセスの仮想時間と等しくなるまで、他のプロセスに追いつきます。もちろん、これは不公平かつ不合理な設計です。したがって、新しい fork() プロセスについては、min_vruntime の値に基づいて、新しいプロセスに割り当てられる仮想時間を適切に調整します。こうすることで、新しく作成されたプロセスがおかしくなることはありません。
- プロセスが一定期間スリープしてから復帰すると、状況が変化しただけです。私たちも新しいプロセスと同様の状況に直面しています。同様に、min_vruntime の値を適切に調整し、プロセスに割り当てる必要があります。
- 移行プロセスに関しては、新たな問題に直面しています。各 CPU のレディ キューの min_vruntime の値は異なります。大きく異なる場合があります。プロセスが CPU0 から CPU1 に移行された場合、プロセスの vruntime は変更される必要がありますか? 当時はそれが必要でしたが、そうでないと移住後に罰則や報奨金が課せられることになります。この方法では、プロセスの vruntime から CPU0 の min_vruntime を減算し、CPU1 の min_vruntime を加算します。
4. 目覚めたプロセスの vruntime にどう対処するか?
前の質問の後、いくつかの答えが得られるはずです。スリープ時間が非常に長い場合は、当然 min_vruntime の値に従って処理されます。問題は、これにどう対処するかということです。プロセスを起動するための vruntime として、min_vruntime の値に基づいた値を減算します。なぜ値を減算するのでしょうか? プロセスは長い間スリープしていて、CPU 時間をあまり消費していないと思います。ある程度の補償はするのが普通です。ほとんどの対話型アプリケーションは基本的にこの状況に陥ります。この処理により、対話型アプリケーションの対応速度が向上します。睡眠時間が短い場合はどうなりますか?もちろん、プロセスの vruntime に干渉する必要はありません。
5. レディキュー上のすべてのプロセスの vruntime は、必ず min_vruntime より大きくなりますか?
答えはもちろんノーです。min_vruntime を導入する意義は、最終レディ キュー上のすべてのプロセスの最小仮想時間ですが、すべてのプロセスの vruntime が min_vruntime よりも大きいという意味ではありません。この疑問は場合によっては当てはまります。例えば、上記のように覚醒プロセスのvruntimeに一定量の補正を与えると、覚醒プロセスのvruntimeの値はmin_vruntime未満となる。
6. 起動されたプロセスは、現在実行中のプロセスをプリエンプトしますか?
これは 2 つの状況に分けられ、ウェイクアップ プリエンプション機能がオンになっているかどうかによって異なります。それが sched_feat の WAKEUP_PREEMPTION です。ウェイクアップ プリエンプション機能がオンになっていない場合は、何も言うことはありません。次に、この機能がオンになっている場合を考えてみましょう。目覚めたプロセスは min_vruntime の値に基づいて一定の報酬を受け取るため、vruntime は現在実行中のプロセスの vruntime よりも小さい可能性が高くなります。それは、起動中のプロセスの vruntime が現在実行中のプロセスの vruntime より小さい限り、プリエンプトされるという意味ですか? あまり。小さな条件を満たすだけでなく、その上に条件を追加する必要があります。2 つの間の差は、ウェイクアップ粒度時間より大きくなければなりません。この時間は変数 sysctl_sched_wakeup_granularity に格納され、デフォルト値は 1ms です。
7. min_vruntime の初期値が非常に奇妙なのはなぜですか?
レディキュー構造体 cfs_rq は、init_cfs_rq() 関数によって初期化されます。機能は次のとおりです。
void init_cfs_rq(struct cfs_rq *cfs_rq) { cfs_rq->min_vruntime = (u64)(-(1LL << 20)); }
初期値は U64_MAX - (1LL << 20) で、U64_MAX は 64 ビット符号なし整数の最大値を表します。ここでも同じ質問がありますが、min_vruntime の初期値が 0 ではないのはなぜですか? これほど大きな数値を持つことは何を意味しますか? 0 と比べて何か利点はありますか? もちろん、私にも答えは見つかりませんでした。以下はすべて私の推測です。min_vruntime の単位は ns です。つまり、システムは約 (1<<20)ns 実行され、min_vruntime は約 1ms でオーバーフローします。したがって、min_vruntime 値のオーバーフローによって引き起こされる問題をより早く検出することが理由である可能性があります。初期値が 0 の場合、min_vruntime オーバーフローによる問題を事前に発見するのに約 545 年かかります (NICE 値 0 のプロセスに基づいて計算、NICE 値 20 に基づいて計算すると約 8 年しかかかりません) 。
8. 最小粒度時間 sysctl_sched_min_granularity は満たされますか?
CFS スケジューリング サイクルの時間設定はプロセス数に依存し、__sched_period() 関数によれば、プロセス数が sched_nr_latency よりも大きい場合、スケジューリング サイクルの時間はプロセス数に sysctl_sched_min_granularity を乗算した時間と等しくなります。
static u64 __sched_period(unsigned long nr_running) { if (ありそうもない(nr_running > sched_nr_latency)) nr_running * sysctl_sched_min_granularity を返します。 それ以外 sysctl_sched_latency を返します。 }
但是这样做是否就意味着进程至少运行sysctl_sched_min_granularity时间才会被抢占呢?如果所有进程的优先级都一样的话,结果的确是这样的。但是当存在优先级不同的进程的时候,而且系统进程数量大于sched_nr_latency,那么NICE值高的进程并不能保证最少运行sysctl_sched_min_granularity时间被强占。这是一种特殊的情况,这种进程在一个调度周期内分配的总时间都不足sysctl_sched_min_granularity。
如果有进程在一个周期内分配的份额大于sysctl_sched_min_granularity会是什么情况呢?在这种情况下,CFS调度器倒是可能可以保证最小粒度时间。我们看下check_preempt_tick()。
- 如果进程运行时间超过本周期分配时间,just reschedule it。
- 第1处不满足,如果这里运行时间不足最小粒度时间,直接退出。
原文地址: