关于 Linux Cgroup 的一些个人理解

最近一直在做关于 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 为单位,计算方式如下图所示:

Alt text

具体代码可见 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_uscpu.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_uscfs_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_enabledcfs_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.sharescpu.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 对进程的调度。

smartkeyerror

日拱一卒,功不唐捐