Integrating asyncio with Tkinter and Other GUI Event Loops¶
A desktop app that talks to the network has two event loops that each believe they own the thread: the GUI toolkit's (root.mainloop() in Tkinter, app.exec() in Qt) and asyncio's. Call asyncio.run() from a button handler and the UI freezes until it returns; call root.mainloop() from inside a coroutine and asyncio stops. There are two workable arrangements: let asyncio drive the GUI by pumping its events periodically, or run asyncio on a background thread and post results back to the GUI thread. In a test with Tkinter, the first cost 1.5% of a core at idle when pumping at 60 Hz; the second returned results from a 100 ms coroutine to the UI in 111–122 ms with the GUI loop untouched. This guide implements both and covers what toolkit-native integrations like qasync add.
Prerequisites¶
- Python 3.11+ with Tkinter (part of most Python builds); the Qt section uses PySide6 and
qasync. - Thread-safe scheduling, from scheduling callbacks with call_soon vs create_task.
- Loop threads, from running multiple event loops in separate threads.
1. Let asyncio drive the GUI by pumping events¶
The simplest integration makes the asyncio loop the main loop and calls the toolkit's "process pending events" function on a timer. In Tkinter that is root.update():
import asyncio
import tkinter as tk
class App:
def __init__(self) -> None:
self.root = tk.Tk()
self.label = tk.Label(self.root, text="idle")
self.label.pack()
tk.Button(self.root, text="Fetch", command=self.on_fetch).pack()
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
self.running = True
self.tasks: set[asyncio.Task] = set()
def on_fetch(self) -> None: # Tk callback, runs inside update()
task = asyncio.create_task(self.fetch())
self.tasks.add(task)
task.add_done_callback(self.tasks.discard)
async def fetch(self) -> None:
self.label["text"] = "loading…"
await asyncio.sleep(1) # stand-in for an HTTP call
self.label["text"] = "done"
def on_close(self) -> None:
self.running = False
async def run(self, hz: int = 60) -> None:
while self.running:
self.root.update() # process all pending Tk events
await asyncio.sleep(1 / hz)
for t in self.tasks:
t.cancel()
self.root.destroy()
asyncio.run(App().run())
Everything runs on one thread, so coroutines can touch widgets directly — the label["text"] = ... assignments are safe. The cost is polling: the UI only reacts at the pump rate, and the loop wakes up even when nothing happens. Measured on an idle window: 1.5% of a core at 60 Hz, 3.1% at 250 Hz.
Verify: click Fetch several times quickly; the UI stays responsive and the label updates after a second for each click.
2. Keep long Tk work out of the pump¶
The pump only works if each update() call is short. Two things break it. A Tk callback that does blocking work — reading a large file, a synchronous HTTP call — stalls both the UI and every coroutine. And modal Tk operations that run their own nested event loop, such as filedialog.askopenfilename() or messagebox.showinfo(), block inside update() until the dialog closes, freezing all asyncio work for that time.
from tkinter import filedialog
async def pick_and_upload(app) -> None:
# a modal dialog would block the pump; run it in a deferred Tk callback,
# and hand the result back to asyncio through a future
loop = asyncio.get_running_loop()
fut: asyncio.Future[str] = loop.create_future()
app.root.after(0, lambda: fut.set_result(filedialog.askopenfilename()))
path = await fut # coroutines are paused while the dialog is open
if path:
await upload(path)
The dialog still pauses coroutines while it is open — there is no way around that in the single-thread design — but the code makes it explicit and keeps the result in async code. If background work must continue while dialogs are open (a download, a websocket), that is the signal to use the threaded design instead.
Verify: start a long-running task, open a file dialog, and observe whether the task's progress stops; if that is unacceptable, switch to section 3.
3. Run asyncio on a background thread instead¶
The robust arrangement leaves the GUI loop alone on the main thread and runs asyncio on a worker thread. The GUI submits coroutines with run_coroutine_threadsafe, and results come back through a queue the GUI polls with after():
import asyncio
import queue
import threading
import tkinter as tk
class AsyncBridge:
def __init__(self, root: tk.Tk) -> None:
self.root = root
self.loop = asyncio.new_event_loop()
self.thread = threading.Thread(target=self.loop.run_forever, name="asyncio", daemon=True)
self.thread.start()
self.results: queue.SimpleQueue = queue.SimpleQueue()
self.root.after(20, self._drain)
def submit(self, coro, on_done) -> None:
fut = asyncio.run_coroutine_threadsafe(coro, self.loop)
fut.add_done_callback(lambda f: self.results.put((on_done, f))) # asyncio thread
def _drain(self) -> None: # Tk thread
while True:
try:
on_done, fut = self.results.get_nowait()
except queue.Empty:
break
on_done(fut)
self.root.after(20, self._drain)
def close(self) -> None:
self.loop.call_soon_threadsafe(self.loop.stop)
self.thread.join(timeout=5)
The critical rule: never touch a widget from the asyncio thread. Tk is not thread-safe, and calls from another thread fail unpredictably — sometimes immediately, sometimes as corrupted state much later. The done-callback only puts the future into a queue; the Tk thread's _drain is the only place results meet widgets.
Measured with a 100 ms coroutine: results reached the UI 111–122 ms after the click, the extra 10–20 ms being the 20 ms drain interval.
Verify: add assert threading.current_thread() is threading.main_thread() inside every function that updates a widget.
4. Use a toolkit-native integration when one exists¶
Some toolkits can run asyncio on their own event loop directly. For Qt, qasync implements an asyncio event loop on top of Qt's, so coroutines and Qt signals share one thread with no polling:
# pip install PySide6 qasync
import asyncio
import sys
from PySide6.QtWidgets import QApplication, QPushButton
import qasync
async def fetch(button: QPushButton) -> None:
button.setText("loading…")
await asyncio.sleep(1)
button.setText("done")
def main() -> None:
app = QApplication(sys.argv)
loop = qasync.QEventLoop(app)
asyncio.set_event_loop(loop)
button = QPushButton("Fetch")
button.clicked.connect(lambda: asyncio.ensure_future(fetch(button)))
button.show()
with loop:
loop.run_forever()
main()
This is the best of both designs — one thread, no polling, modal dialogs that do not freeze coroutines because Qt's nested loops still dispatch asyncio callbacks — for as long as the integration library keeps up with both Qt and asyncio releases. Pin its version and test upgrades of either side together. Tkinter has no equivalent in the standard library.
Verify: idle CPU with the window open is effectively zero, unlike the pumping design.
5. Shut down in the right order¶
Closing the window must stop asyncio work cleanly, or the process hangs on exit or prints "Task was destroyed but it is pending". The order is: stop accepting new work, cancel running tasks on their own loop, stop the loop, then destroy the GUI:
async def _cancel_all() -> None:
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
def on_close(bridge: AsyncBridge, root: tk.Tk) -> None:
fut = asyncio.run_coroutine_threadsafe(_cancel_all(), bridge.loop)
try:
fut.result(timeout=3)
except TimeoutError:
pass # a task ignored cancellation; exit anyway
bridge.close()
root.destroy()
Bounding the wait matters: a coroutine stuck in a network call that ignores cancellation should not leave a window that will not close. The same ordering logic applies to servers, as described in Graceful Shutdown & Signal Handling.
Verify: close the window during an active download; the process exits within three seconds with no pending-task warnings.
Verification¶
The integration is correct when:
- The UI never freezes while coroutines run, apart from modal dialogs in the pump design.
- Widgets are only touched on the GUI thread, enforced by assertions in development builds.
- Idle CPU is acceptable for a desktop app — near zero for threaded and native designs.
- Closing the window cancels tasks and exits promptly.
Diagnostic Hook: time each pump iteration (or each drain callback) and log any over 50 ms — that is a visible UI stutter, and the log tells you which callback caused it. In the threaded design, also record time from submit() to on_done(); when it greatly exceeds the coroutine's own duration, the asyncio thread is blocked by something synchronous.
Pitfalls & edge cases¶
asyncio.run()inside a button handler. It blocks the GUI for the coroutine's whole duration and creates a fresh loop every click.- Updating widgets from the asyncio thread. Tk and Qt widgets are not thread-safe; always marshal through the GUI thread.
- Forgetting task references in GUI callbacks. Tasks created from callbacks can be garbage-collected mid-flight; keep them in a set.
- Pumping too slowly. Below about 30 Hz typing and dragging feel laggy; above 250 Hz the idle cost grows with little benefit.
Frequently Asked Questions¶
How do I use asyncio with Tkinter?
Either run asyncio as the main loop and call root.update() every frame from a coroutine, or keep root.mainloop() on the main thread and run an asyncio loop on a background thread, submitting coroutines with run_coroutine_threadsafe and passing results back through a queue polled with root.after.
Why does my Tkinter window freeze when I call asyncio.run?
asyncio.run blocks the calling thread until the coroutine finishes, and that thread is the one running Tk's event loop. No events are processed until it returns.
Can I update Tkinter widgets from an asyncio thread?
No. Tkinter is not thread-safe. Put results on a queue and apply them to widgets from a callback scheduled with root.after on the main thread.
Is there an asyncio event loop for Qt?
Yes — qasync implements an asyncio event loop on top of Qt's, so coroutines and Qt signals run on one thread without polling.
Related¶
- Event Loop Configuration — up to the topic overview.
- Running an event loop in a background thread — the threaded bridge in more depth.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.