[TOC]
list_lru: Linux内核的可扩展对象缓存管理器
list_lru 是 Linux 内核提供的一套可扩展的、近似 LRU (Least Recently Used) 缓存列表管理机制。它专门设计用来高效地管理大量、小型、生命周期不一的内核对象,例如目录项缓存(dentries)和索引节点缓存(inodes)。
可以将其想象成一个特殊的“图书馆卡片目录系统”,这个系统需要被许多图书管理员(CPU核心)同时、频繁地访问,并且需要一种高效的方式来找出那些最久未被使用的卡片(对象)以便回收。
一、 核心问题:为什么需要 list_lru?
在理解 list_lru 的设计之前,必须先明白它要解决的核心问题:在多核环境下的锁竞争。
一个朴素的 LRU 列表实现通常是这样的:
- 维护一个全局的双向链表。
- 当一个对象被访问时,将它从链表中的当前位置移到链表头(表示最新使用)。
- 当需要回收内存时,从链表尾部(表示最久未使用)开始移除对象。
这种实现在单核系统上工作得很好。但在现代多核系统中,会产生一个巨大的性能瓶颈:所有 CPU 核心都必须竞争同一个全局锁来修改这个链表。由于像 dentry 这样的对象被访问得极其频繁,这个全局锁会成为系统扩展性的噩梦,导致 CPU 核心花费大量时间在等待锁上,而不是做有用的工作。
list_lru 的诞生就是为了在保持 LRU 语义的同时,最大限度地减少锁竞争。
二、 核心原理与设计:Per-CPU 列表 + 批量操作
list_lru 的天才之处在于它避免了对每个对象的每次访问都去争抢一个全局锁。它通过一个两级结构实现了这一点:
Per-CPU 私有列表:
list_lru不再维护一个单一的全局列表,而是在内部为**每个 CPU 核心(或每个 NUMA 节点)**都创建了一个私有的、本地的“新对象”列表。- 当一个对象需要被添加到 LRU 列表时(例如,一个 dentry 被创建或访问),它会被添加到当前正在执行的 CPU 核心的本地列表中。
- 对这个本地列表的访问只需要获取该 CPU 的本地锁,甚至在某些情况下可以无锁操作。因此,不同 CPU 核心之间添加新对象的操作完全不会相互干扰。
一个全局共享列表:
- 除了 Per-CPU 列表外,还有一个传统的、由全局锁保护的共享列表。这个列表存放的是“比较旧”的对象。
批量迁移 (Batching):
- Per-CPU 列表并不会无限增长。当某个 Per-CPU 列表中的对象数量达到一个阈值(或者在某些其他条件下),
list_lru会执行一次批量迁移操作。 - 它会一次性地将这个 Per-CPU 列表中的所有对象作为一个整体,移动到全局共享列表的头部。
- 这个迁移操作虽然需要获取全局锁,但它的开销被摊销了。我们用“一次昂贵的、但 infrequent 的批量操作”替换了“无数次廉价的、但 highly contended 的单次操作”。
- Per-CPU 列表并不会无限增长。当某个 Per-CPU 列表中的对象数量达到一个阈值(或者在某些其他条件下),
回收/扫描:
- 当内核需要回收内存(例如,通过
shrinker机制)时,回收程序只需要扫描全局共享列表。 - 它会从全局列表的尾部开始,获取一批最久未使用的对象进行回收。
- 当内核需要回收内存(例如,通过
设计图示:
1 | CPU 0 CPU 1 CPU 2 CPU 3 |
三、 关键数据结构与 API
struct list_lru:- 这是
list_lru的主容器结构。 - 它包含一个全局的自旋锁 (
lock) 和一个全局的链表头 (list)。 - 最重要的是,它包含一个指向 Per-CPU/Per-Node 列表数组的指针(
struct list_lru_node *node[]),这使得它可以根据 CPU 或 NUMA 节点找到对应的本地列表。
- 这是
struct list_lru_node:- 代表一个 Per-CPU/Per-Node 的本地存储。它内部也包含一个链表头 (
list) 和一个本地锁。
- 代表一个 Per-CPU/Per-Node 的本地存储。它内部也包含一个链表头 (
核心 API:
int list_lru_init(struct list_lru *lru):
初始化一个list_lru结构,包括分配和初始化其内部的 Per-CPU 列表。void list_lru_add(struct list_lru *lru, struct list_head *item):
最常用的函数。将一个新对象item添加到 LRU 中。这个操作通常只访问本地列表,因此速度非常快,竞争很小。void list_lru_del(struct list_lru *lru, struct list_head *item):
从 LRU 中删除一个对象。这个操作比add复杂,因为它需要判断item是在本地列表还是在全局列表中,并可能需要获取全局锁。long list_lru_walk(struct list_lru *lru, list_lru_walk_cb isolate, void *cb_arg, long nr_to_walk):
这是用于扫描和回收的核心函数。它会遍历全局列表,对每个对象调用一个由用户提供的回调函数isolate。内存回收器(shrinker)就是通过这个函数来找到并尝试回收旧对象的。
四、 实际应用场景
list_lru 在内核中被广泛用于需要管理大量可回收对象的子系统。
目录项缓存 (Dentry Cache):
- 这是
list_lru的经典用例。系统中有数以万计甚至百万计的 dentry 对象。d_lru_add()函数内部就调用了list_lru_add()。 - 当内存不足时,VFS shrinker 会调用
list_lru_walk来扫描 dentry LRU,并释放那些未被使用的 dentry。
- 这是
索引节点缓存 (Inode Cache):
- 与 dentry 类似,
inode_add_lru()也是基于list_lru实现的。 - 内存回收时,也会扫描 inode LRU 来回收不活跃的 inode 对象。
- 与 dentry 类似,
其他: 某些文件系统也可能使用
list_lru来管理其私有的元数据缓存。
五、 总结
list_lru 是 Linux 内核为了应对多核时代高并发、高竞争环境而设计出的一套精妙的缓存管理方案。
核心思想:
- 空间换时间: 使用多个 Per-CPU 列表(空间)来避免全局锁竞争(时间)。
- 批处理摊销: 将高频的、有竞争的单次操作,替换为低频的、无竞争的本地操作,外加一次性的批量同步操作,从而将锁竞争的开销降至最低。
通过这种设计,list_lru 成功地为 dentry 和 inode 等内核中最活跃的缓存对象提供了高度可扩展的近似 LRU 管理,是保证 Linux 在现代多核服务器上依然能保持高性能的关键基础设施之一。
include/linux/list_lru.h
LRU 链表初始化宏: list_lru_init, list_lru_init_memcg, list_lru_init_memcg_key
本代码片段定义了一组用于初始化 list_lru 数据结构的宏和内联函数。其核心功能是为内核中需要使用 “最近最少使用”(LRU)策略管理的对象列表提供标准化的初始化接口。它提供了三种变体:一个通用的初始化宏、一个支持内存控制组(memcg)感知的版本,以及一个为锁依赖分析器(lockdep)提供额外调试信息的版本。
实现原理分析
此代码是典型的 C 语言宏封装和条件编译技巧的应用,旨在提供一个清晰、分层的 API,同时隔离复杂的配置选项。
统一后端 (
__list_lru_init): 两个主要的初始化宏list_lru_init和list_lru_init_memcg都是对一个内部函数__list_lru_init的简单封装。这种模式将公共逻辑集中在后端函数中,而前端宏则根据不同的使用场景提供固定的参数(false或true)。list_lru_init传入false,表示这是一个全局的、不区分 cgroup 的 LRU 链表。list_lru_init_memcg传入true,表示这是一个 memcg 感知的 LRU 链表。这种链表能够为每个内存控制组维护独立的 LRU 列表,从而实现更精细的内存回收策略。它还必须与一个shrinker关联,以便在 cgroup 内存压力下被回调。
锁调试支持 (
list_lru_init_memcg_key):- 这是一个内联函数,它进一步封装了
list_lru_init_memcg。 - 其唯一目的是在内核启用了锁依赖分析器(
CONFIG_LOCKDEP)时,允许调用者为list_lru内部的自旋锁指定一个lock_class_key。lockdep是一个强大的内核调试工具,它通过给不同上下文中的锁分配唯一的“类”来静态地分析和检测潜在的死锁风险。 - 通过
#ifdef CONFIG_LOCKDEP条件编译,这段代码在未开启lockdep的生产内核中不会产生任何额外的开销,lru->key = key;这一行代码会直接被预处理器移除。
- 这是一个内联函数,它进一步封装了
代码分析
1 | /** |
mm/list_lru.c
list_lru_add 将对象(item)添加到 LRU(最近最少使用)列表中
1 | /* 调用方必须确保 memcg 生存期。 */ |
list_lru_add_obj 将对象(item)添加到 LRU(最近最少使用)列表中
- lRU 是一种缓存管理机制,优先保留最近使用的对象并逐步移除较少使用的对象。该函数支持内存控制组(Memory Control Groups,memcg)的感知功能,以优化内存管理
1 | bool list_lru_add_obj(struct list_lru *lru, struct list_head *item) |
list_lru_del 从 LRU(最近最少使用)列表中删除指定的对象(item)
1 | /* 调用方必须确保 memcg 生存期。 */ |
list_lru_del_obj 从 LRU(最近最少使用)列表中删除指定的对象(item
1 | bool list_lru_del_obj(struct list_lru *lru, struct list_head *item) |
init_one_lru 初始化一个 list_lru_one 结构
1 | static void init_one_lru(struct list_lru *lru, struct list_lru_one *l) |
__list_lru_init 初始化一个 list_lru 结构
1 | int __list_lru_init(struct list_lru *lru, bool memcg_aware, struct shrinker *shrinker) |
LRU 链表遍历与收缩: list_lru_shrink_walk_irq 及 __list_lru_walk_one
本代码片段是 Linux 内核通用 LRU 链表子系统的核心执行引擎。其主要功能是提供一个通用的、可重入的机制,用于遍历一个 LRU 链表,并对链表中的每个元素执行一个由调用者提供的回调函数(isolate),以尝试隔离(即选中并准备回收)指定数量的对象。该代码通过一个复杂的、支持重试的状态机,优雅地处理了在内核中普遍存在的复杂锁依赖问题。
实现原理分析
此代码的实现精髓在于其对回调函数返回状态的处理,以及由此构建的一个健壮的、可中断并可重启的遍历机制。
通用回调接口:
__list_lru_walk_one函数本身不包含任何针对特定对象的处理逻辑。它通过接受一个list_lru_walk_cb类型的函数指针isolate,将决定如何处理链表元素的权力交给了调用者。这使得该遍历框架可以被用于管理任何类型的内核对象(如 dentry、inode 等)。可重启的状态机:
isolate回调函数的返回值enum lru_status是整个机制的核心。__list_lru_walk_one函数内部的switch语句根据这个返回值来驱动一个状态机:LRU_REMOVED: 表示回调成功隔离了对象。遍历继续。LRU_ROTATE: 表示对象暂时不回收,但要更新其“最近使用”状态,因此将其移动到链表尾部。遍历继续。LRU_RETRY/LRU_REMOVED_RETRY: 这是最关键的两种状态。它们表示回调函数为了执行隔离操作,不得不临时释放了 LRU 链表的锁(通常是为了获取另一个顺序不同的锁,以避免死锁)。一旦锁被释放,当前链表的遍历状态就失效了(可能有其他任务修改了链表)。因此,__list_lru_walk_one必须放弃当前遍历,使用goto restart从链表头部重新开始。这种“乐观遍历-失败重启”的模式是处理复杂并发操作的有效技巧。
死锁规避与活性保证:
- 在内存回收(shrinker)的上下文中,
isolate回调可能需要获取文件系统或特定对象的锁,而这些锁的获取顺序可能与 LRU 锁冲突。通过LRU_RETRY机制,允许回调函数释放 LRU 锁,尝试获取其他锁,从而化解了潜在的死锁风险。 - 为了防止因反复遇到需要
LRU_RETRY的对象而导致的活锁(livelock),代码在调用isolate回调之前就递减了计数器*nr_to_walk。这确保了即使遍历过程不断重启,总的尝试次数也是有限的,循环最终必然会终止。
- 在内存回收(shrinker)的上下文中,
上下文抽象: 代码通过
irq_off布尔参数和两个不同的封装函数 (list_lru_walk_one和list_lru_walk_one_irq),将核心遍历逻辑与具体的执行上下文(是否在中断关闭的环境下)解耦,提高了代码的复用性。
代码分析
1 | /** |
list_lru_isolate 从一个 list_lru_one 链表中原子地移除一个指定的链表元素,并更新该链表的内部计数器
本代码片段提供了一个基础的辅助函数,其核心功能是从一个 list_lru_one 链表中原子地移除一个指定的链表元素,并更新该链表的内部计数器。这是一个在 list_lru 框架中被频繁调用的底层构建块,它本身不包含复杂的逻辑,而是依赖于调用者确保其执行的原子性。
实现原理分析
该函数是 list_lru 子系统中的一个基本操作单元。其设计的核心原则是职责分离:
操作的原子性: 此函数本身并不实现任何锁机制。它假定调用者在调用它之前,已经获取了保护
list参数所指向链表的自旋锁。在list_lru框架中,调用list_lru_isolate的代码路径(例如shadow_lru_isolate)总是已经持有了lru->lock。这种设计避免了在每个底层函数中重复加锁/解锁的开销,并将同步策略的控制权留给了更高层的逻辑。安全的链表删除: 它使用
list_del_init而不是list_del。这是一个重要的细节。list_del仅仅将元素从链表中移除,但元素自身的prev和next指针仍然指向旧的邻居。如果此时对该元素再次进行链表操作,可能会破坏原始链表。而list_del_init在将元素移除后,会立即将其prev和next指针指向它自身,将其重新初始化为一个空链表的头部。这是一种防御性编程,确保了被隔离的元素处于一个已知的、安全的状态,防止了后续的误用。元数据同步: 它在移除链表节点的同时,同步递减了
list->nr_items计数器。这保证了链表的元数据(即缓存的元素数量)与其物理状态(链表中的节点数)始终保持一致。
代码分析
1 | /** |
LRU 链表计数: list_lru_shrink_count 及 list_lru_count_one
本代码片段提供了用于获取 list_lru 实例中元素数量的函数。其核心功能是安全地、无锁地读取一个特定 NUMA 节点和特定内存控制组(memcg)所对应的 LRU 链表中的当前对象总数。它通过 RCU (Read-Copy-Update) 机制来保证读取操作的安全性,避免了与修改链表的操作(如添加、删除元素)产生锁竞争。
实现原理分析
此计数机制的关键在于它如何在不使用传统自旋锁的情况下,安全地访问可能被并发修改的计数器。
RCU 保护: 整个读取过程被
rcu_read_lock()和rcu_read_unlock()包围。这是一个经典的 RCU 应用场景。当一个任务正在读取计数器l->nr_items时,另一个任务(或中断)可能正在修改这个 LRU 链表,甚至在 cgroup 被销毁时,可能会释放并重新分配list_lru_one结构体。RCU 机制保证了在rcu_read_lock临界区内,l指针所指向的内存块不会被释放。读取方可以安全地访问,而修改方在修改完成后,会等待所有现存的 RCU 读临界区全部结束后,才执行最终的内存释放操作。这实现了读取方的“无锁”访问,极大地提高了并发性能。安全读取 (
READ_ONCE): 直接访问l->nr_items可能会被编译器优化(例如,将值缓存到寄存器中),或者在某些CPU架构上,对非原生字长的数据读取可能会被分步执行,导致“撕裂读”。READ_ONCE()宏解决了这两个问题。它作为一个内存屏障,强制编译器每次都从内存中重新加载该值,并保证了读取操作的原子性,确保读取到的是一个完整、有效的值。索引与抽象:
list_lru_shrink_count是一个顶层内联函数,作为 shrinker 框架和list_lru子系统之间的适配器,它从shrink_control结构中提取nid和memcg参数。list_lru_from_memcg_idx负责将 NUMA 节点 ID 和 memcg ID 映射到具体的list_lru_one结构体。在此代码片段的实现中,它直接返回&lru->node[nid].lru,忽略了 memcg 索引。这表明该实现是针对未启用CONFIG_MEMCG的内核配置,或者是一个 memcg 感知 LRU 链表的“根”或“默认”列表。
健壮性处理:
unlikely(count < 0)检查是一个防御性措施。正常情况下,计数器不应为负。如果出现负值,可能表示一个瞬态的竞态条件或是一个程序错误。此时,函数选择返回 0,这是一个安全的默认值,避免了将无效的负值传播到上层调用者。
代码分析
1 | /** |








