传统的 block throttle 的语义是,cgroup 不能超过用户配置的 IOPS/BPS,此时所有 cgroup 会自由竞争 IO 资源;那么其存在的问题就是,如果用户配置的 IOPS/BPS 过高,所有 cgroup 之间就会完全自由竞争 IO 资源,从而无法保证 QoS,而如果用户配置的 IOPS/BPS 过低,又无法充分发挥 block 设备的性能
Facebook 的 Shaohua Li 引入了 Low Limit 来解决传统的 block throttle 的这一问题
Throttling Limit Policy 在支持 low limit 特性之后,每个 block cgroup 实际存在两道 limit,分别是 high limit 与 low limit
high limit 的语义是,在任何情况下都不能突破该限制,相当于是 hard limit,也就是支持 low limit 特性之前的 limit 的语义
相对地,low limit 相当于是 soft limit,在 IO 流量达到 low limit 的时候,可以突破 low limit,继续向 high limit 进发;但是有一个前提,那就是该 blkdev 下所有配置了 low limit 的 cgroup,其 IO 流量都已经达到或超过了对应的 low limit,或者该 cgroup 当前处于 idle 状态,并不存在任何 IO 负载
也就是说,low limit 特性会优先确保所有配置了 low limit 的 cgroup 都能够跑满 low limit,在满足这一条件的基础上,剩余的 IO 配额由所有 cgroup 自由竞争,也就是 cgroup 可以突破 low limit,这一过程称为 upgrade
对应地,一旦有一个配置有 low limit 的 cgroup 的 IO 流量掉到 low limit 以下,其他所有 cgroup 都必须回到 low limit 以下,这一过程称为 downgrade
------------- High Limit ------------
upgrade ^ | downgrade
(overflow) | ------------- Low Limit ------------- | (underflow)
| v
所以 Low Limit 特性在确保 QoS (通过 low limit) 的同时,能够将 block 设备的性能发挥到极致 (通过 high limit)
Tunable
cgroup v2 采用了一组新接口来设置 throttle limit,每个采用 Throttling Limit Policy 的 block group 的目录下,包含以下配置文件
io.low
和 io.max
分别设置 low limit 与 high limit,两个文件的输入格式均为
- "riops =" 与 "wiops =" 分别设置 read/write IOPS
- "rbps =" 与 "wbps =" 分别设置 read/write BPS
- "idle =" 设置 idle time
- "latency =" 设置 latency time
throttle group 中有两个地方保存了 limit 参数,其中 @iops_conf[]/@bps_conf[] 描述用户通过 io.low/io.max 输入的参数,而 @iops[]/@bps[] 描述 throttle group 实际使用的参数
struct throtl_grp {
/* internally used bytes per second rate limits */
uint64_t bps[2][LIMIT_CNT];
/* user configured bps limits */
uint64_t bps_conf[2][LIMIT_CNT];
/* internally used IOPS limits */
unsigned int iops[2][LIMIT_CNT];
/* user configured IOPS limits */
unsigned int iops_conf[2][LIMIT_CNT];
...
}
之所以有两个数组来存储 limit 参数,主要是为了与之前的 memory.low 的语义保持一致,用户输入的 io.low 的参数可以大于 io.max 的参数,但是 throttle group 实际使用的 io.low 的参数自然是小于等于 io.max 的参数
对于 LIMIT_MAX 来说,@iops_conf[]/@bps_conf[] 与 @iops[]/@bps[] 存储的值实际上是相等的
而对于 LIMIT_LOW 来说,@iops_conf[]/@bps_conf[] 存储用户通过 io.low/io.max 输入的参数,而 @iops[]/@bps[] 描述 throttle group 实际使用的参数,即在用户输入参数的基础上不能超过 LIMIT_MAX 对应的参数
用户在通过 "io.low" 配置 read/write IOPS/BPS 的时候,必须同时配置 idle time 和 latency time,才能使得配置生效
- "idle =" 设置 idle time
- "latency =" 设置 latency time
struct throtl_grp {
unsigned long idletime_threshold; /* us */
unsigned long idletime_threshold_conf; /* us */
unsigned long latency_target; /* us */
unsigned long latency_target_conf; /* us */
...
}
Routine
set low limit
limit_valid
@limit_valid[] 描述该 blkdev 设置的 limit 类型
struct throtl_data
{
bool limit_valid[LIMIT_CNT];
...
}
- @limit_valid[LIMIT_MAX] 为 TRUE,描述当前配置有 max limit
- @limit_valid[LIMIT_LOW] 为 TRUE,描述当前配置有 low limit
在 throttle data 初始化的时候,@limit_valid[LIMIT_MAX] 的默认值就为 TRUE
当用户通过 io.low 给某个 blkdev 配置 low limit 的时候,只要该 blkdev 下有一个 block cgroup 配置有 low limit,那么 @limit_valid[LIMIT_LOW] 就为 TRUE
limit_index
@limit_index 则描述该 blkdev 当前处于何种状态,即当前是按照 low limit 还是 high limit 限流
struct throtl_data
{
unsigned int limit_index;
...
}
在 throttle data 初始化的时候,@limit_index 的默认值为 LIMIT_MAX
当用户通过 io.low 配置 low limit 的时候,@limit_index 变更为 LIMIT_LOW
tg_set_limit
blk_throtl_update_limit_valid
for each cgroup under this blkdev:
if low limit configured on this cgroup:
td->limit_valid[LIMIT_LOW] = TRUE
if td->limit_valid[LIMIT_LOW]:
td->limit_index = LIMIT_LOW
run under low limit
一开始每个 block cgroup 的上限就是 low limit,一旦达到上限,当前下发的 IO 就会暂时缓存在当前 throttle group 中
blk_throtl_bio
tg_may_dispatch
bps_limit = tg_bps_limit(),i.e., tg->bps[rw][td->limit_index]
# since td->limit_index == LIMIT_LOW,
# bps_limit = tg->bps[rw][LIMIT_LOW]
# if throttled
td->nr_queued[rw]++
# ... pending in the throttle queue
upgrade to high limit
upgrade
每个 bio 下发过程中,如果当前 throttle data 的 @limit_index 为 LIMIT_LOW,即当前每个 throttle group 的上限尚为 low limit,那么此时就会进行 upgrade 检查,检查该 blkdev 的上限能否升级为 high limit
升级的条件是,该 blkdev 下的所有 cgroup 的 IO 流量都超过了 low limit,即所有 cgroup 都已经满足基本的配额,或者该 cgroup 处于 idle 状态,即根本没有流量下发
具体的算法是,遍历检查该 blkdev 下的所有 cgroup,对于其中的每个 cgroup
- 如果该 cgroup 定义有 low limit,那么检查其 @nr_queued[] 字段,如果该字段不为 0,说明当前该 cgroup 存在 bio 在 throttle 等待,即该 cgroup 的流量已经打满了 low limit
- 或者该 cgroup 处于 idle 状态,即根本没有流量下发,后面会介绍 idle 状态的判定算法
blk_throtl_bio
throtl_upgrade_check
throtl_can_upgrade
for each block cgroup in the cgroup hierarchy:
throtl_hierarchy_can_upgrade(tg)
throtl_tg_can_upgrade(tg)
if low limit for READ defined, and @nr_queued[READ] != 0:
return TRUE
if low limit for WRITE defined, and @nr_queued[WRITE] != 0:
return TRUE
if this cgroup is idle:
return TRUE
如果上述检查通过,那么该 blkdev 就会进行 upgrade,实际上就是将 @limit_index 升级为 LIMIT_MAX,那么接下来该 blkdev 下的所有 cgroup 都会按照 high limit 作为上限
blk_throtl_bio
throtl_upgrade_check
if throtl_can_upgrade():
throtl_upgrade_state
td->limit_index = LIMIT_MAX
last_check_time
每个 bio 下发的时候都会检查当前能够执行 downgrade/upgrade,而每次 downgrade/upgrade 检查都需要遍历整个 cgroup hierarchy,这是一个相当耗时的操作,为了减少这部分开销,downgrade/upgrade 检查存在一个周期,目前该周期的大小是 @throtl_slice,在该周期内只会检查一次,只有当前时刻距离上一次检查时超过了 @throtl_slice 时,才会执行该检查
每个 throttle group 的 @last_check_time 字段就记录了该 throttle group 上一次检查的时刻
struct throtl_grp {
unsigned long last_check_time;
...
}
blk_throtl_bio
throtl_upgrade_check/throtl_downgrade_check
if current jiffies within (@last_check_time, @last_check_time + @throtl_slice):
# skip this check
else:
tg->last_check_time = now // update @last_check_time
# start checking ...
last low overflow time
如果上述 upgrade 检查通过,那么该 blkdev 下的 cgroup hierarchy 升级为 high limit,而如果紧接着其中某个 cgroup 的流量下降到 low limit 以下,那么整个 cgroup hierarchy 又会降级为 low limit,也就是存在着 ping-pong 效应
为了减缓上述效应,一个可行的算法是,即使当前整个 cgroup hierarchy 满足 upgrade 条件,减缓 upgrade 的流程,也就是延迟一段时间之后,再执行 upgrade 检查,如果在延迟的这段时间内,cgroup hierarchy 中的某个 cgroup 的 IO 流量下降到 low limit 以下,那么 upgrade 检查自然是不通过;而如果延迟了一段时间之后,upgrade 检查仍然通过,再进行 upgrade
具体的算法是,每个 throttle group 的 @last_low_overflow_time[] 分别记录了该 throttle group 上一次 READ/WRITE IO 流量超过 low limit 的时刻
struct throtl_grp {
unsigned long last_low_overflow_time[2];
...
}
之前介绍过 bio 下发过程中,在尚未 upgrade 之前 throttle group 的流量上限为 low limit,此时如果该 throttle group 的 IO 流量超过了 low limit,该 bio 就会进入 throttle 流程,同时更新该 throttle group 的 @last_low_overflow_time[] 字段
blk_throtl_bio
tg_may_dispatch
bps_limit = tg_bps_limit(),i.e., tg->bps[rw][td->limit_index]
# since td->limit_index == LIMIT_LOW,
# bps_limit = tg->bps[rw][LIMIT_LOW]
# if throttled
td->nr_queued[rw]++
tg->last_low_overflow_time[rw] = jiffies
...
回到之前的 upgrade 检查流程中,如果当前时刻距离该 cgroup 上次超过 low limit 的时刻还没有超过一个 @throtl_slice,那么此次先不执行 upgrade 检查,让子弹飞一会儿,暂时还是按照 low limit 作为上限
blk_throtl_bio
throtl_upgrade_check
if current jiffies within (@last_low_overflow_time[], @last_low_overflow_time[] + @throtl_slice):
# skip this check
else:
# start checking ...
low_downgrade_time
类似地,@low_downgrade_time 也可以用于减缓上述 ping-pong 效应
struct throtl_data {
unsigned long low_downgrade_time;
...
}
@low_downgrade_time 描述了整个 cgroup hierarchy 上次进行 downgrade 的时刻,为了减缓上述 ping-pong 效应,在上次进行 downgrade 之后,待满一个 @throtl_slice 周期之后,才能进行 upgrade 操作
blk_throtl_bio
throtl_upgrade_check
throtl_can_upgrade
if current jiffies within (@low_downgrade_time, @low_downgrade_time + @throtl_slice):
return FALSE
run under high limit
此时每个 block cgroup 的上限就是 high limit,一旦达到上限,当前下发的 IO 就会暂时缓存在当前 throttle group 中
blk_throtl_bio
tg_may_dispatch
bps_limit = tg_bps_limit(),i.e., tg->bps[rw][td->limit_index]
# since td->limit_index == LIMIT_MAX,
# bps_limit = tg->bps[rw][LIMIT_MAX]
# if throttled
td->nr_queued[rw]++
# ... pending in the throttle queue
downgrade to low limit
此时整个 cgroup hierarchy 按照 high limit 作为上限,而一旦 cgroup hierarchy 中有一个 cgroup 的 IO 流量降到 low limit 以下,就必须进行 downgrade,即恢复为 low limit 作为上限
那么怎么判断 cgroup 的流量是否下降到 low limit 以下呢?具体算法使用到以下字段进行辅助判断
struct throtl_grp {
uint64_t last_bytes_disp[2];
unsigned int last_io_disp[2];
unsigned long last_check_time;
unsigned long last_low_overflow_time[2];
...
}
last_bytes_disp/last_io_disp
@last_bytes_disp[]/@last_io_disp[] 记录了自上次更新 @last_check_time 以来至当前时刻,该 cgroup 下发的 IO 流量
在每次执行 downgrade 检查的时候,都会更新当前 cgroup 的 @last_check_time 时刻,同时将 @last_bytes_disp[]/@last_io_disp[] 清空
blk_throtl_bio
throtl_downgrade_check
tg->last_check_time = now;
# down grade check
# clear @last_bytes_disp[]/@last_io_disp[] no mater whether downgrade check failed or not
tg->last_bytes_disp[READ] = 0;
tg->last_bytes_disp[WRITE] = 0;
tg->last_io_disp[READ] = 0;
tg->last_io_disp[WRITE] = 0;
在 dispatch bio 的过程中,会更新 @last_bytes_disp[]/@last_io_disp[]
blk_throtl_bio
tg_may_dispatch
# pass the throttle check, start to dispatch this bio
throtl_charge_bio
/* Charge the bio to the group */
tg->bytes_disp[rw] += bio_size;
tg->io_disp[rw]++;
tg->last_bytes_disp[rw] += bio_size;
tg->last_io_disp[rw]++;
last_low_overflow_time
当 cgroup hierarchy 跑在 high limit 上限时,每个 cgroup 的@last_low_overflow_time[] 字段还是描述该 cgroup 上次 IO 流量超过 low limit 的时刻,只是此时更新 @last_low_overflow_time[] 字段的地方变成了 downgrade 检查的时候
blk_throtl_bio
throtl_downgrade_check
elapsed_time = now - tg->last_check_time
# check READ IOPS
iops = tg->last_io_disp[READ] * HZ / elapsed_time;
if (iops >= tg->iops[READ][LIMIT_LOW]):
tg->last_low_overflow_time[READ] = now;
# similar for WRITE IOPS, READ/WRITE BPS
...
此时在 downgrade 检查的过程中,会计算上次 @last_check_time 至今的这一段时间内的 READ/WRITE IOPS/BPS 流量,并和配置的 READ/WRITE IOPS/BPS low limit 比较,从而判断这段时间内 cgroup 的流量是否有超过配置的 low limit,并相应地设置 @last_low_overflow_time[] 的值
downgrade
之前介绍过,跑在 high limit 的时候,一旦 cgroup hierarchy 中有一个 cgroup 的 IO 流量降到 low limit 以下,就必须进行 downgrade
具体的算法是,只要当前 cgroup 不处于 idle 状态,同时当前时刻在 (@last_low_overflow_time[] + @throtl_slice) 之后,那么就认为整个 cgroup hierarchy 可以进行 downgrade
这里的重点是,对于某个 cgroup 来说,如果当前时刻在 (@last_low_overflow_time[] + @throtl_slice) 之后,那么就认为当前该 cgroup 的流量在 low limit 以下。怎么理解这条规则呢?
之前介绍过在 downgrade 检查过程中会更新 @last_low_overflow_time[],如果当前该 cgroup 的流量超过了 low limit,那么就会将 @last_low_overflow_time[] 更新为当前时刻,此时上述规则自然也就是不成立的
所以如果在一个 @throtl_slice 的时间周期内,@last_low_overflow_time[] 都没有更新,即这一时间周期内该 cgroup 的流量都在 low limit 以下,那么就可以认为该 cgroup 的流量在 low limit 以下,即该 cgroup 可以执行 downgrade 操作
同时这里之所以设定一个 @throtl_slice 的时间周期,也是为了防止 cgroup 由于扰动进行 downgrade 之后又马上进行 upgrade 带来的 ping-pong 效应
blk_throtl_bio
throtl_downgrade_check
throtl_hierarchy_can_downgrade
throtl_tg_can_downgrade(tg)
if 1)this cgroup is under low limit (i.e., current jiffies is after (@last_low_overflow_time[] + @throtl_slice)),
and 2) this cgroup is not idle:
return TRUE
如果上述检查通过,那么该 blkdev 就会进行 downgrade,实际上就是将 @limit_index 升级为 LIMIT_LOW,那么接下来该 blkdev 下的所有 cgroup 都会按照 low limit 作为上限
blk_throtl_bio
throtl_downgrade_check
if throtl_hierarchy_can_downgrade():
throtl_downgrade_state
td->limit_index = LIMIT_LOW
last_check_time
类似地,为了减缓 cgroup 在 upgrade/downgrade 之间来回切换的 ping-pong 效应,downgrade 过程中也会根据 @last_check_time 来减缓该 ping-pong 效应
blk_throtl_bio
throtl_upgrade_check/throtl_downgrade_check
if current jiffies within (@last_check_time, @last_check_time + @throtl_slice):
# skip this check
else:
tg->last_check_time = now // update @last_check_time
# start checking ...
low_upgrade_time
类似地,downgrade 过程中使用 @low_upgrade_time 来减缓上述 ping-pong 效应
struct throtl_data {
unsigned long low_upgrade_time;
...
}
@low_upgrade_time 描述了整个 cgroup hierarchy 上次进行 upgrade 的时刻,为了减缓上述 ping-pong 效应,在上次进行 upgrade 之后,待满一个 @throtl_slice 周期之后,才能进行 downgrade 操作
blk_throtl_bio
throtl_downgrade_check
throtl_hierarchy_can_downgrade
throtl_tg_can_downgrade
if current jiffies within (@low_upgrade_time, @low_upgrade_time + @throtl_slice):
return FALSE
TODO
idle 算法