0/14 已展开

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-ID20260827232948.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_stateu32 val union,用 try_cmpxchg 翻转 stack_statelru_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:风险与注意点

  1. pi_lock 下做内存分配:Peter 直接否决在 try_to_wake_up 持锁期间调 ensure_stack_is_present();fast path 进一步受限,因为 memcg_kmem_charge_page() 内部有 local_lock (spinlock)。
  2. PREEMPT_RT 不适用alloc_pages_nolock_noprof() 在 RT 下内部 spin_trylock 可能反向取 pi_lock,把锁路径拧成环;Sebastian Siewior 要求硬关 RT 而非 default !PREEMPT_RT 偷懒写法,并要求至少排除 mlock()ed 任务。
  3. trace_sched_waking 位置被改动:patch 把 tracepoint 从 ttwu_runnable 之前挪到之后,sched_waking 时间戳失真,Peter 要求还原。
  4. 类/函数同名混淆DEFINE_CLASS(allow_stack_reclaim, ...) 与同名 static inline void allow_stack_reclaim(struct task_struct *) 共用名字,需要重命名。
  5. memcg 释放 / repopulate 不平衡do_reclaim_stack() 会 uncharge,repopulate 又重新 charge;sashiko-bot 标记 copy_process() 错误路径下 obj_cgroup 泄漏(High),并指出 repopulate 用 NUMA_NO_NODE 静默绕过 mempolicy(Medium)。
  6. OOM 路径变长:被回收栈的 OOM victim 必须先 repopulate 才能 exit,可能导致更多 OOM kill;Android 靠 lmkd 抵消,但上游仍需评估。
  7. vmallocinfo NUMA 偏差:patch 02/10 让 show_numa_info()VM_SPARSE 直接 return,调试工具受影响需要在帮助文档说明。
  8. lockdep 盲点alloc_pages_nolock_noprof() 内部使用 trylock,lockdep 默认认为安全;让上游真正暴露问题需要让 lockdep 拒绝 spin_trylock 嵌套(独立改进)。
  9. 线程数才是根本:Peter 与 Steven Rostedt 都指出,应将每进程 1000+ 线程减到 100 量级(Chrome 930、Minecraft 108),收益比栈回收更大;这条思路削弱了 RFC 动机。
  10. 并发移除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_wakingensure_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 还停留在"先收集反馈"的阶段。