3、缓存对象管理:lv_cache_entry_t 结构体解析,缓存条目的生命周期管理
好,咱们接着聊。上一章我把 LVGL 缓存系统的整体架构讲了一遍,你大概知道了它分几层、怎么运作的。这一章我们钻进去,看看缓存系统里最核心的数据单元——lv_cache_entry_t。
说白了,整个缓存系统就是围着这个结构体转的。你创建缓存、查找缓存、淘汰缓存,最后操作的都是它。我刚开始看源码的时候,觉得这不就是个普通的链表节点嘛,有什么好讲的?后来踩了几个坑才发现,里面的门道还真不少。
3.1 lv_cache_entry_t 结构体解剖
先上代码。这是 LVGL v8.3 里 lv_cache_entry_t 的定义,我稍微精简了一下,去掉了调试相关的字段:
typedef struct _lv_cache_entry_t {
const void * key; /* 缓存键,用于查找 */
void * user_data; /* 用户数据指针,指向实际缓存内容 */
size_t size; /* 缓存数据大小(字节) */
lv_cache_entry_t * next; /* 链表下一个节点 */
lv_cache_entry_t * prev; /* 链表上一个节点 */
uint32_t life; /* 生命周期计数器 */
uint32_t ref_cnt; /* 引用计数 */
uint8_t is_invalid : 1; /* 是否失效标记 */
uint8_t is_reserved : 1; /* 是否被保留(不被淘汰) */
uint8_t reserved : 6; /* 保留位 */
} lv_cache_entry_t;
嗯,看着挺简单对吧?但每个字段都有它的用处。我一个个说。
3.1.1 key 和 user_data:缓存的核心对
key 是查找的依据。你想想看,你要从缓存里拿一张图片,总得告诉系统「我要的是哪张图」吧?这个 key 就是干这个的。它通常是一个字符串指针,指向图片路径或者资源 ID。
user_data 才是真正存数据的地方。对于图片缓存来说,它指向的就是解码后的 lv_img_dsc_t 结构体。我习惯把 key 比作「门牌号」,user_data 比作「房间里的人」。
3.1.2 next 和 prev:双向链表的灵魂
这两个指针把所有的缓存条目串成了一个双向链表。为什么用双向链表?因为缓存淘汰算法(比如 LRU)需要频繁地把一个节点移动到链表头部或尾部。双向链表做这个操作是 O(1) 的,单向链表得从头遍历,效率就差远了。
我在一个项目里就吃过这个亏。当时图省事用了单向链表,结果缓存命中率一高,链表操作成了性能瓶颈。后来改成双向链表,问题就解决了。
3.1.3 life:生命周期计数器
这个字段是给 LFU(最不经常使用)淘汰算法用的。每次访问缓存条目,life 就加 1。淘汰的时候,系统会优先淘汰 life 值最小的那个。
但这里有个坑:life 是 uint32_t 类型的,最大值是 4,294,967,295。如果某个条目被频繁访问,它会不会溢出?
答案是:会。但 LVGL 的处理方式很巧妙——当 life 达到最大值时,它会触发一次全局的 life 值减半操作。所有条目的 life 都除以 2,这样既避免了溢出,又保留了条目之间的相对访问频率关系。
3.1.4 ref_cnt:引用计数
这个字段用来防止「正在使用的缓存被意外淘汰」。当一个缓存条目被外部引用时,ref_cnt 会加 1。淘汰算法在扫描时,会跳过 ref_cnt > 0 的条目。
你可能会问:那如果所有条目都被引用了,淘汰算法怎么办?答案是:它会等待,直到有条目被释放。所以,使用缓存的代码一定要记得在不用的时候调用 lv_cache_release(),否则缓存可能会被「卡死」。
3.1.5 is_invalid 和 is_reserved:两个标记位
is_invalid:标记这个条目是否失效。比如图片文件被删除了,但缓存里还有它的数据。这时候系统会把对应的条目标记为失效,下次淘汰时会优先处理它。
is_reserved:标记这个条目是否被保留。被保留的条目不会被淘汰算法选中。我一般用它来保护一些「永远需要留在缓存里」的数据,比如程序启动时加载的 Logo 图片。
3.2 缓存条目的生命周期
理解了结构体,我们再来看它的生命周期。一个缓存条目从生到死,会经历这么几个阶段:
- 创建:分配内存,初始化字段
- 插入:加入缓存链表
- 访问:被查找或使用,更新 life 和 ref_cnt
- 淘汰:被算法选中,从链表中移除
- 销毁:释放内存,清理资源
下面这张图把整个过程串起来了:
3.3 创建与销毁:谁生谁死
创建缓存条目,通常不是直接调用 lv_mem_alloc 去分配 lv_cache_entry_t,而是通过缓存管理器提供的接口:
lv_cache_entry_t * lv_cache_entry_create(size_t size)
{
lv_cache_entry_t * entry = lv_mem_alloc(sizeof(lv_cache_entry_t));
if(entry == NULL) return NULL;
lv_memset_00(entry, sizeof(lv_cache_entry_t));
entry->size = size;
entry->life = 0;
entry->ref_cnt = 0;
entry->is_invalid = 0;
entry->is_reserved = 0;
entry->next = NULL;
entry->prev = NULL;
return entry;
}
销毁时,系统会先检查 ref_cnt 是否为 0。如果还有人在用,销毁操作会被拒绝。这是保护机制,防止你正在读数据的时候,数据被释放了。
3.4 访问与引用:用的时候别被抢走
当你从缓存中获取一个条目时,系统会做两件事:
- 增加 life:告诉淘汰算法「这个条目被用过了」
- 增加 ref_cnt:告诉淘汰算法「这个条目正在被使用,别碰它」
用完以后,你必须调用 lv_cache_release() 来减少引用计数。否则,这个条目就永远无法被淘汰了。
我见过一个真实案例:有个同事在图片显示回调里获取了缓存条目,但忘记 release 了。结果那几张图片的缓存条目一直占着内存,系统运行几天后内存就爆了。排查的时候发现 ref_cnt 高达几千,一下子就定位到了问题。
3.5 淘汰与失效:该走的时候别留
淘汰是缓存系统最核心的机制。当缓存空间不足时,淘汰算法会遍历链表,找到最适合淘汰的条目。判断标准是:
| 条件 | 优先级 | 说明 |
|---|---|---|
| is_invalid == 1 | 最高 | 失效条目优先淘汰 |
| is_reserved == 1 | 跳过 | 保留条目不参与淘汰 |
| ref_cnt > 0 | 跳过 | 正在使用的条目不淘汰 |
| life 最小 | 次高 | LFU 算法选择最不常用的 |
| size 最大 | 最低 | 同等条件下优先淘汰大块数据 |
嗯,这里要注意:淘汰算法不是简单地把 life 最小的干掉。它会综合判断多个因素。我习惯把淘汰逻辑理解成一个「打分系统」,每个条目都有一个分数,分数最低的就被淘汰。
3.6 实际项目中的管理技巧
最后分享几个我在项目中用到的管理技巧:
- 批量淘汰:当缓存空间不足时,不要只淘汰一个条目。我一般会一次淘汰 5-10 个,减少淘汰操作的频率。
- 定时清理:设置一个定时器,每隔一段时间扫描一次缓存,清理掉失效的条目。防止失效条目堆积。
- 优先级分组:把缓存条目分成高、中、低三个优先级。高优先级的条目设置 is_reserved = 1,永远不会被淘汰。中优先级的正常参与淘汰。低优先级的在创建时就设置一个较低的 life 初始值,让它们更容易被淘汰。
好了,这一章我们把 lv_cache_entry_t 的结构和生命周期讲透了。你理解了这些,后面再看淘汰算法的实现,就会轻松很多。