为什么 tkinter 在销毁实例时不释放内存?

Why doesn't tkinter release memory when an instance is destroyed?

我想要一种快速而肮脏的方式来获取一些文件名而无需输入 shell,所以我有以下代码:

from tkinter.filedialog import askopenfile

file = askopenfile()

现在一切正常,但它确实创建了一个需要关闭的多余 tkinter GUI。我知道我可以这样做来抑制它:

import tkinter as tk
tk.Tk().withdraw()    

但不代表后面没有装。这只是意味着现在有一个我无法 close/destroy.

Tk() 对象

这让我想到了真正的问题。

似乎每次我创建一个Tk(),不管我是del还是destroy(),内存都没有释放。见下文:

import tkinter as tk
import os, psutil
process = psutil.Process(os.getpid())
def mem(): print(f'{process.memory_info().rss:,}')

# initial memory usage
mem()

# 21,475,328
for i in range(20):
    root.append(tk.Tk())
    root[-1].destroy()
    mem()

# 24,952,832
# 26,251,264
# ...
# 47,591,424
# 48,865,280

# try deleting the root instead

del root
mem()

# 50,819,072

正如所见,即使在 Tk() 的每个实例都被销毁并删除 root 之后,python 也不会释放使用量。然而,其他对象并非如此:

class Foo():
    def __init__(self):
        # create a list that takes up approximately the same size as a Tk() on average
        self.lst = list(range(11500))    

for i in range(20):
    root.append(Foo())
    del root[-1]
    mem()

# 52,162,560
# 52,162,560
# ...
# 52,162,560

所以我的问题是,为什么 Tk() 和我的 Foo() 不一样,为什么 destroying/deleting Tk() 没有释放占用的内存起来了吗?

有什么明显的我错过了吗?我的测试是否不足以证实我的怀疑?我在这里和 Google 进行了搜索,但几乎找不到答案。

编辑:以下是我根据评论中的建议尝试过(但失败了)的其他一些方法:

# Force garbage collection
import gc
gc.collect()

# quit() method
root.quit()

# delete the entire tkinter reference
del tk

这里有三个问题,一个是tkinter的错,一个是你的错,一个是按预期运行。

三个问题是:

  1. tkinter 创建一个不可检测的引用循环作为注册其清理处理程序的一部分,只有通过显式调用 destroy 才能打破(如果你不这样做,引用循环是 从不清理,资源永久持有)
  2. 即使在 destroy 对象
  3. 之后,您仍坚持 Tk 对象
  4. 小对象堆很少(如果有的话)在程序终止之前 returned 到 OS(内存保留以供将来分配)

问题 #1 意味着您 必须 destroy 您显式创建的任何 Tk 如果有恢复内存的机会。

问题 #2 意味着如果您希望内存可供其他人使用,则必须在创建新引用之前明确删除对 Tk 的任何引用(在 destroy 之后)目的。在某些情况下,您还希望显式设置 tk.NoDefaultRoot() 以防止您创建的第一个 Tk 作为默认根缓存在 tkinter 上(也就是说,显式调用 destroy 在这样的对象上将清除缓存的默认根目录,因此在许多情况下这不会成为问题。

问题 #3 意味着您必须急切地删除引用,而不是等到程序结束才删除您的 root list;如果你等到最后才删除它,是的,内存将被 returned 到堆,但不会到 OS,所以看起来你仍在使用它。但这不是真正的问题;如果 OS 需要 RAM,未使用的内存将被分页到磁盘(它通常在活动页面之前分页空闲页面),并且保留它可以提高大多数代码的性能。

具体来说,看起来 Tk 实例的 .tk 属性没有被清理,即使你明确地 destroy Tk实例。您可以通过更改循环以摆脱对 Tk 对象的最后引用来限制内存增长,或者如果您只想释放低级 C 资源,请在 [=14 之后显式取消链接 .tk =]添加新的 Tk 元素**:

# Not necessary, but avoids caching any Tk as a root when you don't want it
tk.NoDefaultRoot()  

root = []  # Missing in your original code, but I'm assuming it was a plain list
for i in range(20):
    root.append(tk.Tk())
    root[-1].destroy()

    # Either drop the reference to the `Tk` completely:
    root[-1] = None
    # or just drop the reference to its C level worker object
    root[-1].tk = None

    # Optionally, call gc.collect() here to forcibly reclaim memory faster
    # otherwise you're likely to see memory usage grow by a few KB as uncleaned
    # cycles aren't reclaimed in time so we see phantom leaks (that would
    # eventually be cleaned)
    mem()

根据我稍微修改过的脚本的输出,显式清除引用允许清除底层资源:

12,152,832
17,539,072
17,924,096  # At this point, the original code was above 18.8M bytes
17,965,056
17,965,056  # At this point, the original code was above 21.7M bytes
... remains unchanged until end of program if gc.collect() called regularly ...

第一个对象的内存永远不会完全回收这一事实不足为奇。内存分配器很少费心实际 return 操作系统的内存,除非分配 巨大 (大到足以触发模式切换,向 OS 用于与 "small object heap" 分开管理的内存)。否则,他们维护一个不再使用且可以重复使用的内存空闲列表。

这里的 ~6 MB "waste" 可能是一些小的分配,涉及创建 Tk 对象本身和它管理的对象树,而随后 returned 到堆以供重用,不会被 returned 到 OS 直到程序退出(也就是说,如果堆的那部分不再使用,OS 可能如果内存不足,优先将未使用的部分分页到磁盘)。通过注意到内存使用几乎立即稳定下来,您可以看到这种优化有何帮助;新的 tk.Tk() 对象只是重复使用与第一个对象相同的内存(缺乏完全稳定性可能是由于堆碎片导致需要少量额外分配)。

当您创建 Tk 的实例时,您创建的不仅仅是一个小部件。您正在创建一个具有多个属性(嵌入式 tcl 解释器、小部件列表等)的对象。当您执行 root.destroy() 时,您只是在破坏该对象拥有的部分数据。对象本身仍然存在并占用内存。由于您在列表中保留对该对象的引用,因此该对象永远不会被垃圾收集,因此内存会一直存在。

当您使用 root = tk.Tk() 创建根 window 时,您会得到一个对象 (root)。如果您使用 vars 查看该对象的属性,您会看到以下内容:

>>> root = tk.Tk()
>>> vars(root)
{'children': {}, '_tkloaded': 1, 'master': None, '_tclCommands': ['tkerror', 'exit', '4463962184destroy'], 'tk': <_tkinter.tkapp object at 0x10a1d7f30>}

当您调用 root.destroy() 时,您只是在销毁小部件本身(本质上是 _tclCommands 列表中的元素)。对象的其他部分保持不变。

>>> root.destroy()
>>> vars(root)
{'children': {}, '_tkloaded': 1, 'master': None, '_tclCommands': None, 'tk': <_tkinter.tkapp object at 0x10a1d7f30>}

请注意如何将 _tclCommands 设置为 None,但其余属性仍在占用内存。其中之一,tk 占用了大量永远不会被回收的内存。

要完全删除对象,您需要将其删除。在您的情况下,您需要从列表中删除该项目,以便不再有对该对象的任何引用。然后您可以等待垃圾收集器发挥它的魔力,或者您可以显式调用垃圾收集器。

这可能不会回收 100% 的内存,但应该很接近。


综上所述,tkinter 并非设计为以这种方式使用。潜在的期望是您在程序开始时创建 Tk 的单个实例,并在程序退出之前保持该实例存在。

对于您的情况,我建议您在程序开始时创建一次根 window,然后将其隐藏。然后,您可以在整个程序中随心所欲地调用 askopenfile()。如果您想要更通用的东西,请创建一个函数,该函数在第一次调用时创建根 window 并缓存 window 以便它只需创建一次。