Loading... # 0x01 <div class="tip inlineBlock info"> 本篇分析基于 **Python 3.7.9** 注意信息时效 </div> 内存是程序运行必不可少的系统资源,并且是极其宝贵的有限资源!程序过量的使用不必要的内存,会使用户体验极差。相信大家都有过电脑同时开很多程序导致卡顿的问题,这种卡顿基本就是内存告竭的问题。 作为一个合格的内存管理带师,必须对自己写的程序每一个对象负责,充分了解并掌管每一个对象的生死。可是并不是所有程序员都是内存管理带师,大部分程序员总会忘东忘西,很难到达带师的境界。这个时候就需要语言底层帮我们这种菜鸡程序员擦屁股,好好管管我们的对象。 接下来,我们就捋一捋,Python 是如何掌控我们的对象~ # 引用计数 Python 采用 **引用计数** 的方式回收绝大多数的垃圾对象,实现原理也非常简单。在每一个对象创建之初都会带有一个 `ob_refcnt` 字段记录该对象的引用数,当该引用计数字段归零,Python 会 **立刻** 回收该对象。 ```python >>> import sys >>> a = "Hello World!" >>> sys.getrefcount(a) 2 >>> b = a >>> sys.getrefcount(a) 3 >>> del a >>> sys.getrefcount(a) Traceback (most recent call last): File "<stdin>", line 1, in <module> NameError: name 'a' is not defined >>> sys.getrefcount(b) 2 ``` > 这里有几个要注意的点: > > - 为什么示例代码第六行是 2 而不是 1?因为`sys.getrefcount(a)`函数本身会引用一次 `a` ,当函数返回解除引用,`a`的引用次数才会回到1。 > - 不要用简单的 `1-100` 或者单字节的 `a-z` 去做实验,Python 底层会对这些非常常用的对象建立**静态对象缓存池**。甚至可以说这些对象是 Python 启动时就已经单例创建的,所以最好创建一些特别一点的对象做实验 然而我们应用层开发程序员写的屎山代码,并不是简单的引用计数法就能解决的。我们知道 Python 的内置类型分为 **容器类型** 和 **非容器类型**,如果把对象的引用关系看成一棵树,非容器类型(int,str,float这些)必然处于树的叶子节点并且不会在衍生出新的叶子;而容器类型,因为内部可以再包含任意类型,简单的树结构升级为图结构,而图有一个特点就是能形成环,而环形引用就使上面提到的引用计数失效了。 ```python >>> a = [] # 这里的 [] 引用为 1, 后称 a >>> b = [] # 这里的 [] 引用为 1, 后称 b >>> a.append(b) # b 引用为 2 >>> b.append(a) # a 引用为 2 >>> print(sys.getrefcount(a), sys.getrefcount(b)) 3 3 >>> del a # b 引用应该要减一,但是为什么没减呢? >>> sys.getrefcount(b) 3 ``` # 标记-清除法 <div class="tip inlineBlock info"> 因为**非容器类型**不会有循环引用的问题,所以不会参与**标记-清除垃圾回收**的过程 </div> ## 原理 Python 使用的是 **标记-清除法** 来解决循环引用的问题,原理也非常简单: 1. 遍历每个对象引用的对象,将它们的引用计数减一。最终得到引用数不为 0 的对象,将他们称作根对象。 2. 从根对象出发一层一层恢复**可达对象**,最后只剩下循环引用的垃圾对象了。 <div class='album_block'> [album type="photos"]    [/album] </div> ## 实现 ### 拷贝引用计数器 `ob_refcnt` 进行 **标记-清除** 首先需要修改引用计数器,如果直接操作 `ob_refcnt` 会导致回收过后引用计数器无法恢复,这显然是无法接受的。于是聪明的 Python 设计者多弄了一个 `gc_refs` 字段备份 `ob_refcnt` 引用计数器,并且 标记-清除 的过程中,全程都在操作`gc_refs`这个备份字段不会影响原始的引用链。 处理完引用计数器后,就开始进行 标记-清除 的过程了~ ### 找到根对象 遍历所有对象,把遍历到的对象引用减一。首先执行`subtract_refs(young);` ```cpp static int visit_decref(PyObject *op, void *data) { assert(op != NULL); if (PyObject_IS_GC(op)) { PyGC_Head *gc = AS_GC(op); assert(_PyGCHead_REFS(gc) != 0); /* else refcount was too small */ if (_PyGCHead_REFS(gc) > 0) // 将对象引用计数副本(gc_refs)减一 _PyGCHead_DECREF(gc); } return 0; } static void subtract_refs(PyGC_Head *containers) { traverseproc traverse; PyGC_Head *gc = containers->gc.gc_next; // 遍历链表每一个对象,为什么是链表我们后面再说 for (; gc != containers; gc=gc->gc.gc_next) { // 遍历当前对象所引用的对象,调用visit_decref将它们的引用计数减一 traverse = Py_TYPE(FROM_GC(gc))->tp_traverse; (void) traverse(FROM_GC(gc), (visitproc)visit_decref, NULL); } } ``` ### 对象可达、不可达分类 初始化**不可达链表**,遍历原始链表,将当前引用数 `gc_refs` 为零的对象标记为**暂时不可达状态**并移入不可达链表 ```cpp gc_list_init(&unreachable); move_unreachable(young, &unreachable); ``` ```cpp static void move_unreachable(PyGC_Head *young, PyGC_Head *unreachable){ // 遍历链表每个对象 while (gc != young) { PyGC_Head *next; // 如果引用计数不为0,说明它可达 if (_PyGCHead_REFS(gc)) { PyObject *op = FROM_GC(gc); traverseproc traverse = Py_TYPE(op)->tp_traverse; assert(_PyGCHead_REFS(gc) > 0); // 将它标记为可达 _PyGCHead_SET_REFS(gc, GC_REACHABLE); // 遍历被它引用的对象,调用visit_reachable将被引用对象标记为可达 (void) traverse(op, (visitproc)visit_reachable, (void *)young); next = gc->gc.gc_next; if (PyTuple_CheckExact(op)) { _PyTuple_MaybeUntrack(op); } } else { next = gc->gc.gc_next; // 暂时移入不可达链表 gc_list_move(gc, unreachable); _PyGCHead_SET_REFS(gc, GC_TENTATIVELY_UNREACHABLE); } gc = next; } } ``` ### 还原可达对象 <div class="tip inlineBlock warning"> 这一步是`visit_reachable()`这个函数的具体实现,并不是另一个新的步骤 </div> 如果遍历到的对象是可达的(`gc_refs`不为0),遍历被它引用的对象,调用`visit_reachable()`将被引用对象标记为可达,并移回原链表继续处理(因为引用对象可能还有引用对象) ```#cpp static int visit_reachable(PyObject *op, PyGC_Head *reachable) { if (PyObject_IS_GC(op)) { PyGC_Head *gc = AS_GC(op); const Py_ssize_t gc_refs = _PyGCHead_REFS(gc); if (gc_refs == 0) { // 引用计数为零,将计数设置为1,表明它是可达的 _PyGCHead_SET_REFS(gc, 1); } else if (gc_refs == GC_TENTATIVELY_UNREACHABLE) { // 如果对象被临时标记为不可达,将引用计数设置为1表明它是可达的 // 并移回原链表继续处理 gc_list_move(gc, reachable); _PyGCHead_SET_REFS(gc, 1); } else { assert(gc_refs > 0 || gc_refs == GC_REACHABLE || gc_refs == GC_UNTRACKED); } } return 0; } ``` ### 图示总结 看文字不太好理解,可以看看下面的图,**点击可放大**。 <div class='album_block'> [album type="photos"]      [/album] </div> # 分代回收 然而光有 **引用计数法** 和 **标记-清除法** 就已经完美解决了所有问题了吗?当然不是这样的,还有一个很重要的问题没有解决。标记-清除法需要 **遍历所有对象** 才能发挥他的作用,并且每次进行标记-清除的过程需要应用程序**完全暂停**才能进行,这样在性能上是完全不可接受的。聪明的你一定想到了,解决这个问题可以从 **定时和定量** 两个方向去优化,而且定量似乎更优。而 Python 设计者们也选择了从**定量**的角度去优化,采用了**分代回收**的机制,即每次进行 标记-清除 只处理某一个"代"的对象,这样 GC 运行时间会相对全量对象更短一点。 Python 默认将对象分为三代,越老的代数执行 标记-清除 的频次也就越少。原因就是越新创建的对象越容易成为垃圾(比如各种临时变量)。 ## 分代触发阈值 Python 的分代回收中每一代都对应一条独立的链表。并且每一代都拥有一个独立的触发阈值,当链表的**计数器**(并不是长度)到达阈值,则进行 标记-清除 垃圾回收。默认值如下 ```cpp struct gc_generation generations[NUM_GENERATIONS] = { /* PyGC_Head, threshold, count */ {{{_GEN_HEAD(0), _GEN_HEAD(0), 0}}, 700, 0}, {{{_GEN_HEAD(1), _GEN_HEAD(1), 0}}, 10, 0}, {{{_GEN_HEAD(2), _GEN_HEAD(2), 0}}, 10, 0}, }; ``` ## 分代回收过程 - 新建的对象直接进入第 0 代链表中,第 0 代计数器加一;对象因 **引用计数** 回收,则第 0 代计数器减一 - 当第 0 代链表计数器到达阈值时,则从最老的代数开始找,找到第一个计数器超过阈值的代,以第 1 代为例。将第 0 代的对象链表与第 1 代链接起来,第 0 代的计数器置为 0 (年轻代升为老一代),随后进行标记-清除 - 将第 2 代的计数器加一,第 1 代计数器清零 即每创建 701 个需要标记-清除的对象进行一次 **第 0 代的 标记-清除**;每 **11 次第 0 代的 标记-清除** 进行一次**第 1 代的 标记-清除**;每 **11 次第 1 代的 标记-清除** 进行一次**第 2 代的 标记-清除** # 躲不过的内存泄漏 我们拥有了 **引用计数**、**标记-清除** 和 **分代回收** 三大利器之后,就可以完全避免“内存垃圾”的问题了吗?显然不是,我们回过头来看看垃圾回收的定义: > 在计算机科学中,垃圾回收(GC)是一种自动内存管理的机制。垃圾收集器试图回收由程序分配的、但**不再被引用的内存**--也称为垃圾。 垃圾回收只会帮我们回收逻辑上不可达的内存,然而我们的程序中时长会出现逻辑上可达,但是已经不需要的内存对象,比如 打开了一个文件使用完毕后没有关闭、在内存中创建了缓存但没有清除缓存的机制等等。这种情况我们统称为——内存泄露 内存泄露往往是最棘手的,开发阶段他很难被发现,但往往在正式上线了并运行了一段时间以后,才会暴露出问题,而且一般都是很严重的问题。接下来就来讲讲如何找内存泄露的bug。 ## Python 中内存泄露的几种情况 在 Python 中内存泄露往往发生在以下三种情况中 - 使用了外部资源但是未关闭,如文件、网络等 - 容器泄露,使用的容器数据只进不出,没有或者难以触发清理机制 - 错误使用了 `__del__` 魔术方法,实现了 `__del__` 方法的对象不会进入 GC 需要手动 `del` ## 内存泄露示例代码 ```python from faker import Faker faker = Faker() user_dict = {} def get_user_info(uid: int): if uid not in user_dict: user_dict[uid] = { "name": faker.name(), "address": faker.address(), "email": faker.email(), "desc": faker.text() } return user_dict.get(uid) ``` 假设我们有一个查询用户信息的方法,并且每次查询都会把结果缓存到`user_dict`里。想必一眼就能看出来随着时间的推移,`user_dict` 字典里的内容会越来越多。但是我们先假设不知道这个情况,使用其他工具来找出这个内存泄露的情况。 ## 使用 objgraph ### 准备工作 为了解决内存泄露,我们先得找到内存泄露发生在哪、是什么类型的对象导致了内存泄露。而 `objgraph` 这个包就能帮我们很好地解决这个问题,安装好这个包后将上述示例代码导入解释器中模拟。 ```bash pip install objgraph ``` 首先先执行 10000 次 `get_user_info` 方法模拟内存泄露 ```python for i in range(10000): get_user_info(i) ``` ### 从对象类型入手 ```python import objgraph objgraph.show_most_common_types() function 6916 dict 4483 tuple 3212 list 2694 weakref 1723 wrapper_descriptor 1307 getset_descriptor 1211 method_descriptor 1113 builtin_function_or_method 1028 type 983 ``` 从对象数量的总览`show_most_common_types()`来看,好像看不出有什么问题。然后查阅文档发现 `objgraph.by_type()` 能返回所有传入类型的对象。而一般内存泄露被发现的情况一定是内存暴增到一定程度了,那么发生泄露的对象一般都是该类型里熟练最多的,于是我们再写一个辅助函数,来得到数量前五的对象。 ```python def get_top_obj_by_types(types: str): objects = objgraph.by_type(types) if not objects: return objects.sort(key=lambda x: len(x), reverse=True) return objects[:5] ``` 然后我们回看 `show_most_common_types()` 返回的类型,初步可以判断内存泄露发生在 `dict`、`list`、`tuple` 这几个内建容器类型里面,那么我们依次打印一下。 ```python max_list = get_top_obj_by_types('list') max_tuple = get_top_obj_by_types('tuple') max_dict = get_top_obj_by_types('dict') ``` 因为这官方 REPL 里不方便看容器类型的内容,我们转移到 ide 里面依次查看其结果。    `tuple`类型肯定不是,`list`类型也长得不像,而 `dict` 类型最多的那一个就可以很明显的看出发生泄露的就是他了。 ### 定位泄露位置 既然找到了内存泄露的对象,接下来就是找他在代码中的位置了。如果要找代码中的位置,那么首先要确定对象的名字。`objgraph.find_backref_chain()` 方法可以很容易的找到对象的反向引用链。 ```python chain = objgraph.find_backref_chain(max_dict[0], objgraph.is_proper_module) ``` *is_proper_module* 告诉它,当回溯遇到模块对象后,停止搜索。我们再在 ide 中观察一下 chain 的内容  可以很轻松的看出,发生泄露的是 `__main__` 模块,而第二个是 `__main__` 模块的属性空间,我们再来写一个方法确定泄露对象的名字。 ```python def get_top_obj_name(chain: list, max_obj: list): for node in chain: if isinstance(node, dict): for key, value in node.items(): if max_obj is value: return key ``` ```python >>> get_top_obj_name(chain, max_dict[0]) 'user_dict' ``` 于是我们就找到了泄露对象的名字 `user_dict`,接着只要在代码中简单搜索一下就能找到他的位置~ ## 复杂的现实情况 然而,现实中内存泄露的情况比上述示例要复杂的多,定位往往也不是那么容易的事情。内存泄露不容易在本地复现,而且引用链往往也不像上述那样只有简单的三条。 如果要避免内存泄露的问题,往往预防才是关键,当发生内存泄露才准备 debug 是一件相当头疼的事情。如果早期就做好了程序内存监控,将程序占用内存大小、没中容器对象的最大长度等关键指标提交到监控系统,并绘制趋势图,这样可以极大地减少我们 debug 的难度。 # Reference [垃圾回收的算法与实现](https://item.m.jd.com/product/12010270.html) [全面解读Python垃圾回收机制](https://fasionchan.com/python-source/memory/gc/) [Python 垃圾回收器](https://docs.python.org/zh-cn/3.7/library/gc.html?highlight=gc#module-gc) [Python垃圾回收(GC)三层心法,你了解到第几层?](https://juejin.cn/post/6844903629556547598) [[整理]Python垃圾回收机制--完美讲解!](https://www.jianshu.com/p/1e375fb40506) [Python内存管理机制](https://juejin.cn/post/6856235545220415496) [《深度剖析CPython解释器》28. Python内存管理与垃圾回收](https://www.cnblogs.com/traditional/p/13698244.html) 最后修改:2021 年 07 月 09 日 01 : 20 PM © 允许规范转载 赞赏 如果觉得我的文章对你有用,请随意赞赏 赞赏作者 支付宝微信