尝试修复tkinter GUI冻结(使用线程)

时间:2018-11-28 18:20:17

标签: python python-3.x multithreading tkinter subprocess

我有一个Python 3.x报表创建器,该报表创建器受I / O约束(由于SQL,而不是python),因此在报表创建过程中主窗口将“锁定” 分钟创建。

所有需要做的就是能够在GUI处于锁定状态时使用标准的窗口操作(移动,调整大小/最小化,关闭等)(GUI上的所有其他内容都可以保持“冻结”状态,直到所有报告都已冻结)。完成)。

添加了20181129:换句话说,tkinter必须仅控制应用程序窗口的内容,并将所有标准(外部)窗口控件的处理留给操作系统。如果可以的话,我的问题就消失了,并且我不需要全部使用线程/子进程(冻结成为可接受的行为,类似于禁用“执行报告”按钮)。

执行此操作的最简单/最简单的方法(=对现有代码的最小干扰)是什么?理想情况下,该方法应与Python> = 3.2.2一起使用,并且应采用跨平台的方式(即至少可以在Windows和linux上运行)。


下面的所有内容都是支持性信息,可以更详细地说明问题,尝试的方法以及遇到的一些细微问题。

注意事项:

  • 用户选择他们的报告,然后在主窗口上按“创建报告”按钮(当实际工作开始并且冻结时)。完成所有报告后,报告创建代码将显示一个(顶级)“完成”窗口。关闭此窗口将启用主窗口中的所有内容,从而允许用户退出程序或创建更多报告。

  • 添加了20181129:显然,我可以以随机的间隔(相隔几秒钟)移动窗口。

  • 除了显示“完成”窗口外,报告创建代码不以任何方式涉及GUI或tkinter。

  • 由报表创建代码生成的某些数据必须出现在“完成”窗口中。

  • 没有理由“并行化”报告的创建,尤其是因为使用相同的SQL Server和数据库来创建所有报告。

  • 以防影响解决方案:创建每个报告时,我最终需要在GUI上显示报告名称(现在显示在控制台上)。

  • 第一次使用python进行线程处理/子处理,但同时熟悉其他语言。

  • 添加了20181129:开发环境是Win 10上使用Eclipse Oxygen(pydev插件)的64位Python 3.6.4。应用程序必须至少可移植到linux。


最简单的答案似乎是使用线程。仅需要一个附加线程(用于创建报告的那个)。受影响的行:

DoChosenReports()  # creates all reports (and the "Done" window)

更改为:

from threading import Thread

CreateReportsThread = Thread( target = DoChosenReports )
CreateReportsThread.start()
CreateReportsThread.join()  # 20181130: line omitted in original post, comment out to unfreeze GUI 

成功生成报告,并在创建报告时将其名称显示在控制台上。
但是,GUI保持冻结状态,“ Done”窗口(现在由新线程调用)从不出现。这使用户陷入困境,无法做任何事情, 想知道发生了什么(如果有的话)(这就是为什么我想在创建文件名时在GUI上显示它们)。

顺便说一句,在完成报告之后,报告创建线程必须在显示“完成”窗口之前(或之后)悄悄地自杀。

我也尝试使用

from multiprocessing import Process

ReportCreationProcess = Process( target = DoChosenReports )
ReportCreationProcess.start()

但这与主程序“ if(__name__ =='__main__):'”测试相抵触。


添加了20181129:刚刚发现了“ waitvariable”通用小部件方法(请参见http://infohost.nmt.edu/tcc/help/pubs/tkinter/web/universal.html)。基本思想是将创建报告代码作为由该方法控制的永久执行线程(守护进程?)启动(执行由GUI上的“执行报告”按钮控制)。


根据网络研究,我知道所有tkinter动作都应在主(父)线程内进行, 表示我必须将“完成”窗口移至该线程。
我还需要该窗口来显示它从“子”线程接收的一些数据(三个字符串)。我正在考虑使用使用应用程序级全局变量作为信号量(仅由create report线程写入,而仅由主程序读取)以传递数据。我知道,使用两个以上的线程可能会有风险,但为我的简单情况做更多的事情(例如使用队列?)似乎有点过头了。


总结:允许用户在由于任何原因冻结窗口时对其进行操作(移动,调整大小,最小化等)的最简单方法是什么?换句话说,O / S,而不是tkinter,必须控制主窗口的框架(外部)。
答案需要以跨平台方式(至少在Windows和linux上)在python 3.2.2+上工作

3 个答案:

答案 0 :(得分:1)

您将需要两个函数:第一个封装了程序的长时间运行的工作,第二个创建了处理第一个函数的线程。如果您需要线程在用户仍在运行时关闭程序时立即停止线程(不建议),请使用daemon标志或查看Event对象。如果您不希望用户在功能完成之前再次调用该功能,请在启动时禁用该按钮,然后在最后将其设置回正常状态。

import threading
import tkinter as tk
import time

class App:
    def __init__(self, parent):
        self.button = tk.Button(parent, text='init', command=self.begin)
        self.button.pack()
    def func(self):
        '''long-running work'''
        self.button.config(text='func')
        time.sleep(1)
        self.button.config(text='continue')
        time.sleep(1)
        self.button.config(text='done')
        self.button.config(state=tk.NORMAL)
    def begin(self):
        '''start a thread and connect it to func'''
        self.button.config(state=tk.DISABLED)
        threading.Thread(target=self.func, daemon=True).start()

if __name__ == '__main__':
    root = tk.Tk()
    app = App(root)
    root.mainloop()

答案 1 :(得分:0)

我从其中一本书中找到了一个与您想做的事情类似的好例子,我认为这是在tkinter中使用线程的一种好方法。在Alex Martinelli和David Ascher的第一本书 Python Cookbook 中,它是9.6版的将Tkinter和异步I / O与线程结合。该代码是为Python 2.x编写的,但仅需进行少量修改即可在Python 3中工作。

正如我在评论中所说,如果您希望能够与之交互或者只是调整窗口大小或移动窗口,则需要保持GUI事件循环运行。下面的示例代码通过使用Queue将数据从后台处理线程传递到主GUI线程来完成此操作。

Tkinter具有一个称为after()的通用函数,可以使用该函数来计划要经过一定时间后要调用的函数。在下面的代码中,有一个名为periodic_call()的方法,该方法处理队列中的所有数据,然后在短暂的延迟后调用after()来调度对自身的另一个调用,以便继续进行队列数据处理。

由于after()是tkinter的一部分,因此它允许mainloop()继续运行,从而使GUI在这些定期队列检查之间保持“活动”状态。如果需要,它也可以进行tkinter调用来更新GUI(与在单独线程中运行的代码不同)。

from itertools import count
import sys
import tkinter as tk
import tkinter.messagebox as tkMessageBox
import threading
import time
from random import randint
import queue

# Based on example Dialog 
# http://effbot.org/tkinterbook/tkinter-dialog-windows.htm
class InfoMessage(tk.Toplevel):
    def __init__(self, parent, info, title=None, modal=True):
        tk.Toplevel.__init__(self, parent)
        self.transient(parent)
        if title:
            self.title(title)
        self.parent = parent

        body = tk.Frame(self)
        self.initial_focus = self.body(body, info)
        body.pack(padx=5, pady=5)

        self.buttonbox()

        if modal:
            self.grab_set()

        if not self.initial_focus:
            self.initial_focus = self
        self.protocol("WM_DELETE_WINDOW", self.cancel)
        self.geometry("+%d+%d" % (parent.winfo_rootx()+50, parent.winfo_rooty()+50))
        self.initial_focus.focus_set()

        if modal:
            self.wait_window(self)  # Wait until this window is destroyed.

    def body(self, parent, info):
        label = tk.Label(parent, text=info)
        label.pack()
        return label  # Initial focus.

    def buttonbox(self):
        box = tk.Frame(self)
        w = tk.Button(box, text="OK", width=10, command=self.ok, default=tk.ACTIVE)
        w.pack(side=tk.LEFT, padx=5, pady=5)
        self.bind("<Return>", self.ok)
        box.pack()

    def ok(self, event=None):
        self.withdraw()
        self.update_idletasks()
        self.cancel()

    def cancel(self, event=None):
        # Put focus back to the parent window.
        self.parent.focus_set()
        self.destroy()


class GuiPart:
    TIME_INTERVAL = 0.1

    def __init__(self, master, queue, end_command):
        self.queue = queue
        self.master = master
        console = tk.Button(master, text='Done', command=end_command)
        console.pack(expand=True)
        self.update_gui()  # Start periodic GUI updating.

    def update_gui(self):
        try:
            self.master.update_idletasks()
            threading.Timer(self.TIME_INTERVAL, self.update_gui).start()
        except RuntimeError:  # mainloop no longer running.
            pass

    def process_incoming(self):
        """ Handle all messages currently in the queue. """
        while self.queue.qsize():
            try:
                info = self.queue.get_nowait()
                InfoMessage(self.master, info, "Status", modal=False)
            except queue.Empty:  # Shouldn't happen.
                pass


class ThreadedClient:
    """ Launch the main part of the GUI and the worker thread. periodic_call()
        and end_application() could reside in the GUI part, but putting them
        here means all the thread controls are in a single place.
    """
    def __init__(self, master):
        self.master = master
        self.count = count(start=1)
        self.queue = queue.Queue()

        # Set up the GUI part.
        self.gui = GuiPart(master, self.queue, self.end_application)

        # Set up the background processing thread.
        self.running = True
        self.thread = threading.Thread(target=self.workerthread)
        self.thread.start()

        # Start periodic checking of the queue.
        self.periodic_call(200)  # Every 200 ms.

    def periodic_call(self, delay):
        """ Every delay ms process everything new in the queue. """
        self.gui.process_incoming()
        if not self.running:
            sys.exit(1)
        self.master.after(delay, self.periodic_call, delay)

    # Runs in separate thread - NO tkinter calls allowed.
    def workerthread(self):
        while self.running:
            time.sleep(randint(1, 10))  # Time-consuming processing.
            count = next(self.count)
            info = 'Report #{} created'.format(count)
            self.queue.put(info)

    def end_application(self):
        self.running = False  # Stop queue checking.
        self.master.quit()


if __name__ == '__main__':  # Needed to support multiprocessing.
    root = tk.Tk()
    root.title('Report Generator')
    root.minsize(300, 100)
    client = ThreadedClient(root)
    root.mainloop()  # Display application window and start tkinter event loop.

答案 2 :(得分:0)

我修改了问题,以包括意外省略但很关键的一行。避免GUI冻结的答案非常简单:

Don't call ".join()" after launching the thread.

除上述之外,完整的解决方案还包括:

  • 在“创建报告”线程完成之前禁用“执行报告”按钮(从技术上讲不是必需的,但防止额外的报告创建线程也可以防止用户混淆);
  • 拥有“创建报告”线程会使用以下事件更新主线程:
    • “已完成的报告X”(在GUI上显示进度的增强功能),并且
    • “已完成所有报告”(显示“完成”窗口并重新启用“执行报告”按钮);
  • 将“完成”窗口的调用移动到由上述事件调用的主线程;和
  • 通过事件传递数据,而不使用共享的全局变量。

使用multiprocessing.dummy模块(从3.0和2.6开始可用)的一种简单方法是:

    from multiprocessing.dummy import Process

    ReportCreationProcess = Process( target = DoChosenReports )
    ReportCreationProcess.start()
再次

,请注意没有.join()行。

作为临时黑客,创建报告线程仍会在退出之前立即创建“完成”窗口。可以,但是会导致运行时错误:

RuntimeError: Calling Tcl from different appartment  

但是该错误似乎并未引起问题。并且,正如其他问题所指出的那样,可以通过将“ DONE”窗口的创建移至主线程中来消除错误(并使创建报告线程发送事件以“启动”该窗口)。

最后,我要感谢@ TigerhawkT3(他们很好地概述了我所采用的方法)和@martineau,他们介绍了如何处理更一般的情况,并提供了对看起来有用的资源的参考。这两个答案都值得一读。