LWN: 让SCHED_IDLE的行为更加合理!

关注了就能看到更多这么棒的文章哦~

Fixing SCHED_IDLE

November 26, 2019

This article was contributed by Viresh Kumar

原文来自:https://lwn.net/Articles/805317/

Linux kernel scheduler(调度器)非常非常复杂,每次新发布的kernel版本里面都包含很多针对scheduler的改进。在5.4版本里面就包含了针对现有的SCHED_IDLE调度策略的改进,希望在用户针对低优先级(比如后台进程)task使用SCHED_IDLE策略的情况下能改善用户高优先级(比如用户交互相关)task的调度延迟(latency)。

Scheduling classes and policies

scheduler里面实现了很多种"scheduling class"(调度类别),每个class都可以包含一些调度策略,由scheduler来进行处理。scheduling class在本文后面会进一步介绍,其中Stop class拥有最高优先级,Idle class则是最低优先级。

Stop schedulding class是一个特殊的类别,只在kernel内部使用。其实并没有实现任何针对它的调度策略,也不会有任何用户进程采用这种调度类别。Stop class其实是用作一种强制CPU把手头其他任何工作都停下来从而执行某种特殊任务的机制。因为这是最高优先级的class,因此可以抢占任何其他类别,却不会被任何其他任务抢占。一般是一个CPU想要把另一个CPU停下来执行某些特定功能的时候使用,因此只有SMP系统里才有。Stop class会创建一个per-CPU的内核线程(kthread),名为migration/N,这里N就是CPU编号。这个类别主要用在kernel的task migration, CPU hotplug, RCU, ftrace, clock event等场景。

Deadline scheduling class只制定了唯一一条调度策略,名为SCHED_DEADLINE,它用在系统里最高优先级的用户进程上。主要是针对那些有明确截止时间的任务,例如视频编码、解码任务。在这种调度策略之下,截止时间最近的任务拥有最高优先级。可以使用sched_setattr()系统调用来把某个进程设置为SCHED_DEADLINE调度策略,同时需要传递三个参数进去:运行时间,截止时间,周期。

为了能确保满足deadline的要求,内核必须要确保当前设置为SCHED_DEADLINE的这些线程不会在某些限制之下无法调度。因此内核会在设置或者修改SCHED_DEADLINE策略和属性的时候进行一些检查来确认是否满足接受条件。如果检查下来scheduler无法满足这次更改过的配置,那么sched_setattr()则会出错返回EBUSY。

再下来就是POSIX realtime (简称RT) scheduling class,主要用在一些耗时很短、对延迟很敏感的task之上,例如IRQ thread。这是一个拥有固定优先级的类别,高优先级的task都会在低优先级的task之前调度。这个类别里实现了两种调度策略:SCHED_FIFO和SCHED_RR。SCHED_FIFO策略会让一个task持续运行直到它放弃占用CPU,例如它block在某个资源上,或者完成了执行。而SCHED_RR(round robin)策略则会对task持续执行的一个时间片限制最大值,如果task持续占用CPU超过这个时长,仍然没有block住(也就是仍然期望继续占用CPU),调度器就会把它放到拥有相同优先级的round-robin队列的尾部,并换一个task进来执行。这些采用实时策略的task可以使用1(最低)到99(最高)的优先级。

CFS (completely fair scheduling)class则包含了绝大多数的用户进程。CFS实现了三类调度策略:SCHED_NORMAL,SCHEDULE_BATCH,SCHED_IDLE。采用这三者之中任意一种策略的话,进程就只有在没有任何deadline和realtime class的进程在等待执行的情况下才有机会被调度到(当然缺省来说调度器其实保留了5%的CPU时间专用于CFS task)。scheduler会跟踪各个task的vruntime (virtual runtime),包括那些runnable和blocked状态下的task。一个task的vrtuntime越小,它就越应该优先占用处理器的时间。相应地,CFS会把这些vruntime很低的进程向调度队列的前端移动。

进程的优先级是通过对它的nice值(取值范围-20到+19)加上120而得到的。进程的优先级主要是用来调整进程的权重(weight,会影响vruntime增加速率)的,进而会影响到进程的vruntime。nice值越低,优先级就越高。task的权重因此也会更加高一些,相应的vruntime则会在task执行时增长得更加缓慢。

SCHED_NORMAL调度策略(在user space的名字叫做SCHED_OTHER)是用在Linux环境里运行的绝大多数task上的,例如shell。SCHED_BATCH调度策略则主要用在那些非交互式的任务所需要的批量处理上面,通常这些任务执行中需要一段时间不被打断,因此通常都会在完成所有SCHED_NORMAL工作之后再进行调度切换。SCHED_IDLE调度策略则专用于系统里的低优先级task,他们仅在系统里没有什么需要运行的时候才会执行。尽管实际上说哪怕有其他一些SCHED_NORMAL task,其实SCHED_IDLE task还是会分到一些时间运行的(对于一个nice值为0的task来说大概会有1.4%的时间)。这个调度策略目前用到的很少,有人在试着改进它的工作方式。

最后是Idle scheduling class(不要跟SCHED_IDLE的调度策略弄混了)。这是最低优先级的调度类别。就跟Stop class类似,Idle class其实不会用在任何用户进程之上,因此并没有实现什么调度策略。它其实仅仅是用在名为swapper/N(N是CPU序号)的一系列per-CPU kthread上。这些kthreads也被称为"idle thread",用户空间是看不到的。这些线程负责让系统更加省电,主要是通过在没什么事情要做的时候把CPU放到一些deep idle状态来做到的。

Scheduling classes in the kernel

在kernel代码里面,scheduling 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);
	struct task_struct *(*pick_next_task) (struct rq *rq, struct task_struct *prev, 
			    struct rq_flags *rf);
	/* many fields omitted */
    };

这个结构里主要放了一些指向class相关实现的函数指针(回调函数),供scheduler core调用,从而能让scheduler核心代码不用包含任何class相关的代码。这些调度类别放在一个按照优先级排序的单项链表里面,第一项是Stop scheduling class(最高优先级),最后一项是Idle class(最低优先级)。

Linux kernel每当需要挑选一个新的task在某个local CPU上运行的时候,就会调用schedule()函数,进而调到pick_next_task()来找到合适的next task。pick_next_task()会利用for_each_class()宏来遍历调度类别链表,先找到有task想要运行的最高优先级的调度类别。等找到task之后,就会返回给函数调用者,让这个task在local CPU上运行。在Idle class里面总是会有一个task的,所以如果没有任何其他task要运行了,就直接执行Idle class里的这个task即可。

SCHED_IDLE improvement

CFS scheduler希望能对所有task都公平对待,对高优先级task分配更多CPU时间,低优先级的则少一些。通常来说它并不会针对不同调度策略的task进行特殊处理。例如,采用SCHED_NORMAL和SCHED_IDLE调度策略的多个task都采用同样方式管理。他们都一样在同一个CFS run queue里面排队,所有task的load和CPU占用比率都是进行同样的调整。PELT signal和CPU变频受各个task的影响也都是类似的。唯一会引起差异的就是task的优先级(根据nice值调整的),这回影响task的权重(weight)。

task的权重定义了CPU的load and utilization(负载和利用率)如何受到task的影响。因此,我们在CFS scheduler里面看不到太多SCHED_IDLE调度策略相关的代码。因为采用SCHED_IDLE策略的task都是最低优先级,因此会自动按照最低耗时的原则来得到调度。并且Linux社区里面并没有多少人会使用SCHED_IDLE调度策略,因此自从Linux 2.6.23引入之后,就没人对它进行改进。

每当有一个新的进程被唤醒的时候,scheduler core会用相应调度类别的select_task_rq()回调函数来寻找一个目标run queue(也就是要在哪个CPU上执行)。这个函数会返回将要enqueue这个task的CPU。等task加入队列之后,scheduler会检查task是否应该抢占当前CPU上正在运行的task,这是通过调用相应调度类别的check_preempt_curr()回调函数来实现的。

目前为止,SCHED_IDLE调度策略只在check_preempt_curr()回调函数中有一些特殊处理,这里当前正在运行的如果是SCHED_IDLE task的话,会马上被新唤醒的SCHED_NORMAL task抢占。不过这个抢占只有在新唤醒task加入的CPU run queue里当前正在运行SCHED_IDLE task的时候才会发生。因为在select_task_rq()调用中并没有针对SCHED_IDLE调度策略的相应处理,因此我们并没能做到尽量把新唤醒的SCHED_NORMAL task放到当前正在运行SCHED_IDLE task的CPU上去。

通常来说,scheduler会把task尽量分布到所有CPU上去,也就是在每次有新唤醒的task的时候,都会先寻找一个空闲CPU。5.4 kernel里包含了一个patch set,修改了CFS scheduler的select_task_rq()调用,会更加倾向于把task放在那些当前只在运行SCHED_IDLE task的CPU上去,哪怕有其他一些CPU现在彻底无事可做。在CFS的select_task_rq()代码中有两条路径:slow path和fast path。slow path通常是新fork出来的task使用的路径,会找到最优的CPU来执行task。而fast path则是供那些现存task又可以运行的时候使用的,会尽快找到一个CPU来运行(尽量找空闲 CPU),哪怕并不是最优解。

这两条路径都在新patch中进行了修改,会把当前正在执行SCHED_IDLE task的CPU就当做一个空闲CPU。scheduler会尽量把新唤醒的task放在只有SCHED_IDLE task的CPU上。这样这个新加入队列的task就能在check_preempt_curr()的时候马上抢占掉SCHED_IDLE task。如此以来能减少新加入队列的task的延迟,因为我们不需要费时间把一个空闲CPU从deep idle状态唤醒出来(这个过程可能会需要若干毫秒来完成)。

The results of this change

这组patch进行了测试,显示用rt-app在arm64八核的HiKey平台上测试的,所有CPU都会一起变频。rt-app是一个测试程序,会启动多个周期性线程,从而模拟实时周期性负载。在这个测试中创建了8个SCHED_OTHER task以及5个SCHED_IDLE task。所有task都没有绑定到特定CPU上,因此scheduler可以随意queue到任何CPU。SCHED_NORMAL task在7777us的周期内会执行(busyloop)5333us,而SCHED_IDLE task则持续不停地运行。目标是想看看SCHED_NORMAL task是否会挤在一起互相抢占,还是会抢占SCHED_IDLE task。结果显示,SCHED_NORMAL task的平均调度延迟(rt-app结果里wu_lat 的值)此前是1116us,而打上patch之后降低到了102us,也就是减少了90%的调度延迟,看起来非常有价值。

进一步的测试表明,SCHED_NORMAL task如果去抢占SCHED_IDLE task的话,平均调度延迟是64us;而如果运行在一个浅睡眠CPU(shallow-idle, no cluster idle)上,则是177us;假如运行在deep-idle (cluster idle) CPU上,则是307us。采用kernel function tracer也看到相同的结果,下面是KernelShark展示出的trace数据,先是5.3 kernel的:

仔细看的话,能看到有时候有些CPU会运行一个task很长时间(图中的单色实线),而不会被其他进程抢占。这些长时间执行的task就是SCHED_IDLE task,理论上来说应该被SCHED_NORMAL抢占掉,而看起来很多时候没有这么理想的结果。

5.4 kernel的结果则不同:

仔细看,能看到这张图里的行为模式则非常规律了。SCHED_IDLE task会在每次SCHED_NORMAL task想运行的时候就被立刻抢占,而SCHED_NORMAL task在执行5333us之后会把CPU交还给SCHED_IDLE task。这个行为就是此patch希望看到的结果了。

Other applications

最近Facebook的Song Liu试图解决Facebook server面临的一个问题。这些server会运行一些对延迟很敏感的workload,出于各种原因并不会给它满负载,包括一些容灾角度的考虑。而运行Facebook的用户交互workload(他称之为main workload)的server则有很多空闲的CPU时间,开发者希望能利用起这些时间来进行一些side workload(次要工作),例如视频编解码。不过,Liu的测试结果表明这些side workload会导致main workload延迟增加很多。跟open source讨论过程中有建议他试一下SCHED_IDLE patch,果然试下来对这个问题很有帮助。不过他测试的是这个patch set的早期版本,当时只支持fast path的优化。

另一个潜在受益者是Android系统,因为Android知道各个task对于用户体验的重要性,因为它区分了从"background"(不重要)到"top-app"(most important)的多个类别。SCHED_IDLE调度策越可以用在所有这些background task上,因为这样依赖top-app task就有更高的概率能抢占这些background task来找到空闲CPU了。

很明显,这个工作会带来很多潜在影响,会有更多的驻留产品使用SCHED_IDLE调度策略,同时CFS scheduler这边可能也更需要针对SCHED_IDLE调度策略进行针对性地优化。目前kernel mailing list上意境有一个相关的优化在讨论了,会在CFS scheduler的fast path和slow path中都更激进地选择SCHED_IDLE CPU。并且也可以改进CFS load balancer,目前它并没有对SCHED_IDLE CPU做特殊处理,从而把task均分到各个CPU上,这里也是可以优化的一个点。

全文完

LWN文章遵循CC BY-SA 4.0许可协议。

欢迎分享、转载及基于现有协议再创作~

长按下面二维码关注,关注LWN深度文章以及开源社区的各种新近言论~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值