Linux CFS调度器思考
常见的调度策略
- 时间片轮转
所谓时间片就是每个线程固定运行相同的时间,如果自己的时间用尽,则重新排队等待下一个时间片。 - 优先级调度
高优先级的任务先运行,高优先级的任务不运行时,低优先级的任务才运行。 - 优先级+相同时间片轮转
高优先级的任务同样先运行,低优先级的任务同样只有高优先级任务不运行时才能运行。但是相同优先级的任务按照时间片轮转的方式运行,时间片用尽后,重新回到对应优先级的就绪队列的队尾排队并获得一个新的时间片。 - 优先级+梯度时间片轮转
由于给任务分配了优先级,一种常见的做法是给高优先级的任务分配更长的时间片,这样就能保证高优先级任务在其需要处理器时能够更加及时地拿到处理器。
固定时间片的问题
上文的四种方法,除了单纯地优先级调度外,其余三种都是基于固定时间片的,即时间片大小是实现分配好的一个或者一组固定值,而非程序运行时动态计算出来的。这样的做法有下面几个缺点:
- 有一个固定切换频率。例如时间片为100ms,那么1s内系统至少进行了10此任务切换,而任务切换本身是需要开销的。
- 按照优先级分配时间片,存在优先级选定初值不同使得系统任务运行时间相差巨大。如果优先级1~4分别分配时间片1ms,4ms,7ms,10ms,即按照等差方式分配时间片。考虑两个任务的运行时间,如果选定优先级为1,2,那么这两个任务运行的时间比是1:4,而如果选定优先级为3,4则运行时间比为7:10. 如果考察同样一段运行时间,两个任务的运行时长差距就会很大。(也就是说,直观上,时间片应该随着优先级成等比关系才能保证运行时间不会随着优先级的初值不同而变化)
这些问题本质上,都是由于多个任务需要共用处理器,处理器太少了。 如果系统只运行一个任务,根本不会有这些所谓的问题。但是系统只运行一个任务,显然不太现实。如果每个任务都有一个处理器,也能够解决处理器太少的问题,也不会有这些问题。
CFS调度的目标
官方目标:模拟一个完美的多任务处理器
1 什么是完美的多任务处理器?
我所理解的完美多任务处理器,就是有多少个任务就有多少个对应运行的处理器(核心),也就是说,每个任务都有与之直接对应的硬件处理器,而不需要与其它任务共用一个处理器。
2 如何刻画一个完美的多任务处理器?
显然,完美多任务处理器不可能存在。客观上不存在,但是可以通过逻辑上去接近。我们在任何一段时间内观察一个完美多任务处理器,(假设任务不会阻塞),我们都能够得到一个结论:每个任务都运行了相同的时间。
3 如何模拟一个完美的多任务处理器?
不考虑优先级时,我们应该做到,在一个时间段内观察,每个任务运行的时间一样,就好像每个任务都有一个处理器,只是时间缩水了。
考虑优先级时,我们当然让高优先级任务运行更多的时间,但是相同优先级任务的运行时间必然相同。
CFS调度的实现
1 如何记录任务运行时间
线程运行时,定期更新其已经运行的时间(这被称为时间记账),如果发现有线程的累计运行时间比当前线程少,则当前线程停止运行,选择累计运行时间最少的线程运行。显然,时间记账是实现CFS调度的一个关键点。只有找到一种合适的方式,记录下任务究竟运行了多久,才能干其它的事情。我认为,至少要考虑以下几个问题:
-
时钟源从哪里来?是周期性时钟中断还是主动获取RTC外设时间?
我认为,要获得准确、及时的时间信息,必然是主动+被动的方式获得时间。也就是说需要周期性定时中断,强制更新任务的运行时间,及时发现任务是否运行了过长时间。由于定时中断是周期性的,必然会有一个时间分辨率,也就是Tick的大小,而任务完全可以在这个间隔内阻塞而被切换出处理器或者另一个任务被切换进处理器。这种情况下,仅仅依靠周期性定时中断,是无法获得这一部分小于Tick大小的运行时间信息的。 -
任务的运行时长记录在什么地方?
这个记录应该随着任务结构体走,即需要一个per-task的变量。显然可以放在tcb相关的数据结构中。 -
如何清楚地记录下任务究竟运行了多久,即占用了多少处理器时间?
任务运行期间,每个Tick中断到来时,将任务的运行时间加一个单位。如果任务在中断到来前就变成阻塞态,那么将记录不到。这不是特别合理。如果能够在Tick中断到来、任务切换进处理器、任务切换出处理器三个地方记录任务的时间,那么就可以做到比较精确了。在这三个点都获取当前时间,然后和前一次获取的时间相减,就能够获得这任务的运行时间,将这个时间累加,就能够得到任务运行的累计时长了。
Δ ( n ) = T ( n ) − T ( n − 1 ) \Delta(n)=T(n)-T(n-1) Δ(n)=T(n)−T(n−1)
R ( t a s k , n ) = R ( t a s k , n − 1 ) + Δ ( n ) R(task,n)=R(task,n-1) + \Delta(n) R(task,n)=R(task,n−1)+Δ(n)
/**示意代码
* 这段代码在下面三个场景调用:
* 1. Tick中断
* 2. 任务进入处理器时
* 3. 任务退出处理器时
*/
void record_run_time(enum RecordType record_type)
{
struct Task *curr_task = get_current_task();
if(unlikely(!curr_task))
return;
unsigned long now_time = get_current_time();
if(record_type == TICK)
{
unsigned long delta = now_time - curr_task->last_time;
curr_task->runtime += delta;
curr_task->last_time = now_time;//更新时间
} else if(record_type == SWITCHED_IN)
{
curr_task->last_time = now_time;//更新时间
} else if(record_type == SWITCHED_OUT)
{
unsigned long delta = now_time - curr_task->last_time;
curr_task->runtime += delta;
//没有必要更新时间
}
}
- 如何能够及时地发现自身的运行时间是否是最少的,以便能够及时地让出处理器?
将所有就绪任务,根据累计运行时间组织成红黑树/最小堆等等,每次更新任务的累计运行时间后,都调整这些数据结构,从而找到一个当前运行时间最少的任务。这个任务可能就是当前任务,也可能不是。如果不是,那么就需要发生任务切换了。按照这种方式,不发生任务切换时,更新数据结构里任务的累计运行时间,可以做到 O ( 1 ) O(1) O(1),但是如果需要发生任务切换,则至少需要 O ( l o g n ) O(logn) O(logn)的时间复杂度。显然不再是 O ( 1 ) O(1) O(1)调度。
2 如何记录运行时间——增加优先级
如前面所说,高优先级的任务通常需要更多的处理器时间,这样能够保证任务在需要处理器时更加快速地响应,而不会由于时间片耗尽而被延迟调度。没有优先级时, N N N个任务每个任务运行的时间比例都是 1 / N 1/N 1/N,为了让高优先级的任务拥有更多的时间比例,一个自然的想法是为不同优先级的任务分配权重,让权重比例成为时间比例。也就是说,一个任务的运行时间比例是由式(1)决定的。例如,两个任务的权重分别为2和3,那么他们运行的时间比例为0.4和0.6.显然,不同优先级的任务运行时间具有相似的比例,更加符合直觉。
公式 | 编号 |
---|---|
T ( t i ) = W ( p ( t i ) ) ∑ j = 1 N W ( p ( t j ) ) T(t_i)=\cfrac{W(p(t_i))}{\sum_{j=1}^{N}W(p(t_j))} T(ti)=∑j=1NW(p(tj))W(p(ti)) | (1) |
R v ( T a s k , n ) = R v ( T a s k , n − 1 ) + f ( T a s k , p ) × Δ ( n ) R_v(Task,n)=R_v(Task,n-1)+f(Task,p)\times\Delta(n) Rv(Task,n)=Rv(Task,n−1)+f(Task,p)×Δ(n) | (2) |
f ( p ) = W ( p ) W ( p r ) f(p)=\cfrac{W(p)}{W(p_r)} f(p)=W(pr)W(p) | (3) |
其中 W ( p ) W(p) W(p)表示优先级 p p p的权重, p r p_r pr表示参考优先级。可以通过设置一个权重表达到我们关于优先级不同,运行时间有恒定倍差的设定,设公比为 q q q,则任何一个权重表可以通过一个初始值与下表相乘得到。
… | p r − 2 p_r-2 pr−2 | p r − 1 p_r-1 pr−1 | p r p_r pr | p r + 1 p_r+1 pr+1 | p r + 2 p_r+2 pr+2 | … |
---|---|---|---|---|---|---|
… | q − 2 q^{-2} q−2 | q − 1 q^{-1} q−1 | 1 1 1 | q 1 q^1 q1 | q 2 q^2 q2 | … |
Linux系统中,希望CFS调度时,任务的优先级每提升1级,任务在现有基础上获得额外的10%的处理器时间,每下降1级,任务在现有的基础上减少10%的处理器时间。也就是说,优先级提升一级,期望其运行时间是原来的1.1倍,如式(6)所示。
公式 | 编号 |
---|---|
T = W W + W o t h e r T=\cfrac{W}{W+W_{other}} T=W+WotherW | (4) |
T ′ = q W q W + W o t h e r T'=\cfrac{qW}{qW+W_{other}} T′=qW+WotherqW | (5) |
1.1 = T ′ T = q ( W + W o t h e r ) q W + W o t h e r 1.1=\cfrac{T'}{T}=\cfrac{q(W+W_{other})}{qW+W_{other}}{} 1.1=TT′=qW+Wotherq(W+Wother) | (6) |
q ( x ) = 11 W 10 W o t h e r − W = 11 10 x − 1 q(x)=\cfrac{11W}{10W_{other}-W}=\cfrac{11}{10x-1} q(x)=10Wother−W11W=10x−111, x = W o t h e r W x=\cfrac{W_{other}}{W} x=WWother | (7) |
q ( 1 ) = 11 10 − 1 = 1.22 ≈ 1.25 q(1)=\cfrac{11}{10-1}=1.22\approx1.25 q(1)=10−111=1.22≈1.25 | (8) |
改写式(6)可以得到公比的表达式(7),可以发现并非为常数,Linux选取 q ( 0 ) q(0) q(0)作为了近似的公比。这个公比在Linux的CFS调度器中近似为1.25。显然Linux完全公平调度并不能够做到其所期望的优先级提升1级,运行时间多10%.当系统中有很多任务时,提升优先级带来的权重增加,带来的时间比例提升将极其微小。下面是最新(2022-01-03)主线上的kernel\sched\core.c中10836行~10857行的代码片段。
/*
* Nice levels are multiplicative, with a gentle 10% change for every
* nice level changed. I.e. when a CPU-bound task goes from nice 0 to
* nice 1, it will get ~10% less CPU time than another CPU-bound task
* that remained on nice 0.
*
* The "10% effect" is relative and cumulative: from _any_ nice level,
* if you go up 1 level, it's -10% CPU usage, if you go down 1 level
* it's +10% CPU usage. (to achieve that we use a multiplier of 1.25.
* If a task goes up by ~10% and another task goes down by ~10% then
* the relative distance between them is ~25%.)
*/
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,
};
3 切换频率
如果每次一旦发现运行队列中存在比当前运行的任务运行时间更短的任务就进行任务切换,那么可能会使得系统不断地切换。
显然需要一个缓冲,也就是说,只有只有运行时间比当前运行的任务的运行时间还少一个缓存时间,才对其进行调度。
R
(
t
i
)
<
R
(
t
c
u
r
r
)
+
Δ
R(t_i)<R(t_{curr})+\Delta
R(ti)<R(tcurr)+Δ