The Iron Code

triangles

Combining tokio and egui part one: Simple immediate evaluation

written on 2022-12-10 18:05

Immediate mode guis and why we do this

Egui is one of the most used and loved immediate mode gui frameworks in rust.

Some of the advantages of using egui include:

  • Performance
  • Nice appearance of the widgets
  • Works on most platforms
  • Plain rust, no bindings
  • Easy to setup and get running

So when you try to develop a simple frontend for some stuff you wrote, chances are that egui is exactly what you are looking for. I would even consider it for some larger projects.

Why immediate mode?

Immediate mode guis have different design principles than their counterparts which are called retained guis (think Qt or GTK). In classical retained mode guis the developer somehow describes the layout and then specifies what functions are to be called in which events. Then, at runtime, the gui is displayed and a click on a button is emitting a signal which will be processed inside of an event loop. While you should never take too much time inside a directly called "slot" on the main thread, you can get away with a lot of delay even in commercial softwares. While this makes development easier and faster it's also an invitation to do so much that it really annoys users in the end with a sluggish gui. Indeed, it's not exactly "fail early" and can lead to bloated gui/logic frankenstein code that nobody likes to clean up after a few months.

In the immediate mode approach, an update function is called each frame - which means that each frame the layout has to be specified again. Sounds slow and inconvenient? If done right, it can be 'blazingly fast'. Talking about convenience, well it is obviously subjective. While some really like to connect stuff together without hassle, others may emphasize the clean and direct way immediate mode guis interact with the application state.

No matter the taste, the main hurdle to keep an application smooth is: Make absolutely sure to do no (expensive) work inside the update function. If you waste a few too many milliseconds, even input events may vanish (depending on imgui and os) and users will be angry. This forces the developer to offload every possible operation to another thread, or better: all possible operations should be done asynchronously.

In my last blog entry about async / await I already spoilered: tokio can help us out big time here. We don't have to think about threads, handles, and joining. But we still have to navigate around a few pitfalls and solve some problems.

Getting to work

Making tokio available

First of all, let's checkout this cool snipped from the eframe docs:

// entry point
fn main() {
    let native_options = eframe::NativeOptions::default();
    eframe::run_native("My egui App", native_options, Box::new(|cc| Box::new(MyEguiApp::new(cc))));
}

// our application state
#[derive(Default)]
struct MyEguiApp {}

// ...

impl eframe::App for MyEguiApp {
    /// the update method we have to keep fast
    fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
        egui::CentralPanel::default().show(ctx, |ui| {
            ui.heading("Hello World!");
        });
    }
}

The struct MyEguiApp should contain all application state we need to draw our gui. The update method from the egui::App trait is the function being called each frame and needs to tell the context what to draw. The first thing we need to do is bring tokio into this. We need to make a tokio::Runtime instance available to all places that need to spawn async tasks. This can be done either by explicitly instantiating one and handing it through to all places, or by setting a default runtime. I would totally suggest using the macros feature of tokio to get us into async-land quickly and make a tokio runtime available everywhere:

#[tokio::main]
async fn main() { /* .. */  }

This sets a new default runtime and therefore allows us to spawn futures with tokio::spawn instead of handing an instance around.

N.B.: Another option yielding the same result would be to keep the main function sync and set the runtime up yourself, wrapping all the code originally inside the main function in an async block and using Instance::block_on to spawn it. I don't see any benefit in this, hence the macro-usage.

Start simple: Immediately spawned single values

Why not do it by hand?

Let's create a future and execute it. Since we are almost always in the update function, we can just poll the future ourselves, right? A future in itself is nothing to be afraid of, right??

Unfortunately, that's easier said than done! I suggest reading a little into the rust-lang's async-book but manually managing futures by hand is problematic. You need to pin the future so stuff isn't moved first, then for polling you'd have to get a context up which needs a waker for construction which in turn... Let's just not do it today :)

Okay, b2tokio then: Communication is key

So let's talk about spawning futures with tokio::spawn. It allows us to spawn any future from any place, "for free" - (un)fortunately we will never know in which thread we end up. Also, where to write the result of our future? In the end, it's just a problem of cross thread communication.

There's several solutions, we will cover two in the scope of this series:

  1. "Shared Memory" with Arc and Mutex
  2. Channels (tokio::mpsc is what we will use)

Let's go with the shared memory approach first.

So back in the day in C we would allocate memory for the result struct leading with a bool set to false. The finishing worker thread, as last action after completing, would then set this bool flag to true to indicate that the computation is finished. Who releases the memory? And which thread even is the last if there are many worker threads on the same memory? Also, who frees it again and when? From a modern perspective this is just plain madness...

Fortunately, while stuff is more complex now, it's also safer. Today we can have an instance of a struct safely shared between threads e.g. by using Arc in Rust. This allows us to keep track of reference count over the thread barrier. When the last owner of an Arc instance goes out of scope, the struct inside will also be freed. It's a thread-safe version of C++'s std::shared_ptr. Analogue to the std::shared_ptr, access to the payload is not mutually exclusive by itself. We have to nest some kind of concurrent access control inside. The simplest (and often a good choice if you don't really know what you need) is Mutex (mutually exclusive). While one thread accesses the payload, the others have to wait. Note, that there's other forms of concurrent access control like reader-writer-locks, but we will stick to mutex for now.

Okay, let's implement a solution!

struct MyEguiApp {
    calculation_result: Arc<Mutex<Option<i32>>>,
}

impl Default for MyEguiApp {
    fn default() -> Self {
        let calculation_result = Arc::new(Mutex::new(None));
        let result_for_thread = calculation_result.clone();

        tokio::spawn(async move {
            tokio::time::sleep(Duration::from_millis(5000)).await;
            let mut mutex_lock = result_for_thread.lock().await;
            *mutex_lock = Some(42);
        });

        MyEguiApp { calculation_result }
    }
}


impl eframe::App for MyEguiApp {
    fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
        egui::CentralPanel::default().show(ctx, |ui| {
            if let Ok(val) = self.calculation_result.try_lock() {
                if let Some(val) = *val {
                    ui.label(format!("Value is {}", val));
                } else {
                    ui.spinner();
                }
            }
        });
    }
}

So we nested Arc<Mutex<Option<T>>> to allow us to initialize this as a member of the application state without handing a prior value in. Using Option for delayed initialization is great thing. The result_for_thread is moved inside the future by specifying the async move, but it points to the same memory as calculation_result that is moved into our application state. Our calculation future is a stupid one for the demo reason. It just waits a bit, then locks the mutex, waits until it has acquired a lock and then writes a value inside. Done...

So there's a gazillion of flaws here:

  1. The calculation of all futures is started on creation of the app - that does not scale
  2. No error handling inside the future
  3. Each update() we lock the mutex, even though the value is already calculated

Be lazy

While the first might be the most urgent to get a working gui-app out there, it's also the easiest to fix. We can just wrap the Arc inside an Option and create something like Option<Arc<Mutex<Option<T>>>> which we can initialize with Option::get_or_insert(). We can even outsource the future-construction to a logic module and keep the display stuff separated. That solves the initialization problem in a simple and idiomatic way, again using Option for deferred initialization.

Error handling

The second issue with error handling can be done by making the future not return T but Result<T, Box<dyn Error + Send>>. N.B.: I will not add lazy initialization the code samples below, since it distracts from the error handling Now, we have a first shot for error handling:

type SendableError = Box<dyn Error + Send>;
struct MyEguiApp {
    calculation_result: Arc<Mutex<Option<Result<i32, SendableError>>>>,
}
impl Default for MyEguiApp {
    fn default() -> Self {
        let calculation_result = Arc::new(Mutex::new(None));
        let result_for_thread = calculation_result.clone();

        let value_future = async move {
            tokio::time::sleep(Duration::from_millis(5000)).await;
            let file = File::open("non_existent").map_err(|e| {
                let se: SendableError = Box::new(e);
                se
            })?;
            Ok(42)
        };

        tokio::spawn(async move {
            let result = value_future.await;
            let mut mutex_lock = result_for_thread.lock().await;
            *mutex_lock = Some(result);
        });

        MyEguiApp { calculation_result }
    }
}

We really want to use the ? operator, but to enable us to do it, we have to have an error which can be into()'ed the result error type. Since the result is possibly generated on another thread, the error has to be Send. While this is not a big deal, there's no blanket implementation available so we have to map_err() our way into the box by hand. This might be feasible for the simple example but adds so much bloat to more complex code, that it's not acceptable.

Another thing we added is a "future-evaluating future" which just awaits the "payload" future and then stuffs its result into our shared memory. While this adds a little complexity, it is totally agnostic of the type of our future and can be reused. The idea is to have - same as with functions - a single responsibility (SRP) for each future. Long story short:

  • Improve that dang error handling...

Improved error handling

Let's deal with the error issue first and finalize our error handling. By the orphan rule, we can't implement From since we neither own the struct Box nor the trait. We can help ourselves here with the "New Type Idiom", which allows us to simply make a new type out of an existing one. Let's make our own boxed error and implement Deref so we can use our error comfortably:

pub struct BoxedSendError(Box<dyn Error + Send>);
impl Deref for BoxedSendError {
    type Target = Box<dyn Error + Send>;
    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

Now, we just have to blanket-implement the From trait for this:

impl<E: Error + Send + 'static> From<E> for BoxedSendError {
    fn from(e: E) -> Self {
        BoxedSendError(Box::new(e))
    }
}

This shrinks our implementation to:

struct MyEguiApp {
    calculation_result: Arc<Mutex<Option<Result<i32, BoxedSendError>>>>,
}
impl Default for MyEguiApp {
    fn default() -> Self {
        let calculation_result = Arc::new(Mutex::new(None));
        let result_for_thread = calculation_result.clone();

        let value_future = async move {
            tokio::time::sleep(Duration::from_millis(5000)).await;
            let file = File::open("non_existent")?;
            Ok(42)
        };

        tokio::spawn(async move {
            let result = value_future.await;
            let mut mutex_lock = result_for_thread.lock().await;
            *mutex_lock = Some(result);
        });

        MyEguiApp { calculation_result }
    }
}

Now, we're almost there! We can now extract the whole "launching" code including the launcher future to a separate generic struct.

N.B.: this struct can then internally cache the value outside the Arc<Mutex> construction to avoid locking the mutex on every poll.

I'm not going to cover this in this post, since the result can be found as ImmediateValuePromise in the crate lazy_async_promise.

So that wraps up our first part of the series on making async play nice with egui, hope you had some fun reading.

Tagged:
General
Programming