Introduction¶

The problem with existing libraries¶

There are a couple of async libraries for Python. One, of course, is asyncio from the standard library. Another one is Trio, well-known for its structured concurrency. And Curio, which seems to have had a big influence on Trio, though it now only accepts bug fixes.

They may be different each other but all of them have in common is that they are not suitable for GUI programs. I’m not saying “Having a GUI library and an async library coexist in the same thread is problematic due to their individual main loops”. In fact, Kivy and BeeWare have adapted themselves to work with async libraries, PyGame doesn’t even own a main loop and expects the user to implement one so you could do that by putting an await asyncio.sleep() inside a main loop [1], tkinter and PyQt seem to have 3rd party libraries that allow them to work with async libraries. Even if none of them are your options, Trio has special mode that makes it run without interfering with other main loop. Therefore, I think it’s safe to say that the coexisting problem has been already solved.

Then why do I say “they are not suitable for GUI programs”? It’s because they cannot immediately start/resume tasks. For instance, asyncio.create_task() and asyncio.TaskGroup.create_task() are the ones that start tasks in asyncio, but neither of them does it immediately.

Let me clarify what I mean by “immediately” just in case. It means the following test should pass:

import asyncio

flag = False


async def async_fn():
    global flag; flag = True


async def main():
    asyncio.create_task(async_fn())
    assert flag


asyncio.run(main())

which does not. The same applies to trio.Nursery.start() and trio.Nursery.start_soon() (This has “soon” in its name so it’s obvious).

The same issue arises when they resume tasks. asyncio.Event.set() and trio.Event.set() don’t immediately resume the tasks waiting for it to happen. They schedule the tasks to eventually resume, thus, the following test fails.

import asyncio

flag = False


async def async_fn(e):
    e.set()
    assert flag


async def main():
    e = asyncio.Event()
    asyncio.create_task(async_fn(e))
    await e.wait()
    global flag; flag = True


asyncio.run(main())

Why does the inability to immediately start/resume tasks make async libraries unsuitable for GUI programs? Take a look at the following pseudo code that changes the background color of a button while it is pressed.

async def toggle_button_background_color(button):
    while True:
        await button.to_be_pressed()
        button.background_color = different_color
        await button.to_be_released()
        button.background_color = original_color

Consider a situation where the task is paused at the await button.to_be_pressed() line and the user presses the button. As I mentioned, neither of asyncio nor trio resumes tasks immediately, so the background color won’t change immediately. Now, what happens if the user releases the button before the task resumes? The task eventually resumes and pauses at the await button.to_be_released() line… but the user has already released the button. The task ends up waiting there until the user presses and releases the button again. As a result, the background color of the button remains the different_color until that happens.

Note

The situation is worse in Kivy. In Kivy, touch events are stateful objects. If you fail to handle them promptly, their state might undergo changes, leaving no time to wait for tasks to resume.

Reacting to events without missing any occurrences is challenging for async libraries that cannot start/resume tasks immediately. The only solution I came up with is that, recording events using traditional callback APIs, and supplying them to the tasks that resume late a.k.a. buffering. I’m not sure it’s possible or practical, but it certainly has a non-small impact on performance.

If you use asyncgui, that never be a problem.

asyncgui¶

Immediacy¶

The problem mentioned above doesn’t occur in asyncgui because:

All other APIs work that way as well.

No main loop¶

The coexistence problem I mentioned earlier doesn’t occur in asyncgui because it doesn’t have its own main loop. Instead, asyncgui runs by piggybacking on another main loop, such as one from a GUI library. To achieve this, however, you need to wrap the callback-style APIs associated with the main loop it piggybacks. I’ll explain this further in the Usage section.

Note

“another main loop” can be other async library’s. Yes, you can even run asyncgui and other async library in the same thread (though there are some limitations).

No global state¶

Although it wasn’t originally intended, asyncgui ended up having no global state. All states are represented as:

  • free variables

  • local variables inside coroutines/generators

  • instance attributes

not:

  • module-level variables

  • class-level attributes

Note

Other async libraries have global states.

asyncio.tasks._current_tasks, trio._core.GLOBAL_CONTEXT

Cannot sleep by itself¶

It might surprise you, but asyncgui cannot await sleep(...) by itself. This is because it requires a main loop, which asyncgui lacks.

However, you can achieve this by wrapping the timer APIs of the main loop it piggybacks on, as I mentioned earlier. In fact, that is the intended usage of this library. asyncgui itself only provides the features that depend solely on the Python language (or maybe some CPython-specific behavior), and doesn’t provides the ones that need to interact with the operating system [2].

_images/core-concept-en.svg