在Linux内核中,hlist(哈希链表)使用非常广泛。本文将对其数据结构和核心函数进行分析。
和hlist相关的数据结构有两个:hlist_head 和 hlist_node
//hash桶的头结点
struct hlist_head {
struct hlist_node *first;//指向每一个hash桶的第一个结点的指针
};
//hash桶的普通结点
struct hlist_node {
struct hlist_node *next;//指向下一个结点的指针
struct hlist_node **pprev;//指向上一个结点的next指针的地址
};
结构如下图所示:
hlist_head结构体只有一个域,即first。 first指针指向该hlist链表的第一个节点。
hlist_node结构体有两个域,next 和pprev。 next指针很容易理解,它指向下个hlist_node结点,倘若该节点是链表的最后一个节点,next指向NULL。
pprev是一个二级指针, 它指向前一个节点的next指针的地址 。为什么我们需要这样一个指针呢?它的好处是什么?
在回答这个问题之前,我们先研究另一个问题:为什么哈希表的实现需要两个不同的数据结构?
哈希表的目的是为了方便快速的查找,所以哈希表中hash桶的数量通常比较大,否则“冲突”的概率会非常大,这样也就失去了哈希表的意义。如何做到既能维护一张大表,又能不使用过多的内存呢?就只能从数据结构上下功夫了。所以对于哈希表的每个hash桶,它的结构体中只存放一个指针,解决了占用空间的问题。现在又出现了另一个问题:数据结构不一致。显然,如果hlist_node采用传统的next,prev指针,对于第一个节点和后面其他节点的处理会不一致。这样并不优雅,而且效率上也有损失。
如果,将hlist_head也定义为双向链表结构,优点是同一标准,便于操作,缺点是,hlist_head只需要向后遍历就行了,所以仅仅需要用到一个指针,另外一个就被浪费掉了,双链表有两个链表指针,但是对于hash表来说两个链表指针过于浪费了。这将带来n的空间开销。
反过来想,如果全部的结点都变成单指针的struct hlist_head { struct hlist_node *first; };的结构,最后结点的删除又将带来困难。
这很好的体现了Linux中工程设计的思想。
节点删除过程:
大家可以思考下,为什么头节点(struct hlist_head
)不直接使用struct hlist_node
,而是要重新定义一个结构体呢?答案是为了节省空间。
hash表中同样包含5个元素,hlist2占用的空间为hlist1的1/2。
hlist_node巧妙地将pprev指向上一个节点的next指针的地址,由于hlist_head的first域指向的结点类型和hlist_node指向的下一个结点的结点类型相同,这样就解决了通用性!
如果要删除hash桶对应链表中的第一个普通结点
static inline void __hlist_del(struct hlist_node *n)
{
struct hlist_node *next = n->next;//获取指向第二个普通结点的指针
struct hlist_node **pprev = n->pprev;//保留待删除的第一个结点的pprev域(即头结点first域的地址),此时 pprev = &first
*pprev = next;
/*
因为pprev = &first,所以*pprev = next,相当于 first = next
即将hash桶的头结点指针指向原来的第二个结点,如上图中的黑线1
*/
if (next) //如果第二个结点不为空
next->pprev = pprev;//将第二个结点的pprev域设置为头结点first域的地址,如上图中的黑线2
}
如果要删除hash桶对应链表中的非第一个结点
对应的程序代码如下:
static inline void __hlist_del(struct hlist_node *n)
{
struct hlist_node *next = n->next;//获取指向待删除结点的下一个普通结点的指针
struct hlist_node **pprev = n->pprev;//获取待删除结点的pprev域
*pprev = next; //修改待删除结点的pprev域,逻辑上使待删除结点的前驱结点指向待删除结点的后继结点,如上图中的黑线1
if (next) //如果待删除结点的下一个普通结点不为空
next->pprev = pprev;//设置下一个结点的pprev域,如上图中的黑线2,保持hlist的结构
}
可以看到删除第一个普通结点和删除非第一个普通结点的代码是一样的。
下面再来看看如果hlist_node中包含两个分别指向前驱结点和后继结点的指针
很明显删除hash桶对应链表中的非第一个普通结点,只需要如下两行代码:
n->next->prev = n->prev;
n->prev->next = n->next;
可是,如果是删除的hash桶对应链表中的第一个普通结点:
此时 n->prev->next = n->next 就会出问题,因为hash桶的表头结点没有next域
所以,明显在这种情况下删除hash桶对应链表的第一个普通结点和非第一个普通结点的代码是不一样的。
同样的情况也存在于插入操作。
附一张在hash桶头结点之后,插入第一个普通结点的图:
在遍历上,如果使用hlist_hode, list_node指针进行遍历,两者过程大致相似。
#define list_for_each(pos, head) \
for (pos = (head)->next; pos != (head); pos = pos->next)
#define hlist_for_each(pos, head) \
for (pos = (head)->first; pos ; pos = pos->next)
如果使用其寄生结构的指针进行遍历,则 hlist 与 list 也略有不同, hlist 在遍历时需要一个指向 hlist_node 的临时指针,该指针的引入,一是为了遍历,而 list 的遍历在 list_entry 的参数中实现了,更主要的目的在于判断结束,因为 hlist 最后一个节点的 next 为 NULL ,只有 hlist_node 指向 NULL 时才算结束,而这个 NULL 不包含在任何寄生结构内,不能通过 tpos->member 的方式访问到,故临时变量 pos 的引入时必须的。
#define list_for_each_entry(pos, head, member) \
for (pos = list_entry((head)->next, typeof(*pos), member); \
&pos->member != (head); \
pos = list_entry(pos->member.next, typeof(*pos), member))
#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))
另外, list 和 hlist 的遍历都实现了 safe 版本,因在遍历时,没有任何特别的东西来阻止对链表执行删除操作(通常在使用链表时使用锁来保护并发访问)。安全版本的遍历函数使用临时存放的方法使得检索链表时能不被删除操作所影响。
#define list_for_each_safe(pos, n, head) \
for (pos = (head)->next, n = pos->next; pos != (head); \
pos = n, n = pos->next)
#define hlist_for_each_safe(pos, n, head) \
for (pos = (head)->first; pos && ({ n = pos->next; 1; }); \
pos = n)
参考: