Python:exec语句与意外的垃圾回收行为

7 投票
2 回答
779 浏览
提问于 2025-04-16 19:16

我发现了一个关于 exec 的问题(这个问题出现在一个需要支持用户编写脚本的系统中)。我把问题简化成了以下这段代码:

def fn():
    context = {}
    exec '''
class test:
    def __init__(self):
        self.buf = '1'*1024*1024*200
x = test()''' in context

fn()

我原本以为在调用 fn 函数后,内存应该会被垃圾回收器释放掉。然而,Python 进程仍然消耗着额外的 200MB 内存,我完全搞不清楚这是怎么回事,也不知道怎么手动释放这些分配的内存。

我怀疑在 exec 中定义一个类并不是个好主意,但首先,我想弄明白上面这个例子到底出了什么问题。

看起来把类实例的创建放在另一个函数里可以解决这个问题,但这之间有什么区别呢?

def fn():
    context = {}
    exec '''
class test:
    def __init__(self):
        self.buf = '1'*1024*1024*200
def f1(): x = test()
f1()
    ''' in context
fn()

这是我的 Python 解释器版本:

$ python
Python 2.7 (r27:82500, Sep 16 2010, 18:02:00) 
[GCC 4.5.1 20100907 (Red Hat 4.5.1-3)] on linux2

2 个回答

0

我觉得问题和exec没有关系,垃圾回收器就是没有启动。如果把exec里面的代码提取到主程序中,效果和用exec时是一样的:

class test:
    def __init__(self):
        self.buf = '1'*1024*1024*200
x = test()

# Consumes 200MB

class test:
    def __init__(self):
        self.buf = '1'*1024*1024*200
def f1(): x = test()
f1()

# Memory get collected correctly

这两种方法的不同在于,第二种方法在调用f1()时,局部作用域发生了变化。我认为当x超出作用域,函数把控制权交回主脚本时,垃圾回收器就会启动。如果作用域没有变化,垃圾回收器就会等到分配的对象数量和释放的对象数量之间的差值超过它的阈值时才会启动(在我这台机器上,默认阈值是700,运行的是Python 2.7)。

我们可以稍微了解一下发生了什么:

import sys
import gc

class test:
    def __init__(self):
        self.buf = '1'*1024*1024*200
x = test()

print gc.get_count()
# Prints (168, 8, 0)

所以,我们看到垃圾回收器启动了很多次,但不知道为什么没有回收x。如果你用另一个版本测试:

import sys
import gc

class test:
    def __init__(self):
        self.buf = '1'*1024*1024*200
def f1(): x = test()
f1()

print gc.get_count()
# Prints (172, 8, 0)

在这种情况下,我们知道它确实成功回收了x。所以,看起来当x在全局作用域中声明时,它保持了一些循环引用,导致它无法被回收。我们可以使用del x手动强制回收,但这当然不是最理想的办法。如果使用gc.get_referrers(x),我们可以看到还有哪些对象在引用x,也许这能给我们一些线索,帮助我们解决这个问题。

我知道我没有真正解决这个问题,但希望这能帮助你朝着正确的方向前进。我会记住这个问题,以防以后发现什么。

5

你看到程序占用200Mb内存的时间比预期长,主要是因为你有一个引用循环。简单来说,context是一个字典,它同时引用了xtest。而x又引用了test的一个实例,这个实例又引用了testtest里面有一个属性字典test.__dict__,这个字典里包含了类的__init__函数。__init__函数又引用了它定义时的全局变量,也就是你传给exec的字典context

Python会帮你处理这些引用循环(因为参与的对象都没有__del__方法),但需要运行gc.collect()gc.collect()会在每N次分配后自动运行(这个N是由gc.set_threshold()决定的),所以“内存泄漏”会在某个时刻消失。不过,如果你想让它立即消失,可以自己运行gc.collect(),或者在退出函数之前自己打破这个引用循环。你可以通过调用context.clear()轻松做到这一点,但要注意,这样会影响你在这个字典中创建的所有类的实例。

撰写回答