hash / hashtable(linux kernel 哈希表)
1. 初次见面
哈希表也叫散列表,是一种数据结构。
优点是插入速度和查找速度都比较快。缺点是空间利用率不太确定。
哈希表是一个代码比较简单的模块,反倒是它的原理和用途,可以好好聊一聊。
2. 简单了解
哈希表到底有什么作用呢,它比起其他数据结构有什么优势?
为了解答这个问题,我们需要先设想一个场景:
- 需要保存大约1万个人的信息(含身份证号和姓名);
- 这些信息需要支持较高频的增删操作;
- 这些信息需要支持大量的查询操作(例如输入身份证号,查询对应的姓名)。
我们可以如何实现这个功能呢?举些极端的例子。
普通数组
最容易想到的就是申明一个大小为1万的信息数组。
新增时按就近原则,按顺序有空位就插入;删除、修改、查询时就用for循环遍历,找到对应的信息来操作。
所以,每个操作平均要比对5千次才能找到目标信息。很明显效率很低。
有序数组
其实如果新增操作要远多于查询,那么效率还是可以的。
反之,如果查询操作远多于新增操作,也是可以优化的。
例如我们把它改成有序数组(按照身份证号排序),即每个新增数据的插入,都要找到对位的位置来插入,其他的数据依次往后移腾一个位置出来(开销很大),删除时也同理。这样查询时只需要通过二分法,就能很快定位了。
这种就是牺牲増删效率,提升查询效率。
另外,数组还有一个大缺点,那就是空间固定。场景中的要求是大约1万个,如果实际使用中只用了1千个,那么就浪费了9千单位空间;如果超过了1万个,也会产生巨大的麻烦。
链表
链表和数组很类似,不同之处在于链表的增删更为方便,改几个指针就可以,不需要像数组那样把数据挪位置。但是链表不能随机访问,只能顺序遍历。
另外,链表可以随用随申请,不浪费空间。
红黑树
老实说,红黑树用来处理这种场景,效果还是很好的,查询、增删效率都挺高。但这里先不说。
巨大的数组
在说哈希表之前,我们再提一个方案,那就是用一个巨大的数组来保存。多大呢,就是大到可以容纳查询ID的所有值,例如手机号就是最大的11位数,身份证号就是最大的28位数。当拥有这个大数组后,我们就可以直接用身份证号作为数组下标,来找到这个数据了。无论增加或读取,都非常快。说道这里,我们也知道,其实这是不可能的,我们不可能为了这1万个数据,去准备一个巨大到都不知该如何表达的数组。
哈希表
这个时候,哈希表闪亮登场了。哈希表说,我不需要这么大的数组,只需要给一个1万单位大小的数组就行。
把身份证号做哈希运算,得到一个在0~9999范围内的数字,用这个数字作为数组下标来查询即可。
有这么神奇吗,1万个身份证号做哈希运算后,刚好得到不重复的0~9999的整数?当然不可能。一定存在两个不同的号码映射成同一个地址。但是这无所谓,工程实践中并不要求完美散列。这种计算出相同数值的情况叫哈希冲突,解决哈希冲突的方法有很多(比如开放寻址法、再散列法等),这里将的则是链表。我们把地址相同的数据用链表链接起来即可。
比如,将10个数(val 0~9)散列到大小为10的数组(0~9)中,可能会得到以下结果:
也许其中有6个数是一次性就找到位置的,而有4个数的哈希值和其它数重复了,需要挂在链表上。
我们可以想象得到,1万个数即使不能均匀地分布开,它重复的也不会特别多,所以链表的长度一定是很有限的,可能平均就是查找2~3次,比起此前毫无技巧的平均查询5000次来说,提升的效率是非常可观的。
最后,我们再来看看哈希运算,哈希运算就是一个公式,它输入一个参数(身份证号)后,能够计算出一个结果(数组地址)。这个公式叫做散列式,散列式在不同场景下不是固定的,例如对于哈希表来说,只需要满足以下特性就很棒了:
- 同一个参数,结算结果一定是一致的(特定身份证号每次哈希运算必须要得出相同的数组下标);
- 散列度较好(结果尽可能均匀分布,提升空间利用率,降低链表长度);
- 计算速度快(计算消耗的资源越小,相比起遍历操作性价比越高)。
3. 快速使用
使用哈希表分为5个步骤:
① 包含头文件
#include <linux/hashtable.h>
其实list头文件也被包含在一些其它头文件里,例如如果做了“#include <linux/module.h>”,那么就不需要再包含list.h了。
② 声明一个hlist_head 的哈希表数组
struct hlist_head ht[100];
③ 初始化这个哈希表数组
hash_init(ht);
④ 添加节点(往链表里添加其它“包含哈希表头成员的目标结构体”)
struct demo {
char *name;
unsigned long id;
struct hlist_node node;
};
struct demo dm1;
struct demo dm2;
void demo_add(void)
{
dm1.name = "Demo1";
dm1.id = 123456789;
hash_add(ht, dm1.node, dm1.id);
dm2.name = "Demo2";
dm2.id = 987654321;
hash_add(ht, dm2.node, dm2.id);
}
⑤ 通过key查找节点
例如在demo函数中,通过hash key获取到相关数据:
struct demo *demo_find_from_key(unsigned long key)
{
struct demo *dm = NULL;
hash_for_each_possible(ht, dm, id, key) {
if (dm->id == key) {
break;
}
}
return dm;
}
void demo(void)
{
struct demo *dm = NULL;
dm = demo_find_from_key(123456789);
if (dm) {
printk(KERN_INFO "id(%d)'s name is %s\n", dm->id, dm->name);
}
}
对于例子中这种数据量不大的场景,哈希链表并不能带来效率提升。当数据量千倍、万倍的扩大后,这个新增、查找效率的提升就很明显了。
常用API:
//初始化
hash_init(hashtable)
//添加节点
hash_add(hashtable, node, key)
hash_add_rcu(hashtable, node, key)
//删除节点
void hash_del(struct hlist_node *node)
void hash_del_rcu(struct hlist_node *node)
//判断哈希表是否为空
hash_empty(hashtable)
//遍历
hash_for_each(name, bkt, obj, member)
hash_for_each_rcu(name, bkt, obj, member)
hash_for_each_safe(name, bkt, tmp, obj, member)
//查找
hash_for_each_possible(name, obj, member, key)
hash_for_each_possible_rcu(name, obj, member, key, cond...)
hash_for_each_possible_safe(name, obj, tmp, member, key)
最后一组查找,是通过哈希计算找到对应数组下标,然后遍历该组链表。
4. 读读源码
notifier模块在源码中的相对路径是:
include\linux\hash.h
include\linux\hashtable.h
官网地址(5.16.5版本):Linux kernel stable tree
初始化
初始化API传入的参数是个哈希表数组名,但实际会宏展开成“数组名+数组大小”,接着遍历数组将各链表初始化。
INIT_HLIST_HEAD() 就是将指针指向空,它的实现是在链表模块(list.h)中,可以参考“大雨:kernel list(链表)/ list_head”。此后的一些链表操作,也是如此,就不重复讲解了。
添加节点
添加节点的操作很简单,就是往链表中插入新节点,唯一特殊之处是,我们传入的参数不是链表,而是一个链表数组(哈希表数组)。
所以,在宏展开里,通过哈希计算定位了链表在数组中的位置。
hash_min 是避免数组越界,如果哈希计算得到的数值大于数组大小,则取最小值。
hash_32就是一个哈希散列式,它支持对32位的数进行哈希计算:
kernel的散列式其实一点都不神秘,就是把数值乘以一个魔法数 0x61C88647(右移是防越界)。
至于开发者是如何发现“这个散列式能够满足我们之前说的那几个特性”的,天晓得。
删除节点和添加同理,不赘述。
查找节点
查找节点其实也类似,调用的是链表的遍历函数。区别在于需要先用哈希计算,从链表数组中定位目标链表。
这个区别就是哈希表的关键,通过计算直接定位,而不用通过遍历查找,这在数据量很大时十分高效。定位链表后,由于链表长度不可能太长,所以再用遍历也不会太耗时。
--------------------------------------------------
更多原创文章欢迎关注专栏:RTFSC(Linux kernel源码轻松读)
--------------------------------------------------