Under the Hood

This page explains how qtinter is implemented.

We first give an overview of how asyncio works. We then give an overview of how Qt works. We then explain how qtinter bridges the two.

Loop modes

A QiBaseEventLoop has three modes of operations: owner mode, guest mode, and native mode.

Note

Both owner mode and guest mode require a QtCore.QCoreApplication, QtGui.QGuiApplication or QtWidgets.QApplication instance to exist in order to run the loop. This is because these modes use Qt’s signal-slot mechanism to schedule callbacks.

Owner mode

Owner mode provides 100% asyncio event loop semantics. It should be used if your code calls asyncio.run() or equivalent as its entry point.

You normally launch a QiBaseEventLoop in host mode using the using_qt_from_asyncio() context manager. Alternatively, call new_event_loop() to create a QiBaseEventLoop in host mode and then manipulate it manually.

Note

QiBaseEventLoop executes a QtCore.QEventLoop when operating in owner mode. If a Qt event loop is already running, the new loop will run nested, which may cause the usual subtle consequences with nested loops and therefore is not recommended. If you already have a Qt event loop running and want to use asyncio functionalities, use the using_asyncio_from_qt() context manager instead.

Note

If QtCore.QCoreApplication.exit() has been called, it will be no longer possible to start a QtCore.QEventLoop and hence not possible to run a QiBaseEventLoop in owner mode. You may run the QiBaseEventLoop in native mode if needed.

Guest mode

Guest mode runs a logical asyncio event loop on top of a physical Qt event loop. It is designed to enable asyncio access for Qt-driven code.

Guest mode is normally activated using the using_asyncio_from_qt() context manager. Under the hood, the context manager calls new_event_loop() to create a QiBaseEventLoop object and then calls its QiBaseEventLoop.set_mode() method with argument QiLoopMode.GUEST.

The physical Qt event loop must be run by the application code, e.g. by calling app.exec().

Note

In guest mode, the running state of the logical asyncio event loop is decoupled from and independent of the running state of the physical Qt event loop.

Native mode

A QiBaseEventLoop in native mode runs a physical asyncio event loop and behaves exactly like a standard asyncio event loop; no Qt functionality is involved.

Native mode is activated by the using_asyncio_from_qt() context manager in its clean-up code before running the coroutines to cancel pending tasks and shutdown async generators. This mode allows coroutines to run even after QtCore.QCoreApplication.exec has been called.

To manually activate native mode, call QiBaseEventLoop.set_mode() with argument QiLoopMode.NATIVE.

Note

Because no Qt event loop is running in native mode, you should not use any Qt objects in this mode. In particular, the clean-up code in your coroutines should work without requiring a running Qt event loop.

Interleaved code

By implementing a (logical) asyncio event loop on top of a (physical) Qt event loop, what’s not changed (from the perspective of the asyncio event loop) is that all calls (other than call_soon_threadsafe) are still made from the same thread. This frees us from multi-threading complexities.

What has changed, however, is that in a physical asyncio event loop, no code can run when the scheduler (specifically, _run_once) is blocked in select(), while in a logical asyncio event loop, a select() call that would otherwise block yields, allowing any code to run while the loop is “logically” blocked in select.

For example, BaseEventLoop.stop() is implemented by setting the flag _stopping to True, which is then checked at the end of the iteration to stop the loop. This works because stop can only ever be called from a callback, and a callback can only ever be called after select returns and before the next iteration of _run_once. The behavior changes if select yields and stop is called – the event loop will not wake up until some IO is available.

We refer to code that runs (from the Qt event loop) after select yields and before _run_once is called again as interleaved code. We must examine and handle the implications of such code.

We do this by fitting interleaved code execution into the ‘classical’ asyncio event loop model. Specifically, we treat interleaved code as if they were scheduled with asyncio.loop.call_soon_threadsafe(), which wakes up the selector and executes the code. With some loss of generality, we assume no IO event or timed callback is ready at the exact same time, so that the scheduler will be put back into blocking select immediately after the code finishes running (unless the code calls stop). This simplification is acceptable because the precise timing of multiple IO or timer events should not be relied upon.

In practice, we cannot actually wake up the asyncio scheduler every time interleaved code is executed, firstly because there’s no way to detect their execution, and secondly because doing so would be highly inefficient. Instead, we assume that interleaved code that does not access the event loop object or its selector is benign enough to be treated as independent from the asyncio event loop mechanism and may thus be safely ignored.

This leaves us to just consider interleaved code that accesses the event loop object or its selector and examine its impact on scheduling. The scheduler depends on three things: the _ready queue for “soon” callbacks, the _scheduled queue for timer callbacks, and _selector for IO events. If the interleaved code touches any of these things, it needs to be handled.

While the public interface of asyncio.AbstractEventLoop has numerous methods, the methods that modify those three things boil down to asyncio.loop.call_soon(), asyncio.loop.call_at(), asyncio.loop.call_later(), (arguably) asyncio.loop.stop(), and anything that modifies the selector (proactor). When any of these happens, we physically or logically wake up the selector to simulate a call to asyncio.loop.call_soon_threadsafe().

Eager execution

When the wrapper function returned by asyncslot() is called, it calls QiBaseEventLoop.run_task(), which creates a task wrapping the coroutine and eagerly executes the first step of the task.

This eager execution feature would lead to task nesting if the wrapper function is called from a coroutine. Scenarios that lead to the wrapper function being called from a coroutine include:

  • directly calling or awaiting the wrapper;

  • emitting a signal to which the wrapper is connected by a direct connection;

  • starting a nested Qt event loop (without using modal()) on which a signal connected to the wrapper is emitted.

asyncio does not allow task nesting. Yet some of the above scenarios are valid and cannot be systematically avoided. To make asyncslot() useful in practice, QiBaseEventLoop extends asyncio’s semantics to support a particular form of task nesting, namely:

If QiBaseEventLoop.run_task() is called when there is an active task running, that task is automatically ‘suspended’ when the call begins and ‘resumed’ after the call returns.

This extension only applies to QiBaseEventLoop.run_task() and is therefore “opt-in”: Code that does not call asyncslot() or QiBaseEventLoop.run_task() retains full compliance with asyncio’s semantics.

Note

An alternative implementation of QiBaseEventLoop.run_task() that is free of task nesting by construction is to execute the first step of the coroutine in the caller’s context instead of in its own task context.

The main problem with this approach is that there is no natural way to retrieve the task object that wraps the remainder of the coroutine:

  • It cannot be retrieved within the first step of the coroutine because a task object for the remainder is not created yet; and

  • If returned directly to the caller, it offers no advantage over calling asyncio.create_task() directly to obtain the task object.

In addition, that part of a coroutine may run out of a task context (if invoked from a callback) is just surprising.

Due to these problems, we choose the current implementation in favor of this alternative.