热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

使用GC、Objgraph干掉Python内存泄露与循环引用

Python使用引用计数和垃圾回收来做内存管理,前面也写过一遍文章《Python内存优化》,介绍了在python中,如何profile内存

Python使用引用计数和垃圾回收来做内存管理,前面也写过一遍文章《Python内存优化》,介绍了在python中,如何profile内存使用情况,并做出相应的优化。本文介绍两个更致命的问题:内存泄露与循环引用。内存泄露是让所有程序员都闻风丧胆的问题,轻则导致程序运行速度减慢,重则导致程序崩溃;而循环引用是使用了引用计数的数据结构、编程语言都需要解决的问题。本文揭晓这两个问题在python语言中是如何存在的,然后试图利用gc模块和objgraph来解决这两个问题。

注意:本文的目标是Cpython,测试代码都是运行在Python2.7。另外,本文不考虑C扩展造成的内存泄露,这是另一个复杂且头疼的问题。

一分钟版本
  1. python使用引用计数和垃圾回收来释放(free)Python对象
  2. 引用计数的优点是原理简单、将消耗均摊到运行时;缺点是无法处理循环引用
  3. Python垃圾回收用于处理循环引用,但是无法处理循环引用中的对象定义了__del__的情况,而且每次回收会造成一定的卡顿
  4. gc module是python垃圾回收机制的接口模块,可以通过该module启停垃圾回收、调整回收触发的阈值、设置调试选项
  5. 如果没有禁用垃圾回收,那么Python中的内存泄露有两种情况:要么是对象被生命周期更长的对象所引用,比如global作用域对象;要么是循环引用中存在__del__
  6. 使用gc module、objgraph可以定位内存泄露,定位之后,解决很简单
  7. 垃圾回收比较耗时,因此在对性能和内存比较敏感的场景也是无法接受的,如果能解除循环引用,就可以禁用垃圾回收。
  8. 使用gc module的DEBUG选项可以很方便的定位循环引用,解除循环引用的办法要么是手动解除,要么是使用weakref

python内存管理

Python中,一切都是对象,又分为mutable和immutable对象。二者区分的标准在于是否可以原地修改,“原地“”可以理解为相同的地址。可以通过id()查看一个对象的“地址”,如果通过变量修改对象的值,但id没发生变化,那么就是mutable,否则就是immutable。比如:

Python

 

1

2

3

4

5

6

7

8

9

>>> a = 5;id(a)

 

35170056

>>> a = 6;id(a)

35170044

>>> lst = [1,2,3]; id(lst)

39117168

>>> lst.append(4); id(lst)

39117168

 

a指向的对象(int类型)就是immutable, 赋值语句只是让变量a指向了一个新的对象,因为id发生了变化。而lst指向的对象(list类型)为可变对象,通过方法(append)可以修改对象的值,同时保证id一致。

判断两个变量是否相等(值相同)使用==, 而判断两个变量是否指向同一个对象使用 is。比如下面a1 a2这两个变量指向的都是空的列表,值相同,但是不是同一个对象。

Python

 

1

2

3

4

5

>>> a1, a2 = [], []

>>> a1 == a2

True

>>> a1 is a2

False

为了避免频繁的申请、释放内存,避免大量使用的小对象的构造析构,python有一套自己的内存管理机制。在巨著《Python源码剖析》中有详细介绍,在python源码obmalloc.h中也有详细的描述。如下所示:

1089769-20170919090908056-1998847597

可以看到,python会有自己的内存缓冲池(layer2)以及对象缓冲池(layer3)。在Linux上运行过Python服务器的程序都知道,python不会立即将释放的内存归还给操作系统,这就是内存缓冲池的原因。而对于可能被经常使用、而且是immutable的对象,比如较小的整数、长度较短的字符串,python会缓存在layer3,避免频繁创建和销毁。例如:

Python

 

1

2

3

4

5

6

7

8

9

>>> a, b = 1, 1

>>> a is b

True

>>> a, b = (), ()

>>> a is b

True

>>> a, b = {}, {}

>>> a is b

False

本文并不关心python是如何管理内存块、如何管理小对象,感兴趣的读者可以参考伯乐在线和csdn上的这两篇文章。

本文关心的是,一个普通的对象的生命周期,更明确的说,对象是什么时候被释放的。当一个对象理论上(或者逻辑上)不再被使用了,但事实上没有被释放,那么就存在内存泄露;当一个对象事实上已经不可达(unreachable),即不能通过任何变量找到这个对象,但这个对象没有立即被释放,那么则可能存在循环引用。

引用计数

引用计数(References count),指的是每个Python对象都有一个计数器,记录着当前有多少个变量指向这个对象。

将一个对象直接或者间接赋值给一个变量时,对象的计数器会加1;当变量被del删除,或者离开变量所在作用域时,对象的引用计数器会减1。当计数器归零的时候,代表这个对象再也没有地方可能使用了,因此可以将对象安全的销毁。Python源码中,通过Py_INCREF和Py_DECREF两个宏来管理对象的引用计数,代码在object.h

 

1

2

3

4

5

6

7

8

9

10

11

12

#define Py_INCREF(op) (                         \

    _Py_INC_REFTOTAL  _Py_REF_DEBUG_COMMA       \

    ((PyObject*)(op))->ob_refcnt++)

 

#define Py_DECREF(op)                                   \

    do {                                                \

        if (_Py_DEC_REFTOTAL  _Py_REF_DEBUG_COMMA       \

        --((PyObject*)(op))->ob_refcnt != 0)            \

            _Py_CHECK_REFCNT(op)                        \

        else                                            \

        _Py_Dealloc((PyObject *)(op));                  \

    } while (0)

通过sys.getrefcount(obj)对象可以获得一个对象的引用数目,返回值是真实引用数目加1(加1的原因是obj被当做参数传入了getrefcount函数),例如:

Python

 

1

2

3

4

5

6

7

>>> import sys

>>> s = 'asdf'

>>> sys.getrefcount(s)

2

>>> a = 1

>>> sys.getrefcount(a)

605

从对象1的引用计数信息也可以看到,python的对象缓冲池会缓存十分常用的immutable对象,比如这里的整数1。

引用计数的优点在于原理通俗易懂;且将对象的回收分布在代码运行时:一旦对象不再被引用,就会被释放掉(be freed),不会造成卡顿。但也有缺点:额外的字段(ob_refcnt);频繁的加减ob_refcnt,而且可能造成连锁反应。但这些缺点跟循环引用比起来都不算事儿。

什么是循环引用,就是一个对象直接或者间接引用自己本身,引用链形成一个环。且看下面的例子:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

# -*- coding: utf-8 -*-

import objgraph, sys

class OBJ(object):

    pass

 

def show_direct_cycle_reference():

    a = OBJ()

    a.attr = a

    objgraph.show_backrefs(a, max_depth=5, filename = "direct.dot")

 

def show_indirect_cycle_reference():

    a, b = OBJ(), OBJ()

    a.attr_b = b

    b.attr_a = a

    objgraph.show_backrefs(a, max_depth=5, filename = "indirect.dot")

 

if __name__ == '__main__':

    if len(sys.argv) > 1:

        show_direct_cycle_reference()

    else:

        show_indirect_cycle_reference()

运行上面的代码,使用graphviz工具集(本文使用的是dotty)打开生成的两个文件,direct.dot 和 indirect.dot,得到下面两个图

1089769-20170919090908056-19988475971089769-20170919090908056-1998847597

通过属性名(attr, attr_a, attr_b)可以很清晰的看出循环引用是怎么产生的

前面已经提到,对于一个对象,当没有任何变量指向自己时,引用计数降到0,就会被释放掉。我们以上面左边那个图为例,可以看到,红框里面的OBJ对象想在有两个引用(两个入度),分别来自帧对象frame(代码中,函数局部空间持有对OBJ实例的引用)、attr变量。我们再改一下代码,在函数运行技术之后看看是否还有OBJ类的实例存在,引用关系是怎么样的:

 

1

2

3

4

5

6

7

8

9

10

11

12

# -*- coding: utf-8 -*-

import objgraph, sys

class OBJ(object):

    pass

 

def direct_cycle_reference():

    a = OBJ()

    a.attr = a

    

if __name__ == '__main__':

    direct_cycle_reference()

    objgraph.show_backrefs(objgraph.by_type('OBJ')[0], max_depth=5, filename = "direct.dot"

1089769-20170919090908056-1998847597

修改后的代码,OBJ实例(a)存在于函数的local作用域。因此,当函数调用结束之后,来自帧对象frame的引用被解除。从图中可以看到,当前对象的计数器(入度)为1,按照引用计数的原理,是不应该被释放的,但这个对象在函数调用结束之后就是事实上的垃圾,这个时候就需要另外的机制来处理这种情况了。

python的世界,很容易就会出现循环引用,比如标准库Collections中OrderedDict的实现(已去掉无关注释):

 

1

2

3

4

5

6

7

8

9

10

11

class OrderedDict(dict):

    def __init__(self, *args, **kwds):

        if len(args) > 1:

            raise TypeError('expected at most 1 arguments, got %d' % len(args))

        try:

            self.__root

        except AttributeError:

            self.__root = root = []                     # sentinel node

            root[:] = [root, root, None]

            self.__map = {}

        self.__update(*args, **kwds)

注意第8、9行,root是一个列表,列表里面的元素之自己本身!

垃圾回收

这里强调一下,本文中的的垃圾回收是狭义的垃圾回收,是指当出现循环引用,引用计数无计可施的时候采取的垃圾清理算法。

在python中,使用标记-清除算法(mark-sweep)和分代(generational)算法来垃圾回收。在《Garbage Collection for Python》一文中有对标记回收算法,然后在《Python内存管理机制及优化简析》一文中,有对前文的翻译,并且有分代回收的介绍。在这里,引用后面一篇文章:

在Python中, 所有能够引用其他对象的对象都被称为容器(container). 因此只有容器之间才可能形成循环引用. Python的垃圾回收机制利用了这个特点来寻找需要被释放的对象. 为了记录下所有的容器对象, Python将每一个 容器都链到了一个双向链表中, 之所以使用双向链表是为了方便快速的在容器集合中插入和删除对象. 有了这个 维护了所有容器对象的双向链表以后, Python在垃圾回收时使用如下步骤来寻找需要释放的对象:

  1. 对于每一个容器对象, 设置一个gc_refs值, 并将其初始化为该对象的引用计数值.
  2. 对于每一个容器对象, 找到所有其引用的对象, 将被引用对象的gc_refs值减1.
  3. 执行完步骤2以后所有gc_refs值还大于0的对象都被非容器对象引用着, 至少存在一个非循环引用. 因此 不能释放这些对象, 将他们放入另一个集合.
  4. 在步骤3中不能被释放的对象, 如果他们引用着某个对象, 被引用的对象也是不能被释放的, 因此将这些 对象也放入另一个集合中.
  5. 此时还剩下的对象都是无法到达的对象. 现在可以释放这些对象了.

关于分代回收:

除此之外, Python还将所有对象根据’生存时间’分为3代, 从0到2. 所有新创建的对象都分配为第0代. 当这些对象 经过一次垃圾回收仍然存在则会被放入第1代中. 如果第1代中的对象在一次垃圾回收之后仍然存货则被放入第2代. 对于不同代的对象Python的回收的频率也不一样. 可以通过gc.set_threshold(threshold0[, threshold1[, threshold2]]) 来定义. 当Python的垃圾回收器中新增的对象数量减去删除的对象数量大于threshold0时, Python会对第0代对象 执行一次垃圾回收. 每当第0代被检查的次数超过了threshold1时, 第1代对象就会被执行一次垃圾回收. 同理每当 第1代被检查的次数超过了threshold2时, 第2代对象也会被执行一次垃圾回收.

注意,threshold0,threshold1,threshold2的意义并不相同!

为什么要分代呢,这个算法的根源来自于weak generational hypothesis。这个假说由两个观点构成:首先是年亲的对象通常死得也快,比如大量的对象都存在于local作用域;而老对象则很有可能存活更长的时间,比如全局对象,module, class。

垃圾回收的原理就如上面提示,详细的可以看Python源码,只不过事实上垃圾回收器还要考虑__del__,弱引用等情况,会略微复杂一些。

什么时候会触发垃圾回收呢,有三种情况:

  1. 达到了垃圾回收的阈值,Python虚拟机自动执行
  2. 手动调用gc.collect()
  3. Python虚拟机退出的时候

对于垃圾回收,有两个非常重要的术语,那就是reachable与collectable(当然还有与之对应的unreachable与uncollectable),后文也会大量提及。

reachable是针对python对象而言,如果从根集(root)能到找到对象,那么这个对象就是reachable,与之相反就是unreachable,事实上就是只存在于循环引用中的对象,Python的垃圾回收就是针对unreachable对象。

而collectable是针对unreachable对象而言,如果这种对象能被回收,那么是collectable;如果不能被回收,即循环引用中的对象定义了__del__, 那么就是uncollectable。Python垃圾回收对uncollectable对象无能为力,会造成事实上的内存泄露。

gc module

这里的gc(garbage collector)是Python 标准库,该module提供了与上一节“垃圾回收”内容相对应的接口。通过这个module,可以开关gc、调整垃圾回收的频率、输出调试信息。gc模块是很多其他模块(比如objgraph)封装的基础,在这里先介绍gc的核心API。

gc.enable(); gc.disable(); gc.isenabled()

开启gc(默认情况下是开启的);关闭gc;判断gc是否开启

gc.collection() 

执行一次垃圾回收,不管gc是否处于开启状态都能使用

gc.set_threshold(t0, t1, t2); gc.get_threshold()

设置垃圾回收阈值; 获得当前的垃圾回收阈值

注意:gc.set_threshold(0)也有禁用gc的效果

gc.get_objects()

返回所有被垃圾回收器(collector)管理的对象。这个函数非常基础!只要python解释器运行起来,就有大量的对象被collector管理,因此,该函数的调用比较耗时!

比如,命令行启动python

Python

 

1

2

3

>>> import gc

>>> len(gc.get_objects())

3749

gc.get_referents(*obj)

返回obj对象直接指向的对象

gc.get_referrers(*obj)

返回所有直接指向obj的对象

下面的实例展示了get_referents与get_referrers两个函数

Python

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

>>> class OBJ(object):

 

... pass

...

>>> a, b = OBJ(), OBJ()

>>> hex(id(a)), hex(id(b))

('0x250e730', '0x250e7f0')

 

 

>>> gc.get_referents(a)

[]

>>> a.attr = b

>>> gc.get_referents(a)

[{&#39;attr&#39;: <__main__.OBJ object at 0x0250E7F0>}, ]

>>> gc.get_referrers(b)

[{&#39;attr&#39;: <__main__.OBJ object at 0x0250E7F0>}, {&#39;a&#39;: <__main__.OBJ object at 0x0250E730>, &#39;b&#39;: <__main__.OBJ object at 0x0250E7F0>, &#39;OBJ&#39;: , &#39;__builtins__&#39;:

le &#39;__builtin__&#39; (built-in)>, &#39;__package__&#39;: None, &#39;gc&#39;: , &#39;__name__&#39;: &#39;__main__&#39;, &#39;__doc__&#39;: None}]

>>>

a, b都是类OBJ的实例&#xff0c;执行”a.attr &#61; b”之后&#xff0c;a就通过‘’attr“这个属性指向了b。

gc.set_debug(flags)

设置调试选项&#xff0c;非常有用&#xff0c;常用的flag组合包含以下

gc.DEBUG_COLLETABLE&#xff1a; 打印可以被垃圾回收器回收的对象

gc.DEBUG_UNCOLLETABLE&#xff1a; 打印无法被垃圾回收器回收的对象&#xff0c;即定义了__del__的对象

gc.DEBUG_SAVEALL&#xff1a;当设置了这个选项&#xff0c;可以被拉起回收的对象不会被真正销毁&#xff08;free&#xff09;&#xff0c;而是放到gc.garbage这个列表里面&#xff0c;利于在线上查找问题


内存泄露

既然Python中通过引用计数和垃圾回收来管理内存&#xff0c;那么什么情况下还会产生内存泄露呢&#xff1f;有两种情况&#xff1a;

第一是对象被另一个生命周期特别长的对象所引用&#xff0c;比如网络服务器&#xff0c;可能存在一个全局的单例ConnectionManager&#xff0c;管理所有的连接Connection&#xff0c;如果当Connection理论上不再被使用的时候&#xff0c;没有从ConnectionManager中删除&#xff0c;那么就造成了内存泄露。

第二是循环引用中的对象定义了__del__函数&#xff0c;这个在《程序员必知的Python陷阱与缺陷列表》一文中有详细介绍&#xff0c;简而言之&#xff0c;如果定义了__del__函数&#xff0c;那么在循环引用中Python解释器无法判断析构对象的顺序&#xff0c;因此就不错处理。

在任何环境&#xff0c;不管是服务器&#xff0c;客户端&#xff0c;内存泄露都是非常严重的事情。

如果是线上服务器&#xff0c;那么一定得有监控&#xff0c;如果发现内存使用率超过设置的阈值则立即报警&#xff0c;尽早发现些许还有救。当然&#xff0c;谁也不希望在线上修复内存泄露&#xff0c;这无疑是给行驶的汽车换轮子&#xff0c;因此尽量在开发环境或者压力测试环境发现并解决潜在的内存泄露。在这里&#xff0c;发现问题最为关键&#xff0c;只要发现了问题&#xff0c;解决问题就非常容易了&#xff0c;因为按照前面的说法&#xff0c;出现内存泄露只有两种情况&#xff0c;在第一种情况下&#xff0c;只要在适当的时机解除引用就可以了&#xff1b;在第二种情况下&#xff0c;要么不再使用__del__函数&#xff0c;换一种实现方式&#xff0c;要么解决循环引用。

那么怎么查找哪里存在内存泄露呢&#xff1f;武器就是两个库&#xff1a;gc、objgraph

在上面已经介绍了gc这个模块&#xff0c;理论上&#xff0c;通过gc模块能够拿到所有的被garbage collector管理的对象&#xff0c;也能知道对象之间的引用和被引用关系&#xff0c;就可以画出对象之间完整的引用关系图。但事实上还是比较复杂的&#xff0c;因为在这个过程中一不小心又会引入新的引用关系&#xff0c;所以&#xff0c;有好的轮子就直接用吧&#xff0c;那就是objgraph。

objgraph

objgraph的实现调用了gc的这几个函数&#xff1a;gc.get_objects(), gc.get_referents(), gc.get_referers()&#xff0c;然后构造出对象之间的引用关系。objgraph的代码和文档都写得比较好&#xff0c;建议一读。

下面先介绍几个十分实用的API

def count(typename)

返回该类型对象的数目&#xff0c;其实就是通过gc.get_objects()拿到所用的对象&#xff0c;然后统计指定类型的数目。

def by_type(typename)

返回该类型的对象列表。线上项目&#xff0c;可以用这个函数很方便找到一个单例对象

def show_most_common_types(limits &#61; 10)

打印实例最多的前N&#xff08;limits&#xff09;个对象&#xff0c;这个函数非常有用。在《Python内存优化》一文中也提到&#xff0c;该函数能发现可以用slots进行内存优化的对象

def show_growth()

统计自上次调用以来增加得最多的对象&#xff0c;这个函数非常有利于发现潜在的内存泄露。函数内部调用了gc.collect()&#xff0c;因此即使有循环引用也不会对判断造成影响。

值得一提&#xff0c;该函数的实现非常有意思&#xff0c;简化后的代码如下&#xff1a;

 

1

2

3

4

5

6

7

8

9

10

11

def show_growth(limit&#61;10, peak_stats&#61;{}, shortnames&#61;True, file&#61;None):

    gc.collect()

    stats &#61; typestats(shortnames&#61;shortnames)

    deltas &#61; {}

    for name, count in iteritems(stats):

        old_count &#61; peak_stats.get(name, 0)

        if count > old_count:

            deltas[name] &#61; count - old_count

            peak_stats[name] &#61; count

    deltas &#61; sorted(deltas.items(), key&#61;operator.itemgetter(1),

                    reverse&#61;True)

注意形参peak_stats使用了可变参数作为默认形参&#xff0c;这样很方便记录上一次的运行结果。在《程序员必知的Python陷阱与缺陷列表》中提到&#xff0c;使用可变对象做默认形参是最为常见的python陷阱&#xff0c;但在这里&#xff0c;却成为了方便的利器&#xff01;

def show_backrefs()

生产一张有关objs的引用图&#xff0c;看出看出对象为什么不释放&#xff0c;后面会利用这个API来查内存泄露。

该API有很多有用的参数&#xff0c;比如层数限制(max_depth)、宽度限制(too_many)、输出格式控制(filename output)、节点过滤(filter, extra_ignore)&#xff0c;建议使用之间看一些document。

def find_backref_chain(obj, predicate, max_depth&#61;20, extra_ignore&#61;()):

找到一条指向obj对象的最短路径&#xff0c;且路径的头部节点需要满足predicate函数 &#xff08;返回值为True&#xff09;

可以快捷、清晰指出 对象的被引用的情况&#xff0c;后面会展示这个函数的威力

def show_chain():

将find_backref_chain 找到的路径画出来, 该函数事实上调用show_backrefs&#xff0c;只是排除了所有不在路径中的节点。

查找内存泄露

在这一节&#xff0c;介绍如何利用objgraph来查找内存是怎么泄露的

如果我们怀疑一段代码、一个模块可能会导致内存泄露&#xff0c;那么首先调用一次obj.show_growth()&#xff0c;然后调用相应的函数&#xff0c;最后再次调用obj.show_growth()&#xff0c;看看是否有增加的对象。比如下面这个简单的例子&#xff1a;

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

# -*- coding: utf-8 -*-

import objgraph

 

_cache &#61; []

 

class OBJ(object):

    pass

 

def func_to_leak():

    o  &#61; OBJ()

    _cache.append(o)

    # do something with o, then remove it from _cache

 

    if True: # this seem ugly, but it always exists

        return

    _cache.remove(o)

 

if __name__ &#61;&#61; &#39;__main__&#39;:

    objgraph.show_growth()

    try:

        func_to_leak()

    except:

        pass

    print &#39;after call func_to_leak&#39;

    objgraph.show_growth()

运行结果&#xff08;我们只关心后一次show_growth的结果&#xff09;如下

Python

 

1

2

3

4

5

6

wrapper_descriptor 1073 &#43;13

member_descriptor 204 &#43;5

getset_descriptor 168 &#43;5

weakref 338 &#43;3

dict 458 &#43;3

OBJ 1 &#43;1

代码很简单&#xff0c;函数开始的时候讲对象加入了global作用域的_cache列表&#xff0c;然后期望是在函数退出之前从_cache删除&#xff0c;但是由于提前返回或者异常&#xff0c;并没有执行到最后的remove语句。从运行结果可以发现&#xff0c;调用函数之后&#xff0c;增加了一个类OBJ的实例&#xff0c;然而理论上函数调用结束之后&#xff0c;所有在函数作用域&#xff08;local&#xff09;中声明的对象都改被销毁&#xff0c;因此这里就存在内存泄露。

当然&#xff0c;在实际的项目中&#xff0c;我们也不清楚泄露是在哪段代码、哪个模块中发生的&#xff0c;而且往往是发生了内存泄露之后再去排查&#xff0c;这个时候使用obj.show_most_common_types就比较合适了&#xff0c;如果一个自定义的类的实例数目特别多&#xff0c;那么就可能存在内存泄露。如果在压力测试环境&#xff0c;停止压测&#xff0c;调用gc.collet&#xff0c;然后再用obj.show_most_common_types查看&#xff0c;如果对象的数目没有相应的减少&#xff0c;那么肯定就是存在泄露。

当我们定位了哪个对象发生了内存泄露&#xff0c;那么接下来就是分析怎么泄露的&#xff0c;引用链是怎么样的&#xff0c;这个时候就该show_backrefs出马了&#xff0c;还是以之前的代码为例&#xff0c;稍加修改&#xff1a;

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

import objgraph

 

_cache &#61; []

 

class OBJ(object):

    pass

 

def func_to_leak():

    o  &#61; OBJ()

    _cache.append(o)

    # do something with o, then remove it from _cache

 

    if True: # this seem ugly, but it always exists

        return

    _cache.remove(o)

 

if __name__ &#61;&#61; &#39;__main__&#39;:

    try:

        func_to_leak()

    except:

        pass

    objgraph.show_backrefs(objgraph.by_type(&#39;OBJ&#39;)[0], max_depth &#61; 10, filename &#61; &#39;obj.dot&#39;)

show_backrefs查看内存泄露

注意&#xff0c;上面的代码中&#xff0c;max_depth参数非常关键&#xff0c;如果这个参数太小&#xff0c;那么看不到完整的引用链&#xff0c;如果这个参数太大&#xff0c;运行的时候又非常耗时间。

然后打开dot文件&#xff0c;结果如下

1089769-20170919090908056-1998847597

可以看到泄露的对象&#xff08;红框表示&#xff09;&#xff0c;是被一个叫_cache的list所引用&#xff0c;而_cache又是被__main__这个module所引用。

对于示例代码&#xff0c;dot文件的结果已经非常清晰&#xff0c;但是对于真实项目&#xff0c;引用链中的节点可能成百上千&#xff0c;看起来非常头大&#xff0c;下面用tornado起一个最最简单的web服务器&#xff08;代码不知道来自哪里&#xff0c;且没有内存泄露&#xff0c;这里只是为了显示引用关系&#xff09;&#xff0c;然后绘制socket的引用关关系图&#xff0c;代码和引用关系图如下&#xff1a;

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

import objgraph

import errno

import functools

import tornado.ioloop

import socket

 

def connection_ready(sock, fd, events):

    while True:

        try:

            connection, address &#61; sock.accept()

            print &#39;connection_ready&#39;, address

        except socket.error as e:

            if e.args[0] not in (errno.EWOULDBLOCK, errno.EAGAIN):

                raise

            return

        connection.setblocking(0)

        # do sth with connection

 

 

if __name__ &#61;&#61; &#39;__main__&#39;:

    sock &#61; socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)

    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

    sock.setblocking(0)

    sock.bind(("", 8888))

    sock.listen(128)

 

    io_loop &#61; tornado.ioloop.IOLoop.current()

    callback &#61; functools.partial(connection_ready, sock)

    io_loop.add_handler(sock.fileno(), callback, io_loop.READ)

    #objgraph.show_backrefs(sock, max_depth &#61; 10, filename &#61; &#39;tornado.dot&#39;)

    # objgraph.show_chain(

    #     objgraph.find_backref_chain(

    #         sock,

    #         objgraph.is_proper_module

    #     ),

    #     filename&#61;&#39;obj_chain.dot&#39;

    # )

    io_loop.start()

 

tornado_server实例

1089769-20170919090908056-1998847597

可见&#xff0c;代码越复杂&#xff0c;相互之间的引用关系越多&#xff0c;show_backrefs越难以看懂。这个时候就使用show_chain和find_backref_chain吧&#xff0c;这种方法&#xff0c;在官方文档也是推荐的&#xff0c;我们稍微改改代码&#xff0c;结果如下&#xff1a;

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

import objgraph

 

_cache &#61; []

 

class OBJ(object):

    pass

 

def func_to_leak():

    o  &#61; OBJ()

    _cache.append(o)

    # do something with o, then remove it from _cache

 

    if True: # this seem ugly, but it always exists

        return

    _cache.remove(o)

 

if __name__ &#61;&#61; &#39;__main__&#39;:

    try:

        func_to_leak()

    except:

        pass

    # objgraph.show_backrefs(objgraph.by_type(&#39;OBJ&#39;)[0], max_depth &#61; 10, filename &#61; &#39;obj.dot&#39;)

    objgraph.show_chain(

        objgraph.find_backref_chain(

            objgraph.by_type(&#39;OBJ&#39;)[0],

            objgraph.is_proper_module

        ),

        filename&#61;&#39;obj_chain.dot&#39;

    )

1089769-20170919090908056-1998847597

上面介绍了内存泄露的第一种情况&#xff0c;对象被“非期望”地引用着。下面看看第二种情况&#xff0c;循环引用中的__del__&#xff0c; 看下面的代码&#xff1a;

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

# -*- coding: utf-8 -*-

import objgraph, gc

class OBJ(object):

    def __del__(self):

        print(&#39;Dangerous!&#39;)

 

def show_leak_by_del():

    a, b &#61; OBJ(), OBJ()

    a.attr_b &#61; b

    b.attr_a &#61; a

 

    del a, b

    print gc.collect()

 

    objgraph.show_backrefs(objgraph.by_type(&#39;OBJ&#39;)[0], max_depth &#61; 10, filename &#61; &#39;del_obj.dot&#39;)

上面的代码存在循环引用&#xff0c;而且OBJ类定义了__del__函数。如果没有定义__del__函数&#xff0c;那么上述的代码会报错&#xff0c; 因为gc.collect会将循环引用删除&#xff0c;objgraph.by_type(‘OBJ’)返回空列表。而因为定义了__del__函数&#xff0c;gc.collect也无能为力&#xff0c;结果如下&#xff1a;

1089769-20170919090908056-1998847597

从图中可以看到&#xff0c;对于这种情况&#xff0c;还是比较好辨识的&#xff0c;因为objgraph将__del__函数用特殊颜色标志出来&#xff0c;一眼就看见了。另外&#xff0c;可以看见gc.garbage&#xff08;类型是list&#xff09;也引用了这两个对象&#xff0c;原因在document中有描述&#xff0c;当执行垃圾回收的时候&#xff0c;会将定义了__del__函数的类实例&#xff08;被称为uncollectable object&#xff09;放到gc.garbage列表&#xff0c;因此&#xff0c;也可以直接通过查看gc.garbage来找出定义了__del__的循环引用。在这里&#xff0c;通过增加extra_ignore来排除gc.garbage的影响&#xff1a;

将上述代码的最后一行改成&#xff1a;

Python

 

1

  objgraph.show_backrefs(objgraph.by_type(&#39;OBJ&#39;)[0], extra_ignore&#61;(id(gc.garbage),),  max_depth &#61; 10, filename &#61; &#39;del_obj.dot&#39;)

1089769-20170919090908056-1998847597

另外&#xff0c;也可以设置DEBUG_UNCOLLECTABLE 选项&#xff0c;直接将uncollectable对象输出到标准输出&#xff0c;而不是放到gc.garbage

循环引用

除非定义了__del__方法&#xff0c;那么循环引用也不是什么万恶不赦的东西&#xff0c;因为垃圾回收器可以处理循环引用&#xff0c;而且不准是python标准库还是大量使用的第三方库&#xff0c;都可能存在循环引用。如果存在循环引用&#xff0c;那么Python的gc就必须开启&#xff08;gc.isenabled()返回True&#xff09;&#xff0c;否则就会内存泄露。但是在某些情况下&#xff0c;我们还是不希望有gc&#xff0c;比如对内存和性能比较敏感的应用场景&#xff0c;在这篇文章中&#xff0c;提到instagram通过禁用gc&#xff0c;性能提升了10%&#xff1b;另外&#xff0c;在一些应用场景&#xff0c;垃圾回收带来的卡顿也是不能接受的&#xff0c;比如RPG游戏。从前面对垃圾回收的描述可以看到&#xff0c;执行一次垃圾回收是很耗费时间的&#xff0c;因为需要遍历所有被collector管理的对象&#xff08;即使很多对象不属于垃圾&#xff09;。因此&#xff0c;要想禁用GC&#xff0c;就得先彻底干掉循环引用。

同内存泄露一样&#xff0c;解除循环引用的前提是定位哪里出现了循环引用。而且&#xff0c;如果需要在线上应用关闭gc&#xff0c;那么需要自动、持久化的进行检测。下面介绍如何定位循环引用&#xff0c;以及如何解决循环引用。

定位循环引用

这里还是是用GC模块和objgraph来定位循环引用。需要注意的事&#xff0c;一定要先禁用gc&#xff08;调用gc.disable()&#xff09;&#xff0c; 防止误差。

这里利用之前介绍循环引用时使用过的例子&#xff1a; a&#xff0c; b两个OBJ对象形成循环引用

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

# -*- coding: utf-8 -*-

import objgraph, gc

class OBJ(object):

    pass

 

def show_cycle_reference():

    a, b &#61; OBJ(), OBJ()

    a.attr_b &#61; b

    b.attr_a &#61; a

 

if __name__ &#61;&#61; &#39;__main__&#39;:

    gc.disable()

    for _ in xrange(50):

        show_cycle_reference()

    objgraph.show_most_common_types(20)

运行结果&#xff08;部分&#xff09;&#xff1a;

Python

 

1

2

3

wrapper_descriptor 1060

dict 555

OBJ 100

上面的代码中使用的是show_most_common_types&#xff0c;而没有使用show_growth&#xff08;因为growth会手动调用gc.collect()&#xff09;&#xff0c;通过结果可以看到&#xff0c;内存中现在有100个OBJ对象&#xff0c;符合预期。当然这些OBJ对象没有在函数调用后被销毁&#xff0c;不一定是循环引用的问题&#xff0c;也可能是内存泄露&#xff0c;比如前面OBJ对象被global作用域中的_cache引用的情况。怎么排除是否是被global作用域的变量引用的情况呢&#xff0c;方法还是objgraph.find_backref_chain(obj)&#xff0c;在__doc__中指出&#xff0c;如果找不到符合条件的应用链&#xff08;chain&#xff09;&#xff0c;那么返回[obj]&#xff0c;稍微修改上面的代码&#xff1a;

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

# -*- coding: utf-8 -*-

import objgraph, gc

class OBJ(object):

    pass

 

def show_cycle_reference():

    a, b &#61; OBJ(), OBJ()

    a.attr_b &#61; b

    b.attr_a &#61; a

 

if __name__ &#61;&#61; &#39;__main__&#39;:

    gc.disable()

    for _ in xrange(50):

        show_cycle_reference()

    ret &#61; objgraph.find_backref_chain(objgraph.by_type(&#39;OBJ&#39;)[0], objgraph.is_proper_module)

    print ret

上面的代码输出&#xff1a;

Python

 

1

[<__main__.OBJ object at 0x0244F810>]

验证了我们的想法&#xff0c;OBJ对象不是被global作用域的变量所引用。

在实际项目中&#xff0c;不大可能到处用objgraph.show_most_common_types或者objgraph.by_type来排查循环引用&#xff0c;效率太低。有没有更好的办法呢&#xff0c;有的&#xff0c;那就是使用gc模块的debug 选项。在前面介绍gc模块的时候&#xff0c;就介绍了gc.DEBUG_COLLECTABLE 选项&#xff0c;我们来试试&#xff1a;

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

# -*- coding: utf-8 -*-

import gc, time

class OBJ(object):

    pass

 

def show_cycle_reference():

    a, b &#61; OBJ(), OBJ()

    a.attr_b &#61; b

    b.attr_a &#61; a

 

if __name__ &#61;&#61; &#39;__main__&#39;:

    gc.disable() # 这里是否disable事实上无所谓

    gc.set_debug(gc.DEBUG_COLLECTABLE | gc.DEBUG_OBJECTS)

    for _ in xrange(1):

        show_cycle_reference()

    gc.collect()

    time.sleep(5)

上面代码第13行设置了debug flag&#xff0c;可以打印出collectable对象。另外&#xff0c;只用调用一次show_cycle_reference函数就足够了&#xff08;这也比objgraph.show_most_common_types方便一点&#xff09;。在第16行手动调用gc.collect()&#xff0c;输出如下&#xff1a;

Python

 

1

2

3

4

gc: collectable

gc: collectable

gc: collectable

gc: collectable

注意&#xff1a;只有当对象是unreachable且collectable的时候&#xff0c;在collect的时候才会被输出&#xff0c;也就是说&#xff0c;如果是reachable&#xff0c;比如被global作用域的变量引用&#xff0c;那么也是不会输出的。

通过上面的输出&#xff0c;我们已经知道OBJ类的实例存在循环引用&#xff0c;但是这个时候&#xff0c;obj实例已经被回收了。那么如果我想通过show_backrefs找出这个引用关系&#xff0c;需要重新调用show_cycle_reference函数&#xff0c;然后不调用gc.collect&#xff0c;通过show_backrefs 和 by_type绘制。有没有更好的办法呢&#xff0c;可以让我在一次运行中发现循环引用&#xff0c;并找出引用链&#xff1f;答案就是使用DEBUG_SAVEALL&#xff0c;下面为了展示方便&#xff0c;直接在命令行中操作&#xff08;当然&#xff0c;使用ipython更好&#xff09;

Python

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

>>> import gc, objgraph

>>> class OBJ(object):

... pass

...

>>> def show_cycle_reference():

... a, b &#61; OBJ(), OBJ()

... a.attr_b &#61; b

... b.attr_a &#61; a

...

>>> gc.set_debug(gc.DEBUG_SAVEALL| gc.DEBUG_OBJECTS)

>>> show_cycle_reference()

>>> print &#39;before collect&#39;, gc.garbage

before collect []

>>> print gc.collect()

4

>>>

>>> for o in gc.garbage:

... print o

...

<__main__.OBJ object at 0x024BB7D0>

<__main__.OBJ object at 0x02586850>

{&#39;attr_b&#39;: <__main__.OBJ object at 0x02586850>}

{&#39;attr_a&#39;: <__main__.OBJ object at 0x024BB7D0>}

>>>

>>> objgraph.show_backrefs(objgraph.at(0x024BB7D0), 5, filename &#61; &#39;obj.dot&#39;)

Graph written to obj.dot (13 nodes)

>>>

上面在调用gc.collect之前&#xff0c;gc.garbage里面是空的&#xff0c;由于设置了DEBUG_SAVEALL&#xff0c;那么调用gc.collect时&#xff0c;会将collectable对象放到gc.garbage。此时&#xff0c;对象没有被释放&#xff0c;我们就可以直接绘制出引用关系&#xff0c;这里使用了objgraph.at&#xff0c;当然也可以使用objgraph.by_type&#xff0c; 或者直接从gc.garbage取对象&#xff0c;结果如下&#xff1a;

1089769-20170919090908056-1998847597

出了循环引用&#xff0c;可以看见还有两个引用&#xff0c;gc.garbage与局部变量o&#xff0c;相信大家也能理解。

消灭循环引用

找到循环引用关系之后&#xff0c;解除循环引用就不是太难的事情&#xff0c;总的来说&#xff0c;有两种办法&#xff1a;手动解除与使用weakref。

手动解除很好理解&#xff0c;就是在合适的时机&#xff0c;解除引用关系。比如&#xff0c;前面提到的collections.OrderedDict&#xff1a;

Python

 

1

2

3

4

5

6

7

8

9

>>> root &#61; []

>>> root[:] &#61; [root, root, None]

>>>

>>> root

[[...], [...], None]

>>>

>>> del root[:]

>>> root

[]

更常见的情况&#xff0c;是我们自定义的对象之间存在循环引用&#xff1a;要么是单个对象内的循环引用&#xff0c;要么是多个对象间的循环引用&#xff0c;我们看一个单个对象内循环引用的例子&#xff1a;

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

class Connection(object):

    MSG_TYPE_CHAT &#61; 0X01

    MSG_TYPE_CONTROL &#61; 0X02

    def __init__(self):

        self.msg_handlers &#61; {

            self.MSG_TYPE_CHAT : self.handle_chat_msg,

            self.MSG_TYPE_CONTROL : self.handle_control_msg

        }

 

    def on_msg(self, msg_type, *args):

        self.msg_handlers[msg_type](*args)

 

    def handle_chat_msg(self, msg):

        pass

 

    def handle_control_msg(self, msg):

        pass

上面的代码非常常见&#xff0c;代码也很简单&#xff0c;初始化函数中为每种消息类型定义响应的处理函数&#xff0c;当消息到达(on_msg)时根据消息类型取出处理函数。但这样的代码是存在循环引用的&#xff0c;感兴趣的读者可以用objgraph看看引用图。如何手动解决呢&#xff0c;为Connection增加一个destroy&#xff08;或者叫clear&#xff09;函数&#xff0c;该函数将 self.msg_handlers 清空&#xff08;self.msg_handlers.clear()&#xff09;。当Connection理论上不在被使用的时候调用destroy函数即可。

对于多个对象间的循环引用&#xff0c;处理方法也是一样的&#xff0c;就是在“适当的时机”调用destroy函数&#xff0c;难点在于什么是适当的时机

另外一种更方便的方法&#xff0c;就是使用弱引用weakref&#xff0c; weakref是Python提供的标准库&#xff0c;旨在解决循环引用。

weakref模块提供了以下一些有用的API&#xff1a;

&#xff08;1&#xff09;weakref.ref(object, callback &#61; None)

创建一个对object的弱引用&#xff0c;返回值为weakref对象&#xff0c;callback: 当object被删除的时候&#xff0c;会调用callback函数&#xff0c;在标准库logging &#xff08;__init__.py&#xff09;中有使用范例。使用的时候要用()解引用&#xff0c;如果referant已经被删除&#xff0c;那么返回None。比如下面的例子

 

1

2

3

4

5

6

7

8

9

10

11

12

# -*- coding: utf-8 -*-

import weakref

class OBJ(object):

    def f(self):

        print &#39;HELLO&#39;

 

if __name__ &#61;&#61; &#39;__main__&#39;:

    o &#61; OBJ()

    w &#61; weakref.ref(o)

    w().f()

    del o

    w().f()

运行上面的代码&#xff0c;第12行会抛出异常&#xff1a;AttributeError: ‘NoneType’ object has no attribute ‘f’。因为这个时候被引用的对象已经被删除了

&#xff08;2&#xff09;weakref.proxy(object, callback &#61; None)

创建一个代理&#xff0c;返回值是一个weakproxy对象&#xff0c;callback的作用同上。使用的时候直接用 和object一样&#xff0c;如果object已经被删除 那么跑出异常   ReferenceError: weakly-referenced object no longer exists。

 

1

2

3

4

5

6

7

8

9

10

11

12

# -*- coding: utf-8 -*-

import weakref

class OBJ(object):

    def f(self):

        print &#39;HELLO&#39;

 

if __name__ &#61;&#61; &#39;__main__&#39;:

    o &#61; OBJ()

    w &#61; weakref.proxy(o)

    w.f()

    del o

    w.f()

注意第10行 12行与weakref.ref示例代码的区别

&#xff08;3&#xff09;weakref.WeakSet

这个是一个弱引用集合&#xff0c;当WeakSet中的元素被回收的时候&#xff0c;会自动从WeakSet中删除。WeakSet的实现使用了weakref.ref&#xff0c;当对象加入WeakSet的时候&#xff0c;使用weakref.ref封装&#xff0c;指定的callback函数就是从WeakSet中删除。感兴趣的话可以直接看源码&#xff08;_weakrefset.py&#xff09;&#xff0c;下面给出一个参考例子&#xff1a;

 

1

2

3

4

5

6

7

8

9

10

11

12

13

# -*- coding: utf-8 -*-

import weakref

class OBJ(object):

    def f(self):

        print &#39;HELLO&#39;

 

if __name__ &#61;&#61; &#39;__main__&#39;:

    o &#61; OBJ()

    ws &#61; weakref.WeakSet()

    ws.add(o)

    print len(ws) #  1

    del o

    print len(ws) # 0

&#xff08;4&#xff09;weakref.WeakValueDictionary&#xff0c; weakref.WeakKeyDictionary

实现原理和使用方法基本同WeakSet

总结

本文的篇幅略长&#xff0c;首选是简单介绍了python的内存管理&#xff0c;重点介绍了引用计数与垃圾回收&#xff0c;然后阐述Python中内存泄露与循环引用产生的原因与危害&#xff0c;最后是利用gc、objgraph、weakref等工具来分析并解决内存泄露、循环引用问题。

references
  • Garbage Collector Interface
  • objgraph
  • Garbage Collection for Python
  • 禁用Python的GC机制后&#xff0c;Instagram性能提升10%
  • Python内存管理机制及优化简析
  • library weakref

推荐阅读
  • Java容器中的compareto方法排序原理解析
    本文从源码解析Java容器中的compareto方法的排序原理,讲解了在使用数组存储数据时的限制以及存储效率的问题。同时提到了Redis的五大数据结构和list、set等知识点,回忆了作者大学时代的Java学习经历。文章以作者做的思维导图作为目录,展示了整个讲解过程。 ... [详细]
  • 在Docker中,将主机目录挂载到容器中作为volume使用时,常常会遇到文件权限问题。这是因为容器内外的UID不同所导致的。本文介绍了解决这个问题的方法,包括使用gosu和suexec工具以及在Dockerfile中配置volume的权限。通过这些方法,可以避免在使用Docker时出现无写权限的情况。 ... [详细]
  • 本文介绍了C#中生成随机数的三种方法,并分析了其中存在的问题。首先介绍了使用Random类生成随机数的默认方法,但在高并发情况下可能会出现重复的情况。接着通过循环生成了一系列随机数,进一步突显了这个问题。文章指出,随机数生成在任何编程语言中都是必备的功能,但Random类生成的随机数并不可靠。最后,提出了需要寻找其他可靠的随机数生成方法的建议。 ... [详细]
  • 本文介绍了OC学习笔记中的@property和@synthesize,包括属性的定义和合成的使用方法。通过示例代码详细讲解了@property和@synthesize的作用和用法。 ... [详细]
  • sklearn数据集库中的常用数据集类型介绍
    本文介绍了sklearn数据集库中常用的数据集类型,包括玩具数据集和样本生成器。其中详细介绍了波士顿房价数据集,包含了波士顿506处房屋的13种不同特征以及房屋价格,适用于回归任务。 ... [详细]
  • 如何自行分析定位SAP BSP错误
    The“BSPtag”Imentionedintheblogtitlemeansforexamplethetagchtmlb:configCelleratorbelowwhichi ... [详细]
  • GetWindowLong函数
    今天在看一个代码里头写了GetWindowLong(hwnd,0),我当时就有点费解,靠,上网搜索函数原型说明,死活找不到第 ... [详细]
  • Linux服务器密码过期策略、登录次数限制、私钥登录等配置方法
    本文介绍了在Linux服务器上进行密码过期策略、登录次数限制、私钥登录等配置的方法。通过修改配置文件中的参数,可以设置密码的有效期、最小间隔时间、最小长度,并在密码过期前进行提示。同时还介绍了如何进行公钥登录和修改默认账户用户名的操作。详细步骤和注意事项可参考本文内容。 ... [详细]
  • 生成式对抗网络模型综述摘要生成式对抗网络模型(GAN)是基于深度学习的一种强大的生成模型,可以应用于计算机视觉、自然语言处理、半监督学习等重要领域。生成式对抗网络 ... [详细]
  • Iamtryingtomakeaclassthatwillreadatextfileofnamesintoanarray,thenreturnthatarra ... [详细]
  • 在Android开发中,使用Picasso库可以实现对网络图片的等比例缩放。本文介绍了使用Picasso库进行图片缩放的方法,并提供了具体的代码实现。通过获取图片的宽高,计算目标宽度和高度,并创建新图实现等比例缩放。 ... [详细]
  • 云原生边缘计算之KubeEdge简介及功能特点
    本文介绍了云原生边缘计算中的KubeEdge系统,该系统是一个开源系统,用于将容器化应用程序编排功能扩展到Edge的主机。它基于Kubernetes构建,并为网络应用程序提供基础架构支持。同时,KubeEdge具有离线模式、基于Kubernetes的节点、群集、应用程序和设备管理、资源优化等特点。此外,KubeEdge还支持跨平台工作,在私有、公共和混合云中都可以运行。同时,KubeEdge还提供数据管理和数据分析管道引擎的支持。最后,本文还介绍了KubeEdge系统生成证书的方法。 ... [详细]
  • Commit1ced2a7433ea8937a1b260ea65d708f32ca7c95eintroduceda+Clonetraitboundtom ... [详细]
  • 阿,里,云,物,联网,net,core,客户端,czgl,aliiotclient, ... [详细]
  • 本文讨论了一个关于cuowu类的问题,作者在使用cuowu类时遇到了错误提示和使用AdjustmentListener的问题。文章提供了16个解决方案,并给出了两个可能导致错误的原因。 ... [详细]
author-avatar
MR付的世界
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有