woboq / qmetaobject-rs

Integrate Qml and Rust by building the QMetaObject at compile time.
MIT License
644 stars 89 forks source link

Asynchronous Rust and Qt signals and slots #102

Open rubdos opened 4 years ago

rubdos commented 4 years ago

I have already touched upon my experiments in a slightly related #98, and since @ratijas seemed to be interested, I decided to start a discussion here too.

Maybe you're also interested in reading this little proof of concept that I made last month

Truly interesting. I've been experimenting with Sailfish OS during university course. Wonderful and terribly underestimated piece of mobile technology.

Context

What qmetaobject-rs currently does, is starting Qt's event loop and handing off control to Qt. From there, Qt calls back into Rust's primitives. In my proof of concept (blog post), I've built an event loop in Rust that's capable of driving all events in Qt (read: all events that I needed on a SailfishOS application). I currently wrote this in the application I am writing, having to duplicate some C++ code from qmetaobject on the way.

Dispatching the Qt events from a Rust based event loop has one big advantage: there is no need any more to synchronize data between a Rust event loop (you're possibly running sockets and I/O from Rust) and the Qt event loop, because they're one and the same: before this integration, I had to basically put everything behind Arc<Mutex<T>>. It might also reduce some resource usage, since it reduces the thread count by one (although Qt loves to spawn threads, apparently, so that's not a lot).

Proposals

  1. I think it might be interesting to provide a trait QAbstractEventDispatcher, for further implementation by Rust applications. This should be fairly doable. We'd also require to correctly export struct QSockNotifier, and probably some other types that I have currently not implemented (because they're unneeded in my own case).
  2. I have written a very bare, ugly, and slightly hacky implementation to tie Tokio to a QAbstractEventDispatcher. With thorough clean-up, this could land in this crate in two forms:
    • as an example of how to implement QAbstractEventDispatcher
    • as an actual implementation, feature-gated behind a cfg(feature = "tokio"), possibly following with an async-std implementation too.
  3. This is something I haven't tried yet, but I hope to resolve this some time in the near future: a tighter integration of Qt signals and slots with asynchronous Rust. I have no clue yet how this would take form, and this is probably the most challenging of everything I write in this issue.

Possible results

The benefit of all of this combined, would be to write very idiomatic async Rust code, which would also be driving the GUI. For example, my main application logic currently reads (using Actix):

sys.block_on(async {
    gui::run().await.unwrap();
});

This run() method in turns spawn some asynchronous workers, on the same event loop that the GUI is running on.

Alternative forms

Of course, this is all a lot of new (and extremely experimental) features. Whatever the verdict, I will move the bare Tokio logic out of the application I am writing into an external crate. If you think it is too far out of scope for qmetaobject, I'll start a separate crate for that.

The discussion I would like to have here is which one of the 1, 2, 3 mentioned above would be welcome here. If part should be externalized, I'd like to discuss what interfaces are still necessary in qmetaobject-rs, as to keep the Tokio-related stuff clean.

I believe that the design of #100 might keep (3) in mind, so I'm tagging that as slightly-related.

ratijas commented 4 years ago

Hi,

Thanks for nicely structured write-up.

provide a trait QAbstractEventDispatcher

Maybe if we do that, we could figure out more about bridging C++ and Rust in the process. For #81 as well.

as an actual implementation, feature-gated behind a cfg(feature = "tokio"), possibly following with an async-std implementation too.

And don't forget, the big guy is writing the ultimate smol runtime to rule 'em all!

extremely experimental

What I like so much about qmetaobject-rs is the duality of the project: it is both very experimental and very useful. And it gave me a change to better understand Qt internals and its design patterns.

ogoffart commented 4 years ago

Thanks for working on this.

I think the 1. definitively make sense to expose the primitives so one can run the application with any event loop. Integrating with existing runtime such as tokio behind a feature gate would also be nice.

As for 3, there is already qmetaobject::future::wait_on_signal which i believe is what you meant.

rubdos commented 4 years ago

As for 3, there is already qmetaobject::future::wait_on_signal which i believe is what you meant.

I had not seen that, very interesting! That seems to implement already half of what I mean, although I am also thinking about a more "safe" approach to it, and with a bit of syntactic aid; it would be great to read e.g. my_qt_obj.my_signal.await. Not that I know already how that could be done, but the ergonomics of such things would be awesome.

glaebhoerl commented 4 years ago

I recently read another story about integrating event loops, in case it's relevant: https://github.com/python-trio/trio/pull/1551

rubdos commented 4 years ago

I recently read another story about integrating event loops, in case it's relevant: python-trio/trio#1551

That's looks to be basically the other way around, and seems to be more-or-less supported already.

The most challenging part of this all is to cleanly integrate my previous work in qmetaobject. I will probably do this after I get Whisperfish 0.6-alpha.1 out, so don't expect it very soon :-)

Be-ing commented 3 years ago

I'm trying to adapt this clever architecture @rubdos came up with for driving the Qt event loop with Tokio in Whisperfish so it runs on a typical Linux stack without Sailfish OS (primarily so I can have a Signal client on my PinePhone, but replacing the Signal Electron application on my laptop would be nice too). I'm puzzled why it is deadlocking in the QGuiApplication constructor on Wayland. Sailfish OS uses Qt 5.6 and I'm using Qt 5.12.2 on my laptop running Fedora 34, however the QtWaylandClient::QWaylandDisplay::forceRoundTrip function where it deadlocks has not changed between Qt 5.6 and 5.12.

My best guess is that the TokioQEventDisptacher class that @rubdos wrote to process Qt events within the Tokio event loop is violating some undocumented assumption of Qt. Interestingly this occurs before any event loop is started, and this would be executed before calling QGuiApplication::exec in a C++ Qt application or a qmetaobject-rs application with an architecture similar to the examples. If we can resolve this, it would be cool to merge the code upstream into this crate or separate it into its own crate. @ogoffart @ratijas, if you could take a look at my summary of the issue and share any insights, that would be much appreciated.

rubdos commented 3 years ago

More brain dump.

The approach that I took in Whisperfish involved polling the Qt event loop from Tokio. Qt would register its timers and sockets through the QAbstractEventDispatch interface, and Tokio would do the necessary polling. One big issue here, is that the Wayland events (SailfishOS runs on a Wayland compositor) were to be polled through a private, undocumented Qt interface. This broke when Jolla discovered they had still to update their QWayland dependency from 5.4 to 5.6 (wow), because apparently I was assuming things I shouldn't have assumed. In short: polling Qt from Tokio is unmaintainable.

A year ago, when I started the intertwining of the Qt and Tokio loops, I wrote about three approaches. Tokio-poll-Qt, Qt-polls-Tokio, and both running their own lives in their own threads and using synchronisation primitives.

Since Literally Everything™ broke two weeks ago, I started reworking the event loop intertwining, and I think the new approach is a valid one for inclusion in either qmetaobject-rs, or possibly in a new subcrate qmetaobject-tokio-rs or alike. The new approach uses Qt-polls-Tokio, with a small amount of threading involved.

At program start, I create (but don't start) a single threaded Tokio executor. This name is a bit ill-chosen: the single threaded Tokio executor is capable of running on multiple threads, but spawned futures stay on their own thread. I think this is crucial if you want friendly cooperation with Qt, but I'm not sure. It is important to me, because I'm running Actix.

Notably, I manually create the Runtime and LocalSet that would drive Tokio's loop, and store both in thread-local-storage. Then, I spawn this future (slightly simplified) on the Qt event loop, which polls the LocalSet:

    qmetaobject::future::execute_async(futures::future::poll_fn(move |cx| {
        RUNTIME.with(|rt| {
            let _guard = rt.enter();
            LOCALSET.with(|ls| {
                let mut ls = ls.borrow_mut();

                // If the localset is "ready", the queue is empty.
                let _rdy = futures::ready!(futures::Future::poll(std::pin::Pin::new(&mut *ls), cx));
                log::warn!("LocalSet is empty, application will either shut down or lock up.");

                std::task::Poll::Ready(())
            })
        })
    }));

This ensures that tokio::spawn'd futures get polled by Qt on the main thread. I also have a with_executor() method, such that callbacks from Qt can be run in the executor context, for example with_executor(|| tokio::spawn(async {})) can be called from a qt_method!() context. I've also conveniently wrapped that in a proc_macro, such that this works:

#[derive(QObject, Default)]                        
pub struct Foo {                          
    base: qt_base_class!(trait QObject),  
    count: qt_method!(fn(&self) -> usize),         
}

impl Foo {
    #[with_executor]
    fn count(&self) -> usize { tokio::spawn(async {}) }
}

Now, this is not enough to drive Tokio's I/O and networking. For that, the Runtime needs starting. Runtime::block_on is a clever system though. In a single-threaded executor, the first call to block_on will drive the I/O for all threads, i.e., it'll wake up the main thread (which is Qt) if there's an I/O or socket dependency. To make that work, I've spawned this:

    // Spawn the TOkio I/O loop on a separate thread.
    // The first thread to call `block_on` drives the I/O loop; the I/O that gets spawned by other
    // threads gets polled on this "main" thread.
    let (tx, rx) = futures::channel::oneshot::channel();
    let driver = std::thread::Builder::new()
        .name("Tokio I/O driver".to_string())
        .spawn(move || {
            runtime.block_on(async move {
                rx.await.unwrap();
            });
        })?;

    with_runtime(|rt| {
        let _guard = rt.enter();
        app.exec();
        tx.send(()).unwrap();
    });
    driver.join().unwrap();

This starts the Tokio+mio I/O polling on a separate thread, until Qt decides the application has quit, which will also quit the Tokio I/O polling thread.


I think this is a valid approach for a really clean interaction between Tokio and Qt (unlike the previous one). The diffstat for Whisperfish is around +60-600, and the application runs smoother than before.

Moreover, the approach with the proc_macro attribute might be moot, if we could integrate this approach into qmetaobject: if async_executor_exists {with_executor( CALL_METHOD_HERE() )}.

I'm a bit sad that the Tokio I/O needs its own thread, but given that Qt already starts a dozen threads for itself, having one for my own isn't that bad I guess.

rubdos commented 3 years ago

I have extracted the above idea into this crate: https://gitlab.com/rubdos/qmeta-async , and I'm converting Whisperfish such that it uses that.

rubdos commented 2 years ago

I have now also talked about this on the Belgian Rust Meetup. Video here: https://video.rubdos.be/w/bhhMcctgLXTX5hrVARw3eu?start=51m45s (starts at 51m45s, but that's in the link)