How do I run an async task and update UI when finished?

I’m creating a simple app using the Simple Async template, and currently based on the simple counter app. When I hit the D key, the program shows a “Scanning” message, does a potentially slow network operation (detect all ONVIF cameras on the network) and then shows the count of detected devices.

It works, but the UI blocks until the network operation has finished, 1 or 2 second. So I never see the “Scanning” message.

I’m trying to use tokio::spawn to run the network operation asynchronously, but I’m having a hard time with this code:

pub struct App {
    pub running: bool,
    pub counter: u8,
    /// Show the spinner when the app is doing something
    pub show_spinner: bool
}

impl App {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn set_counter(&mut self, value: u8) {
        self.counter = value;
    }

    
    pub async fn discover_devices(&mut self) {
        self.toggle_spinner();
        tokio::spawn(async move {
            match core::discover::discover_onvif_devices().await {
                Ok(devices) => {
                    self.set_counter(devices.len() as u8);
                }
                Err(_e) => {
                    // error!("Error discovering devices: {}", e);
                    self.set_counter(0);
                }
            }
            self.toggle_spinner();
        });
    }

    pub fn toggle_spinner(&mut self) {
        self.show_spinner = ! self.show_spinner;
    }
}

Rust complains with this error:

error[E0521]: borrowed data escapes outside of method
  --> tui/src/app.rs:60:9
   |
58 |       pub async fn discover_devices(&mut self) {
   |                                     ---------
   |                                     |
   |                                     `self` is a reference that is only valid in the method body
   |                                     let's call the lifetime of this reference `'1`
59 |           self.toggle_spinner();
60 | /         tokio::spawn(async move {
61 | |             match core::discover::discover_onvif_devices().await {
62 | |                 Ok(devices) => {
63 | |                     self.set_counter(devices.len() as u8);
...  |
70 | |             self.toggle_spinner();
71 | |         });
   | |          ^
   | |          |
   | |__________`self` escapes the method body here
   |            argument requires that `'1` must outlive `'static`

I’m still trying to understand how async code works on Rust; but what I understand so far is that there’s a possibility that the App instance might be gone by the time my async code finishes so I must do something about it. What I don’t understand is what? Do I need to add a lock on App.counter or something like that? What are the options available?

I would create a Channel and use it to communicate with the main app task/thread to toggle the spinner probably in the place where you run the UI drawing loop.

1 Like

If you’re new to async, I highly recommend reading through the tokio tutorials at https://tokio.rs before going futher.

What you’re running into here in your disover method is:

  • it takes a mutable reference to self (there can only every be one mutable reference at a time to something in rust, so this is fine
  • You’re then trying to move that reference into the spawned task to be used later by the self.set_counter calls. If that worked then the task would be the only thing allowed to modify self until it finishes.
  • The task outlives the discover_devices method, but the reference to the mutable self is only valid for the lifetime of the disover method (that’s the contract that the method parameter implies). When the method is done, the ownership of self passes back to the method’s caller.
  • So if the mutable reference to self is moved, but it has to be returned, then there’s a problem

That explains why this is a problem. Now let’s look at what you can do to fix it. This basically boils down to 3 approaches:

  • Shared state (Arc) with a the ability for individual scopes to lock access to the state for writing (Mutex)
  • Message passing using channels / oneshot (mpsc::Sender / mpsc::Receiver / oneshot::Sender / oneshot::Receiver)
  • Atomic variables / other primitives like this

The last is the simplest of them all, but usually only works for simple types - take a look at AtomicU64, AtomicBool etc. These allow multiple threads / tasks to safely update values and see the changes.

The shared state aproach has start with an Arc, which allows multiple pointers to the same data to be spread around your app safely. Then inside that you wrap a Mutex. That wraps your state (which can be any value). To read or write to the value requires you to first lock the value and then update it. A common pattern to simplify this and avoid littering the lock/unlock code all around your app is to wrap the Arc<Mutex<>> inside your type and provide methods which read / upate.

The other approach is sending messages using channels. Creating these is fairly simple too. You create a sender and receiver pair (using e.g. the mpsc::channel() method) and pass the sender to the code which is running in the background and keep the receiver. Then when the background task is done it sends a message. The main task needs to have some portion of its code work in a way which waits for one of the events to succeed (e.g. by calling the tokio::select! macro).

In complexity order you have atomic types, shared state, message passing. In how powerful these methods are, you might reverse the order.

To fix your app, you could use an AtomicU64 and an AtomicBool. If you needed to report details of what happened though (that error), you might consider using the message passing approach.

1 Like