Block Throttle - Low Limit

传统的 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.lowio.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 算法

上一篇:细节是魔鬼——基于计数器的锁机制的实现准则


下一篇:Long Story of Block - 1 Data Unit