首发于RTFSC

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

初始化

hash_init

初始化API传入的参数是个哈希表数组名,但实际会宏展开成“数组名+数组大小”,接着遍历数组将各链表初始化。

INIT_HLIST_HEAD() 就是将指针指向空,它的实现是在链表模块(list.h)中,可以参考“大雨:kernel list(链表)/ list_head”。此后的一些链表操作,也是如此,就不重复讲解了。

添加节点

hash_add

添加节点的操作很简单,就是往链表中插入新节点,唯一特殊之处是,我们传入的参数不是链表,而是一个链表数组(哈希表数组)。

所以,在宏展开里,通过哈希计算定位了链表在数组中的位置。

hash_min

hash_min 是避免数组越界,如果哈希计算得到的数值大于数组大小,则取最小值。

hash_32就是一个哈希散列式,它支持对32位的数进行哈希计算:

hash_32

kernel的散列式其实一点都不神秘,就是把数值乘以一个魔法数 0x61C88647(右移是防越界)。

至于开发者是如何发现“这个散列式能够满足我们之前说的那几个特性”的,天晓得。

删除节点和添加同理,不赘述。

查找节点

hash_for_each_possible

查找节点其实也类似,调用的是链表的遍历函数。区别在于需要先用哈希计算,从链表数组中定位目标链表。

这个区别就是哈希表的关键,通过计算直接定位,而不用通过遍历查找,这在数据量很大时十分高效。定位链表后,由于链表长度不可能太长,所以再用遍历也不会太耗时。

--------------------------------------------------

更多原创文章欢迎关注专栏:RTFSC(Linux kernel源码轻松读)

--------------------------------------------------

编辑于 2023-06-10 16:54・IP 属地广东