脏页大小 - 18446744073709551614 ?!

April 11, 2024

分享一个 etcd-io/etcd@17615 问题:用户发现 etcd 进程写入 WAL 日志有抖动,但是磁盘压力并不大,而且 fsync 系统调用都很正常; 后来通过 Tracing 工具定位到了内核 memory-cgroup 脏页数据统计有问题,不过该问题仅会出现在 v5.15.0-63 小版本。 17615 问题单里有详尽的定位过程,推荐看看。 因为这个问题,我翻阅了相关的内核 PATCH 邮件,做了以下的脏页分配限流 (简单) 总结。

脏页分配限流 - balance_dirty_pages

回写 (Writeback) 是指内核负责将脏页 (DirtyPage) 刷入存储设备上,它涉及到脏页分配控制。

在 v4.2 版本之前,脏页分配更多是由 memory-cgroup 来控制;而脏页流控核心 - balance_dirty_pages - 根据全局的脏页水位情况来决定是否需要控速。 在 LinuxCon Japan 2015 会议上,内核开发者 Tejun Heo 认为,在没有感知到全局脏页水位的情况,memory-cgroup 无法对脏页进行有效的控制。 核心 balance_dirty_pages 函数通过 vm.dirty[_background]_{ratio, bytes} 参数来计算全局脏页的水位上限。 Tejun Heo 认为普通的 memory-cgroup 也应采用同样的方式来计算组内的脏页水位上限,而根组 memory-cgroup 只不过是退化到全局模式,当然全局脏页的水位优先级更高。

stack

NOTE: balance_dirty_pages_ratelimited 会调用 balance_dirty_pages

writeback: cgroup writeback support PATCH 合并到 v4.2 之后,每个 memory-cgroup 都有独立的设备回写控制器 (bdi_writeback),它用于执行回写操作。 回写控制器维护写入带宽 - dirty_ratelimit - ,初始状态下为 100 MiB/s。假设当前脏页大小为 dirty 和同时有 N 个线程在写入,该线程需要等待的时长大约为

pause = dirty / (dirty_ratelimit / N)

writeback: dirty rate control PATCH 里,内核开发者 Wu Fengguang 给出了 N = roundup_pow_of_two(1 + HZ / 8) 的计算方法; 自 2011 年以来,内核一直在使用该方法计算 N。 让线程停顿是为了防止脏页增长过快。但如果整体可用内存水位状态良好,内核应该尽量避免不必要的停顿,因此脏页水位有三个状态 freerun, setpoint 和 limit。

threshold = vm.dirty_ratio * total_available_memory 
background_threshold = vm.dirty_background_ratio * total_available_memory

* freerun = (threshold + background_threshold) / 2
* limit = threshold
* setpoint = (freerun + limit) / 2

当脏页水位线低于 freerun 时,那么线程并不需要停顿,内存允许脏页快速增长;当脏页水位高于 limit 时,内核将通过 sleep(pause) 来禁止线程写入新的脏页,内核需要通过回写来降低水位线到安全的位置。 为了更好地计算停顿时长,内核引入了线程级别的带宽概念 task_ratelimit: 利用水位状态 pos_ratio 来调节写入带宽。

* pos_ratio(dirty) = 1.0 + ((setpoint - dirty) / (limit - setpoint) ) ^3 
* task_ratelimit(dirty) = dirty_ratelimit * pos_ratio(dirty)

* pause = dirty / (task_ratelimit / N)

pos_ratio

内核还会根据 IO 回写控制器的情况来微调 pos_ratio,尤其是在内存较大的机器上能充分利用内存优势, 通过维持较高的写入带宽来迅速降低脏页水位线,细节可以查询 writeback: dirty position control PATCH。

memory-cgroup 脏页状态更新 - rstat

当进程跑在非根组的 memory-cgroup 时,balance_dirty_pages 需要从对应 memory-cgroup 获取当前的脏页大小。

2019 年 mm: memcontrol: make cgroup stats and events query API explicitly local PATCH 让内核在更新侧 (内存的分配和释放) 做 per-cpu 的批量数据聚合,减少读取侧因遍历 memory-cgroup 树形结构测带来的 CPU 消耗。 假设每次更新 32 个页的数据,在 32 CPU 下运行着 32 个 memory-cgroup,那么最大误差会在 128 MiB 左右。 为了解决误差和读取效率问题,内核开发者 Johannes Weiner 在 mm: memcontrol: switch to rstat PATCH 里引入了 rstat 框架: 更新侧仅需为祖辈们维护 pending-update cgroup 队列,而读取侧仅选择性地做数据聚合,减少了不必要的遍历。

假设当前 memory-cgroup 结构为 root -> A -> B -> C,当线程在 C 内分配了内存,那么内核在更新 C 的同时,它也会标记 B, A, root 为待更新状态; 当有线程需要读取 C 状态时,那么读取侧需要将 B, A, root 也更新了,才能保证 root -> A -> B -> C 链路上的数据是一致的。 如果新增了 root -> A -> D -> E memory cgroup, 但 D, E 没有数据的变化,那么在读取 C 的数据时,内核并不会遍历 D, E。 rstat 架构的选择性聚合优化了读取效率和准确性。关于 rstat 的更多细节可以查看 Linux Plumbers Conferences 2022 - cgroup rstat’s advanced adoption

脏页大小 - 18446744073709551614 ?

回到 ETCD 遇到的这个问题上。 在更新 memory-cgroup 字段时,线程仅更新当前 CPU 的 memcg->vm_stats_percpu->state 上,由读取侧调用 cgroup_rstat_flush_locked 函数来做 CPU 级别的数据聚合。

cgroup_rstat_flush_locked

那么问题来了,假设有 32 个 CPU,当 cgroup_rstat_flush_locked 读取第三个 CPU 上的数据时,第一个 CPU 产生了新的脏页,然后被第 30 个 CPU 刷盘了。 错过了第一个 CPU 产生的增量,导致在那个时刻的统计结果里脏页是负数。虽然状态最终是正确的,但负数被转化成 unsigned long 将变成非常大。

static inline unsigned long memcg_page_state(struct mem_cgroup *memcg, int idx)
{
        return READ_ONCE(memcg->vmstats.state[idx]);
}

根据前面提到的停顿时长计算公式,线程相当于无限期停服了。 好在内核允许的最大停顿时间为 200 ms,下一轮检查大概率就恢复正常了。 Revert “memcg: cleanup racy sum avoidance code PATCH 修复也非常简单,就是回滚之前的一个 PATCH。

我在一个 CPU 32 vcores - Memory 64 GiB 虚拟机上复现了这个问题:

* 单实例 ETCD 运行在 /sys/fs/cgroup/testing1 里,并使用 ETCD benchmark 疯狂发写请求
* 反复在 /sys/fs/cgroup/testing2 里运行 dd 来产生大量的脏页,迫使 ETCD 进程进入 balance_dirty_pages

通过 Tracing Event 日志发现,脏页大小为 18446744073709551614 (-2)。

dirtypage=-2

核数越多,越容易遇到这个问题。

最后

博客停更好久了,有时间就多写写吧。