使用asyncio和Tkinter(或另一个GUI库)一起,而不会冻结GUI。

31

我希望在tkinter GUI中结合使用asyncio

我对asyncio还很新,并不是很熟悉。这个例子在单击第一个按钮时启动了10个任务。任务只是模拟几秒钟的工作,使用sleep()实现。

这个例子代码可以在Python 3.6.4rc1 中正常运行。但是问题在于GUI会被冻结。当我按下第一个按钮并开始执行10个asyncio任务时,直到所有任务完成之前我都无法按下GUI中的第二个按钮。我的目标是GUI永远不会冻结。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from tkinter import *
from tkinter import messagebox
import asyncio
import random

def do_freezed():
    """ Button-Event-Handler to see if a button on GUI works. """
    messagebox.showinfo(message='Tkinter is reacting.')

def do_tasks():
    """ Button-Event-Handler starting the asyncio part. """
    loop = asyncio.get_event_loop()
    try:
        loop.run_until_complete(do_urls())
    finally:
        loop.close()

async def one_url(url):
    """ One task. """
    sec = random.randint(1, 15)
    await asyncio.sleep(sec)
    return 'url: {}\tsec: {}'.format(url, sec)

async def do_urls():
    """ Creating and starting 10 tasks. """
    tasks = [
        one_url(url)
        for url in range(10)
    ]
    completed, pending = await asyncio.wait(tasks)
    results = [task.result() for task in completed]
    print('\n'.join(results))


if __name__ == '__main__':
    root = Tk()

    buttonT = Button(master=root, text='Asyncio Tasks', command=do_tasks)
    buttonT.pack()
    buttonX = Button(master=root, text='Freezed???', command=do_freezed)
    buttonX.pack()

    root.mainloop()

一个小问题

...就是因为这个错误,我无法再次运行任务。

Exception in Tkinter callback
Traceback (most recent call last):
  File "/usr/lib/python3.6/tkinter/__init__.py", line 1699, in __call__
    return self.func(*args)
  File "./tk_simple.py", line 17, in do_tasks
    loop.run_until_complete(do_urls())
  File "/usr/lib/python3.6/asyncio/base_events.py", line 443, in run_until_complete
    self._check_closed()
  File "/usr/lib/python3.6/asyncio/base_events.py", line 357, in _check_closed
    raise RuntimeError('Event loop is closed')
RuntimeError: Event loop is closed

多线程

多线程是否是一个可行的解决方案?只有两个线程 - 每个循环都有自己的线程?

编辑:在审查此问题及其相关答案后,发现几乎所有GUI库(例如PygObject / Gtk,wxWidgets,Qt等)都适用。


你的代码在我的电脑上使用Python 3.6.3(Windows系统)可以正常运行。它需要一些时间,但最终会以“url: 3 sec: 6”,“url: 4 sec: 4”等格式打印出10行内容。也许你遇到了一个已经修复的错误(如果你使用的是早期版本的Python)。 - martineau
1
@martineau 在win10上使用3.7.0a2版本,当第二次按下Asyncio Tasks按钮时,我会得到相同的错误。 - Terry Jan Reedy
@martineau 但是在任务运行时GUI会冻结,是吗?这就是问题所在。 关于错误:请提供错误报告的链接。 - buhtz
buhtz:是的,它会暂停一小段时间。如果在它被冻结时我点击了其他按钮,那么当它解冻时,弹出窗口就会出现。我没有具体的错误想法,这也是为什么我说可能是一个错误。 - martineau
1
你的问题类似于这个 one,而我的回答适用于你的设置。你所需要做的就是稍微重构一下程序(如@Terry建议的那样),并正确地绑定你的协程(通过 command/bind)。第一个问题很明显 - 你卡在了内部循环(asyncio)中,而外部循环是不可达的(tkinter),因此GUI处于未响应状态。第二个问题 - 你已经关闭了 asyncio 循环。你可以像 @Terry 建议的那样,在最后一次关闭它,或者每次都创建 一个新的 - CommonSense
8个回答

32
尝试同时运行两个事件循环是一个可疑的建议。然而,由于root.mainloop只是重复调用root.update,因此可以通过将update重复调用作为asyncio任务来模拟mainloop。这是一个这样做的测试程序。我假设将asyncio任务添加到tkinter任务中会起作用。我检查过它仍然可以在3.7.0a2上运行。
"""Proof of concept: integrate tkinter, asyncio and async iterator.

Terry Jan Reedy, 2016 July 25
"""

import asyncio
from random import randrange as rr
import tkinter as tk


class App(tk.Tk):
    
    def __init__(self, loop, interval=1/120):
        super().__init__()
        self.loop = loop
        self.protocol("WM_DELETE_WINDOW", self.close)
        self.tasks = []
        self.tasks.append(loop.create_task(self.rotator(1/60, 2)))
        self.tasks.append(loop.create_task(self.updater(interval)))

    async def rotator(self, interval, d_per_tick):
        canvas = tk.Canvas(self, height=600, width=600)
        canvas.pack()
        deg = 0
        color = 'black'
        arc = canvas.create_arc(100, 100, 500, 500, style=tk.CHORD,
                                start=0, extent=deg, fill=color)
        while await asyncio.sleep(interval, True):
            deg, color = deg_color(deg, d_per_tick, color)
            canvas.itemconfigure(arc, extent=deg, fill=color)

    async def updater(self, interval):
        while True:
            self.update()
            await asyncio.sleep(interval)

    def close(self):
        for task in self.tasks:
            task.cancel()
        self.loop.stop()
        self.destroy()


def deg_color(deg, d_per_tick, color):
    deg += d_per_tick
    if 360 <= deg:
        deg %= 360
        color = '#%02x%02x%02x' % (rr(0, 256), rr(0, 256), rr(0, 256))
    return deg, color

loop = asyncio.get_event_loop()
app = App(loop)
loop.run_forever()
loop.close()

随着时间间隔的减少,tk更新开销和时间分辨率都会增加。对于GUI更新(与动画不同),每秒20次可能已经足够。

我最近成功运行了包含tkinter调用和await的异步def协程,并在mainloop中使用了原型的asyncio任务和Futures,但我不知道添加普通的asyncio任务是否有效。如果想要同时运行asyncio和tkinter任务,我认为使用一个asyncio循环来运行tk更新是一个更好的选择。

编辑:至少在上述用法中,没有异步def协程的异常会终止协程,而是被某处捕获并丢弃。无声错误非常讨厌。

编辑2:附加代码和注释请见https://bugs.python.org/issue27546


5
目前至少,我将让您进行实验。 - Terry Jan Reedy
1
那么你的回答并不符合我的问题,也不能帮助其他读者,这与StackExchange的主要目标不符。 - buhtz
17
答案确实能够帮助其他人。 - gboffi
4
另一个更新:我认为我已经将这个内存问题缩小到了MacOs附带的Tk版本8.5。升级到8.6会解决问题。不幸的是,如果您使用homebrew安装Python3,则很难获得升级的Tk以连接Python。如果您从Python.org安装Python3,则会获得正确的内容。 - chmedly
1
请注意,将GUI更新作为asyncio任务的一个缺点是,tk.update()会阻塞直到所有事件被处理,从而暂停其他协程的执行。这可以通过调整窗口大小几秒钟来重现,画布弧形将停止更新。 - A. Rodas
显示剩余11条评论

22

在稍微修改您的代码后,我在主线程中创建了asyncio event_loop并将其作为参数传递给asyncio线程。现在在获取URL时Tkinter不会被冻结。

from tkinter import *
from tkinter import messagebox
import asyncio
import threading
import random

def _asyncio_thread(async_loop):
    async_loop.run_until_complete(do_urls())


def do_tasks(async_loop):
    """ Button-Event-Handler starting the asyncio part. """
    threading.Thread(target=_asyncio_thread, args=(async_loop,)).start()

    
async def one_url(url):
    """ One task. """
    sec = random.randint(1, 8)
    await asyncio.sleep(sec)
    return 'url: {}\tsec: {}'.format(url, sec)

async def do_urls():
    """ Creating and starting 10 tasks. """
    tasks = [one_url(url) for url in range(10)]
    completed, pending = await asyncio.wait(tasks)
    results = [task.result() for task in completed]
    print('\n'.join(results))


def do_freezed():
    messagebox.showinfo(message='Tkinter is reacting.')

def main(async_loop):
    root = Tk()
    Button(master=root, text='Asyncio Tasks', command= lambda:do_tasks(async_loop)).pack()
    Button(master=root, text='Freezed???', command=do_freezed).pack()
    root.mainloop()

if __name__ == '__main__':
    async_loop = asyncio.get_event_loop()
    main(async_loop)

3
为什么要在主线程而不是工作线程中调用asyncio.get_event_loop()? - Brent
你为什么使用 buttonX = Button(...).pack()?请看这里查看问题。 - TheLizzard
1
@TheLizzard - 是的,你说得对。然而,由于在那行之后我们没有再使用buttonX变量,所以在这个例子中它并不重要。 - bhaskarc
2
@bhaskarc 这可能会让未来看到这个代码的人感到困惑。最好只使用 Button(...).pack()。很多新手都会犯这个错误。 - TheLizzard

4

如果你不需要针对Windows系统,可以使用aiotkinter实现你想要的功能。我修改了你的代码来展示如何使用这个包:

from tkinter import *
from tkinter import messagebox
import asyncio
import random

import aiotkinter

def do_freezed():
    """ Button-Event-Handler to see if a button on GUI works. """
    messagebox.showinfo(message='Tkinter is reacting.')

def do_tasks():
    task = asyncio.ensure_future(do_urls())
    task.add_done_callback(tasks_done)

def tasks_done(task):
    messagebox.showinfo(message='Tasks done.')

async def one_url(url):
    """ One task. """
    sec = random.randint(1, 15)
    await asyncio.sleep(sec)
    return 'url: {}\tsec: {}'.format(url, sec)

async def do_urls():
    """ Creating and starting 10 tasks. """
    tasks = [
        one_url(url)
        for url in range(10)
    ]
    completed, pending = await asyncio.wait(tasks)
    results = [task.result() for task in completed]
    print('\n'.join(results))

if __name__ == '__main__':
    asyncio.set_event_loop_policy(aiotkinter.TkinterEventLoopPolicy())
    loop = asyncio.get_event_loop()
    root = Tk()
    buttonT = Button(master=root, text='Asyncio Tasks', command=do_tasks)
    buttonT.pack()
    buttonX = Button(master=root, text='Freezed???', command=do_freezed)
    buttonX.pack()
    loop.run_forever()

2

您可以通过在正确的位置添加调用root.update_idletasks()来在按下Button后保持GUI活动:

from tkinter import *
from tkinter import messagebox
import asyncio
import random

def do_freezed():
    """ Button-Event-Handler to see if a button on GUI works. """
    messagebox.showinfo(message='Tkinter is reacting.')

def do_tasks():
    """ Button-Event-Handler starting the asyncio part. """
    loop = asyncio.get_event_loop()
    try:
        loop.run_until_complete(do_urls())
    finally:
        loop.close()

async def one_url(url):
    """ One task. """
    sec = random.randint(1, 15)
    root.update_idletasks()  # ADDED: Allow tkinter to update gui.
    await asyncio.sleep(sec)
    return 'url: {}\tsec: {}'.format(url, sec)

async def do_urls():
    """ Creating and starting 10 tasks. """
    tasks = [one_url(url) for url in range(10)]
    completed, pending = await asyncio.wait(tasks)
    results = [task.result() for task in completed]
    print('\n'.join(results))


if __name__ == '__main__':
    root = Tk()

    buttonT = Button(master=root, text='Asyncio Tasks', command=do_tasks)
    buttonT.pack()
    buttonX = Button(master=root, text='Freezed???', command=do_freezed)
    buttonX.pack()

    root.mainloop()

它不起作用。行为没有改变。你有用我的MWE测试过你的解决方案吗? - buhtz
是的,我用你问题中的代码运行了它 - 无论当我发布我的答案时它是什么(如果你自那时以来已经更改了它)。 - martineau
在这种情况下,您能否请发布您的完整工作代码。 - buhtz
当然会,这是一个事实,@buhtz在我发布完整的可工作代码后从未承认过。 - martineau

2
一种解决方案是使用 async_tkinter_loop 模块(由我编写)。
在内部,这种方法与 答案Terry Jan Reedy代码类似,但使用起来更简单:只需将异步处理程序包装到async_handler函数调用中,并将它们用作命令或事件处理程序,然后使用async_mainloop(root)代替root.mainloop()
from tkinter import *
from tkinter import messagebox
import asyncio
import random
from async_tkinter_loop import async_handler, async_mainloop


def do_freezed():
    """ Button-Event-Handler to see if a button on GUI works. """
    messagebox.showinfo(message='Tkinter is reacting.')


async def one_url(url):
    """ One task. """
    sec = random.randint(1, 15)
    await asyncio.sleep(sec)
    return 'url: {}\tsec: {}'.format(url, sec)


async def do_urls():
    """ Creating and starting 10 tasks. """
    tasks = [
        asyncio.create_task(one_url(url))  # added create_task to remove warning "The explicit passing of coroutine objects to asyncio.wait() is deprecated since Python 3.8, and scheduled for removal in Python 3.11."
        for url in range(10)
    ]
    print("Started")
    completed, pending = await asyncio.wait(tasks)
    results = [task.result() for task in completed]
    print('\n'.join(results))
    print("Finished")


if __name__ == '__main__':
    root = Tk()

    # Wrap async function into async_handler to use it as a button handler or an event handler
    buttonT = Button(master=root, text='Asyncio Tasks', command=async_handler(do_urls))
    buttonT.pack()
    buttonX = Button(master=root, text='Freezed???', command=do_freezed)
    buttonX.pack()

    # Use async_mainloop(root) instead of root.mainloop()
    async_mainloop(root)

1
我曾使用multiprocessing解决过类似的任务。
主要部分包括:
  1. 主进程是使用mainloopTk进程。
  2. daemon=True进程,使用aiohttp服务执行命令
  3. 使用双工Pipe进行通信,因此每个进程都可以使用它的端点。
此外,我正在制作Tk的虚拟事件,以简化应用程序端的消息跟踪。您需要手动应用补丁。您可以查看Python的错误跟踪器以获取详细信息。
我在双方每0.25秒检查一次Pipe
$ python --version
Python 3.7.3

main.py

import asyncio
import multiprocessing as mp

from ws import main
from app import App


class WebSocketProcess(mp.Process):

    def __init__(self, pipe, *args, **kw):
        super().__init__(*args, **kw)
        self.pipe = pipe

    def run(self):
        loop = asyncio.get_event_loop()
        loop.create_task(main(self.pipe))
        loop.run_forever()


if __name__ == '__main__':
    pipe = mp.Pipe()
    WebSocketProcess(pipe, daemon=True).start()
    App(pipe).mainloop()

app.py

import tkinter as tk


class App(tk.Tk):

    def __init__(self, pipe, *args, **kw):
        super().__init__(*args, **kw)
        self.app_pipe, _ = pipe
        self.ws_check_interval = 250;
        self.after(self.ws_check_interval, self.ws_check)

    def join_channel(self, channel_str):
        self.app_pipe.send({
            'command': 'join',
            'data': {
                'channel': channel_str
            }
        })

    def ws_check(self):
        while self.app_pipe.poll():
            msg = self.app_pipe.recv()
            self.event_generate('<<ws-event>>', data=json.dumps(msg), when='tail')
        self.after(self.ws_check_interval, self.ws_check)

ws.py

import asyncio

import aiohttp


async def read_pipe(session, ws, ws_pipe):
    while True:
        while ws_pipe.poll():
            msg = ws_pipe.recv()

            # web socket send
            if msg['command'] == 'join':
                await ws.send_json(msg['data'])

            # html request
            elif msg['command'] == 'ticker':
                async with session.get('https://example.com/api/ticker/') as response:
                    ws_pipe.send({'event': 'ticker', 'data': await response.json()})

        await asyncio.sleep(.25)


async def main(pipe, loop):
    _, ws_pipe = pipe
    async with aiohttp.ClientSession() as session:
        async with session.ws_connect('wss://example.com/') as ws:
            task = loop.create_task(read_pipe(session, ws, ws_pipe))
            async for msg in ws:
                if msg.type == aiohttp.WSMsgType.TEXT:
                    if msg.data == 'close cmd':
                        await ws.close()
                        break
                    ws_pipe.send(msg.json())
                elif msg.type == aiohttp.WSMsgType.ERROR:
                    break

这是一个高质量的回答!我不确定Pipe是如何工作的,但如果确实创建了一个新进程,那么应该考虑开销。相比于新线程,新进程会带来很多开销。更多细节请查看https://timber.io/blog/multiprocessing-vs-multithreading-in-python-what-you-need-to-know/ - buhtz

1
使用Python3.9,可以通过创建多个异步函数来实现,其中一个函数负责Tk update()。在主循环中,可以使用ensure_future()调用所有这些异步函数,然后开始asyncio循环。
#!/usr/bin/env python3.9

import aioredis
import asyncio
import tkinter as tk 
import tkinter.scrolledtext as st 
import json

async def redis_main(logs):
    redisS = await aioredis.create_connection(('localhost', 6379))  
    subCh = aioredis.Channel('pylog', is_pattern=False)
    await redisS.execute_pubsub('subscribe', subCh)
    while await subCh.wait_message():
            msg = await subCh.get()
            jmsg = json.loads(msg.decode('utf-8'))
            logs.insert(tk.INSERT, jmsg['msg'] + '\n')

async def tk_main(root):
    while True:
        root.update()
        await asyncio.sleep(0.05)

def on_closing():
    asyncio.get_running_loop().stop()

if __name__ == '__main__':
    root = tk.Tk()
    root.protocol("WM_DELETE_WINDOW", on_closing)
    logs = st.ScrolledText(root, width=30, height=8)
    logs.grid()
    
    tkmain = asyncio.ensure_future(tk_main(root))
    rdmain = asyncio.ensure_future(redis_main(logs))
    
    loop = asyncio.get_event_loop()
    try:
        loop.run_forever()
    except KeyboardInterrupt:
        pass

    tkmain.cancel()
    rdmain.cancel()

1
这是一个问题,不是答案。 - buhtz
@buhtz,这种形式看起来像是一个问题,但实际上是一种解决方案。你有花时间去看吗? - Arthur Cheuk
3
写得不够清晰,不太能看出这是一个答案。您能否编辑一下措辞,以便未来的读者不会感到困惑? - Jeremy Caney

-1

我在应用程序创建的开始就在另一个线程上运行了I/O循环,并使用asyncio.run_coroutine_threadsafe(..)将任务投入其中,这样做非常成功。

我有点惊讶的是,我可以在其他asyncio循环/线程上更改tkinter小部件,也许这对我来说是个意外,但它确实有效。

请注意,当asyncio任务正在进行时,其他按钮仍然处于活动状态并且可以响应。我总是喜欢在其他按钮上执行禁用/启用操作,以免意外触发多个任务,但这只是一个UI问题。

import threading
from functools import partial
from tkinter import *
from tkinter import messagebox
import asyncio
import random


# Please wrap all this code in a nice App class, of course

def _run_aio_loop(loop):
    asyncio.set_event_loop(loop)
    loop.run_forever()
aioloop = asyncio.new_event_loop()
t = threading.Thread(target=partial(_run_aio_loop, aioloop))
t.daemon = True  # Optional depending on how you plan to shutdown the app
t.start()

buttonT = None

def do_freezed():
    """ Button-Event-Handler to see if a button on GUI works. """
    messagebox.showinfo(message='Tkinter is reacting.')

def do_tasks():
    """ Button-Event-Handler starting the asyncio part. """
    buttonT.configure(state=DISABLED)
    asyncio.run_coroutine_threadsafe(do_urls(), aioloop)

async def one_url(url):
    """ One task. """
    sec = random.randint(1, 3)
    # root.update_idletasks()  # We can delete this now
    await asyncio.sleep(sec)
    return 'url: {}\tsec: {}'.format(url, sec)

async def do_urls():
    """ Creating and starting 10 tasks. """
    tasks = [one_url(url) for url in range(3)]
    completed, pending = await asyncio.wait(tasks)
    results = [task.result() for task in completed]
    print('\n'.join(results))
    buttonT.configure(state=NORMAL)  # Tk doesn't seem to care that this is called on another thread


if __name__ == '__main__':
    root = Tk()

    buttonT = Button(master=root, text='Asyncio Tasks', command=do_tasks)
    buttonT.pack()
    buttonX = Button(master=root, text='Freezed???', command=do_freezed)
    buttonX.pack()

    root.mainloop()

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接