Python 3 Tkinter Treeview 多线程时性能低下

Python 3 Tkinter Treeview Slow Performance while Multithreaded

Tl;dr:从另一个线程向 Treeview 小部件插入 1500 次需要 40-340 秒才能完成,但如果在主线程或根线程中插入,则只需 1.2-1.7 秒即可完成 window太小了,看不到Treeview

我正在做一个涉及神经网络识别游戏图像的小项目,我想弄清楚为什么在识别图像后我的结果 Treeview 更新如此缓慢。我下面的代码是我 运行 遇到的一般问题的 MCVE。

我的应用程序目前训练网络,根据训练模型进行预测,然后在显示这些预测结果时花费的时间比训练和预测显示 1500 个结果所花费的时间长。在我的应用程序和这个例子中,我发现通常填充按钮完成和显示所有结果所需的时间似乎与 window 的高度(或 Treeview 小部件的高度)成正比,这个例子在 40 到 340 秒之间显示我当前机器上的所有 1500 行输出。我尝试使用 cProfile 和 pstats 来确定更具体地导致延迟的原因,但是我对它们仍然缺乏经验,尽管我知道大约 99% 的时间花在了 '{method 'call' of '_tkinter.tkapp' objects}' 和 '{method 'globalsetvar' of '_tkinter.tkapp' objects}',我不知道这些是什么,也不知道如何处理这些信息。

但是,我发现如果在 window 太小以至于无法显示 Treeview 时启动 worker 函数,那么显示所有结果大约需要 1.2 - 1.7 秒。通过观察我的示例中的进度条可以清楚地看到这一点,并且可以看到 window 越小进度条越快。由于它可以在这段时间内显示所有结果这一事实表明(至少对我而言)在插入结果时 Treeview 可见的大部分时间都花在一遍又一遍地渲染文本和更新高度上的滚动条。为此,我一直在尝试找到一种方法来一次插入大量行,或者至少在每次更改后都不会再次呈现 Treeview,但还没有找到任何似乎能够做到这一点的方法。

在尝试创建此 MCVE 时,我发现如果我在主线程上调用 add_entries 函数而不是调用它,则显示所有结果所需的时间同样短(~1.5 秒)在另一个线程上。虽然我认为这可能是一个可行的解决方案,但我很好奇是否有更好的解决方案可用,同时试图获取有关我迄今为止一直难以找到的问题的更多信息。

到目前为止我发现的最接近的是 this discussion 谈论如何使用类似的模块 (GTK3) 有人遇到类似的问题,解决方案是将 Treeview 设置为固定高度模型,但是我找不到关于常规 tkinter Treeview 小部件可用的类似选项的任何信息,而不仅仅是 GTK3,并且很好奇 tkinter 中是否存在这样的选项,或者它是否是其他模块独有的,或者是否有更好的解决方案完全没想到

    import random, threading
    import cProfile, pstats
    from tkinter import *
    from tkinter.font import Font
    from tkinter.ttk import Progressbar, Treeview
    from string import printable
    
    COLS = list(range(10))
    ROWS = 1500
    
    def __main__():
        root = Tk()
        program_window = App(root)
        try:
            root.destroy()
        except TclError:
            pass
    
    class App(Frame):
        def __init__(self, parent=None):
            self.parent = parent
            
            self.bar_int = IntVar(value=0)
            self.tree = Treeview(self.parent, columns=COLS, show="headings")
            self.vsb = Scrollbar(self.parent, orient='vertical', command=self.tree.yview)
            self.bar = Progressbar(self.parent, variable=self.bar_int)
            self.btn = Button(self.parent, text="Populate", command=self.add_entries)
            
            for col in COLS:
                self.tree.heading(col, text=str(col))
                self.tree.column(col, width=Font().measure(str(col)))
            
            self.tree.grid(row=0, column=0, sticky='nsew', columnspan=2)
            self.vsb. grid(row=0, column=2, sticky='nsew')
            self.bar. grid(row=1, column=0, sticky='nsew')
            self.btn. grid(row=1, column=1, sticky='nsew', columnspan=2)
            
            self.parent.columnconfigure(0, weight=1)
            self.parent.rowconfigure(0, weight=1)
            self.parent.geometry('300x200')
            
            self.parent.mainloop()
        
        def add_entries(self):
            worker = threading.Thread(target=self.add_entries_worker)
            worker.start()
        
        def add_entries_worker(self):
            self.tree.delete(*self.tree.get_children())
            self.bar.configure(maximum=ROWS)
            with cProfile.Profile() as profile:
                for i in range(ROWS):
                    self.bar_int.set(i)
                    li = [random.sample(printable, 10) for i in COLS]
                    self.tree.insert('', 'end', values=li)
                ps = pstats.Stats(profile)
                ps.print_stats()
    
    if __name__ == "__main__":
        __main__()

更新:在 hussic 的建议下,在与朋友讨论了这个问题后,我调查了 root.after() 和 Queue。根据 hussic 的建议,我能够使我当前的代码在一定程度上工作,但它似乎仍然不稳定,我不确定我是否已按我应该的方式完成了所有工作。下面是我添加的新 refresher() 函数,以及对 add_entries() 函数的修改。 add_entries_worker() 刚刚删除了对 tkinter 小部件的所有引用,并将其插入到树中替换为附加到 self.data_queue.

    def refresher(self):
        # while loop so I can pop data out of the list one at a time rather than
        # for loop and potentially delete unprocessed data when clearing the list
        while self.data_queue:
            i, item = self.data_queue.pop(0)
            self.bar_int.set(i)
            self.tree.insert('', 'end', values=item)
        
        if self.btn['state'] == 'disabled':
            self.parent.after(100, self.refresher)
    
    def add_entries(self):
        self.btn.configure(state=DISABLED)
        self.tree.delete(*self.tree.get_children())
        self.bar.configure(maximum=ROWS)
        self.refresher()
        
        worker = threading.Thread(target=self.add_entries_worker)
        worker.start()

我为 tkinter 线程 Tkworker 添加了一个 class,它使用 Queue 和 after()。要完成的工作被分成块(参见:self.chunksize=500)。打印一些东西到终端以检查顺序。

import random, threading, queue
from tkinter import Tk, TclError, Frame, IntVar, Scrollbar, Button
from tkinter.font import Font
from tkinter.ttk import Progressbar, Treeview
from string import printable

COLS = list(range(10))
ROWS = 1500


def __main__():
    root = Tk()
    program_window = App(root)
    try:
        root.destroy()
    except TclError:
        pass


class Tkworker:
    Empty = queue.Empty

    def __init__(self, root, producer, consumer, ms=200):
        self.root = root
        self.consumer = consumer
        self.ms = ms
        self.queue = queue.Queue()  # type: queue.Queue
        self.thread = threading.Thread(target=producer)

    def start(self):
        self.stop = False
        self.thread.start()
        self._consumer_call()

    def put(self, item):
        self.queue.put(item)

    def get(self):
        return self.queue.get(False)

    def _consumer_call(self):
        self.consumer()
        if not self.stop:
            self.root.after(self.ms, self._consumer_call)


class App(Frame):

    def __init__(self, parent=None):
        self.parent = parent

        self.bar_int = IntVar(value=0)
        self.tree = Treeview(self.parent, columns=COLS, show="headings")
        self.vsb = Scrollbar(self.parent, orient='vertical', command=self.tree.yview)
        self.bar = Progressbar(self.parent, variable=self.bar_int)
        self.btn = Button(self.parent, text="Populate", command=self.start_entries)

        for col in COLS:
            self.tree.heading(col, text=str(col))
            self.tree.column(col, width=Font().measure(str(col)))

        self.tree.grid(row=0, column=0, sticky='nsew', columnspan=2)
        self.vsb. grid(row=0, column=2, sticky='nsew')
        self.bar. grid(row=1, column=0, sticky='nsew')
        self.btn. grid(row=1, column=1, sticky='nsew', columnspan=2)

        self.parent.columnconfigure(0, weight=1)
        self.parent.rowconfigure(0, weight=1)
        self.parent.geometry('300x200')

        self.parent.mainloop()

    def start_entries(self):
        self.chunksize = 500
        self.tkwr = Tkworker(self.parent, self.worker, self.add_entries, ms=100)
        self.tree.delete(*self.tree.get_children())
        self.bar.configure(maximum=ROWS)
        self.num = 0
        self.tkwr.start()

    def worker(self):
        chunk = []
        for i in range(ROWS):
            chunk.append([random.sample(printable, 10) for _ in COLS])
            if  i % self.chunksize == 0:
                self.tkwr.put(chunk)
                chunk = []
        if chunk:
            self.tkwr.put(chunk)
        print('worker end')

    def add_entries(self):
        try:
            chunk = self.tkwr.get()
            for li in chunk:
                self.tree.insert('', 'end', values=li)
            self.num += len(chunk)
            self.bar_int.set(self.num)
        except self.tkwr.Empty:
            if self.num == ROWS:
                self.tkwr.stop = True
                print('stop')


if __name__ == "__main__":
    __main__()