最近一直在做关于 Linux Control Group 的一些工作,包括 version 1 和 version 2,在调研的过程中发现 Linux 这部分的文档并没有解释的很清楚,其它的一些博客也只是简单的介绍如何使用 Cgroup,对其内部机理并没有做过多描述。因此,这里简单写一些个人关于 Cgroup 理解。
1. Linux CFS
CFS 为 Completely Fair Scheduler 的缩写,即完全公平调度器,是 Linux 所实现的一种进程调度类。除了 CFS 以外,Linux 同时也实现了实时调度,但是对于普通进程而言,只需要关注 CFS 调度即可。
在一般的抢占式调度实现中,通常会有 CPU 时间片的概念,也就是一个进程使用的 CPU 配额时间。当一个进程用完所分配的固定 CPU 时间片时,内核将会停止一个进程的运行,转而运行其它的进程。CFS 中也有类似于时间片的概念,不过这里称之为虚拟运行时间,virtual runtime,简写为 vruntime。
CFS 并没有直接对进程进⾏优先级分配,⽽是使⽤ vruntime 来记录进程的虚拟执⾏时间。之所以是虚拟执⾏时间,是因为该值是通过进程的优先级(如 nice 值)与实际进程执⾏时间加权所得到的⼀个值,以 ns 为单位,计算方式如下图所示:
具体代码可见 calc_delta_fair()
函数:
static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
if (unlikely(se->load.weight != NICE_0_LOAD))
delta = __calc_delta(delta, NICE_0_LOAD, &se->load);
return delta;
}
PS: nice 值是指⼀个进程的友好度,⼀个进程的 nice 值越⾼,表⽰该进程越友好,就更乐意把 cpu 让给别⼈。换⽽⾔之,nice 值越⼤,进程优先级越低,其取值为 [-20, 19]。
当决定下⼀个调度进程时,调度器将选择最⼩虚拟运⾏时间(vruntime)的任务。
以具有相同优先级的 I/O 密集型任务和 CPU 密集型任务为例。I/O 密集型任务通常在运⾏很短的时间以后就开始等待 I/O 事件并让出 CPU。⽽ CPU 密集型任务只要能拿到 CPU,就可能⼀直运⾏。因此,⼀段时间以后,I/O 密集型任务的 vruntime 将⼩于 CPU 密集型任务的 vruntime,从⽽将拥有更⾼的调度优先级,那么也就有了更低的响应延迟。
同样的,当我们调整进程的 Nice 值时,会直接影响进程的 vruntime 的计算,使得和普通进程相比在运行同样的 CPU 时间下具有更小的 vruntime,从而也就会以更高的优先级被调度。
2. Cgroup 是如何工作的?
Linux 内核通过虚拟文件的方式对外暴露 Cgroup 的相关接口,通过修改虚拟文件内容来达到修改对应 Cgroup 配置参数的效果,并且这个修改是即时生效,将会直接修改运行时的内核状态。
内核通过 poll()
来监听 Cgroup 所挂载目录下的全部文件读写,例如最经常使用的 cpu.cfs_quota_us
,cpu.shares
。Cgroup 文件被修改时将会调用 cgroup_file_write()
函数,一方面将数据写入到虚拟文件中,另一方面则是修改内核运行时的一些数据结构。
2.1 限制进程使用 CPU 资源上限
以 cpu.cfs_quota_us
为例,该文件限制某个进程在 cpu.cfs_period_us
时间内能够使用的最大 CPU 配额,默认值为 -1,表示该进程没有使用 CPU 资源的限制。cfs_period_us
的默认值为 100000,单位为微秒。
- 当
cfs_period_us
为 100000,而cfs_quota_us
为 50000 时,表示当前进程可以在 100 毫秒内使用 50 毫秒的 CPU,也就是 0.5 个 CPU。 - 当
cfs_period_us
为 100000,而cfs_quota_us
为 200000 时,表示当前进程可以使用 2 个 CPU。
那么这里就有一个问题,既然我们需要通过 cfs_quota_us
和 cfs_period_us
的比例来决定进程能使用多少个 CPU 的话,那么 100:1000 和 1000:10000 又有什么区别呢?
更大的周期(cfs_period_us
)将会使得进程有更大的突发能力,但是无法保证延迟响应, 这里的突发能力指的是允许应用程序短暂地突破他们的配额限制。更小的周期将会以牺牲突发容量为代价来确保稳定的延迟响应,因为这样一来内核就必须要较小的时间内对进程进行调度,这自然会有更稳定的延迟响应。
话说回 cpu.cfs_period_us
文件,当我们修改这个文件内容时,后续内核会调用 cpu_cfs_period_write_u64()
函数来修改对应 task_group
的 CPU 带宽:
static int tg_set_cfs_period(struct task_group *tg, long cfs_period_us)
{
u64 quota, period, burst;
if ((u64)cfs_period_us > U64_MAX / NSEC_PER_USEC)
return -EINVAL;
period = (u64)cfs_period_us * NSEC_PER_USEC;
quota = tg->cfs_bandwidth.quota;
burst = tg->cfs_bandwidth.burst;
return tg_set_cfs_bandwidth(tg, period, quota, burst);
}
这个值的修改最终会导致 cfs_rq.runtime_enabled
和 cfs_rq.runtime_remaining
这两个值发生变化,从而直接影响 CFS 的调度。当一个 Group 的 runtime_remaining
小于等于 0 时,CFS 直接对其进行限流,对应函数可见 check_enqueue_throttle()
:
/*
* When a group wakes up we want to make sure that its quota is not already
* expired/exceeded, otherwise it may be allowed to steal additional ticks of
* runtime as update_curr() throttling can not trigger until it's on-rq.
*/
static void check_enqueue_throttle(struct cfs_rq *cfs_rq)
{
if (!cfs_bandwidth_used())
return;
/* an active group must be handled by the update_curr()->put() path */
if (!cfs_rq->runtime_enabled || cfs_rq->curr)
return;
/* ensure the group is not already throttled */
if (cfs_rq_throttled(cfs_rq))
return;
/* update runtime allocation */
account_cfs_rq_runtime(cfs_rq, 0);
if (cfs_rq->runtime_remaining <= 0)
throttle_cfs_rq(cfs_rq);
}
2.2 设置进程在满负载情况下的优先级
除了限制进程 CPU 使用上限以外,Cgroup 同时也我们提供了设置进程在满负载情况下的优先级接口,也就是 cpu.shares
。cpu.shares
理解起来稍微有点儿绕,其默认值为 1024,可以认为进程的默认优先级就是 1024。当 Group A 的 cpu.shares
设置为 2048,Group B 的 cpu.shares
设置为 1024 时,在系统满负载的情况下 Group A 和 Group B 获得的 CPU 资源比例为 2:1。
cpu.shares
是一个相对值,并不会和 cfs_period_us
一样存在突发能力,2048:1024 和 2:1 的结果是一样的。
修改 cpu.shares
将会调用 sched_group_set_shares()
函数,最后会调用 update_load_avg()
去修改 task_group 的 load_avg
,同样会直接影响 CFS 对进程的调度。