sched discussion
[RFC 00/10] Reclaimable kernel stacks
LLM 分析
Reclaimable kernel stacks:调度器回收阻塞任务的栈内存
系列概况
- 标题:[RFC 00/10] Reclaimable kernel stacks
- 作者:David Stevens stevensd@google.com
- 版本:RFC v0(未合并)
- 规模:10 个 patch
- 修改文件:arch/Kconfig, arch/arm64/Kconfig, arch/arm64/include/asm/processor.h, arch/x86/Kconfig, arch/x86/include/asm/processor.h, drivers/android/binder/thread.rs, fs/eventpoll.c, fs/pipe.c, fs/select.c, include/linux/list_lru.h, include/linux/sched.h, include/linux/sched/task_stack.h, include/linux/vmalloc.h, kernel/Makefile, kernel/fork.c, kernel/futex/waitwake.c, kernel/sched/core.c, kernel/sched/sched.h, kernel/signal.c, kernel/stack_shrinker.c (新增), kernel/stack_shrinker.h (新增), kernel/time/hrtimer.c, mm/vmalloc.c, rust/kernel/task.rs
- 代码统计:24 文件改动,新增约 1175 行,删除约 45 行
- Message-ID:20260827232948.2520558-1-stevensd@google.com
- 完整性:cover letter + 9 patch 完整;patch 06/10 的 stack_shrinker.c 长实现被截断但状态机已展示
补丁目的
Android 系统进程常开 2000–3000 线程,应用再加 1000+,1–2% 系统 RAM 被 vmap 内核栈吃掉,且大多数线程长期处于阻塞状态。本 RFC 想做两件事:① 在任务进入安全阻塞时把未触及的栈页交给 shrinker 回收;② 唤醒前 fast path 重新映射栈页,必要时 fallback 到 workqueue + GFP_KERNEL 重建栈。
旧流程的问题
fork() -> __vmalloc_node() allocate full stack at once
task blocks -> whole stack stays pinned, counted in VMALLOC
task wakeup -> resume directly, no rebuild needed
问题:
- 即使线程长期睡眠,16 KiB 栈依旧常驻物理内存。
- shrinker 拿不到这部分内存,回收压力落到其它缓存。
- Android进程/线程模型下,浪费被放大到 1–2% 系统 RAM。
新流程
allocate phase (fork, patch 05/10)
alloc_vmap_stack()
get_vm_area_node(VM_SPARSE) reserve vaddr, no page mapping
kcalloc_node(pages[]) save struct page * per page
alloc_pages(GFP_VMAP_STACK) per page fill pages[]
vmap_pages_range() build full vmap at once
block phase (sched, patch 06/10)
__block_task()
prepare_stack_for_reclaim(p) mark STACK_PREPARE_RECLAIM
finish_task_switch()
allow_stack_reclaim(prev) flip to STACK_RECLAIMABLE queue_irq_work -> kworker
kworker
do_reclaim_stack(p) reclaim unused pages via listreclaim phase (patch 07/10)
shrinker scan process_new_reclaimable_stacks()
add candidates to list_lru
isolate_lru_stack()
pick object, atomic flip lru_state
do_reclaim_stack()
remove_vm_area() + __free_page()
wakeup phase (sched, patch 06/10)
try_to_wake_up()
ensure_stack_is_present(p) fast path: alloc + vmap_pages_range
on failure:
WRITE_ONCE p->__state = TASK_STACK_RECLAIM
do_deferred_repopulate_wake = true
exit try_to_wake_up, then:
wake_stack_repopulate() wake kworker for GFP_KERNEL slow path
stack_reclaim_state (u32) state machine
+----------------------+
| STACK_IN_USE | <- alloc_vmap_stack() done
+----------------------+
|
| __block_task -> prepare_stack_for_reclaim()
v
+----------------------+
| STACK_PREPARE_RECLAIM|
+----------------------+
|
| finish_task_switch -> allow_stack_reclaim()
v
+----------------------+
| STACK_RECLAIMABLE | -> list_lru_add() into shrinker view
+----------------------+
|
| shrinker selects and atomically flips
v
+----------------------+
| STACK_RECLAIMING | (wakeup side flips back to IN_USE)
+----------------------+
|
v
+----------------------+
| STACK_RECLAIMED | (pages __free_page'd,
+----------------------+ task needs repopulate to run)
Patch 概览
01/10 list_lru: stub memcg_list_lru_alloc() when !MEMCG
02/10 vmalloc: skip vmallocinfo NUMA stats for VM_SPARSE regions
03/10 fork: refactor alloc/free vmap stack into helpers
04/10 vmalloc: export get_vm_area_node(size, align, flags, node)
05/10 fork: CONFIG_RECLAIMABLE_STACK path uses VM_SPARSE directly,
free path moves to queue_rcu_work (no vfree in RCU)
06/10 core: PF_RECLAIMABLE_STACK + stack_reclaim_state machine,
trigger repopulate in try_to_wake_up, add TASK_STACK_RECLAIM
07/10 shrinker: replace stack_reclaim_work with list_lru shrinker,
node/priority policy moves into shrinker framework
08/10 sites: mark safe wait sites with guard(allow_stack_reclaim)()
(ep_poll, pipe_read, poll_schedule_timeout, futex,
nanosleep, freezer_trap, sigtimedwait, binder looper)
09/10 x86: select HAVE_ARCH_RECLAIMABLE_STACK (X86_64),
top_of_blocked_task_stack() = thread->sp
10/10 arm64: same, top_of_blocked_task_stack() = thread->cpu_context.sp
关键实现
- 栈页追踪:
alloc_vmap_stack()用vm_area->pages[]保存每页struct page *,回收时直接__free_page(),不走 vmalloc 内部 page 查找;patch 05/10 拆free_vmap_stack()正是为此服务。 - 状态机与并发:
stack_reclaim_state是u32 valunion,用try_cmpxchg翻转stack_state与lru_state;新对象先入new_reclaimable_stacks全局链表,再在 shrinker 注册时搬到 list_lru,期间靠tryget_task_struct()防与__put_task_struct()竞速。 - 安全阻塞声明:patch 08/10 用
guard(allow_stack_reclaim)()(DEFINE_CLASS cleanup guard) 在特定 wait 站点打开PF_RECLAIMABLE_STACK;真正翻转到STACK_RECLAIMABLE要等finish_task_switch(),避免误标正在运行的进程。 - 唤醒路径:Peter Zijlstra 在 patch 06/10明确否决"在
pi_lock下调ensure_stack_is_present"。David 后续想改成预分配零页池 + raw_spinlock,但 Peter 又指出memcg_kmem_charge_page()内部要拿local_lock(spinlock),不能在 raw_spinlock 区使用。 - PREEMPT_RT 限制:默认
default !PREEMPT_RT,因alloc_pages_nolock_noprof()在 RT 下内部spin_trylock可能反向取pi_lock;wakeup 走 kworker + GFP_KERNEL 分配,RT 上 latency 不可接受。 - 平台钩子:
top_of_blocked_task_stack(thread)由各架构实现(x86:thread->sp;arm64:thread->cpu_context.sp),用来定位 SP 之上仍可能命中的页面。
类比
把这套机制想成"图书馆的自习座位":每个线程是一位读者,16 KiB 的 vmap 栈是一张专属 4 联座。读者离开去吃饭(__schedule 切走)时,图书馆员在桌子上贴回收标签 (PF_RECLAIMABLE_STACK + STACK_RECLAIMABLE);夜班清洁工 (shrinker) 巡场,把没人坐的椅子折叠起来,把坐垫 (pages[]) 收回仓库 (__free_page)。读者回来(try_to_wake_up → ensure_stack_is_present)时,图书馆员要么立刻把椅子重新摆好(fast path:零页池 + vmap_pages_range),要么交给全职前台 (kworker + GFP_KERNEL) 慢慢摆好。没贴回收标签的工位(调度器自身睡眠等不安全点)永远不会被碰。
Highlight:风险与注意点
pi_lock下做内存分配:Peter 直接否决在try_to_wake_up持锁期间调ensure_stack_is_present();fast path 进一步受限,因为memcg_kmem_charge_page()内部有local_lock(spinlock)。- PREEMPT_RT 不适用:
alloc_pages_nolock_noprof()在 RT 下内部spin_trylock可能反向取pi_lock,把锁路径拧成环;Sebastian Siewior 要求硬关 RT 而非default !PREEMPT_RT偷懒写法,并要求至少排除mlock()ed 任务。 - trace_sched_waking 位置被改动:patch 把 tracepoint 从
ttwu_runnable之前挪到之后,sched_waking时间戳失真,Peter 要求还原。 - 类/函数同名混淆:
DEFINE_CLASS(allow_stack_reclaim, ...)与同名static inline void allow_stack_reclaim(struct task_struct *)共用名字,需要重命名。 - memcg 释放 / repopulate 不平衡:
do_reclaim_stack()会 uncharge,repopulate 又重新 charge;sashiko-bot 标记copy_process()错误路径下obj_cgroup泄漏(High),并指出 repopulate 用NUMA_NO_NODE静默绕过 mempolicy(Medium)。 - OOM 路径变长:被回收栈的 OOM victim 必须先 repopulate 才能 exit,可能导致更多 OOM kill;Android 靠 lmkd 抵消,但上游仍需评估。
- vmallocinfo NUMA 偏差:patch 02/10 让
show_numa_info()对VM_SPARSE直接 return,调试工具受影响需要在帮助文档说明。 - lockdep 盲点:
alloc_pages_nolock_noprof()内部使用trylock,lockdep 默认认为安全;让上游真正暴露问题需要让 lockdep 拒绝spin_trylock嵌套(独立改进)。 - 线程数才是根本:Peter 与 Steven Rostedt 都指出,应将每进程 1000+ 线程减到 100 量级(Chrome 930、Minecraft 108),收益比栈回收更大;这条思路削弱了 RFC 动机。
- 并发移除:
remove_from_stack_shrinker()与 shrinker 并发处理stack_reclaim_list时必须小心,目前靠tryget_task_struct()+ 状态机翻转兜底。
版本变化
只有 RFC v0。讨论中浮现但尚未合入的潜在 v1 调整方向:
guard(allow_stack_reclaim)()改写为scoped_guard(...)风格(Prateek Nayak 提议)。default !PREEMPT_RT改成硬性depends on !PREEMPT_RT,并加 mlock() 例外。- 还原
trace_sched_waking到ensure_stack_is_present之前。 - fast path 改 per-CPU 零页池 + 不做 memcg re-charge(或延迟 charge 到 workqueue)。
- 重命名
allow_stack_reclaim类,避免与同名函数混淆。 - 修掉 sashiko-bot 指出的
obj_cgroup泄漏与 NUMA_NO_NODE 绕过 mempolicy 问题。 - 增加 mlock() / PF_FROZEN 任务 opt-out。
一句话总结
通过把阻塞任务的 vmap 内核栈挂到 list_lru shrinker 上、按状态机回收栈页并按需 repopulate,本 RFC 想把 Android 上 1–2% RAM 的内核栈浪费降一半;但 pi_lock 下不允许做分配、PREEMPT_RT 与 tracepoint 位置等硬约束让 v0 还停留在"先收集反馈"的阶段。