分享

身虽死,道未消,解密字典的缓存池

 古明地觉O_o 2024-08-30 发布于北京

世界上哪来那么多一见如故,不过是因为我喜欢你,所以眼里都是你。


楔子

本篇文章来聊一聊字典的缓存池,我们知道字典有一个 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 个。

// Include/internal/pycore_dict_state.h
#define PyDict_MAXFREELIST 80

struct _Py_dict_state {
    // 全局计数器,用于设置字典的 ma_version_tag 字段
    // 每次创建字典以及修改字典时,该计数器都会递增
    // 当然这两个字段我们不用关注
    uint64_t global_version;
    uint32_t next_keys_version;

#if PyDict_MAXFREELIST > 0
    PyDictObject *free_list[PyDict_MAXFREELIST];
    PyDictKeysObject *keys_free_list[PyDict_MAXFREELIST];
    int numfree;
    int keys_numfree;
#endif

    PyDict_WatchCallback watchers[DICT_MAX_WATCHERS];
};

我们看到 PyDictObject 和 PyDictKeysObject 的缓存池都位于该结构体中,容量都是 80。

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

// Objects/dictobject.c

// 缓存池位于 _Py_dict_state 结构体实例当中
// 该实例在解释器启动之后会自动初始化好,并挂在进程状态对象上面
static struct _Py_dict_state *
get_dict_state(PyInterpreterState *interp)
{
    return &interp->dict_state;
}

// 减少 ma_keys->dk_refcnt
static inline void
dictkeys_decref(PyInterpreterState *interp, PyDictKeysObject *dk)
{   
    // 如果 dk_refcnt 等于 2 ** 32 - 1,说明这组 key 永远不会被释放
    if (dk->dk_refcnt == _Py_IMMORTAL_REFCNT) {
        return;
    }
    assert(dk->dk_refcnt > 0);
    // 将 dk_refcnt 减 1
    // 如果字典是结合表,那么 dk->dk_refcnt 减 1 之后一定为 0
    // 如果字典是分离表,那么 dk->dk_refcnt 减 1 之后则不一定为 0
    if (--dk->dk_refcnt == 0) {
        // 释放 ma_keys,该函数稍后再聊
        free_keys_object(interp, dk);
    }
}

static void
dict_dealloc(PyDictObject *mp)
{
    PyInterpreterState *interp = _PyInterpreterState_GET();
    // ...
    PyDictValues *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) {
        // 将每个 value 的引用计数减 1
        for (i = 0, n = mp->ma_keys->dk_nentries; i < n; i++) {
            Py_XDECREF(values->values[i]);
        }
        // 释放 ma_values
        free_values(values);
        // 将 ma_keys->dk_refcnt 减 1,至于是否会释放 ma_keys
        // 则看是否还有其它组的 value 使用它
        dictkeys_decref(interp, keys);
    }
    // 否则说明是结合表
    else if (keys != NULL) {
        // 结合表的话,dk_refcnt 一定等于 1,因为每组 value 都独占一组 key
        assert(keys->dk_refcnt == 1 || keys == Py_EMPTY_KEYS);
        // dk_refcnt 减 1 之后等于 0,内部会调用 free_keys_object
        // 在里面会先将每个 key 的引用计数减 1,然后再释放 ma_keys
        dictkeys_decref(interp, keys);
    }
#if PyDict_MAXFREELIST > 0
    // 获取 _Py_dict_state 结构体实例
    struct _Py_dict_state *state = get_dict_state(interp);
    // 如果 numfree 没达到 80,那么放入缓存池
    if (state->numfree < PyDict_MAXFREELIST && Py_IS_TYPE(mp, &PyDict_Type)) {
        // PyDictObject 缓存池是一个数组,直接添加在数组的尾部即可
        state->free_list[state->numfree++] = mp;
        OBJECT_STAT_INC(to_freelist);
    }
    else
#endif
    {
        // 否则将空间交还给系统堆
        Py_TYPE(mp)->tp_free((PyObject *)mp);
    }
    Py_TRASHCAN_END
}

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

// Objects/dictobject.c

static PyObject *
new_dict(PyInterpreterState *interp,
         PyDictKeysObject *keys, PyDictValues *values,
         Py_ssize_t used, int free_values_on_failure)

{
    PyDictObject *mp;
    assert(keys != NULL);
#if PyDict_MAXFREELIST > 0
    struct _Py_dict_state *state = get_dict_state(interp);
    // 如果 state->numfree > 0,证明缓存池有可用元素
    if (state->numfree) {
        // 从缓存池当中获取
        mp = state->free_list[--state->numfree];
        assert (mp != NULL);
        assert (Py_IS_TYPE(mp, &PyDict_Type));
        OBJECT_STAT_INC(from_freelist);
        // 将引用计数设置为 1
        _Py_NewReference((PyObject *)mp);
    }
    else
#endif
    {
        // 否则从堆区申请内存
        mp = PyObject_GC_New(PyDictObject, &PyDict_Type);
        // mp == NULL 表示申请失败
        if (mp == NULL) {
            // 如果 ma_keys 申请好了,那么还要释放掉
            dictkeys_decref(interp, keys);
            if (free_values_on_failure) {
                free_values(values);
            }
            return NULL;
        }
    }
    // 初始化字段,然后返回 (PyObject *)mp
    mp->ma_keys = keys;
    mp->ma_values = values;
    mp->ma_used = used;
    mp->ma_version_tag = DICT_NEXT_VERSION(interp);
    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
static inline void
dictkeys_decref(PyInterpreterState *interp, PyDictKeysObject *dk)
{
    if (dk->dk_refcnt == _Py_IMMORTAL_REFCNT) {
        return;
    }
    assert(dk->dk_refcnt > 0);
    // 分离表:多组 value 可以共享一组 key
    // 结合表:每组 value 独占一组 key
    // 因此要先将 dk_refcnt 减 1,如果结果为 0,那么才能释放 ma_keys
    if (--dk->dk_refcnt == 0) {
        free_keys_object(interp, dk);
    }
}

static void
free_keys_object(PyInterpreterState *interp, PyDictKeysObject *keys)
{
    // Py_EMPTY_KEYS 是针对空字典而预定义好的,它不会被回收
    // 因此这里 keys 一定不等于 Py_EMPTY_KEYS
    assert(keys != Py_EMPTY_KEYS);
    // 如果字典的 key 都是字符串
    // 那么 keys->dk_entries 里面的 entry 由 PyDictUnicodeEntry 结构体表示
    if (DK_IS_UNICODE(keys)) {
        PyDictUnicodeEntry *entries = DK_UNICODE_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);
        }
    }
    // key 的类型不做限制
    // 那么 keys->dk_entries 里面的 entry 由 PyDictKeyEntry 结构体表示
    else {
        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);
            Py_XDECREF(entries[i].me_value);
        }
    }
#if PyDict_MAXFREELIST > 0
    struct _Py_dict_state *state = get_dict_state(interp);
    // 放入缓存池,但需要满足三个条件
    /* 1) dk_log2_size == 3,即哈希表的容量等于 8 */
    /* 2) 缓存池中已缓存的元素个数小于 80 */
    /* 3) 所有的 key 必须都是字符串,换句话说,缓存的必须是 Unicode table 的 ma_keys
          Generic table 的 ma_keys 不会被缓存 */

    if (DK_LOG_SIZE(keys) == PyDict_LOG_MINSIZE
            && state->keys_numfree < PyDict_MAXFREELIST
            && DK_IS_UNICODE(keys)) {
        state->keys_free_list[state->keys_numfree++] = keys;
        OBJECT_STAT_INC(to_freelist);
        return;
    }
#endif
    // 如果条件不满足,释放 ma_keys,将内存交还给系统堆
    PyObject_Free(keys);
}

所以 PyDictKeysObject 的缓存池和列表同样是高度相似的,只不过它想要被缓存,除了缓存池有剩余空间之外,还需要满足两个额外的条件。

  • 哈希表的容量等于 8,这个限制是出于对内存方面的考量。

  • 必须是 Unicode table。

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

// Objects/dictobject.c
static PyDictKeysObject*
new_keys_object(PyInterpreterState *interp, uint8_t log2_size, bool unicode)
{
    // ...

#if PyDict_MAXFREELIST > 0
    struct _Py_dict_state *state = get_dict_state(interp);
    // 如果 log2_size == 3、unicode == 1、缓存池有可用元素
    // 那么从缓存池当中获取
    if (log2_size == PyDict_LOG_MINSIZE && unicode && 
            state->keys_numfree > 0) {
        dk = state->keys_free_list[--state->keys_numfree];
        OBJECT_STAT_INC(from_freelist);
    }
    else
#endif
    // 否则从堆区申请内存
    {
        dk = PyObject_Malloc(sizeof(PyDictKeysObject)
                             + ((size_t)1 << log2_bytes)
                             + entry_size * usable);
        if (dk == NULL) {
            PyErr_NoMemory();
            return NULL;
        }
    }
    // ...
    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 [123]}
print(
    "d2.ma_keys:", PyDictObject.from_address(id(d2)).ma_keys
)
# d2 的容量虽然等于 8,但不满足 key 全部是字符串
# 换句话说,字典使用的是 Generic table,不是 Unicode table
# 因此它的 ma_keys 也不会被缓存
del d2

d3 = {k: 1 for k in ["a""b""c"]}
print(
    "d3.ma_keys:", PyDictObject.from_address(id(d3)).ma_keys
)
# 容量等于 8,key 都是字符串,所以 d3.ma_keys 会被缓存
del d3

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

d5 = {k: 1 for k in "abc"}
print(
    "d5.ma_keys:", PyDictObject.from_address(id(d5)).ma_keys
)
# d5 满足 key 都是字符串,并且容量等于 8
# 因此 d5.ma_keys 会从缓存池中获取,并且会复用 d3.ma_keys 的地址
# 最终打印结果如下
"""
d1.ma_keys: 140487120770976
d2.ma_keys: 140487123034448
d3.ma_keys: 140487120873136
d4.ma_keys: 140487123100656
d5.ma_keys: 140487120873136
"""

从打印的结果来看,由于 d5.ma_keys 和 d3.ma_keys 是相同的,因此证实了我们的结论。不像列表和字典,它们是只要被销毁,就会放到缓存池里面,因为它们没有存储具体的数据,大小是固定的。

但是 PyDictKeysObject 不同,由于它存储了 entry,每个 entry 占 16 或 24 字节,如果内部的 entry 非常多,那么缓存起来会有额外的内存开销。因此 Python 的策略是,只有在哈希表容量等于 8 的时候,才会缓存。当然这三者在缓存池的实现上,是基本一致的。


小结

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

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

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多