☰
Linux内核链表进阶:klist带锁与引用计数的安全遍历机制
2026/10/11 18:52:42 网站建设 项目流程

干内核开发这些年,链表打交道最多的就是struct list_head,两个指针打天下,配合container_of宏几乎能在任意结构体里嵌进去用。但只要处理过驱动模型、设备管理这类并发很重的子系统,你很快就会意识到list_head有几个绕不过去的毛病:没有自带锁、节点没有生命周期概念、遍历和删除互相打架。内核里后来的klist链表,就是冲着这几个痛点设计的。

我个人把 klist 理解成“带锁、带引用计数、带节点状态”的增强型双向链表。它不是为了替代 list_head,而是在 list_head 的底层之上补了一层安全机制,尤其适合“一边遍历一边有人删节点、删了节点还不能马上释放内存”的场景。这篇文章不打算从源码逐行抄注释,而是从使用者的角度把 klist 拆开,讲清楚为什么要有它、怎么用、踩过哪些坑。

1. 为什么会有 klist:标准 list_head 的三大痛点

1.1 无锁操作让并发全靠自觉

list_head的所有操作都是裸指针操作,list_add、list_del、list_for_each都不会帮你做任何加锁。这不算是设计缺陷,因为链表本来就是基础组件,锁策略应该由上层调用者决定。但问题在于:一旦链表被多个执行路径共享,比如一个进程在遍历,另一个中断或线程在删除节点,你必须保证每一步都加锁,而且还得防止锁的粒度不对导致遍历越界。

实际开发中很多人把list_for_each_entry用得顺手,但在遍历循环体里并发删节点的场景就没那么安全了。虽然内核提供了list_for_each_entry_safe,它的实现原理是缓存下一个节点的指针。如果你在循环体里删的是“当前节点”,那确实安全;但如果是别的执行上下文同时删掉了“下一个节点”,safe版本也救不了你。这种问题用普通链表做起来非常别扭,要么整个遍历期间锁住整个链表,导致并发性能被锁死,要么就得自己设计一套引用计数来保护节点内存。

1.2 遍历和删除的冲突没法优雅解决

拿一个最常见的设备管理场景举例:你在遍历总线上的设备链表,准备给设备匹配驱动,这时候设备被热拔插了。设备节点从链表里摘除,紧接着设备对象被释放,而你手里还拿着指向这个设备的指针。这基本就是 use-after-free 的教科书案例。

用list_head时,常见的解决办法是遍历的时候对每个节点get一下增加引用计数,处理完再put。思路是对的,但问题是这套逻辑每次都要自己实现,而且很容易在某个分支忘记put或者多put一次。链表本身不管节点状态,节点是否还在链上、是否还有人引用,开发者没法从节点自身看出来。

1.3 节点没有状态,不知道自己“在不在链上”

list_head节点被list_del之后,prev和next会被置成一个特定值,算是标记了一下,但也就仅此而已。你在代码里很难快速判断一个节点当前挂在哪条链表上,也不知道节点是否已经被摘除。尤其在错误处理路径里,经常出现重复删除、删除一个从未入链的节点,这些问题都很难排查。

klist 的设计初衷就是把这几个问题系统性地解决掉:链表自带自旋锁,节点带引用计数,节点记录自己归属于哪个 klist,遍历器在遍历期间持有节点引用。这样“遍历 + 并发删除 + 延迟释放”整个链路都变得可控了。

2. klist 的数据结构与设计思路

2.1 从数据结构看设计者的意图

klist 的核心结构是struct klist和struct klist_node,在include/linux/klist.h里可以看到定义。klist是整个链表的控制块,内部有一把自旋锁k_lock、一个标准的list_head双向链表k_list,以及get、put两个函数指针。get和put由使用者注册,klist 在遍历到某个节点时会调用get增加对该节点的引用,遍历结束后调用put释放引用。

struct klist { spinlock_t k_lock; struct list_head k_list; void (*get)(struct klist_node *); void (*put)(struct klist_node *); };

这里需要特别留意get和put的语义。它们不是 klist 自己实现的,而是由你提供的回调。常见做法是回调内部调用kref_get和kref_put,把节点的引用计数和生命周期管理结合起来。如果你不提供这两个回调,klist 依然能用,但遍历时就不会有引用保护了,相当于退化成了带锁的普通链表。

struct klist_node则更像一个“智能节点”:

struct klist_node { void *n_klist; struct list_head n_node; struct kref n_ref; };

n_klist指向这个节点所属的 klist,节点因此能知道自己挂在哪条链表上。n_node是真正参与链式连接的list_head。n_ref是节点自身的引用计数,整个生命周期管理都是围绕它展开的。

2.2 迭代器机制:遍历与删除的平衡点

klist 最精华的部分是它的迭代器。先看结构定义:

struct klist_iter { struct klist *i_klist; struct klist_node *i_cur; };

使用迭代器遍历时,核心流程是:

  1. 用klist_iter_init初始化迭代器,拿到链表控制块。
  2. 反复调用klist_next获取下一个节点。klist_next内部会先释放上一个节点通过get取得的引用,再对要返回的节点调用get,然后把节点指针返回给你。
  3. 遍历结束或中途跳出循环后,必须调用klist_iter_exit释放迭代器当前持有的节点引用。

这套机制解决的核心问题是:迭代器持有的引用,保证了遍历过程中节点内存不会被释放。即使另一个执行路径调用了klist_del,把节点从链表上摘除,只要迭代器还没释放引用,节点的内存就是安全的。等到迭代器步进到下一个节点或调用klist_iter_exit时,引用计数才会降下来,如果这是最后一个引用,release 回调才会真正释放内存。

所以用 klist 时,遍历和删除不再是对立的关系,而是天然配合的关系。删节点的代码不需要关心是否有人正在遍历,遍历的代码也不需要担心节点被并发释放。这个设计在驱动模型和设备管理中非常实用。

2.3 引用计数:get 与 put 的配对哲学

klist 节点内嵌了struct kref,这就是get/put回调配合的对象。使用 klist 时,一个节点的引用计数通常包含几个部分:

  • 创建节点时的初始引用,一般通过kref_init置为 1。
  • 节点在链表上被遍历器临时持有的引用,遍历时get,结束时put。
  • 外部代码主动持有的引用,比如某个驱动正在使用这个设备,显式kref_get持有。

klist_del做的工作只是把节点从双向链表中摘除,它不负责释放内存,也不负责处理引用计数。节点的内存是否释放,取决于最后一个kref_put是否让引用计数归零。这种设计让“摘链”和“销毁”彻底解耦,摘链之后如果还有人在用,节点会一直活着,直到所有使用者都释放引用。

懂得了这个原理,你就明白为什么 klist 很适合做设备管理了:设备在系统里被多个子系统可能同时引用,拔出设备时先把设备从总线链表摘除,但设备对象本身可以继续存活,直到驱动、文件系统、用户态打开句柄等所有引用都释放,内存才真正回收。这是普通的list_head很难优雅实现的。

3. 完整实操:从零实现一个 klist 设备管理器

3.1 定义结构体与初始化链表

理论讲了这么多,不如直接上一段能跑的示例。假设我要管理一组虚拟设备,每个设备用结构体描述,嵌入一个struct klist_node,同时用kref管理生命周期:

#include <linux/klist.h> #include <linux/kref.h> #include <linux/slab.h> #include <linux/module.h> #include <linux/string.h> static struct klist device_list; struct my_device { struct kref kref; struct klist_node node; unsigned int id; char name[64]; };

初始化链表时,我们要注册 get 和 put 回调。这里的回调就是直接对节点里的 kref 做操作:

static void device_release(struct kref *kref) { struct my_device *dev; dev = container_of(kref, struct my_device, kref); pr_info("device %s(id=%u) released\n", dev->name, dev->id); kfree(dev); } static void device_get(struct klist_node *n) { struct my_device *dev = container_of(n, struct my_device, node); kref_get(&dev->kref); } static void device_put(struct klist_node *n) { struct my_device *dev = container_of(n, struct my_device, node); kref_put(&dev->kref, device_release); } static void manager_init(void) { klist_init(&device_list, device_get, device_put); }

这里的device_get和device_put就是 klist 遍历时的引用保护回调。注意container_of是从klist_node反推到外层my_device结构的标准手法,和 list_head 家族完全一致。

3.2 注册设备与删除设备

创建设备时,先在堆上分配结构体,初始化 kref 引用计数为 1,然后把节点加到链尾。这里初始引用代表“这个设备对象刚刚建立,由链表容器持有”的语义:

static struct my_device *device_create(const char *name, unsigned int id) { struct my_device *dev; dev = kzalloc(sizeof(*dev), GFP_KERNEL); if (!dev) return NULL; kref_init(&dev->kref); strscpy(dev->name, name, sizeof(dev->name)); dev->id = id; klist_add_tail(&device_list, &dev->node); return dev; }

删除设备时,先把节点从链表摘除,再释放掉创建时那一个初始引用。如果遍历器或者其他代码此时还持有着额外的引用,kref_put不会触发 release,设备对象会继续存活:

static void device_destroy(struct my_device *dev) { klist_del(&dev->node); kref_put(&dev->kref, device_release); }

这一段就是 klist 的精髓所在。klist_del只是摘链,没有释放任何引用。之后kref_put减少创建时的那份引用。如果节点已经在遍历器手中被 get 过,由遍历器负责后续的 put,释放时机完全由引用计数掌控。

3.3 安全遍历设备列表

遍历 klist 的标准姿势是初始化迭代器、循环取节点、退出迭代器:

static void device_dump(void) { struct klist_iter iter; struct klist_node *node; struct my_device *dev; klist_iter_init(&device_list, &iter); while ((node = klist_next(&iter))) { dev = container_of(node, struct my_device, node); pr_info("found device: %s, id=%u\n", dev->name, dev->id); } klist_iter_exit(&iter); }

这个遍历过程即使有另一个执行路径并发调用device_destroy(dev),也不会出现 use-after-free。因为device_destroy里kref_put只释放初始引用,而迭代器通过klist_next拿到的引用还在,设备对象不会因为摘链而立刻销毁。

3.4 按条件查找并返回设备给调用者

遍历找到一个设备后,如果要返回给调用者,不能直接 return,因为迭代器持有的引用会在klist_iter_exit时释放。必须在释放迭代器之前,主动为调用者持有一份引用:

static struct my_device *device_find(unsigned int id) { struct klist_iter iter; struct klist_node *node; struct my_device *dev = NULL; klist_iter_init(&device_list, &iter); while ((node = klist_next(&iter))) { dev = container_of(node, struct my_device, node); if (dev->id == id) { kref_get(&dev->kref); break; } } klist_iter_exit(&iter); return dev; }

调用者拿到device_find返回的设备后,用完之后必须调用kref_put(&dev->kref, device_release)释放这个引用。如果没有主动kref_get这一步,迭代器退出时就会把唯一能保命的引用给丢了,返回出去的指针随时可能成为野指针。这是我见过出错率最高的一个点。

4. klist 在内核驱动模型中的经典应用

4.1 总线、设备、驱动的三角关系

klist 在内核驱动模型里几乎是标配。每个struct bus_type内部维护了两个 klist:一个保存注册到该总线上的设备,另一个保存注册到该总线上的驱动。在设备模型里可以这样理解:

  • 设备注册:device_register最终把设备节点加入到总线的设备 klist。
  • 驱动注册:driver_register最终把驱动节点加入到总线的驱动 klist。
  • 匹配过程:总线遍历设备 klist,对每个设备尝试匹配驱动 klist 里的驱动。

为什么这里必须用 klist?因为设备可能随时热插拔,驱动也可能动态加载卸载。假设总线的设备链表用普通list_head,内核在一个遍历循环里调用驱动匹配设备时,另一个 CPU 上正好有设备被拔出,设备对象释放,那匹配逻辑就会踩到悬空指针。klist 遍历时持有节点引用,就能保证设备对象在匹配过程中一直有效。

4.2 设备热拔插场景下的 klist 价值

热拔插是最能体现 klist 优势的场景。设备拔出时,内核会调用device_del,把设备从总线的 klist 中摘除。但这个设备对象往往还被很多地方引用,比如正在进行 probe 的驱动、被用户态打开的接口、挂起的异步任务等。如果摘链就直接释放,这些都是悬空引用。

有了 klist 的引用计数机制,摘链只是“设备不再对外可见”的第一步。设备对象继续存活,直到所有持有者都释放引用,最终由最后一个kref_put触发 release。这跟用户态里文件引用计数、内存映射引用计数的道理是相通的。在内核里,驱动代码只要确保自己每次使用设备时都持有引用,设备拔掉也不会出大问题。

bus_for_each_dev这类遍历 API 正是基于 klist 迭代器实现的。子系统代码在遍历总线设备时,不需要关心设备是否正在被并发删除,迭代器会做好引用保护。这个接口在内核里被大量驱动子系统调用,比如网络设备子系统、输入子系统、USB 子系统,都说到底受益于 klist 的这套遍历安全机制。

4.3 其他内核子系统中的 klist 使用方式

除了驱动模型,还有一些子系统用到 klist 时不一定暴露那么清楚。比如某些文件系统或框架需要维护一组全局对象,这些对象支持动态注册、动态注销,同时经常需要做全量遍历。只要满足“遍历期间可能被并发删改”这个条件,klist 都是不错的选择。

我个人在实际项目中的体会是:判断一个场景要不要用 klist,核心就看两个特征。第一,节点是否会动态增删,且发生频率不低;第二,遍历节点时是否需要确保节点对象在遍历期间不被释放。如果两个都中,哪怕不是驱动模型,也可以把 klist 拿过来用。它的 API 并不复杂,比起自己在 list_head 上造一套轮子,klist 的成熟度和调试工具链都要好得多。

5. 实战排查:klist 使用中常见的坑

5.1 忘记配对 get/put,引用计数乱掉

klist 本身不会帮你自动配对引用。get和put回调只是两个被调用的钩子,具体实现是你自己的业务逻辑。最常见的错误是把kref_get和kref_put的调用时机搞乱,比如在一次遍历中,某个分支提前break,却没在退出前处理好迭代器当前节点的引用。

正常套路是:klist_next返回的节点,由迭代器负责持有引用。如果你 break 跳出循环,迭代器 i_cur 仍然指向当前节点,这个引用会在klist_iter_exit中被释放。所以 break 本身是安全的,不需要手动额外释放。但如果你在 break 之前又自己调用了kref_put,就把迭代器的引用提前释放掉了,节点可能立刻销毁,后面再访问必然出事。

5.2 klist_del 之后不要急着 kfree

新手最容易犯的一个错:klist_del(&dev->node)之后立刻kfree(dev)。这在单线程、没人遍历的情况下可能没事,但只要有一个遍历器正拿着这个节点的引用,kfree就会立刻制造一个悬空指针。

正确的心法是:klist_del只从链表摘除节点,它不代表你有权销毁对象。销毁的唯一依据是引用计数归零。所以在复杂驱动里,设备销毁路径应该只做“摘链 + 释放自身持有的那份引用”,至于对象何时真正释放,交给 kref 机制去决定。

5.3 遍历期间不能睡眠,锁的粒度要心里有数

klist_next内部会在持有 klist 的自旋锁时遍历链表和调用get回调。自旋锁保护区内是不能睡眠的,所以你的 get 回调里绝对不能做GFP_KERNEL的分配、互斥锁获取、可能调度的操作。如果你需要在遍历过程中完成一些重量级操作,应该先通过遍历拿到节点并自己持有一份引用,退出迭代器后再做重活,做完再释放引用。

另外一点很容易被忽略:klist_next会在内部对上一个节点调用 put。如果你的 put 回调里触发了 release 函数,而 release 函数又尝试获取同一个 klist 的自旋锁,就有可能出现访问已释放锁的问题。建议 put 回调里只做引用计数递减,不要做复杂的清理逻辑,真正的回收动作放在kref的 release 回调里,并且 release 回调不要再去碰链表本身。

5.4 多个 klist 的锁顺序要统一

如果代码里要同时遍历两条 klist,比如驱动模型里可能要遍历设备 klist 和驱动 klist,那么两个klist_next各自都会持有自旋锁。两个遍历器以不同顺序对两个 klist 加锁,就可能形成 ABBA 死锁。

解决方案通常有两种。一是严格约定全局加锁顺序,比如永远先遍历设备链表再遍历驱动链表,所有代码路径都遵守。二是先通过一次遍历把感兴趣的节点用引用保护住,收集到一个临时数组里,退出所有迭代器后再做后续处理,这样就不会长时间持有链表锁。

5.5 调试技巧:善用引用计数检查

klist 相关的内存问题如果不确定,建议在 get/put 回调里加一些调试输出,打印节点的地址和当前引用计数。kref_read(&dev->kref)可以在 put 前后读取当前值,观察引用计数是否按预期增减。出现“节点没有被释放”的现象时,先怀疑是不是有某处多持了一份引用没放;出现“释放过早”的现象时,先怀疑是不是有遍历器没有正确持有引用就访问了节点。

我在实际定位过一个挺隐蔽的问题:设备对象被释放后,内存被复用作其他对象,导致总线上残留的 klist 节点不再指向有效的设备结构,遍历时container_of取出来的名字乱码。最后查下来,是因为某条错误路径在klist_del之后对同一个节点再次调用了klist_del,二次摘链扰乱了节点的n_klist状态。所以一个节点只能摘一次,如果需要判断节点是否还在链上,可以借助节点内嵌结构的标志位自行维护,比如在结构体里加一个bool registered。

5.6 迭代器用完后必须 exit

这个虽然基础,但我要专门说一次。代码里如果提前 return 而忘了klist_iter_exit,迭代器持有的节点引用就一直不会释放,设备对象就永远无法销毁,时间长了就是内核内存泄漏。凡是遇到“设备明明删了,但内存却一直不回收”的现象,先把所有klist_iter_init的配对klist_iter_exit检查一遍,往往就能发现问题。

我个人写 klist 遍历代码时,习惯把迭代器的生命周期控制得特别短:进入循环前初始化,拿到需要的数据立刻 break,退出函数前一定会调用klist_iter_exit。函数内部所有 return 分支都整理成单出口,避免哪条路径漏掉 exit,这是把 klist 用稳的基础习惯。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询