Search code examples
pythonc++python-asynciocoroutinepybind11

Integrate embedded python asyncio into boost::asio event loop


I have a C++ binary with an embedded python interpreter, done via pybind11::scoped_interpreter.

It also has a number of tcp connections using boost::asio which consume a proprietary messaging protocol and update some state based on the message contents.

On startup we import a python module, instantiate a specific class therein and obtain pybind11::py_object handles to various callback methods within the class.

namespace py = pybind11;

class Handler
{
public:
    Handler(const cfg::Config& cfg)
        : py_interpreter_{std::make_unique<py::scoped_interpreter>()}
    {
        auto module = py::module_::import(cfg.module_name);
        auto Class = module.attr(cfg.class_name);

        auto obj = Class(this);
        py_on_foo_ = obj.attr("on_foo");
        py_on_bar_ = obj.attr("on_bar");
    }

    std::unique_ptr<py::scoped_interpreter> py_interpreter_;

    py::object py_on_foo_;
    py::object py_on_bar_;
};

For each specific message which comes in, we call the associated callback method in the python code.

void Handler::onFoo(const msg::Foo& foo)
{
    py_on_foo_(foo); // calls python method
}

All of this works fine... however, it means there is no "main thread" in the python code - instead, all python code execution is driven by events originating in the C++ code, from the boost::asio::io_context which is running on the C++ application's main thread.


What I'm now tasked with is a way to get this C++-driven code to play nicely with some 3rd-party asyncio python libraries.

What I have managed to do is to create a new python threading.Thread, and from there add some data to a thread-safe queue and make a call to boost::asio::post (exposed via pybind11) to execute a callback in the C++ thread context, from which I can drain the queue.

This is working as I expected, but I'm new to asyncio, and am lost as to how to create a new asyncio.event_loop on the new thread I've created, and post the async results to my thread-safe queue / C++ boost::asio::post bridge to the C++ thread context.

I'm not sure if this is even a recommended approach... or if there is some asyncio magic I should be using to wake up my boost::asio::io_context and have the events delivered in that context?

Questions:

  • How can I integrate an asyncio.event_loop into my new thread and have the results posted to my thread-safe event-queue?
  • Is it possible to create a decorator or some such similar functionality which will "decorate" an async function so that the results are posted to my thread-safe queue?
  • Is this approach recommended, or is there another asyncio / "coroutiney" way of doing things I should be looking at?

Solution

  • There are three possibilities to integrate the asio and asyncio event loops:

    1. Run both event loops in the same thread, alternating between them
    2. Run one event loop in the main thread and the other in a worker thread
    3. Merge the two event loops together.

    The first option is straightforward, but has the downside that you will be running that thread hot since it never gets the chance to sleep (classically, in a select), which is inconsiderate and can disguise performance issues (since the thread always uses all available CPU). Here option 1a would be to run the asio event loop as a guest in asyncio:

    async def runAsio(asio: boost.asio.IoContext):
        while await asyncio.sleep(0, True):
            asio.poll()
    

    And option 1b would be to run the asyncio event loop as a guest in asio:

    boost::asio::awaitable<void> runAsyncio(py::object asyncio) {
        for (;; co_await boost::asio::defer()) {
            asyncio.attr("stop")();
            asyncio.attr("run_forever")();
        }
    }
    

    The second option is more efficient, but has the downside that completions will be invoked on either thread depending on which event loop they're triggered by. This is the approach taken by the asynchronizer library; it spawns a std::thread to run the asio event loop on the side (option 2a), but you could equally take your approach (option 2b) of spawning a threading.Thread and running the asyncio event loop on the side. If you're doing this you should create a new event loop in the worker thread and run it using run_forever. To post callbacks to this event loop from the main thread use call_soon_threadsafe.

    Note that a downside of approach 2b would be that Python code invoked in the main thread won't be able to access the asyncio event loop using get_running_loop and, worse any code using the deprecated get_event_loop in the main thread will hang. If instead you use option 2a and run the C++ event loop in the worker thread you can ensure that any Python callbacks that might want access to the asyncio event loop are running in the main thread.

    Finally, the third option is to replace one event loop with the other (or even possibly both with a third, e.g. libuv). Replacing the asio scheduler/reactor/proactor is pretty involved and fairly pointless (since it would mean adding overhead to C++ code that should be fast), but replacing the asyncio loop is far more straightforward and is very much a supported use case; see Event Loop Implementations and Policies and maybe take a look at uvloop which replaces the asyncio event loop with libuv. On the downside, I'm not aware of a fully supported asio implementation of the asyncio event loop, but there is a GSoC project that looks pretty complete, although it's (unsurprisingly) written using Boost.Python so might need a little work to integrate with your pybind11 codebase.