B.6 持锁与内存分配:关键路径的「预分配」模式

发布时间:2026/8/25 14:17:07
B.6 持锁与内存分配:关键路径的「预分配」模式 内核开发中绕不开一个问题持锁时到底能不能分配内存。答案取决于锁的类型与锁的使用方式而非笼统的「持锁与否」。围绕这一点内核形成了一个反复出现的写法——把一次内存使用拆成两步先在「宽松」的上下文中预分配好容量允许失败、可回退再在「严格」的关键路径中使用它保证不失败。是否需要这样拆分由三条层层递进的约束决定它们恰好对应锁语境的三个层面锁的类型是否允许睡眠持锁期间的分配会否经回收反向拿锁以及所持锁保护的提交序列能否容忍失败。下面沿着「持锁语境」这条主线逐层展开。1. 锁的类型决定能否睡眠分配第一个层面看锁的类型。能否在持锁时分配内存取决于当前上下文能否睡眠而这由所持锁的类型决定上下文能否睡眠允许的分配进程上下文、仅持mutex/ww_mutex能GFP_KERNEL通常可以持spinlock/ 关中断 / softirq / RCU 读侧不能只能GFP_ATOMIC/GFP_NOWAITGFP_KERNEL含__GFP_RECLAIM在内存紧张时会触发**直接回收direct reclaim**并可能睡眠。因此在原子上下文中它被禁止——might_sleep()会告警而持mutex/ww_mutex这类可睡眠锁时单从「能否睡眠」看GFP_KERNEL分配是允许的。可见,可睡眠锁下就地分配本身是合法的。促使代码采用「预分配 关键路径使用」的,是接下来两个更微妙的层面。2. 约束一原子上下文不能睡眠这是最硬的一条边界清晰特指原子上下文持spinlock、rwlock关中断local_irq_disable、中断处理、softirq/taskletRCU 读侧临界区rcu_read_lock。在这些上下文里调用可能睡眠的分配是 bug。若确需分配只能用不睡眠的GFP_ATOMIC动用紧急保留易失败或GFP_NOWAIT尽力而为更易失败。这类分配天然可能失败因此关键路径宁可提前在可睡眠上下文分配好再进入原子区使用。KVM 的影子页表填充是典型例子kvm_mmu_topup_memory_caches()在进入持mmu_lockspinlock的页表遍历之前先把每 vCPU 的对象缓存填满临界区内只从缓存取不再分配。3. 约束二分配可能触发回收回收可能反向拿锁锁反转这是真正贯穿整个内核的一条也是那条最容易被忽略却无处不在的约束。GFP_KERNEL分配在内存不足时会进入直接回收而回收路径会反向调用各子系统去释放内存写回脏页文件系统、块层、触发 shrinker、调用mmu_notifier使映射失效……如果当前已经持有回收路径也会去获取的锁就会形成锁反转乃至自死锁持有锁 Lkmalloc(GFP_KERNEL)内存不足 → 直接回收回收调用 shrinker / fs 写回 / mmu_notifier这些路径尝试获取锁 L自己等自己 → 死锁内核用两套机制应对收窄 GFP 标志GFP_NOFS清__GFP_FS回收不再进文件系统、GFP_NOIO再清__GFP_IO不发起 IO。推荐用作用域 APImemalloc_nofs_save()/memalloc_noio_save()圈定范围而非逐处硬编码标志。lockdep 静态检查fs_reclaim伪锁把「分配时的回收上下文」建模成一把锁——在持有它等价于在回收路径时若再拿某锁而该锁又曾在GFP_KERNEL分配外围被持有lockdep 立即报出潜在死锁。这条约束在**回收会等待某种「完成信号」**的子系统里尤其尖锐。例如某些异步完成对象存在一个「信号临界区」回收资源需要等待它完成而临界区内若分配内存又可能触发回收回收再去等待另一个尚未完成的信号最终形成跨子系统的循环等待。内核对这类临界区同样用 lockdep 伪锁建模禁止在其中做会触发回收的分配——把分配移出临界区正好规避这一整类风险。4. 约束三提交点不容失败failure atomicity前两条讲「能不能」分配这一条讲「该不该允许失败」——在很多场景里它才是采用预分配的主因。许多关键路径存在一个「不可回头点」一旦跨过它例如已持有一批锁、已启动一段不可逆的提交序列后续步骤必须确定性完成。若此刻还可能因-ENOMEM中断错误处理将无从下手已建立的中间状态难以干净回滚。通用解法是把「唯一可能失败的分配」提前到可以安全回退的阶段可安全回退未进入临界区失败成功提交临界区不可回头使用预分配的容量保证不失败预分配preload / preallocate / reserve可返回 -ENOMEM回退重试或报错这个范式在内核中反复出现名字不同、机理一致。按用途大致可分为三类数据结构ID / 索引 / 树节点分配子系统预分配接口临界区内的「不失败」操作IDRidr_preload()/idr_preload_end()idr_alloc()预留段内不失败radix treeradix_tree_preload()填 per-CPU 预留radix_tree_insert()XArrayxas_nomem()重试循环xas_store()在xa_lock内不再分配maple treeVMAmas_preallocate()mas_store_prealloc()写侧遍历不再分配节点虚拟化 / IO缓存池与内存池子系统预分配接口临界区内的「不失败」操作KVM MMUkvm_mmu_topup_memory_caches()持mmu_lock的页表构建从 per-vCPU 缓存取用块层 IOmempool_create()/bioset_init()内存压力下mempool_alloc()/ bio 提交不失败tracingring_buffer_alloc()预置页ring_buffer_lock_reserve()写入路径不分配文件系统事务前预留子系统预分配接口临界区内的「不失败」操作ext4 / jbd2ext4_journal_start()预留 handle credits事务内元数据修改不因空间不足而失败XFSxfs_trans_reserve()预留 log space / block事务提交阶段确定性完成btrfsbtrfs_block_rsv_*预留空间事务内分配从预留中扣减它们共享同一句设计箴言把可能失败的分配挪到临界区之外让临界区成为确定性的、不可失败的操作。5. 三条约束往往叠加出现上述三条约束并非互斥真实的热点路径常常同时命中多条从而更坚定地采用预分配约束三是最常见的主因处于提交的不可回头点不能容忍分配失败约束二强化它该路径处在回收 / shrinker / 完成信号的敏感区即便技术上能在可睡眠锁下分配也应尽量避免触发回收约束一在部分路径成立某些调用点持有 spinlock 或邻近不可睡眠区睡眠分配本就受限。以 KVM 影子页表为例页表构建持mmu_lockspinlock命中约束一又不能在遍历中途失败约束三于是kvm_mmu_topup_memory_caches()在加锁前一次性把每 vCPU 缓存填满。VMA 操作的mas_preallocate()亦然——先在可失败阶段备好 maple tree 节点写侧持锁遍历时便不再分配。预分配往往还能顺带做清理既然已经付出了一次分配/扩容的代价就把过期条目的回收一并完成例如 IDR 预留段整理、树节点复用让临界区更轻。6. 小结与判断清单持锁时能否就地分配内存由三条约束共同决定锁的类型决定能否睡眠分配原子上下文spinlock / 关中断 / RCU 读侧禁止睡眠分配约束一可睡眠锁下则允许。锁的使用方式决定该不该就地分配持锁期间的分配可能经回收反向拿锁约束二回收递归/锁反转或落在不容失败的提交序列中约束三提交点不容失败。实践中判断「这里能不能直接分配」可用三问当前能睡眠吗持 spinlock / 关中断 / RCU 读侧 → 不能只能GFP_ATOMIC/NOWAIT或预分配。回收会不会绕回我持有的锁处于 fs / IO / shrinker / mmu_notifier / 完成信号等敏感路径 → 用memalloc_nofs/noio作用域或预分配。这里允许失败吗已过不可回头点已持批量锁、已启动不可逆序列→ 不允许必须提前预分配。三问中任意一条为「否 / 不允许」就应采用「预分配 临界区不失败」的范式。当三条同时成立时预分配几乎是唯一正确的选择。

相关新闻