第一次在内核源码里看到 hlist 的时候,我是有点懵的。它看起来像一个双向链表,又不完全是:普通双向链表的每个节点都有 prev 和 next,可 hlist 的头部居然只放了一个 first 指针,节点的 prev 字段换成了一个叫 pprev 的二级指针。这个结构无论从哪个角度看都有点"不对称"。直到我在一个哈希表场景里把内存账算清楚,才明白这种"别扭"其实是精心的设计。hlist 是 Linux 内核专门为哈希表准备的链表实现,也叫哈希链表。它要解决的问题并不复杂:当你有成千上万个哈希桶、其中大部分还是空桶时,链表头每多一个指针,整张哈希表就要多付出可观的内存。这篇笔记我会从数据结构定义、指针维护逻辑、常用 API、遍历删除陷阱几个方面,把 hlist 的设计思路和实际用法掰开揉碎讲清楚,希望能帮到正在啃内核链表代码的朋友。
1. 哈希桶里省下的那一个指针:hlist 要解决的真实问题
1.1 list_head 在哈希桶上的浪费
先看内核里最常见的链表 struct list_head:
c复制struct list_head {
struct list_head *next, *prev;
};
这是一个循环双向链表,任何节点都有 next 和 prev 两个指针。用在普通的链表场景里它非常顺滑,头节点也参与循环,遍历和删除都写得极其简洁。但如果你拿它来做哈希表的桶,问题就来了。哈希表的结构一般是"桶数组 + 每个桶下一个链表",桶是用数组表达的,每个数组元素就是一个链表头。如果这个链表头是 struct list_head,那么每个桶即使完全为空,也要固定占用两个指针(64 位系统下是 16 字节)。一个哈希表通常有几千甚至上万个桶,乘以 16 字节,这个开销并不小。
算一笔账:4096 个桶,64 位系统下 list_head 每个 16 字节,光桶头数组就要 64KB。而 hlist_head 只有一个指针,8 字节,桶头数组只要 32KB。如果一个内核模块里有好几张哈希表,这个差距会成倍放大。对内存敏感的内核来说,这不是可以忽略的。
1.2 哈希场景的三个特殊规律
哈希表的使用模式有三个跟普通链表很不一样的规律:
- 桶的数量大。为了保证均摊 O(1) 查找,哈希表会把桶数设计得明显多于实际存储的节点数,通常装载因子在 0.5 到 1 之间。也就是说,桶数组本身就需要占用大量连续内存。
- 空桶占多数。即使装载因子在 0.5,随机散列的情况下也有相当比例的桶是空的。这些空桶没有任何节点,但它们数组元素的内存仍然被占用。桶头越小,浪费越少。
- 插入位置集中在头部。哈希冲突后,新数据总是放在桶链表的头部即可,不需要像有序链表那样找位置。所以一个单向
next指针就够用了,prev指针在日常插入操作里几乎用不到。
用装载因子 0.5 来算,1024 个桶里大约 200 个节点,空桶比例大概是 80% 以上。也就是说,桶数组的大部分内存都在承载"空链表头"。在这种场景下,把链表头从 16 字节压缩到 8 字节,相当于直接把哈希表桶数组的固定开销砍掉一半。
1.3 一个指针对 cache 的附加好处
桶头数组是连续内存,访问任意一个桶时,hlist_head 的 8 字节可以跟桶数组的其他元素一起被 cache 加载。查找路径上每次取 head->first,都是在访问一块热内存,单个指针的紧凑布局让这块数组更容易留在 L1/L2 cache 里。这个附加影响虽然不是设计 hlist 的首要原因,但在哈希表这种随机访问场景下,内存紧凑带来的 cache 优势是实打实的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 结构体定义拆解:head 只有一个指针,node 里藏了二级指针
2.1 两个结构体的定义
hlist 总共就两个结构体,定义在 linux/list.h 里:
c复制struct hlist_head {
struct hlist_node *first;
};
struct hlist_node {
struct hlist_node *next, **pprev;
};
先看这两个结构体,总共才三个指针字段。hlist_head 只有一个 first,hlist_node 则有一个 next 和一个 pprev。注意 pprev 不是 "previous node",它是 "pointer to previous node's next pointer",也就是指向"上一个节点中指向当前节点的那个 next 指针"的指针。换句话说,它是一个二级指针。
2.2 内存布局与链表形态
当链表有多个节点时,内存关系是这样的:
head.first指向第一个节点;- 第一个节点的
pprev指向head.first字段本身; - 第 k 个节点的
next指向第 k+1 个节点; - 第 k 个节点(k >= 2)的
pprev指向第 k-1 个节点的next字段; - 最后一个节点的
next为 NULL。
用文字画个示意:
text复制head.first --> node1.next --> node2.next --> NULL
^ ^
| |
node1.pprev = &head.first node2.pprev = &node1.next
注意这个结构不是循环链表,最后一个节点的 next 是 NULL,链表末尾是平铺的。这在哈希表里没问题,因为遍历到 NULL 就停。
2.3 为什么 prev 字段会变成 pprev
如果用传统的 prev 指针,第一个节点的 prev 必须指回 head。那么 head 就必须有一个 prev 指针,也就是说 head 本身要像一个完整节点那样有两个指针,这就回到了 list_head。为了压缩 head 的宽度,hlist 取消了 head 的 prev,那么第一个节点就没法通过 prev 指回 head 了。
于是设计者把 prev 改成了 pprev,存的是"上一个节点 next 字段的地址",或者对于第一个节点,存的是"head.first 字段的地址"。这样无论哪个节点,删除自己时都可以通过 *pprev 直接修改前驱的 next。这就是"用二级指针换掉头节点的 prev 指针"的整个思路,也是 hlist 设计的灵魂。
3. pprev 的维护逻辑:不遍历前驱也能完成删除
3.1 删除场景的天然困境
双向链表删除节点很容易,因为可以通过 prev 找到前驱,直接改前驱的 next。但 hlist 的 head 没有 prev,如果某个节点是第一个节点,它怎么知道自己的"前驱"是 head 呢?
答案是 pprev。节点的 pprev 字段不是指向"前驱节点",而是指向"前驱中指向自己的那个 next 指针字段"的地址。对于第一个节点,它的前驱是 head,pprev 就指向 head.first;对于其他节点,它指向前一个节点的 next 字段地址。这样一来,无论节点在链表什么位置,它都带着一把通往"前驱入口"的钥匙。
3.2 pprev 指向哪里
写代码之前先把 pprev 的含义钉死:
- 若 n 是链表第一个节点,
n->pprev == &head->first。 - 若 n 不是第一个节点,假设前驱是 p,那么
n->pprev == &p->next。
所以对任意节点 n,*n->pprev 都等于 n 自己。这句话是理解 hlist 的钥匙。你不需要知道前驱节点的地址,只需要知道"前驱中指向我的那个指针放在哪里",而这个位置在插入节点时就已经存进自己的 pprev 了。
3.3 hlist_del 的三种情况统一
内核里 hlist_del 最终会调用 __hlist_del:
c复制static inline void __hlist_del(struct hlist_node *n)
{
struct hlist_node *next = n->next;
struct hlist_node **pprev = n->pprev;
WRITE_ONCE(*pprev, next);
if (next)
next->pprev = pprev;
}
删除节点 n 时:
- 如果是第一个节点,
*pprev是head.first,*pprev = next相当于让head.first指向n->next; - 如果是中间节点,
*pprev是前驱的next字段,*pprev = next相当于把前驱的next接到n->next上; - 如果是最后一个节点,
next为 NULL,*pprev = NULL即可,后面if (next)不执行。
三种情况走同一条路径,完全不需要判断"我是不是头节点",也不像 list_del 那样需要传入 head 指针。这个巧妙之处在于,节点里保存的前驱信息不是"前驱节点",而是"前驱中指向自己的那个指针的位置"。
3.4 对并发场景的额外意义
hlist 这种 pprev 设计在并发场景还有额外好处。删除节点时只需要修改两个位置:前驱的 next 指针(或 head.first)以及后继的 pprev。因为修改的是指针字段本身,而不是节点里的业务数据,读侧通过 pprev 去定位前驱的过程始终是稳定的。
内核里 hlist 经常配合 RCU 做无锁读,读侧遍历一个正在被并发删除的链表时,读到 NULL 就停,读到有效节点就继续,整个过程处于一个一致的状态。这当然不是说 hlist 天然并发安全,读写锁和 RCU 保护仍然需要,但它的指针更新更"局部化"、更可预测,这给并发场景的实现留下了很大空间。
4. 核心 API 走读与头插法带来的行为惯性
4.1 hlist_add_head:前插法的完整流程
哈希表最常见的插入是头插法,hlist_add_head 的实现如下:
c复制static inline void hlist_add_head(struct hlist_node *n, struct hlist_head *h)
{
struct hlist_node *first = h->first;
n->next = first;
if (first)
first->pprev = &n->next;
h->first = n;
n->pprev = &h->first;
}
流程拆开是四步:
- 保存当前第一个节点到
first; - 让
n->next指向原来的第一个节点; - 如果原来桶里有人,把原来第一个节点的
pprev从&h->first改成&n->next; - 更新
head.first为n,并让n->pprev指向&h->first。
两个细节值得注意。第一,更新顺序是先改原首节点的 pprev,再改 head.first。如果反过来,中间会有一个短暂状态:原首节点的 pprev 还指向 head.first,但 head.first 已经变成 n,通过原首节点的 pprev 就找不到自己了。单线程下问题不大,但并发读时这个顺序是敏感的。第二,插入发生在链表头部,这是哈希冲突处理的标准操作,时间复杂度 O(1),并且最近插入的节点离 head 最近,遍历时先被访问,天然贴近"新数据更可能被再次访问"的局部性假设。
4.2 hlist_add_before 与 hlist_add_behind
除了头插法,hlist 还有两个指定位置插入的 API:
c复制static inline void hlist_add_before(struct hlist_node *n, struct hlist_node *next)
{
n->pprev = next->pprev;
n->next = next;
next->pprev = &n->next;
WRITE_ONCE(*(n->pprev), n);
}
static inline void hlist_add_behind(struct hlist_node *n, struct hlist_node *prev)
{
n->next = prev->next;
prev->next = n;
n->pprev = &prev->next;
if (n->next)
n->next->pprev = &n->next;
}
hlist_add_before 把 n 插到 next 前面:n->pprev 先继承 next->pprev,然后 next->pprev 改为指向 n->next,最后通过 *(n->pprev) = n 更新前一个节点(或 head)对 next 的指向。hlist_add_behind 把 n 插到 prev 后面,逻辑跟 hlist_add_head 类似,只是锚点从 head 变成了 prev 节点。
使用这两个函数时,传入的 next 或 prev 必须已经存在于链表中,别拿一个孤立节点当锚点。哈希表场景下这两个 API 用得不多,但在某些需要维护链表顺序的逻辑里会用到。
4.3 判空与在链判断:容易用错的 API
c复制static inline int hlist_unhashed(const struct hlist_node *h)
{
return !h->pprev;
}
static inline int hlist_empty(const struct hlist_head *h)
{
return !READ_ONCE(h->first);
}
hlist_empty 判断整个桶是不是空,看 head.first 是否为 NULL 即可。hlist_unhashed 判断某个节点是否"不在任何链表里",实现是看 pprev 是否为 NULL。
但这里有个坑:hlist_del 之后,节点的 pprev 会被设置成 LIST_POISON2(内核的 poison 机制,一个特殊地址),并不是 NULL。所以你如果在一个节点上先调用 hlist_del,再调用 hlist_unhashed 判断它是否还在链上,得到的是 false,也就是"还在链上",但实际它已经被删掉了。
正确做法是使用 hlist_del_init:
c复制static inline void hlist_del_init(struct hlist_node *n)
{
if (!hlist_unhashed(n)) {
__hlist_del(n);
INIT_HLIST_NODE(n);
}
}
它删除节点后会调用 INIT_HLIST_NODE 把 n->next 和 n->pprev 都置成 NULL。之后 hlist_unhashed(n) 才返回真。判断"某个节点是否在某张哈希表里",要配合 hlist_del_init 使用,这个细节容易在写删除逻辑时踩坑。
提示:如果只调用
hlist_del,节点指针会被置为LIST_POISON1和LIST_POISON2,这时再访问n->next或n->pprev属于未定义行为。要么立刻释放内存,要么用hlist_del_init清干净。
5. 遍历时删除的经典考题:safe 宏到底 safe 在哪里
5.1 普通遍历宏的写法与限制
内核提供的 hlist_for_each_entry 是日常查找的主力宏:
c复制#define hlist_for_each_entry(pos, head, member) \
for (pos = hlist_entry_safe((head)->first, typeof(*(pos)), member); \
pos; \
pos = hlist_entry_safe((pos)->member.next, typeof(*(pos)), member))
用起来长这样:
c复制struct foo {
int key;
struct hlist_node node;
};
struct foo *p;
hlist_for_each_entry(p, &head, node) {
if (p->key == target) {
// do something
}
}
普通遍历宏在每次迭代末尾通过 pos->member.next 获得下一个节点。如果你在这次迭代中把当前节点从链表里删掉,hlist_del 会修改 n->next 为 LIST_POISON1(或至少不再指向原后继),于是下一次迭代取 next 时就会拿到一个无效值,轻则遍历提前终止,重则访问野指针。所以普通 hlist_for_each_entry 内部不能删除当前节点。
5.2 safe 宏的保存 next 策略
hlist_for_each_entry_safe 多了一个参数:
c复制#define hlist_for_each_entry_safe(pos, n, head, member) \
for (pos = hlist_entry_safe((head)->first, typeof(*(pos)), member); \
pos && ({ n = pos->member.next; 1; }); \
pos = hlist_entry_safe(n, typeof(*(pos)), member))
safe 宏在每次循环开始时先把 pos->member.next 保存到 n,然后循环体内随便你删除 pos,下一次迭代用 n 走到后继。关键点在于,n 是一个 struct hlist_node *,它保存的是"下一个 hlist_node 的地址"。即使当前节点被 hlist_del 污染了 pos->member.next,也不影响 n,因为 n 在删除之前就已经取出来了。
另一个容易忽略的边界是:safe 宏只保证"删除当前位置节点"安全,不保证"删除未来节点"安全。如果你在遍历中删除的是 n 指向的下一个节点,那就会出问题,因为下一次迭代还要靠 n 继续走。所以删除操作应该只针对当前的 pos。
5.3 一个实际删除场景的完整代码
c复制struct foo *p;
struct hlist_node *n;
int key_to_delete = 42;
hlist_for_each_entry_safe(p, n, &head, node) {
if (p->key == key_to_delete) {
hlist_del_init(&p->node);
kfree(p);
}
}
注意这里的 n 类型是 struct hlist_node *,不是 struct foo *。遍历宏会通过 container_of 把 n 翻译回 pos。如果你写代码时把 n 声明成 struct foo *,编译器很可能会因为类型不匹配警告,或者行为不符合预期。
这里再补充一下 hlist_entry_safe 的定义,方便理解:
c复制#define hlist_entry_safe(ptr, type, member) \
({ typeof(ptr) ____ptr = (ptr); \
____ptr ? hlist_entry(____ptr, type, member) : NULL; \
})
它先判断 ptr 是否为空,为空直接返回 NULL,非空则做 container_of。遍历宏的循环终止条件依赖这个宏返回 NULL。
6. 选型参考:hlist 与 list_head 的内核分工
6.1 对比表
| 维度 | list_head | hlist |
|---|---|---|
| 链表头大小 | 16 字节(两个指针) | 8 字节(一个指针) |
| 链表形态 | 循环双向链表 | 单向 next + pprev 维护 |
| 删除节点是否需要 head | list_del 不需要 head |
hlist_del 也不需要 head |
| 遍历终止条件 | 回到 head 即停止 | next 为 NULL 即停止 |
| 是否适合哈希桶 | 可以但桶头浪费 | 专门为此设计 |
| 是否适合普通双向链表 | 非常适合 | 不太适合,会丢失部分双向遍历能力 |
| 删除当前节点后继续遍历 | 需要 safe 宏 | 需要 safe 宏 |
选型上,如果你在写一个普通的链表(比如进程列表、事件队列、消息队列),list_head 绝对是首选。它 API 丰富、代码可读性好、循环结构天然适合各种 for_each 遍历。hlist 的应用场景基本锁定在"用数组做哈希桶、每个桶下挂链表"的地方。
6.2 内核里的典型栖身之处
内核里很多哈希表用的就是 hlist,比如文件系统、网络协议栈、进程管理里的各种哈希缓存。这些哈希表的共性都是"桶多、散列访问、头插法"。你在阅读内核代码时,看到 struct hlist_head 数组,基本就能断定这是一个哈希表。
hlist 的使用往往伴随着哈希函数、桶索引、读写锁或 RCU。看这类代码时,建议先画一张"桶数组 + 冲突链"的结构图,把数据是怎么从 key 到 hash 到 bucket 到 node 串起来的理清楚,再去看增删查逻辑会轻松很多。很多初学者一上来就扎进宏定义里,反而把整体结构丢了。
6.3 用户态还原与学习建议
如果你不在内核态工作,也可以把这段结构体拷到用户态的 C 代码里做练习。写一个简单的哈希表:
- key 通过 hash 函数计算,取模找桶;
- 用
hlist_add_head插入; - 用
hlist_for_each_entry查找; - 用
hlist_for_each_entry_safe删除。
整个过程大概几十行,却能让你彻底理解这三个指针之间的关系。我建议的顺序是:先写一个 hlist_add_head,打印每个节点的 next 和 pprev 指向的实际地址;再写一个 hlist_del,观察首节点删除和中间节点删除时 *pprev 的变化;最后再用 safe 宏写一次遍历删除。把这三步做下来,比读十遍注释都管用。
至于更深一层的体会,我个人的看法是:hlist 的价值不在链表本身,而在"设计者如何看待哈希表的访问模式"。它用最少的指针字段满足了哈希表的全部操作需求,把内存开销压到了极限,同时没有牺牲任何核心操作的效率。内核代码里类似的设计非常多,hlist 是练习"从内存布局反推设计动机"很好的一个起点。抓住 pprev 这个关键点之后,再回头读那些使用 hlist 的内核模块,你会发现原来的阅读障碍一下子少了很多。
