3

本篇的上篇为 Nginx 源码分析:ngx_hash_t(上)
建议先读完上篇再来读下篇。


上篇回顾了hash表的基础概念,分析了Nginx中hash表的内存模型及逻辑模型,从而引出了其核型数据结构ngx_hash_elt_tngx_hash_t,并从设计的角度解释了如何初始化这两个结构体。

本篇主要分析,在Nginx源码中是如何初始化这两个结构体的。

主体思路

  1. 分析Nginx中使用的哈希函数,围绕初始化时使用的ngx_hash_key_t结构体展开。
  2. 分析Nginx如何估算所需内存大小,如何分配内存。

相信仔细看过上篇后,对这两个问题已经心里有底了。

ngx_hash_key_t结构体

上篇说过,Nginx中hash表是只读的,因此,可以提前对需要多大的内存进行预估。
Nginx中,提前将索引值、对应hash值,及内容计算出来,放入一个ngx_hash_key_t结构体中。

typedef struct {
    ngx_str_t         key;        // 索引值
    ngx_uint_t        key_hash;   // 对应hash值
    void             *value;      // 内容
} ngx_hash_key_t;

那么,如何计算一个字符串key的哈希值呢?答案是哈希函数。
Nginx提供的哈希函数有两个:

#define ngx_hash(key, c)   ((ngx_uint_t) key * 31 + c)

ngx_uint_t
ngx_hash_key(u_char *data, size_t len)
{
    ngx_uint_t  i, key;

    key = 0;

    for (i = 0; i < len; i++) {
        key = ngx_hash(key, data[i]);
    }

    return key;
}

ngx_uint_t
ngx_hash_key_lc(u_char *data, size_t len)
{
    ngx_uint_t  i, key;

    key = 0;

    for (i = 0; i < len; i++) {
        key = ngx_hash(key, ngx_tolower(data[i]));
    }

    return key;
}

其中ngx_hash_key_lc是大小写无关,ngx_hash_key是大小写相关的。

一般情况下,我们会得到一个ngx_hash_key_t的数组。
例如HTTP请求的通用首部:

Host:
Connection:
User-Agent:
...

每一条首部对应一个ngx_hash_key_t。这样得到一个关于HTTP请求的首部哈希数组。

有了这些信息,就可以提前预估根据这个数组生成的哈希表究竟需要多大的内存。

ngx_hash_init_t结构体

关于Nginx的哈希表,上篇提到过两点:

1)Nginx的哈希表本身是向ngx_pool_t申请的一块连续的内存,因此初始化哈希表需要知道ngx_pool_t
2)Nginx的哈希表解决哈希冲突采用了hash桶的办法,因此,在逻辑上,哈希表是一个二维数组。这个数组的大小受两个因素影响:桶的大小和桶的个数。

一般来讲,桶的个数越大,所需要桶的大小越小。

因此,这个在初始化时预先对内存进行估计的结构体ngx_hash_init_t是长成这个样子的:

typedef ngx_uint_t (*ngx_hash_key_pt) (u_char *data, size_t len);

typedef struct {
    ngx_hash_t       *hash;            // 用于管理哈希表的结构体
    ngx_hash_key_pt   key;             // 哈希函数

    ngx_uint_t        max_size;        // 哈希桶个数的最大值
    ngx_uint_t        bucket_size;     // 哈希桶的大小

    char             *name;            // 哈希表的名字
    ngx_pool_t       *pool;            // 使用的内存池
    ngx_pool_t       *temp_pool;       // 估算时临时使用的内存池
} ngx_hash_init_t;

其中*hash用于指向创建的哈希表管理结构体ngx_hash_t,当这个值为NULL时,在完成初始化时,回从内存池中获取一块内存。

  • max_size是限制生成的哈希表中桶的个数上限制。这个值越大,桶的大小越小,因此冲突项越少,查询速度更快,但是浪费的内存会增多。
  • bucket_size用来限制每个桶的大小上限值。

这两个参数是给哈希表生成时提供一个上限参考,并不是哈希表生成的最终大小。

Nginx哈希表的生成

铺垫了这么久,终于进入正题。-_-!!

Nginx的哈希表生成函数声明如下:

ngx_int_t 
ngx_hash_init(ngx_hash_init_t *hinit, ngx_hash_key_t *names,ngx_uint_t nelts);

其中,*hinit是初始化的限制条件,详见上一小节的说明;
*namesNginx根据索引值,提前算出来的哈希值数组,详见上上一小节;
nelts*names数组的大小。

总体来说:这个函数做了三件事情:

  1. 根据输入的ngx_hash_key_t结构体数组及ngx_hash_init_t结构体,估算出hash表所需的hash桶的个数及每个hash桶的大小;
  2. 根据算出的hash桶的个数及hash桶的大小,向内存池中申请空间;
  3. ngx_hash_key_t数组的内容装入生成的hash表中。

因为ngx_hash_init的函数定义很长,为了更好的说明问题,我按照次序捡取主要内容一段一段拆开来分析。感兴趣的可以看Ngx_hash.c的源码完整版

校验bucket_size值

首先,对传入的hinitnames中的bucket_size大小进行校验。
条件是,hinit中的bucket_size >= sizeof(ngx_hash_elt_t)

#define ngx_align(d, a)     (((d) + (a - 1)) & ~(a - 1))

#define NGX_HASH_ELT_SIZE(name)                                               \
    (sizeof(void *) + ngx_align((name)->key.len + 2, sizeof(void *)))

for (n = 0; n < nelts; n++) {
        if (hinit->bucket_size < NGX_HASH_ELT_SIZE(&names[n]) + sizeof(void *))
        {
            ngx_log_error(NGX_LOG_EMERG, hinit->pool->log, 0,
                          "could not build the %s, you should "
                          "increase %s_bucket_size: %i",
                          hinit->name, hinit->name, hinit->bucket_size);
            return NGX_ERROR;
        }
    }

可以看到,NGX_HASH_ELT_SIZE的最小值为 sizeof(void*) + sizeof(void*),因此bucket_size的最小值是两个指针的大小

这里需要解释一下ngx_align(d,a)这个宏的作用:将da的向上整数倍。例如,b=5a=4,那么ngx_align(5,4)的结果就是8

在实现原理上,利用了二进制计算的技巧。这里稍微讲一下(已经懂的可以跳过):

===========我是华丽的分割线===============
a2的某个幂的值时(例如a=2^2=4,或a=2^3=8),有以下特点:

a = 4:  二进制: 0000 0100       从右起,第三位为1,剩下全为0;
a = 8:  二进制: 0000 1000       从右起,第四位为1, 剩下全为0;
a = 16: 二进制:  0001 0000       从右起,第五位为1,剩下全为0;

a - 1 = 3:  二进制: 0000 0011   从右起,第三位之前,全是1;
a - 1 = 7:  二进制: 0000 0111   从右起,第四位之前,全是1;
a - 1 = 15: 二进制: 0000 1111   从右起,第五位之前,全是1;

~(a - 1) = ~3:  二进制: 1111 1100   从右起,第二位之后,全是1;
~(a - 1) = ~7:  二进制: 1111 1000   从右起,第三位之后,全是1;
~(a - 1) = ~15: 二进制: 1111 0000   从右起,第四位之后,全是1;

(理解的关键点)一个数,一定是这个数的二进制从右起第一个不为零的位所表示的数的整数倍
比如:

a = 12:  二进制: 0000 1100 
从右起,第一个不为零的位所表示的整数为 0000 0100 即 4
那么,a = 12 一定是 4 的整数倍

如果,我们需要任意的一个数a4取整怎么办呢?很简单,只需要把a从右起的若干位置0就可以了。
比如:

a = 13: 二进制:0000 1101
向0000 0100 即 4 取整,只需要将 0000 1101从右起,前两位置0,即可得到,0000 1100 即12

这个置0的过程可以表达为0000 1101 &  1111 1100
而 1111 1100 = ~(4 - 1),因此,13 对 4 取整的二进制运算即为:13 & ~(4 - 1)

可以看到,这样的二进制运算的结果是向下取整数倍
但是,在申请内存时,只能比需求的大,而不能比需求的小,因此需要向上取整数倍

对于一个任意的数d和一个2的任意次幂a:
d对a向下取整的二进制运算为:d & ~(a -1)
d对a向上取整的二进制运算为:(d + (a - 1)) & ~(a - 1)

相信到这里,已经可以很容易理解ngx_align这个宏的含义了

#define ngx_align(d, a)     (((d) + (a - 1)) & ~(a - 1))

===========我是华丽的分割线===============

估算hash桶的个数及hash桶的大小

准备工作

现在我们手头上有所有索引值及其对应的hash值所组成的ngx_hash_key_t数组,这个数组的大小表示将来形成的hash表中有效元素的数目。

那么,可知,hash桶数目 * 每个hash桶能放置元素的个数 > ngx_hash_key_t 数组大小

从上一小节的分析可知:bucket_size的最小值为2 * sizeof(void*),那么,预设的ngx_hash_init_t中的bucket_size / 2 * sizeof(void*)就能得到每个桶所能放置的冲突项的个数最大值。

因此,ngx_hash_key_t的数组的大小值nelts / (bucket_size / 2 * sizeof(void*))就是预估的hash表所需hash桶数目的最小值。

因此,接下来Nginx有这么一段:

    bucket_size = hinit->bucket_size - sizeof(void *);
    // start 为预估hash桶数目的最小值,因为会随着计算不断向上增加,所以命名为start
    start = nelts / (bucket_size / (2 * sizeof(void *)));
    start = start ? start : 1;

    if (hinit->max_size > 10000 && nelts && hinit->max_size / nelts < 100) {
        start = hinit->max_size - 1000;
    }

这部分代码可以看作是在估算hash表中hash桶数目的最小值的合理值。

开始估算
ngx_hash_key_t数组中含有所有索引值及其对应的哈希值,为了将hash表的hash桶数目控制在一定数量内,需要对ngx_hash_key_t内的哈希值进行取模运算

取模运算会将结果控制在一定整数范围内。
比如:key_hash % size,表示运算得到的结果一定在[0, size)区间内。

这样,对于上述代码得到的start,我们就可以从start开始到hinit->max_size结束,挨个尝试,看看每个start所算出的bucket_size是否符合要求(<= hinit->bucket_size

代码如下:

// 从start 开始,到hints->max_size为止,挨个尝试
for (size = start; size <= hinit->max_size; size++) {

        ngx_memzero(test, size * sizeof(u_short));

        for (n = 0; n < nelts; n++) {
            if (names[n].key.data == NULL) {
                continue;
            }
            // 取模运算,将hash值控制在当前size范围内
            key = names[n].key_hash % size;
            // 累加计算每个hash值对应的冲突项占用的hash桶空间
            test[key] = (u_short) (test[key] + NGX_HASH_ELT_SIZE(&names[n]));
            // 当某个hash值所有冲突项占用的hash桶空间超过上限,
            // 表示当前hash桶个数设置太小,进入下一轮循环
            if (test[key] > (u_short) bucket_size) {
                goto next;
            }
        }
        // 找到合适的hash桶个数,接下来进入申请空间及初始化结构体阶段
        goto found;

    next:

        continue;
    }

当代码运行到goto found时,表示找到合适的hash桶数目,可以进入下一阶段:申请内存空间及初始化结构体了。

根据估算的hash表的大小参数,向内存池申请空间

根据上一篇文章对于ngx_hash_t内存的描述,可知需要申请的内存空间分成两部分:

1)用于管理hash表的ngx_hash_t结构体,及用于指向内存不同分组的ngx_hash_elt_t *数组。根据上一小节的估算,ngx_hash_elt_t *数组的大小为size
2)用于存放hash表的连续内存块,其大小为所有hash桶占用空间的总和。

这部分的源代码如下:

    // 清空上次为了估算hash桶数目所存储在test中的数据
    for (i = 0; i < size; i++) {
        test[i] = sizeof(void *);
    }
    // 重新计算各个hash值所占用的内存空间
    for (n = 0; n < nelts; n++) {
        if (names[n].key.data == NULL) {
            continue;
        }

        key = names[n].key_hash % size;
        test[key] = (u_short) (test[key] + NGX_HASH_ELT_SIZE(&names[n]));
    }

    len = 0;
    // 将所有hash值占用的内存空间加和,得到总共需要的内存空间len
    for (i = 0; i < size; i++) {
        if (test[i] == sizeof(void *)) {
            continue;
        }

        test[i] = (u_short) (ngx_align(test[i], ngx_cacheline_size));

        len += test[i];
    }

对所有的ngx_hash_key_t重新计算了一遍,得到了存放所有数据需要的内存空间len

然后,就可以向ngx_pool_t内存池申请空间了。

if (hinit->hash == NULL) {
        // 在堆上创建hash管理结构体ngx_hash_t,及ngx_hash_elt_t*
        hinit->hash = ngx_pcalloc(hinit->pool, sizeof(ngx_hash_wildcard_t)
                                             + size * sizeof(ngx_hash_elt_t *));
        if (hinit->hash == NULL) {
            ngx_free(test);
            return NGX_ERROR;
        }
        // 将ngx_hash_t** 指针指向hash桶管理结构体
        buckets = (ngx_hash_elt_t **)
                      ((u_char *) hinit->hash + sizeof(ngx_hash_wildcard_t));

    } else {
        buckets = ngx_pcalloc(hinit->pool, size * sizeof(ngx_hash_elt_t *));
        if (buckets == NULL) {
            ngx_free(test);
            return NGX_ERROR;
        }
    }
    // 向内存池申请hash表所使用的内存空间
    elts = ngx_palloc(hinit->pool, len + ngx_cacheline_size);
    if (elts == NULL) {
        ngx_free(test);
        return NGX_ERROR;
    }

    elts = ngx_align_ptr(elts, ngx_cacheline_size);

这段代码的逻辑分成两部分:

1)向内存池申请管理hash表的结构体所使用的内存空间,包括ngx_hash_t结构体及ngx_hash_elt_t*指针数组。
2)向内存池申请hash表所使用的内存空间,直接使用之前计算的len申请。当然,Nginx的源码中为了效率,进行了对齐操作。

正确初始化管理结构体的内容

OK, 有了管理结构体,有了hash表,最后的任务就是将管理结构体的各个指针指向正确的地址,各个变量赋于正确的值即可。

    // 将ngx_hash_key_t* 数组指向各个内存段
    for (i = 0; i < size; i++) {
        if (test[i] == sizeof(void *)) {
            continue;
        }

        buckets[i] = (ngx_hash_elt_t *) elts;
        elts += test[i];

    }

    for (i = 0; i < size; i++) {
        test[i] = 0;
    }

    // 向hash表中填入正确的内容
    for (n = 0; n < nelts; n++) {
        if (names[n].key.data == NULL) {
            continue;
        }

        key = names[n].key_hash % size;
        elt = (ngx_hash_elt_t *) ((u_char *) buckets[key] + test[key]);

        elt->value = names[n].value;
        elt->len = (u_short) names[n].key.len;

        ngx_strlow(elt->name, names[n].key.data, names[n].key.len);

        test[key] = (u_short) (test[key] + NGX_HASH_ELT_SIZE(&names[n]));
    }

    for (i = 0; i < size; i++) {
        if (buckets[i] == NULL) {
            continue;
        }

        elt = (ngx_hash_elt_t *) ((u_char *) buckets[i] + test[i]);

        elt->value = NULL;
    }

    ngx_free(test);

    // hash表管理结构体赋于正确的内容
    hinit->hash->buckets = buckets;
    hinit->hash->size = size;

这样,就完成了初始化hash表的整个工作。

总结

个人经验:理解ngx_hash_t的关键点如下:

1)Nginx的哈希表是只读的,所以采用了预先估算hash表所需占用内存的大小的办法。源码中大多数的代码是跟预估hash表大小相关的。
2)Nginx的哈希表的内存空间采用内存池管理,因此理解其内存模型是理解代码逻辑的关键。内存模型请查看上一篇文章
3)Nginx的哈希表的核心是hash表的管理结构体ngx_hash_tngx_hash_elt_t*数组、及hash表内存空间分配。

个人觉得,Nginx的高效并不是来自于Nginx有什么屠龙大法,而是来自于针对性很强的优化,ngx_hash_t就是一个很好的例子。
ngx_hash_t中,随处可见各种优化,不论是这种特殊的hash表结构设计,还是内存分配上的各种对齐,都很值得学习。


_Zhao
1.2k 声望82 粉丝