在第3章中,你学习了如何使用命名空间控制 Linux 进程的可见性,并了解了它们在内核中的实现方式。在本章中,我们将探讨另一个重要方面——资源控制,它使我们能够对各种内核资源应用配额。
正如你在第3章中学习的那样,命名空间使我们能够通过将进程放入不同的命名空间来限制进程的资源可见性。第3章还介绍了内核中涉及的相关数据结构,帮助你理解命名空间如何在 Linux 内核中实现。
现在我们来思考一个问题:“仅仅限制可见性是否足够实现虚拟化,还是需要做更多的工作?”假设我们在一个命名空间中运行 tenant1 的进程,在另一个命名空间中运行 tenant2 的进程。尽管这些进程无法访问彼此的资源(挂载点、进程树等),因为这些资源被限定在各自的命名空间内,但我们仅通过这种限定无法实现真正的隔离。
例如,什么能阻止 tenant1 启动一个可能通过无限循环占用 CPU 的进程?有缺陷的代码可能会不断泄漏内存(例如,它可能占用操作系统页面缓存的大量内存)。一个行为不当的进程可以通过 fork 创建大量进程,启动 fork 炸弹,甚至导致内核崩溃。
这意味着我们需要为命名空间内的进程引入资源控制的方法。这是通过一个称为控制组(commonly known as cgroups)的机制来实现的。cgroups 基于 cgroup 控制器的概念,并在 Linux 内核中通过一个称为 cgroupfs 的文件系统表示。
当前使用的 cgroups 版本是 cgroup v2。在本章中,我们将探讨一些 cgroups 的工作原理以及内核代码中存在的一些 cgroup 控制器。我们还将研究 cgroups 在 Linux 内核中的实现方式。但在此之前,让我们简要了解一下 cgroups 的基本概念。
首先,要使用 cgroup,我们需要在挂载点挂载 cgroup 文件系统,如下所示:
mount -t cgroup2 none $MOUNT_POINT
cgroup v1 和 v2 之间的区别在于,在 v1 中挂载时,我们可以指定挂载选项来启用特定的控制器,而在 cgroup v2 中,无法传递这样的挂载选项。
创建一个示例 Cgroup
让我们创建一个名为 mygrp 的示例 cgroup。要创建 cgroup,我们首先需要创建一个存储 cgroup 相关文件的文件夹,如下所示:
mkdir mygrp
现在我们可以使用以下命令创建一个 cgroup:
请注意,cgroup2 在内核版本 4.12.0-rc5 及以后版本中支持。我使用的是 Ubuntu 20.04.6 LTS,该版本的内核是 5.15.0。
mount -t cgroup2 none mygrp
我们创建了一个名为 mygrp 的目录,然后在其上挂载了 cgroup v2 文件系统。当我们进入 mygrp 目录时,可以看到多个文件:
cgroup.controllers:此文件包含支持的控制器。所有未挂载在 cgroup v1 上的控制器都会显示出来。目前在我的系统上,systemd 已挂载了一个 cgroup v1。以下显示了所有控制器都存在的情况:
只有在从 v1 卸载控制器之后,v2 才会显示这些控制器。有时我们可能需要添加内核启动参数 systemd.unified_cgroup_hierarchy=1 并重启内核,以使这些更改生效。在我的机器上进行更改后,我看到以下控制器:
-
cgroup.procs:此文件包含根 cgroup 中的进程。当 cgroup 刚创建时,其中不会有任何 PID。通过将 PID 写入此文件,它们就会成为该 cgroup 的一部分。 -
cgroup.subtree_control:此文件包含为直接子组启用的控制器。
在父 cgroup 的直接子组中启用和禁用控制器只需写入其 cgroup.subtree_control 文件。例如,启用内存控制器可以使用以下命令:
echo "+memory" > mygrp/cgroup.subtree_control
禁用它则使用以下命令:
echo "-memory" > mygrp/cgroup.subtree_control
cgroup.events:这是 cgroup 核心接口文件。该接口文件是非根子组独有的。cgroup.events文件反映了附加到该子组的进程数量,其中包含一项——populated: value。当没有进程附加到该子组或其后代时,值为 0;当有一个或多个进程附加到该子组或其后代时,值为 1。
除了这些文件之外,还会创建特定于控制器的接口文件。例如,对于内存控制器,会创建一个 memory.events 文件,可以监控内存不足(OOM)等事件。类似地,PID 控制器有像 pids.max 这样的文件,以避免发生像 fork 炸弹这样的情况。
在我的示例中,我在 mygrp 下创建了一个子 cgroup。以下文件出现在子目录中:
我们可以看到特定控制器的文件,例如 memory.max。名为 memory.events 的接口文件列出了不同的事件,如 oom,这些事件可以启用或禁用:
下一节将解释 cgroups 在内核中的实现方式以及它们如何实现资源控制。
Cgroup 类型
根据我们想要控制的资源类型,cgroups 有不同的类型。这里我们将介绍两种 cgroup 类型:
- CPU:为用户空间进程提供 CPU 限制
- 块 I/O:为用户空间进程在块设备上的 I/O 提供限制
CPU Cgroup
从内核的角度来看,我们来看看 cgroup 是如何实现的。CPU cgroups 可以基于两个调度器实现:
- 完全公平调度器(CFS)
- 实时调度器
在本章中,我们只讨论完全公平调度器(CFS)。
CPU cgroup 提供了不同类型的 CPU 资源控制:
-
cpu.shares:包含一个整数值,指定 cgroup 中任务可用的相对 CPU 时间份额。例如,两个 cgroup 中的任务,如果cpu.shares设置为 100,它们将获得相同的 CPU 时间;但如果一个 cgroup 中的任务cpu.shares设置为 200,则它们将获得两倍于cpu.shares设置为 100 的 cgroup 中任务的 CPU 时间。cpu.shares文件中指定的值必须为 2 或更高。 -
cpu.cfs_quota_us:指定在一个周期内(由cpu.cfs_period_us定义)cgroup 中所有任务可以运行的总时间,以微秒(μs,以“us”表示)为单位。一旦 cgroup 中的任务用完了配额指定的时间,它们将在该周期的剩余时间内被停止,直到下一个周期才能继续运行。 -
cpu.cfs_period_us:指定从中分配 cgroup CPU 配额(cpu.cfs_quota_us)的周期。配额和周期参数按每个 CPU 进行操作。例如:- 允许 cgroup 每秒能访问单个 CPU 的 0.2 秒,设置
cpu.cfs_quota_us为 200000 和cpu.cfs_period_us为 1000000。 - 允许一个进程利用 100% 的单个 CPU,设置
cpu.cfs_quota_us为 1000000 和cpu.cfs_period_us为 1000000。 - 允许一个进程利用 100% 的两个 CPU,设置
cpu.cfs_quota_us为 2000000 和cpu.cfs_period_us为 1000000。
- 允许 cgroup 每秒能访问单个 CPU 的 0.2 秒,设置
要理解这两种控制机制,我们可以研究 Linux CFS 任务调度器的相关内容。该调度器的目的是为系统上运行的所有任务提供公平的 CPU 资源分配。
我们可以将这些任务分为两种类型:
- CPU 密集型任务:如加密、机器学习、查询处理等任务
- I/O 密集型任务:如使用磁盘或网络 I/O 的任务,如数据库客户端
调度器负责调度这两类任务。CFS 使用 vruntime 的概念。vruntime 是 sched_entity 结构的一个成员,而 sched_entity 是 task_struct 结构的一个成员(在 Linux 中,每个进程都由一个 task_struct 结构表示):
struct task_struct {
int prio, static_prio, normal_prio;
unsigned int rt_priority;
struct list_head run_list;
const struct sched_class *sched_class;
struct sched_entity se;
unsigned int policy;
cpumask_t cpus_allowed;
unsigned int time_slice;
};
struct sched_entity {
/* For load-balancing: */
struct load_weight load;
struct rb_node run_node;
struct list_head group_node;
unsigned int on_rq;
u64 exec_start;
u64 sum_exec_runtime;
u64 vruntime;
u64 prev_sum_exec_runtime;
u64 nr_migrations;
struct sched_statistics statistics;
#ifdef CONFIG_FAIR_GROUP_SCHED
Int depth;
struct sched_entity *parent;
/* rq on which this entity is (to be) queued: */
struct cfs_rq *cfs_rq;
/* rq "owned" by this entity/group: */
struct cfs_rq *my_q;
/* cached value of my_q->h_nr_running */
unsigned long runnable_weight;
task_struct 结构引用了 sched_entity,而 sched_entity 持有对 vruntime 的引用。
vruntime 的计算步骤如下:
- 计算进程在 CPU 上花费的时间。
- 根据可运行进程的数量对计算出的运行时间进行加权。
内核使用定义在 fair.c 文件 中的 update_curr 函数来更新当前任务的运行时统计信息。
/*
* Update the current task's runtime statistics.
*/
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;
if (unlikely((s64)delta_exec <= 0))
return;
curr->exec_start = now;
schedstat_set(curr->statistics.exec_max, max(delta_exec, curr->statistics.exec_max));
curr->sum_exec_runtime += delta_exec;
schedstat_add(cfs_rq->exec_clock, delta_exec);
curr->vruntime += calc_delta_fair(delta_exec, curr);
update_min_vruntime(cfs_rq);
if (entity_is_task(curr)) {
struct task_struct *curtask = task_of(curr);
trace_sched_stat_runtime(curtask, delta_exec, curr->vruntime);
cgroup_account_cputime(curtask, delta_exec);
account_group_exec_runtime(curtask, delta_exec);
}
account_cfs_rq_runtime(cfs_rq, delta_exec);
}
函数首先计算 delta_exec,即当前任务在 CPU 上花费的时间。然后将此 delta_exec 作为参数传递给另一个名为 calc_delta_fair 的函数调用。此调用返回相对于可运行进程数量的加权进程运行时间值。一旦计算出 vruntime,它就会作为 sched_entity 结构的一部分存储。
此外,作为更新任务 vruntime 的一部分,update_curr 函数还调用 update_min_vruntime。该函数计算所有可运行进程中 vruntime 的最小值,并将其作为左侧节点添加到红黑树中。然后,CFS 调度器可以查看红黑树以调度具有最低 vruntime 的进程。
基本上,CFS 调度器通过其启发式调度更频繁地调度 I/O 密集型任务,但在单次运行中为 CPU 密集型任务提供更多时间。这也可以从之前讨论的 vruntime 概念中理解。由于 I/O 任务主要等待网络/磁盘,它们的 vruntime 值往往比 CPU 任务小。这意味着 I/O 任务会更频繁地被调度。一旦 CPU 密集型任务被调度,它们将获得更多时间来完成工作。通过这种方式,CFS 试图实现任务的公平调度。
让我们暂停片刻,思考这种调度可能带来的潜在问题。
假设你有两个属于不同用户的进程 A 和 B。这些进程各自获得 50% 的 CPU 份额。假设拥有进程 A 的用户启动了另一个名为 A1 的进程。现在,CFS 将为每个进程分配 33% 的份额。这实际上意味着 A 和 A1 的用户现在获得了 66% 的 CPU。一个经典的例子是像 PostgreSQL 这样的数据库,它为每个连接创建进程。随着连接数量的增加,进程数量也会增加。如果有公平调度,每个连接都会削弱在同一台机器上运行的其他非 PostgreSQL 进程的份额。
这个问题导致了组调度的出现。为了理解这一概念,我们来看看另一个内核数据结构:
/* CFS 相关字段在运行队列中 */
struct cfs_rq {
struct load_weight load;
unsigned int nr_running;
unsigned int h_nr_running; /* SCHED_{NORMAL, BATCH, IDLE} */
unsigned int idle_h_nr_running; /* SCHED_IDLE */
u64 exec_clock;
u64 min_vruntime;
#ifndef CONFIG_64BIT
u64 min_vruntime_copy;
#endif
struct rb_root_cached tasks_timeline;
};
/*
* 'curr' 指向当前正在此 cfs_rq 上运行的实体。
* 否则设置为 NULL(即没有正在运行的实体)。
*/
struct sched_entity *curr;
struct sched_entity *next;
struct sched_entity *last;
struct sched_entity *skip;
这个结构保存了 nr_running 成员中的可运行任务数量。curr 成员是指向当前运行调度实体或任务的指针。
此外,sched_entity 结构现在表示为一个分层数据结构:
struct sched_entity {
/* 用于负载均衡: */
struct load_weight load;
struct rb_node run_node;
struct list_head group_node;
unsigned int on_rq;
u64 exec_start;
u64 sum_exec_runtime;
u64 vruntime;
u64 prev_sum_exec_runtime;
u64 nr_migrations;
struct sched_statistics statistics;
#ifdef CONFIG_FAIR_GROUP_SCHED
Int depth;
struct sched_entity *parent;
/* rq 上的此实体(或将要排队的实体): */
struct cfs_rq *cfs_rq;
/* 由此实体/组“拥有”的 rq: */
struct cfs_rq *my_q;
/* my_q->h_nr_running 的缓存值 */
unsigned long runnable_weight;
#endif
#ifdef CONFIG_SMP
/*
* 每个实体的负载平均值跟踪。
*
* 放入单独的缓存行中,因此它不会与上面的大部分只读值发生冲突。
*/
struct sched_avg avg;
#endif
};
这意味着现在可以有不与进程(task_struct)关联的 sched_entities 结构。相反,这些实体可以表示一组进程。每个 sched_entity 现在维护自己的一组运行队列。一个进程可以被移动到子调度实体中,这意味着它将成为子调度实体拥有的运行队列的一部分。此运行队列可以表示组中的进程。
调度器中的代码流将执行以下操作。
调用 pick_next_entity 方法来选择最佳的调度候选者。我们假设此时只有一个组在运行。这意味着与 sched_entity 进程关联的红黑树是空白的。方法现在尝试获取当前 sched_entity 的子 sched_entity。它检查 cfs_rq,其中排队了组的进程。然后调度该进程。
vruntime 基于组内进程的权重。这使我们能够进行公平调度,并防止组内进程影响其他组内进程的 CPU 使用情况。
了解了进程可以被放入组中后,我们来看如何将带宽限制应用于该组。cfs_bandwidth 数据结构在 sched.h 中定义,起着重要作用:
struct cfs_bandwidth {
#ifdef CONFIG_CFS_BANDWIDTH
raw_spinlock_t lock;
ktime_t period;
u64 quota;
u64 runtime;
s64 hierarchical_quota;
u8 idle;
u8 period_active;
u8 distribute_running;
u8 slack_started;
struct hrtimer period_timer;
struct hrtimer slack_timer;
struct list_head throttled_cfs_rq;
/* 统计数据: */
Int nr_periods;
Int nr_throttled;
u64 throttled_time;
#endif
};
这个结构跟踪组的运行时间配额。当在公平调度器实现文件的 account_cfs_rq_runtime 方法中检查时,cff_bandwith_used 函数返回一个布尔值。如果没有剩余运行时间配额,将调用 throttle_cfs_rq 方法。它将任务从 sched_entity 的运行队列中排队,并设置 throttled 标志。函数实现如下所示:
static 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;
long task_delta, idle_task_delta, dequeue = 1;
bool empty;
se = cfs_rq->tg->se[cpu_of(rq_of(cfs_rq))];
/* 冻结层次结构可运行的平均值,同时节流 */
rcu_read_lock();
walk_tg_tree_from(cfs_rq->tg, tg_throttle_down, tg_nop, (void *)rq);
rcu_read_unlock();
task_delta = cfs_rq->h_nr_running;
idle_task_delta = cfs_rq->idle_h_nr_running;
for_each_sched_entity(se) {
struct cfs_rq *qcfs_rq = cfs_rq_of(se);
/* 被节流的实体或节流于停用时 */
if (!se->on_rq)
break;
if (dequeue) {
dequeue_entity(qcfs_rq, se, DEQUEUE_SLEEP);
} else {
update_load_avg(qcfs_rq, se, 0);
se_update_runnable(se);
}
qcfs_rq->h_nr_running -= task_delta;
qcfs_rq->idle_h_nr_running -= idle_task_delta;
if (qcfs_rq->load.weight)
dequeue = 0;
}
if (!se)
sub_nr_running(rq, task_delta);
cfs_rq->throttled = 1;
cfs_rq->throttled_clock = rq_clock(rq);
raw_spin_lock(&cfs_b->lock);
empty = list_empty(&cfs_b->throttled_cfs_rq);
/*
* 添加到列表的头部,以便已经启动的
* distribute_cfs_runtime 将看不到我们。
* 如果 distribute_cfs_runtime 没有运行,则添加到尾部,以便后续运行队列不会被饿死。
*/
if (cfs_b->distribute_running)
list_add_rcu(&cfs_rq->throttled_list, &cfs_b->throttled_cfs_rq);
else
list_add_tail_rcu(&cfs_rq->throttled_list, &cfs_b->throttled_cfs_rq);
/*
* 如果我们是第一个被节流的任务,请确保带宽定时器正在运行。
*/
if (empty)
start_cfs_bandwidth(cfs_b);
raw_spin_unlock(&cfs_b->lock);
}
这解释了 CPU cgroups 如何允许将任务/进程分组,并使用 CPU 份额机制在组内强制执行公平调度。这也解释了如何在组内执行配额和带宽限制。现在我们讨论另一种 cgroup 类型,它对块 I/O 资源施加限制。
块 I/O Cgroups
块 I/O cgroup 的目的有两个:
- 为各个 cgroup 提供公平性:使用一种名为完全公平排队(Complete Fair Queuing)的调度器。
- 执行块 I/O 限速:为每个 cgroup 的块 I/O(以字节和 iops 为单位)设置配额。
在深入探讨块 I/O cgroup 的实现细节之前,我们先稍作探讨,了解一下 Linux 块 I/O 的工作原理。图 4-1 是一个关于块 I/O 请求如何从用户空间流向设备的高级框图。
应用程序通过文件系统或内存映射文件发出读/写请求。无论哪种情况,请求都会命中页面缓存(用于缓存文件数据的内核缓冲区)。在基于文件系统的调用中,虚拟文件系统(VFS)处理系统调用,并调用底层注册的文件系统。
接下来的层是块层,在这里构造实际的 I/O 请求。块层中有三个重要的数据结构:
request_queue:这是一个单队列架构,每个设备有一个请求队列。块层与 I/O 调度器协同工作,在此队列中排队请求。设备驱动程序从请求队列中提取请求并将其提交给实际设备。request:request结构表示要传递给 I/O 设备的单个 I/O 请求。该请求由一系列bio结构组成。bio:bio结构是块 I/O 的基本容器。内核中的bio结构(定义在<linux/bio.h>中)表示正在进行的块 I/O 操作,这些操作以段列表的形式存在。段是内存中连续的缓冲区块。
图示中,bio 结构如图 4-2 所示。
bio_vec 表示一个特定的段,并包含一个指向持有特定偏移量块数据的页面的指针。
请求被提交到请求队列,并由设备驱动程序提取。实现块 I/O cgroup 的重要数据结构如下所示:
struct blkcg {
struct cgroup_subsys_state css;
spinlock_t lock;
struct radix_tree_root blkg_tree;
struct blkcg_gq __rcu *blkg_hint;
struct hlist_head blkg_list;
struct blkcg_policy_data *cpd[BLKCG_MAX_POLS];
#ifdef CONFIG_CGROUP_WRITEBACK
struct list_head cgwb_list;
refcount_t cgwb_refcnt;
#endif
struct list_head all_blkcgs_node;
};
该结构表示块 I/O cgroup。正如前面解释的那样,每个块 I/O cgroup 都映射到一个请求队列:
/* 块 cgroup 和请求队列之间的关联 */
struct blkcg_gq {
/* 指向关联的 request_queue 的指针 */
struct request_queue *q;
struct list_head q_node;
struct hlist_node blkcg_node;
struct blkcg *blkcg;
/*
* 每个 blkg 都分别陷入阻塞状态,并将阻塞状态传播到相应的 bdi_writeback_congested。
*/
struct bdi_writeback_congested *wb_congested;
/* 所有非根 blkcg_gq 都确保能够访问父级 */
struct blkcg_gq *parent;
/* 为此 blkcg-q 对分配的请求列表 */
struct request_list rl;
/* 引用计数 */
atomic_t refcnt;
/* 此 blkg 是否在线?由 blkcg 和 q 锁保护 */
bool online;
struct blkg_rwstat stat_bytes;
struct blkg_rwstat stat_ios;
struct blkg_policy_data *pd[BLKCG_MAX_POLS];
struct rcu_head rcu_head;
atomic_t use_delay;
atomic64_t delay_nsec;
atomic64_t delay_start;
u64 last_delay;
int last_use;
};
每个请求队列都与一个块 I/O cgroup 相关联。
理解公平性
在这个上下文中,公平性意味着每个 cgroup 都应获得设备发出的 I/O 的公平份额。为了实现这一点,必须配置一个名为 CFQ(完全公平排队)的调度器。在没有 cgroups 的情况下,CFQ 调度器为每个进程分配一个队列,然后为每个队列分配一个时间片,从而处理公平性。
服务树是调度器运行的活跃队列/进程列表。所以基本上,CFQ 调度器从服务树中的队列中服务请求。
在有 cgroups 的情况下,引入了 CFQ 组的概念。现在,调度不再是按进程进行,而是按组进行。这意味着每个 cgroup 有多个服务树,其中安排了组队列。然后在一个全局服务树上调度 CFQ 组。
CFQ 组结构定义如下:
struct cfq_group {
/* 必须是第一个成员 */
struct blkg_policy_data pd;
/* 组服务树成员 */
struct rb_node rb_node;
/* 组服务树键 */
u64 vdisktime;
/*
* 活跃的 cfqg 的数量及其权重总和在此 cfqg 下。包括此 cfqg 的 leaf_weight 和所有子节点的权重,但不包括进一步的后代权重。
* 如果 cfqg 在服务树上,则表示它是活跃的。一个活跃的 cfqg 也会激活其父节点并为父节点的 children_weight 做出贡献。
*/
int nr_active;
unsigned int children_weight;
/*
* vfraction 是此 cfqg 中任务应得的 vdisktime 的一部分。通过从此 cfqg 向上走到根节点来确定。
* 这是以 CFQ_SERVICE_SHIFT 为单位的定点数,并且服务树上所有 vfraction 的总和大约为 1。由于四舍五入错误和 cfqg 进入和离开服务树引起的波动,总和可能会略有偏差。
*/
unsigned int vfraction;
/*
* 有两个权重 - (internal) weight 是此 cfqg 相对于同级 cfqg 的权重。leaf_weight 是此 cfqg 相对于子 cfqg 的权重。对于根 cfqg,这两个权重保持同步以向后兼容。
*/
unsigned int weight;
unsigned int new_weight;
unsigned int dev_weight;
unsigned int leaf_weight;
unsigned int new_leaf_weight;
unsigned int dev_leaf_weight;
/* 当前在该组上的 cfqq 数量 */
int nr_cfqq;
/*
* 每组繁忙队列平均值。对工作负载切片计算有用。
* 我们为每个优先级类创建数组,但在运行时,仅用于 RT 和 BE 类,而 IDLE 类的槽位未使用。这主要是为了避免混淆和 gcc 警告。
*/
unsigned int busy_queues_avg[CFQ_PRIO_NR];
/*
* rr 队列的列表。我们为 RT 和 BE 类维护服务树。这些树根据工作负载类型分为 SYNC、SYNC_NOIDLE 和 ASYNC 子类。
* 对于 IDLE 类,没有子分类,所有 CFQ 队列都在一个服务树 service_tree_idle 上。
* 计数嵌入在 cfq_rb_root 中
*/
struct cfq_rb_root service_trees[2][3];
struct cfq_rb_root service_tree_idle;
u64 saved_wl_slice;
enum wl_type_t saved_wl_type;
enum wl_class_t saved_wl_class;
/* dispatch 列表或驱动程序中的请求数量 */
int dispatched;
struct cfq_ttime ttime;
struct cfqg_stats stats; /* 此 cfqg 的统计数据 */
/* 每个优先级类的异步队列 */
struct cfq_queue *async_cfqq[2][IOPRIO_BE_NR];
struct cfq_queue *async_idle_cfqq;
};
每个 CFQ 组包含一个可以在 cgroup 中配置的 “I/O 权重” 值。CFQG(CFQ 组)的 vdisktime 决定其在 “cfqg 服务树” 上的位置,然后根据 “I/O 权重” 对其进行计算。
理解限速
限速为块 I/O 提供了一种应用资源限制的手段。这使得内核能够控制用户空间进程能够获得的最大块 I/O。内核通过块 I/O cgroup 来实现这一点。
对每个 cgroup 的块 I/O 进行限速是通过一组不同的函数完成的。第一个函数是 blk_throtl_bio,它定义在 blk-throttle.c 中(参见 elixir.bootlin.com/linux/lates…):
bool blk_throtl_bio(struct request_queue *q, struct blkcg_gq *blkg, struct bio *bio)
{
struct throtl_qnode *qn = NULL;
struct throtl_grp *tg = blkg_to_tg(blkg ?: q->root_blkg);
struct throtl_service_queue *sq;
bool rw = bio_data_dir(bio);
bool throttled = false;
struct throtl_data *td = tg->td;
WARN_ON_ONCE(!rcu_read_lock_held());
/* 参见 throtl_charge_bio() */
if (bio_flagged(bio, BIO_THROTTLED) || !tg->has_rules[rw])
goto out;
spin_lock_irq(q->queue_lock);
throtl_update_latency_buckets(td);
if (unlikely(blk_queue_bypass(q)))
goto out_unlock;
blk_throtl_assoc_bio(tg, bio);
blk_throtl_update_idletime(tg);
sq = &tg->service_queue;
again:
while (true) {
if (tg->last_low_overflow_time[rw] == 0)
tg->last_low_overflow_time[rw] = jiffies;
throtl_downgrade_check(tg);
throtl_upgrade_check(tg);
/* Throtl 是 FIFO - 如果 bios 已经排队,我们应该排队 */
if (sq->nr_queued[rw])
break;
/* 如果超过限制,则中断排队 */
if (!tg_may_dispatch(tg, bio, NULL)) {
tg->last_low_overflow_time[rw] = jiffies;
if (throtl_can_upgrade(td, tg)) {
throtl_upgrade_state(td);
goto again;
}
break;
}
/* 在限制范围内,让我们直接计费和调度 */
throtl_charge_bio(tg, bio);
/*
* 即使 bios 未排队,我们也需要修剪时间片,否则可能会发生长时间未排队的 bio 扩展时间片,而未调用修剪。
* 如果突然降低限制,我们会以新的低速率考虑到到目前为止的所有已调度 I/O,而新排队的 I/O 获得非常长的调度时间。
*
* 因此,即使 bio 未排队,也要继续修剪时间片。
*/
throtl_trim_slice(tg, rw);
/*
* @bio 通过此层未被限速。
* 上升。如果我们已经在顶层,可以直接执行。
*/
qn = &tg->qnode_on_parent[rw];
sq = sq->parent_sq;
tg = sq_to_tg(sq);
if (!tg)
goto out_unlock;
}
/* 超出限制,排队到 @tg */
throtl_log(sq, "[%c] bio. bdisp=%llu sz=%u bps=%llu iodisp=%u iops=%u queued=%d/%d",
rw == READ ? 'R' : 'W',
tg->bytes_disp[rw], bio->bi_iter.bi_size,
tg_bps_limit(tg, rw),
tg->io_disp[rw], tg_iops_limit(tg, rw),
sq->nr_queued[READ], sq->nr_queued[WRITE]);
tg->last_low_overflow_time[rw] = jiffies;
td->nr_queued[rw]++;
throtl_add_bio_tg(bio, qn, tg);
throttled = true;
/*
* 更新 @tg 的调度时间,并在 @tg 之前为空时强制调度。如果 @tg 的调度时间不是将来的,则强制调度不太可能导致不必要的延迟。
*/
if (tg->flags & THROTL_TG_WAS_EMPTY) {
tg_update_disptime(tg);
throtl_schedule_next_dispatch(tg->service_queue.parent_sq, true);
}
out_unlock:
spin_unlock_irq(q->queue_lock);
out:
bio_set_flag(bio, BIO_THROTTLED);
#ifdef CONFIG_BLK_DEV_THROTTLING_LOW
if (throttled || !td->track_bio_latency)
bio->bi_issue.value |= BIO_ISSUE_THROTL_SKIP_LATENCY;
#endif
return throttled;
}
以下代码片段检查 bio 是否可以被调度以推送到设备驱动程序:
if (!tg_may_dispatch(tg, bio, NULL)) {
tg->last_low_overflow_time[rw] = jiffies;
if (throtl_can_upgrade(td, tg)) {
throtl_upgrade_state(td);
goto again;
}
break;
}
tg_may_dispatch 的定义如下:
static bool tg_may_dispatch(struct throtl_grp *tg, struct bio *bio, unsigned long *wait)
{
bool rw = bio_data_dir(bio);
unsigned long bps_wait = 0, iops_wait = 0, max_wait = 0;
/*
* 当前组的整个状态机取决于队列中组 bio 列表中的第一个 bio。因此,如果已经有其他 bio 排队,就不应该调用这个函数。
*/
BUG_ON(tg->service_queue.nr_queued[rw] && bio != throtl_peek_queued(&tg->service_queue.queued[rw]));
/* 如果 tg->bps = -1,则带宽无限制 */
if (tg_bps_limit(tg, rw) == U64_MAX && tg_iops_limit(tg, rw) == UINT_MAX) {
if (wait) *wait = 0;
return true;
}
/*
* 如果前一个时间片过期,启动一个新时间片,否则续订/扩展现有时间片,以确保它至少 throtl_slice 长度的间隔。
* 新时间片仅为空节流组启动。如果有排队的 bio,这意味着应该有一个活跃的时间片,并且应该扩展它。
*/
if (throtl_slice_used(tg, rw) && !(tg->service_queue.nr_queued[rw])) throtl_start_new_slice(tg, rw);
else {
if (time_before(tg->slice_end[rw], jiffies + tg->td->throtl_slice))
throtl_extend_slice(tg, rw, jiffies + tg->td->throtl_slice);
}
if (tg_with_in_bps_limit(tg, bio, &bps_wait) && tg_with_in_iops_limit(tg, bio, &iops_wait)) {
if (wait) *wait = 0;
return true;
}
max_wait = max(bps_wait, iops_wait);
if (wait) *wait = max_wait;
if (time_before(tg->slice_end[rw], jiffies + max_wait)) throtl_extend_slice(tg, rw, jiffies + max_wait);
return false;
}
代码片段 if (tg_with_in_bps_limit(tg, bio, &bps_wait) && tg_with_in_iops_limit(tg, bio, &iops_wait)) 确定 bio 是否在该 cgroup 的限制范围内。如明显所示,它检查 cgroup 的每秒字节限制和每秒 I/O 限制。
如果未超出限制,首先将 bio 记入 cgroup 的账:
/* 在限制范围内,让我们直接计费和调度 */ throtl_charge_bio(tg, bio);
static void throtl_charge_bio(struct throtl_grp *tg, struct bio *bio) {
bool rw = bio_data_dir(bio);
unsigned int bio_size = throtl_bio_data_size (bio);
/* 将 bio 计入组 */
tg->bytes_disp
总结
在本章中,我们探讨了如何使用 Linux 内核中的 cgroups 机制来约束租户的资源。我们介绍了 Linux 内核中支持 cgroups 的各种数据结构。同时,我们也检查了一些代码示例,展示了如何从用户空间应用程序启用 cgroups。