楔子

本篇文章来聊一聊字典的缓存池,我们知道字典有一个 ma_keys 字段和一个 ma_values 字段。当哈希表为分离表时,键由 ma_keys 维护,值由 ma_values 维护;当哈希表为结合表时,键和值均由 ma_keys 维护。

那么当我们销毁一个 PyDictObject 时,也肯定要先释放 ma_keys 和 ma_values。

  • 如果是分离表,会将每个 value 的引用计数减 1,然后释放 ma_values;再将每个 key 的引用计数减 1,然后释放 ma_keys。最后再释放 PyDictObject 本身。
  • 如果是结合表,由于 key、value 都在 ma_keys 中,将每个 key、value 的引用计数减 1 之后,只需释放 ma_keys 即可。最后再释放 PyDictObject 本身。

整个过程还是很清晰的,只不过这里面遗漏了点什么东西,没错,就是缓存池。在介绍浮点数的时候,我们说不同的对象都有自己的缓存池,当然字典也不例外。并且除了 PyDictObject 之外,PyDictKeysObject 也有相应的缓存池,毕竟它负责存储具体的键值对。

那么下面我们就来研究一下这两者的缓存池。

PyDictObject 缓存池

字典的缓存池和列表的缓存池高度相似,都是采用数组实现的,并且容量也是 80 个。

// Objects/dictobject.c
#define PyDict_MAXFREELIST 80

static PyDictObject *free_list[PyDict_MAXFREELIST];
static int numfree = 0;

下面看一下字典的销毁过程,因为放入缓存池这个动作,一定是在对象销毁时发生的。

// Objects/dictobject.c

static inline void
dictkeys_decref(PyDictKeysObject *dk)
{
    assert(dk->dk_refcnt > 0);
    _Py_DEC_REFTOTAL;
    // 将 dk_refcnt 减 1
    // 如果字典是结合表,那么 dk->dk_refcnt 减 1 之后一定为 0
    // 如果字典是分离表,那么 dk->dk_refcnt 减 1 之后则不一定为 0
    if (--dk->dk_refcnt == 0) {
        // 释放 ma_keys,该函数稍后再聊
        free_keys_object(dk);
    }
}

static void
dict_dealloc(PyDictObject *mp)
{
    PyObject **values = mp->ma_values;
    PyDictKeysObject *keys = mp->ma_keys;
    Py_ssize_t i, n;

    // 因为要被销毁,所以让 GC 不再跟踪
    PyObject_GC_UnTrack(mp);
    // 用于延迟释放
    Py_TRASHCAN_BEGIN(mp, dict_dealloc)
    // 如果 values 不为 NULL,说明是分离表  
    if (values != NULL) {
        if (values != empty_values) {
            // 将每个 value 的引用计数减 1
            for (i = 0, n = mp->ma_keys->dk_nentries; i < n; i++) {
                Py_XDECREF(values[i]);
            }
            // 释放 ma_values
            free_values(values);
        }
        // 将 ma_keys->dk_refcnt 减 1,至于是否会释放 ma_keys
        // 则看是否还有其它组的 value 使用它
        dictkeys_decref(keys);
    }
    // 否则说明是结合表
    else if (keys != NULL) {
        // 结合表的话,dk_refcnt 一定等于 1,因为每组 value 都独占一组 key
        assert(keys->dk_refcnt == 1);
        // dk_refcnt 减 1 之后等于 0,内部会调用 free_keys_object
        // 在里面会先将每个 key、value 的引用计数减 1,然后再释放 ma_keys
        dictkeys_decref(keys);
    }
    // 如果 numfree 没达到 80,那么放入缓存池
    if (numfree < PyDict_MAXFREELIST && Py_TYPE(mp) == &PyDict_Type)
        // PyDictObject 缓存池是一个数组,直接添加在数组的尾部即可,然后 numfree 自增 1
        free_list[numfree++] = mp;
    else
        // 否则将空间交还给系统堆
        Py_TYPE(mp)->tp_free((PyObject *)mp);
    Py_TRASHCAN_END
}

同理,当创建字典时,也会优先从缓存池里面获取。

// Objects/dictobject.c

static PyObject *
new_dict(PyDictKeysObject *keys, PyObject **values)
{
    PyDictObject *mp;
    assert(keys != NULL);
    // 如果 numfree != 0,证明缓存池有可用元素
    if (numfree) {
        // 从缓存池当中获取
        mp = free_list[--numfree];
        assert (mp != NULL);
        assert (Py_TYPE(mp) == &PyDict_Type);
        // 将引用计数设置为 1
        _Py_NewReference((PyObject *)mp);
    }
    else {
        // 否则从堆区申请内存
        mp = PyObject_GC_New(PyDictObject, &PyDict_Type);
        // ...
    }
    // 初始化字段,然后返回 (PyObject *)mp
    mp->ma_keys = keys;
    mp->ma_values = values;
    mp->ma_used = 0;
    mp->ma_version_tag = DICT_NEXT_VERSION();
    ASSERT_CONSISTENT(mp);
    return (PyObject *)mp;
}

因此在缓存池的实现上,字典和列表有着很高的相似性。不仅都由数组实现,在销毁的时候也会放在数组的尾部,创建的时候也会从数组的尾部获取。当然啦,因为这么做符合数组的特性,如果销毁和创建都是在数组的头部操作,那么时间复杂度就从 O(1) 变成了 O(n)。

我们用 Python 来测试一下:

d1 = {k: 1 for k in "abcdef"}
d2 = {k: 1 for k in "abcdef"}
print("id(d1):", id(d1))
print("id(d2):", id(d2))
# 放到缓存池的尾部
del d1
del d2
# 缓存池:[d1, d2]

# 从缓存池的尾部获取
# 显然 id(d3) 和上面的 id(d2) 是相等的
d3 = {k: 1 for k in "abcdefghijk"}
# id(d4) 和上面的 id(d1) 是相等的
d4 = {k: 1 for k in "abcdefghijk"}
print("id(d3):", id(d3))
print("id(d4):", id(d4))
"""
id(d1): 140079181793600
id(d2): 140079181775488
id(d3): 140079181775488
id(d4): 140079181793600
"""

输出结果和我们的预期是相符合的,以上就是 PyDictObject 的缓存池。

PyDictKeysObject 缓存池

PyDictKeysObject 也有自己的缓存池,同样基于数组实现,大小是 80。

// Objects/dictobject.c

#define PyDict_MAXFREELIST 80
// PyDictObject 缓存池以及容量
static PyDictObject *free_list[PyDict_MAXFREELIST];
static int numfree = 0;
// PyDictKeysObject 缓存池以及容量
static PyDictKeysObject *keys_free_list[PyDict_MAXFREELIST];
static int numfreekeys = 0;

来看一下 PyDictKeysObject 的销毁过程:

// Objects/dictobject.c

static inline void
dictkeys_decref(PyDictKeysObject *dk)
{
    assert(dk->dk_refcnt > 0);
    _Py_DEC_REFTOTAL;
    // 分离表:多组 value 可以共享一组 key
    // 结合表:每组 value 独占一组 key
    // 因此要先将 dk_refcnt 减 1,如果结果为 0,那么才能释放 ma_keys
    if (--dk->dk_refcnt == 0) {
        free_keys_object(dk);
    }
}

static void
free_keys_object(PyDictKeysObject *keys)
{
    // 获取键值对数组
    PyDictKeyEntry *entries = DK_ENTRIES(keys);
    Py_ssize_t i, n;
    // 遍历 dk_entries,减少 key、value 的引用计数
    for (i = 0, n = keys->dk_nentries; i < n; i++) {
        Py_XDECREF(entries[i].me_key);
        // 如果是分离表,那么 me_value == NULL
        // 而当参数为 NULL 时,Py_XDECREF 不做任何处理
        Py_XDECREF(entries[i].me_value);
    }
    // 放入缓存池,除了要保证缓存池没满之外,还要保证 dk_size = 8
    // 也就是说,只有容量为 8 的哈希表的 PyDictKeysObject 才会被缓存
    if (keys->dk_size == PyDict_MINSIZE && numfreekeys < PyDict_MAXFREELIST) {
        keys_free_list[numfreekeys++] = keys;
        return;
    }
    // 如果条件不满足,释放 ma_keys,将内存交还给系统堆
    PyObject_FREE(keys);
}

所以 PyDictKeysObject 的缓存池和列表的缓存池同样是高度相似的,只不过它想要被缓存,除了保证缓存池有剩余空间之外,还要满足哈希表的容量等于 8,这个限制是出于对内存方面的考量。

以上是 ma_keys 的销毁过程,再来看看它的创建过程。

// Objects/dictobject.c

// 为 PyDictKeysObject 实例申请内存
static PyDictKeysObject *new_keys_object(Py_ssize_t size)
{
    PyDictKeysObject *dk;
    Py_ssize_t es, usable;

    assert(size >= PyDict_MINSIZE);
    assert(IS_POWER_OF_2(size));
    
    // 获取键值对数组的长度
    usable = USABLE_FRACTION(size);
    // 计算哈希索引数组中每个元素的大小
    if (size <= 0xff) {
        es = 1;
    }
    else if (size <= 0xffff) {
        es = 2;
    }
#if SIZEOF_VOID_P > 4
    else if (size <= 0xffffffff) {
        es = 4;
    }
#endif
    else {
        es = sizeof(Py_ssize_t);
    }
    // 如果容量等于 8,并且缓存池有可用元素,那么从缓存池中获取
    if (size == PyDict_MINSIZE && numfreekeys > 0) {
        dk = keys_free_list[--numfreekeys];
    }
    else {
        // 否则在堆区申请内存,而内存包含三部分
        // sizeof(PyDictKeysObject):结构体 PyDictKeysObject 的大小
        // es * size:哈希索引数组的大小
        // sizeof(PyDictKeyEntry) * usable):键值对数组的大小
        dk = PyObject_MALLOC(sizeof(PyDictKeysObject)
                             + es * size
                             + sizeof(PyDictKeyEntry) * usable);
        if (dk == NULL) {
            PyErr_NoMemory();
            return NULL;
        }
    }
    _Py_INC_REFTOTAL;
    // 初始化字段
    dk->dk_refcnt = 1;
    dk->dk_size = size;
    dk->dk_usable = usable;
    dk->dk_lookup = lookdict_unicode_nodummy;
    dk->dk_nentries = 0;
    // 将哈希索引数组中的每个元素都设置成 -1
    memset(&dk->dk_indices[0], 0xff, es * size);
    // 将键值对数组中的每个元素(entry)的所有字段都设置成 0
    memset(DK_ENTRIES(dk), 0, sizeof(PyDictKeyEntry) * usable);
    return dk;
}

非常简单,我们来验证一下。

from ctypes import *

class PyObject(Structure):
    _fields_ = [("ob_refcnt", c_ssize_t),
                ("ob_type", c_void_p)]

class PyDictObject(PyObject):
    _fields_ = [("ma_used", c_ssize_t),
                ("ma_version_tag", c_uint64),
                ("ma_keys", c_void_p),
                ("ma_values", c_void_p)]

d1 = {k: 1 for k in "komeiji satori"}
print(
    "d1.ma_keys:", PyDictObject.from_address(id(d1)).ma_keys
)
# 键值对个数超过了 8,哈希表的容量必然也超过了 8
# 那么当销毁 d1 的时候,d1.ma_keys 不会被缓存,而是会直接释放掉
del d1

d2 = {k: 1 for k in "abc"}
print(
    "d2.ma_keys:", PyDictObject.from_address(id(d2)).ma_keys
)
# 容量等于 8,所以 d2.ma_keys 会被缓存
del d2

d3 = {k: 1 for k in "komeiji koishi"}
print(
    "d3.ma_keys:", PyDictObject.from_address(id(d3)).ma_keys
)
# 尽管 d2 的 ma_keys 被缓存起来了,但是 d3 的 dk_size 大于 8
# 因此它不会从缓存池中获取,而是重新创建

d4 = {k: 1 for k in "abc"}
print(
    "d4.ma_keys:", PyDictObject.from_address(id(d4)).ma_keys
)
# d4 的 dk_size 等于 8,因此它会从缓存池中获取,从而复用被销毁的 d2.ma_keys
# 最终打印结果如下
"""
d1.ma_keys: 94324986272656
d2.ma_keys: 140165216613312
d3.ma_keys: 140165225069456
d4.ma_keys: 140165216613312
"""

从打印的结果来看,由于 d4.ma_keys 和 d2.ma_keys 是相同的,因此证实了我们的结论。不像列表和字典,它们是只要被销毁,就会放到缓存池里面,因为它们没有存储具体的数据,大小是固定的。但 PyDictKeysObject 不同,由于它存储了 entry,每个 entry 占 24 字节,如果内部的 entry 非常多,那么缓存起来会有额外的内存开销。因此 Python 的策略是,只有在哈希表容量等于 8 的时候,才会缓存。当然这三者在缓存池的实现上,是基本一致的。

不难看出,Python 在性能和内存使用方面都做了考量。但如果你追求更高的效率,那么也可以自己定制 Python 解释器,比如增大缓存池的容量等等,用更多的空间去换取时间。

小结

到此,字典相关的内容就全部介绍完了。和元组一样,字典也在我们看不到的地方被大量使用,比如对象的属性字典、名字空间等等。正因为解释器内部也在大量使用字典,所以字典是一个被高度优化的数据结构,不仅要保证搜索效率,还要减少内存使用。

下一篇文章,我们来介绍 Python 的集合。


 

欢迎大家关注我的公众号:古明地觉的编程教室。

如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。