pythongtkpython-asynciogtk3pygobject

How to make GTK interface work with asyncio?


I'm trying to write a Python program with a GTK interface that gets output from functions using async/await that take a few seconds to execute, what I'm asking for is the best solution for running this while not freezing the GUI..

I was using threads before and sort of got it working, but both the GUI and the backend need to communicate both ways and sometimes at a high rate (every time the user types a new character into a search box, for instance)

and then I found out the 'backend' I'm using also supports async/await so I thought if I could figure out how to get GTK events to play nicely with asyncio, that would probably be the best option..

I've written an example of what I'm trying to do

import asyncio
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk


async def simulate_get_results():
    await asyncio.sleep(5)
    return ['Imagine these are results from an API']


class Window(Gtk.Window):
    def __init__(self):
        super().__init__()
        self.search_button = Gtk.Button(label='Press me')

        async def button_pressed(button: Gtk.Button):
            results = await simulate_get_results()
            button.set_label(results[0])

        def non_async_button_callback_wrapper(button: Gtk.Button):
            asyncio.run(button_pressed(button))

        self.search_button.connect('clicked', non_async_button_callback_wrapper)
        self.add(self.search_button)


win = Window()
win.connect('destroy', Gtk.main_quit)
win.show_all()
Gtk.main()

if you can get this to work in a way that it doesn't freeze the GUI after clicking the button, but still displays the results 5 seconds later, you'll most likely have solved my problem


Solution

  • One option to implement this is to merge event loops as shown in the other answer. The downside of that approach is that it tends to lead to CPU churn, as each event loop busy-loops to avoid blocking the other. An alternative approach is to run both event loops normally, each in its own thread. The GTK event loop insists to run in the main thread, so we'd spawn the asyncio event loop in a background thread, like this:

    _loop = asyncio.new_event_loop()
    def run_loop():
        # run asyncio loop and wait forever
        threading.Thread(target=_loop.run_forever, daemon=True).start()
    run_loop()
    

    With that in place, we can write a function that submits a coroutine to the running event loop, using asyncio.run_coroutine_threadsafe. Furthermore, we set up a callback that notifies us when the coroutine is done, and invokes some code in the GTK thread, using GLib.idle_add to do so:

    def submit(coro, when_done):
        fut = asyncio.run_coroutine_threadsafe(coro, _loop)
        def call_when_done():
            when_done(fut.result())
        fut.add_done_callback(lambda _: GLib.idle_add(call_when_done))
    

    With the submit function in place, you can run any async code from GTK without blocking the GUI, and be notified when it's done by executing a callback invoked in the GTK thread, which can update the GUI accordingly. While the callback-when-done approach is not quite as convenient as the code in the question (where the async function runs asyncio and gtk code interchangeably), it is still practical. It would be used like this:

    class Window(Gtk.Window):
        def __init__(self):
            super().__init__()
            self.search_button = Gtk.Button(label='Press me')
    
            def non_async_button_callback_wrapper(button: Gtk.Button):
                submit(simulate_get_results(), lambda results: button.set_label(results[0]))
    
            self.search_button.connect('clicked', non_async_button_callback_wrapper)
            self.add(self.search_button)
    

    The rest of the code is unchanged. (Full code.)