事件循环#

在上一章节的末尾,我们解释了如何使用进度条来向用户提供长时间运行操作的反馈。使用进度条本身很简单:调用它的 start 方法,执行你的操作,然后调用它的 stop 方法。不幸的是,你会发现如果你尝试这样做,你的应用程序很可能会完全冻结。

为了理解为什么会这样,我们需要重新回顾一下我们在 Tk 概念章节 中讨论的事件处理机制。正如我们所看到的,构建了应用程序的初始用户界面后,它会进入 Tk 事件循环。事件循环不断处理从系统事件队列中拉取的事件,通常每秒数十次。它监视鼠标或键盘事件,根据需要调用命令回调和事件绑定。

不那么明显的是,所有屏幕更新都只在事件循环中处理。例如,你可能会改变标签小部件的文本。然而,这个变化不会立即出现在屏幕上。相反,该小部件会通知 Tk 它需要被重绘。稍后,在处理其他事件之间,Tk 的事件循环会要求小部件重新绘制自己。所有的绘制只发生在事件循环中。变化似乎立即发生是因为改变小部件和事件循环中的实际重绘之间的时间非常短。

事件循环显示应用程序回调和屏幕更新

阻塞事件循环#

当事件循环被长时间阻止处理事件时,您会碰到问题。您的应用程序不会重新绘制或响应事件,看起来就像被冻结了一样。这就是所谓的事件循环阻塞。这种情况是如何发生的呢?

让我们首先把事件循环想象成一个执行时间轴。在正常情况下,每次从事件循环中偏离(回调、屏幕更新)只需花费几秒钟的时间就会返回控制权给事件循环。

一个表现良好的事件循环的执行时间线

在我们的场景中,整个事件可能始于用户按下按钮。因此,事件循环调用我们的应用程序代码来处理该事件。我们的代码创建了进度条,执行(耗时的)操作,并停止进度条。只有在这之后,我们的代码才将控制权返回给事件循环。在此过程中,没有其他事件被处理,也没有屏幕重绘发生。事件在事件队列中不断累积。

过长的回调阻塞了事件循环

为了防止阻塞事件循环,事件处理程序必须快速执行并迅速将控制权返回给事件循环。

如果你需要进行长时间的操作或像网络 I/O 这类可能耗时较长的任务,你可以考虑几种不同的方法。

小技巧

对于技术倾向较强的开发者来说,Tk 采用的是单线程、事件驱动的编程模型。所有的 GUI 代码、事件循环和你的应用程序都运行在同一个线程中。因此,任何会阻塞事件处理程序的调用或计算都是极力不推荐的。一些GUI工具包使用不同的模型,允许在单独的线程中运行阻塞代码、GUI 和事件处理程序等。试图将这些模型硬套入Tk可能会导致挫败感,并产生脆弱且拼凑的代码。如果你尊重而不是对抗 Tk 的模式,就不会遇到问题。

逐步进行#

如果可能的话,你能做的最好的事情就是将你的操作分解成微小的步骤,每一步都能迅速执行。你让事件循环负责决定下一步何时发生。这样,事件循环就会继续运行,处理常规事件,更新屏幕,并在所有这些过程中,调用你的代码来执行操作的下一步。

为了做到这一点,我们利用定时器事件。我们的程序可以要求事件循环在稍后的时间产生一个这样的事件。作为其常规工作的一部分,当事件循环到达那个时间时,它会回调到我们的代码中以处理该事件。我们的代码将执行操作的下一步。然后它为操作的下一步安排另一个定时器事件,并立即将控制权返回给事件循环。

将大型操作分解为多个小步骤,并通过定时事件将这些步骤串联起来

Tk 的 after 命令可用于生成定时器事件。您需要提供等待触发事件的毫秒数。如果 Tk 正在忙于处理其他事件,实际触发可能会晚于此时间,但绝不会早于这个时间。您还可以要求生成一个空闲事件;当队列中没有其他事件需要处理时,它将被触发。(Tk 的屏幕更新和重绘发生在空闲事件的上下文中。)您可以在参考手册中找到有关 after 的更多详细信息。

在以下示例中,我们将执行一个被细分为20个小步骤的长时间操作。在此操作进行期间,我们将更新进度条并允许用户中断操作。

def start():
    b.configure(text='Stop', command=stop)
    l['text'] = 'Working...'
    global interrupt; interrupt = False
    root.after(1, step)
    
def stop():
    global interrupt; interrupt = True
    
def step(count=0):
    p['value'] = count
    if interrupt:
        result(None)
        return
    root.after(100)  # next step in our operation; don't take too long!
    if count == 20:  # done!
        result(42)
        return
    root.after(1, lambda: step(count+1))
    
def result(answer):
    p['value'] = 0
    b.configure(text='Start!', command=start)
    l['text'] = "Answer: " + str(answer) if answer else "No Answer"
    
f = ttk.Frame(root); f.grid()
b = ttk.Button(f, text="Start!", command=start); b.grid(column=1, row=0, padx=5, pady=5)
l = ttk.Label(f, text="No Answer"); l.grid(column=0, row=0, padx=5, pady=5)
p = ttk.Progressbar(f, orient="horizontal", mode="determinate", maximum=20); 
p.grid(column=0, row=1, padx=5, pady=5)

备注

为了中断这一过程,我们设置了一个全局变量,每次定时器事件触发时都会检查它。另一种选择是取消待处理的定时器事件。当我们创建定时器事件时,它会返回一个唯一的 ID 号来标识这个待处理的定时器。要取消它,我们可以调用 after_cancel 方法,并传递那个唯一的 ID。

你还会发现,我们使用了阻塞形式的 after 来模拟执行我们的操作。在这种形式下,调用会阻塞,等待给定的时间后才返回。它的工作方式与 sleep 系统调用相同。

异步I/O#

计时器事件负责将长时间运行的计算分解成多个步骤,每个步骤都能保证快速完成,以便处理程序返回到事件循环。如果你有一个可能不会快速完成的操作呢?这种情况可能发生在你对操作系统进行多种调用时。最常见的情况是当我们进行某种 I/O 操作时,无论是写入文件、与数据库通信还是从远程 Web 服务器检索数据。

大多数 I/O 调用都是阻塞的,因此它们不会立即返回,直到操作完成(或失败)。相反,我们应该使用非阻塞或异步 I/O 调用。当你进行异步 I/O 调用时,它会立即返回,在操作完成之前。你的代码可以继续运行,或者在这种情况下返回到事件循环。稍后,当 I/O 操作完成时,你的程序会被通知并可以处理 I/O 操作的结果。

如果这听起来像将 I/O 视为另一种类型的事件,那么你完全正确。事实上,这也被称为事件驱动 I/O。

在 Python 中,异步 I/O 由 asyncio 模块及其上层模块提供。

所有 asyncio 应用程序都高度依赖于事件循环。多么方便; Tkinter 有很好的事件循环!不幸的是,asyncio 事件循环和 Tkinter 事件循环不是同一个。你无法在同一个线程中同时运行它们(尽管你可以让一个反复调用另一个,但这非常脆弱)。

我的建议:保持 Tkinter 在主线程中,并在另一个线程中启动你的 asyncio 事件循环。

在主线程中运行的应用程序代码可能需要与在其他线程中运行的 asyncio事件循环协调。你可以使用 asynciocall_soon_threadsafe 方法从 Tkinter 事件循环(例如在小部件回调中)调用运行在 asyncio 事件循环线程中的函数。要从 asyncio 事件循环调用 Tkinter,请继续阅读。

线程或进程#

有时将长时间运行的计算分解为每个步骤都能快速完成的离散部分是不可能的,或者不实际。或者你可能在使用不支持异步操作的库。或者,就像Python 的 asyncio 一样,它与Tk的事件循环不兼容。在这些情况下,为了保持你的Tk GUI响应,你需要将那些耗时的操作或库调用移出事件处理程序,并在其他地方运行它们。线程或甚至其他进程可以帮助实现这一点。

在线程中运行任务、与它们通信等超出了本教程的范围。然而,有一些关于在线程中使用 Tk 的限制你应该了解。主要规则是你必须仅从加载 Tk 的线程中进行 Tk 调用。

Tkinter 内部做了很多工作,以便你可以从多个线程中进行 Tkinter 调用,并将它们路由到主线程(创建 Tk 实例的线程)。它大多能正常工作,但并不总是如此。尽管它尽了最大努力,我还是强烈建议你只从一个线程中进行所有 Tkinter 调用。

如果你需要从另一个线程向运行 Tkinter 的线程发送消息,请尽量简单。使用 event_generate 将虚拟事件发布到 Tkinter 事件队列,然后在你的代码中绑定到该事件。

root.event_generate("<<MyOwnEvent>>")

这可能会更复杂。Tcl/Tk 库可以带或不带线程支持构建。如果你的应用程序中有多个线程,请确保你在线程化构建中运行。如果你不确定,检查 Tcl 变量 tcl_platform(threaded);它应该是 1,而不是 0

>>> tkinter.Tcl().eval('set tcl_platform(threaded)')

备注

大多数人都应该运行线程化构建。在未来,在 Tcl/Tk 中创建非线程化构建的能力可能会消失。如果你在使用非线程化构建的线程化代码,请将其视为应用程序中的一个错误,而不是使其工作的障碍。

嵌套事件处理#

前三种方法是处理长时间运行操作同时保持 Tk GUI 响应的正确方法。它们的共同点是单个事件循环连续处理各种事件。该事件循环将调用你的应用程序代码中的事件处理程序,这些处理程序执行其操作并快速返回。

还有一种方法。在你的长时间运行的操作中,你可以调用事件循环来处理一批事件。你可以用简单的命令 update 来实现这一点。你不需要处理计时器事件或异步 I/O。相反,你只需在操作中散布一些 update 调用。如果你想只保持屏幕重绘而不处理其他事件,还有一个选项可以实现这一点(update_idletasks)。

这种方法诱人且易于实现。如果你幸运的话,它可能有效。至少一段时间内是这样。但迟早,你会遇到严重困难,试图以这种方式做事。某些东西不会更新,事件处理程序不会被调用,事件会丢失或顺序错误,或者更糟。你会把你的程序逻辑搞乱,试图让它再次工作时会抓狂。

备注

当你使用update时,你并不是将控制权返回给正在运行的事件循环。实际上,你是在现有事件循环中嵌套启动了一个新的事件循环。请记住,事件循环遵循单线程执行:没有多线程,也没有协程。如果你不小心,最终会陷入一个事件循环调用另一个事件循环的困境……嗯,你应该明白我的意思。即使你意识到你在这样做,要解开这些事件循环(每个循环可能有不同的终止条件)将是一个有趣的挑战。实际情况不会符合你对简单事件循环逐个独立派发事件的心理模型。这是与Tk模型对抗的典型例子。在非常特定的情况下,可能可以让它工作。但实际上,你是在自找麻烦。不要说我没有警告过你…… 嵌套的事件循环... 正是这种结构导致了混乱