The Main Event Loop

We now got comfortable using callbacks, but how do they actually work? All of this happens asynchronously, so there must be something managing the events and scheduling the responses. Unsurprisingly, this is called the main event loop.

Diagram showing the main event loop

The main loop manages all kinds of events — from mouse clicks and keyboard presses to file events. It does all of that within the same thread. Quickly iterating between all tasks gives the illusion of parallelism. That is why you can move the window at the same time as a progress bar is growing.

However, you surely saw GUIs that became unresponsive, at least for a few seconds. That happens when a single task takes too long. The following example uses std::thread::sleep to represent a long-running task.

Filename: listings/main_event_loop/1/main.rs

use std::thread;
use std::time::Duration;

use gtk::prelude::*;
use gtk::{self, glib, Application, ApplicationWindow, Button};

const APP_ID: &str = "org.gtk_rs.MainEventLoop1";

fn main() -> glib::ExitCode {
    // Create a new application
    let app = Application::builder().application_id(APP_ID).build();

    // Connect to "activate" signal of `app`
    app.connect_activate(build_ui);

    // Run the application
    app.run()
}

fn build_ui(app: &Application) {
    // Create a button
    let button = Button::builder()
        .label("Press me!")
        .margin_top(12)
        .margin_bottom(12)
        .margin_start(12)
        .margin_end(12)
        .build();

    // Connect to "clicked" signal of `button`
    button.connect_clicked(move |_| {
        // GUI is blocked for 5 seconds after the button is pressed
        let five_seconds = Duration::from_secs(5);
        thread::sleep(five_seconds);
    });

    // Create a window
    let window = ApplicationWindow::builder()
        .application(app)
        .title("My GTK App")
        .child(&button)
        .build();

    // Present window
    window.present();
}

After we press the button, the GUI is completely frozen for five seconds. We can't even move the window. The sleep call is an artificial example, but frequently, we want to run a slightly longer operation in one go.

How to Avoid Blocking the Main Loop

In order to avoid blocking the main loop, we can spawn a new task with gio::spawn_blocking and let the operation run on the thread pool.

Filename: listings/main_event_loop/2/main.rs

use std::thread;
use std::time::Duration;

use gtk::prelude::*;
use gtk::{self, gio, glib, Application, ApplicationWindow, Button};

const APP_ID: &str = "org.gtk_rs.MainEventLoop2";

fn main() -> glib::ExitCode {
    // Create a new application
    let app = Application::builder().application_id(APP_ID).build();

    // Connect to "activate" signal of `app`
    app.connect_activate(build_ui);

    // Run the application
    app.run()
}

fn build_ui(app: &Application) {
    // Create a button
    let button = Button::builder()
        .label("Press me!")
        .margin_top(12)
        .margin_bottom(12)
        .margin_start(12)
        .margin_end(12)
        .build();

    // Connect to "clicked" signal of `button`
    button.connect_clicked(move |_| {
        // The long running operation runs now in a separate thread
        gio::spawn_blocking(move || {
            let five_seconds = Duration::from_secs(5);
            thread::sleep(five_seconds);
        });
    });

    // Create a window
    let window = ApplicationWindow::builder()
        .application(app)
        .title("My GTK App")
        .child(&button)
        .build();

    // Present window
    window.present();
}

Now the GUI doesn't freeze when we press the button. However, nothing stops us from spawning as many tasks as we want at the same time. This is not necessarily what we want.

If you come from another language than Rust, you might be uncomfortable with the thought of running tasks in separate threads before even looking at other options. Luckily, Rust's safety guarantees allow you to stop worrying about the nasty bugs that concurrency tends to bring.

Channels

Typically, we want to keep track of the work in the task. In our case, we don't want the user to spawn additional tasks while an existing one is still running. In order to exchange information with the task we can create a channel with the crate async-channel. Let's add it by executing the following in the terminal:

cargo add async-channel

We want to send a bool to inform, whether we want the button to react to clicks or not. Since we send in a separate thread, we can use send_blocking. But what about receiving? Every time we get a message, we want to set the sensitivity of the button according to the bool we've received. However, we don't want to block the main loop while waiting for a message to receive. That is the whole point of the exercise after all!

We solve that problem by waiting for messages in an async block. This async block is spawned on the glib main loop with spawn_future_local

See also spawn_future for spawning async blocks on the main loop from outside the main thread.

Filename: listings/main_event_loop/3/main.rs

use std::thread;
use std::time::Duration;

use glib::clone;
use gtk::prelude::*;
use gtk::{gio, glib, Application, ApplicationWindow, Button};

const APP_ID: &str = "org.gtk_rs.MainEventLoop3";

fn main() -> glib::ExitCode {
    // Create a new application
    let app = Application::builder().application_id(APP_ID).build();

    // Connect to "activate" signal of `app`
    app.connect_activate(build_ui);

    // Run the application
    app.run()
}

fn build_ui(app: &Application) {
    // Create a button
    let button = Button::builder()
        .label("Press me!")
        .margin_top(12)
        .margin_bottom(12)
        .margin_start(12)
        .margin_end(12)
        .build();

    // Create channel that can hold at most 1 message at a time
    let (sender, receiver) = async_channel::bounded(1);
    // Connect to "clicked" signal of `button`
    button.connect_clicked(move |_| {
        let sender = sender.clone();
        // The long running operation runs now in a separate thread
        gio::spawn_blocking(move || {
            // Deactivate the button until the operation is done
            sender
                .send_blocking(false)
                .expect("The channel needs to be open.");
            let five_seconds = Duration::from_secs(5);
            thread::sleep(five_seconds);
            // Activate the button again
            sender
                .send_blocking(true)
                .expect("The channel needs to be open.");
        });
    });

    // The main loop executes the asynchronous block
    glib::spawn_future_local(clone!(
        #[weak]
        button,
        async move {
            while let Ok(enable_button) = receiver.recv().await {
                button.set_sensitive(enable_button);
            }
        }
    ));

    // Create a window
    let window = ApplicationWindow::builder()
        .application(app)
        .title("My GTK App")
        .child(&button)
        .build();

    // Present window
    window.present();
}

As you can see, spawning a task still doesn't freeze our user interface. However, now we can't spawn multiple tasks at the same time since the button becomes insensitive after the first task has been spawned. After the task is finished, the button becomes sensitive again.

What if the task is asynchronous by nature? Let's try glib::timeout_future_seconds as representation for our task instead of std::thread::sleep. It returns a std::future::Future, which means we can await on it within an async context. The converted code looks and behaves very similar to the multithreaded code.

Filename: listings/main_event_loop/4/main.rs

use glib::clone;
use gtk::prelude::*;
use gtk::{glib, Application, ApplicationWindow, Button};

const APP_ID: &str = "org.gtk_rs.MainEventLoop4";

fn main() -> glib::ExitCode {
    // Create a new application
    let app = Application::builder().application_id(APP_ID).build();

    // Connect to "activate" signal of `app`
    app.connect_activate(build_ui);

    // Run the application
    app.run()
}

fn build_ui(app: &Application) {
    // Create a button
    let button = Button::builder()
        .label("Press me!")
        .margin_top(12)
        .margin_bottom(12)
        .margin_start(12)
        .margin_end(12)
        .build();

    // Create channel that can hold at most 1 message at a time
    let (sender, receiver) = async_channel::bounded(1);
    // Connect to "clicked" signal of `button`
    button.connect_clicked(move |_| {
        glib::spawn_future_local(clone!(
            #[strong]
            sender,
            async move {
                // Deactivate the button until the operation is done
                sender
                    .send(false)
                    .await
                    .expect("The channel needs to be open.");
                glib::timeout_future_seconds(5).await;
                // Activate the button again
                sender
                    .send(true)
                    .await
                    .expect("The channel needs to be open.");
            }
        ));
    });

    // The main loop executes the asynchronous block
    glib::spawn_future_local(clone!(
        #[weak]
        button,
        async move {
            while let Ok(enable_button) = receiver.recv().await {
                button.set_sensitive(enable_button);
            }
        }
    ));

    // Create a window
    let window = ApplicationWindow::builder()
        .application(app)
        .title("My GTK App")
        .child(&button)
        .build();

    // Present window
    window.present();
}

Since we are single-threaded again, we can even get rid of the channel while achieving the same result.

Filename: listings/main_event_loop/5/main.rs

use glib::clone;
use gtk::prelude::*;
use gtk::{glib, Application, ApplicationWindow, Button};

const APP_ID: &str = "org.gtk_rs.MainEventLoop5";

fn main() -> glib::ExitCode {
    // Create a new application
    let app = Application::builder().application_id(APP_ID).build();

    // Connect to "activate" signal of `app`
    app.connect_activate(build_ui);

    // Run the application
    app.run()
}

fn build_ui(app: &Application) {
    // Create a button
    let button = Button::builder()
        .label("Press me!")
        .margin_top(12)
        .margin_bottom(12)
        .margin_start(12)
        .margin_end(12)
        .build();

    // Connect to "clicked" signal of `button`
    button.connect_clicked(move |button| {
        glib::spawn_future_local(clone!(
            #[weak]
            button,
            async move {
                // Deactivate the button until the operation is done
                button.set_sensitive(false);
                glib::timeout_future_seconds(5).await;
                // Activate the button again
                button.set_sensitive(true);
            }
        ));
    });

    // Create a window
    let window = ApplicationWindow::builder()
        .application(app)
        .title("My GTK App")
        .child(&button)
        .build();

    // Present window
    window.present();
}

But why did we not do the same thing with our multithreaded example?

use std::{thread, time::Duration};

use glib::{clone, MainContext, PRIORITY_DEFAULT};
use gtk::{glib, gio};
use gtk::prelude::*;
use gtk::{Application, ApplicationWindow, Button};

fn main() {
    // Create a new application
    let app = Application::builder()
       .application_id("org.gtk_rs.MainEventLoop6")
       .build();

    // Connect to "activate" signal
    app.connect_activate(build_ui);

    // Get command-line arguments
    let args: Vec<String> = args().collect();
    // Run the application
    app.run(&args);
}

// When the application is launched…
fn build_ui(application: &Application) {
    // Create a window
    let window = ApplicationWindow::builder()
        .application(application)
        .title("My GTK App")
        .build();

    // Create a button
    let button = Button::builder()
        .label("Press me!")
        .margin_top(12)
        .margin_bottom(12)
        .margin_start(12)
        .margin_end(12)
        .build();

    // DOES NOT COMPILE!
    // Connect to "clicked" signal of `button`
    button.connect_clicked(move |button| {
        button.clone();
        // The long running operation runs now in a separate thread
        gio::spawn_blocking(move || {
            // Deactivate the button until the operation is done
            button.set_sensitive(false);
            let five_seconds = Duration::from_secs(5);
            thread::sleep(five_seconds);
            // Activate the button again
            button.set_sensitive(true);
        });
    });

    // Add button
    window.set_child(Some(&button));
    window.present();
}

Simply because we would get this error message:

error[E0277]: `NonNull<GObject>` cannot be shared between threads safely

help: within `gtk4::Button`, the trait `Sync` is not implemented for `NonNull<GObject>`

After reference cycles we found the second disadvantage of GTK GObjects: They are not thread safe.

Embed blocking calls in an async context

We've seen in the previous snippets that spawning an async block or async future on the glib main loop can lead to more concise code than running tasks on separate threads. Let's focus on a few more aspects that are interesting to know when running async functions with gtk-rs apps.

For a start, blocking functions can be embedded within an async context. In the following listing, we want to execute a synchronous function that returns a boolean and takes ten seconds to run. In order to integrate it in our async block, we run the function in a separate thread via spawn_blocking. We can then get the return value of the function by calling await on the return value of spawn_blocking.

Filename: listings/main_event_loop/6/main.rs

use glib::clone;
use gtk::prelude::*;
use gtk::{gio, glib};
use gtk::{Application, ApplicationWindow, Button};
use std::thread;
use std::time::Duration;

const APP_ID: &str = "org.gtk_rs.MainEventLoop6";

fn main() -> glib::ExitCode {
    // Create a new application
    let app = Application::builder().application_id(APP_ID).build();

    // Connect to "activate" signal of `app`
    app.connect_activate(build_ui);

    // Run the application
    app.run()
}

fn build_ui(app: &Application) {
    // Create a button
    let button = Button::builder()
        .label("Press me!")
        .margin_top(12)
        .margin_bottom(12)
        .margin_start(12)
        .margin_end(12)
        .build();

    // Connect to "clicked" signal of `button`
    button.connect_clicked(move |button| {
        // The main loop executes the asynchronous block
        glib::spawn_future_local(clone!(
            #[weak]
            button,
            async move {
                // Deactivate the button until the operation is done
                button.set_sensitive(false);
                let enable_button = gio::spawn_blocking(move || {
                    let five_seconds = Duration::from_secs(5);
                    thread::sleep(five_seconds);
                    true
                })
                .await
                .expect("Task needs to finish successfully.");
                // Set sensitivity of button to `enable_button`
                button.set_sensitive(enable_button);
            }
        ));
    });

    // Create a window
    let window = ApplicationWindow::builder()
        .application(app)
        .title("My GTK App")
        .child(&button)
        .build();

    // Present window
    window.present();
}

Run async functions from external crates

Asynchronous functions from the glib ecosystem can always be spawned on the glib main loop. Typically, crates depending on async-std or smol work as well. Let us take ashpd for example which allows sandboxed applications to interact with the desktop. Per default it depends on async-std. We can add it to our dependencies by running the following command.

cargo add ashpd --features gtk4

You need to use a Linux desktop environment in order to run the following example locally. This example is using ashpd::desktop::account::UserInformation to access user information. We are getting a gtk::Native object from our button, create a ashpd::WindowIdentifier and pass it to the user information request.

We need to pass the WindowIdentifier to make the dialog modal. This means that it will be on top of the window and freezes the rest of the application from user input.

Filename: listings/main_event_loop/7/main.rs

use glib::clone;
use gtk::prelude::*;
use gtk::{glib, Application, ApplicationWindow, Button};

const APP_ID: &str = "org.gtk_rs.MainEventLoop7";

fn main() -> glib::ExitCode {
    // Create a new application
    let app = Application::builder().application_id(APP_ID).build();

    // Connect to "activate" signal of `app`
    app.connect_activate(build_ui);

    // Run the application
    app.run()
}

fn build_ui(app: &Application) {
    // Create a button
    let button = Button::builder()
        .label("Press me!")
        .margin_top(12)
        .margin_bottom(12)
        .margin_start(12)
        .margin_end(12)
        .build();

    // Connect to "clicked" signal of `button`
    button.connect_clicked(move |button| {
        // The main loop executes the asynchronous block
        glib::spawn_future_local(clone!(
            #[weak]
            button,
            async move { fetch_user_information(button).await }
        ));
    });

    // Create a window
    let window = ApplicationWindow::builder()
        .application(app)
        .title("My GTK App")
        .child(&button)
        .build();

    // Present window
    window.present();
}

#[cfg(target_os = "linux")]
async fn fetch_user_information(button: Button) {
    use ashpd::desktop::account::UserInformation;
    use ashpd::WindowIdentifier;

    // Get native of button for window identifier
    let native = button.native().expect("Need to be able to get native.");
    // Get window identifier so that the dialog will be modal to the main window
    let identifier = WindowIdentifier::from_native(&native).await;
    let request = UserInformation::request()
        .reason("App would like to access user information.")
        .identifier(identifier)
        .send()
        .await;

    if let Ok(response) = request.and_then(|r| r.response()) {
        println!("User name: {}", response.name());
    } else {
        println!("Could not access user information.")
    }
}

#[cfg(not(target_os = "linux"))]
async fn fetch_user_information(_button: Button) {
    println!("fetching user information not available outside target_os = \"linux\"");
}

After pressing the button, a dialog should open that shows the information that will be shared. If you decide to share it, you user name will be printed on the console.

Dialog requesting user information.

Tokio

tokio is Rust's most popular asynchronous platform. Therefore, many high-quality crates are part of its ecosystem. The web client reqwest belongs to this group. Let's add it by executing the following command

cargo add reqwest@0.12 --features rustls-tls --no-default-features

As soon as the button is pressed, we want to send a GET request to www.gtk-rs.org. The response should then be sent to the main thread via a channel.

Filename: listings/main_event_loop/8/main.rs

use glib::clone;
use gtk::glib;
use gtk::prelude::*;
use gtk::{Application, ApplicationWindow, Button};

const APP_ID: &str = "org.gtk_rs.MainEventLoop8";

fn main() -> glib::ExitCode {
    // Create a new application
    let app = Application::builder().application_id(APP_ID).build();

    // Connect to "activate" signal of `app`
    app.connect_activate(build_ui);

    // Run the application
    app.run()
}

fn build_ui(app: &Application) {
    // Create a button
    let button = Button::builder()
        .label("Press me!")
        .margin_top(12)
        .margin_bottom(12)
        .margin_start(12)
        .margin_end(12)
        .build();

    let (sender, receiver) = async_channel::bounded(1);
    // Connect to "clicked" signal of `button`
    button.connect_clicked(move |_| {
        // The main loop executes the asynchronous block
        glib::spawn_future_local(clone!(
            #[strong]
            sender,
            async move {
                let response = reqwest::get("https://www.gtk-rs.org").await;
                sender
                    .send(response)
                    .await
                    .expect("The channel needs to be open.");
            }
        ));
    });

    // The main loop executes the asynchronous block
    glib::spawn_future_local(async move {
        while let Ok(response) = receiver.recv().await {
            if let Ok(response) = response {
                println!("Status: {}", response.status());
            } else {
                println!("Could not make a `GET` request.");
            }
        }
    });

    // Create a window
    let window = ApplicationWindow::builder()
        .application(app)
        .title("My GTK App")
        .child(&button)
        .build();

    // Present window
    window.present();
}

This compiles fine and even seems to run. However, nothing happens when we press the button. Inspecting the console gives the following error message:

thread 'main' panicked at
'there is no reactor running, must be called from the context of a Tokio 1.x runtime'

At the time of writing, reqwest doesn't document this requirement. Unfortunately, that is also the case for other libraries depending on tokio. Let's bite the bullet and add tokio:

cargo add tokio@1 --features rt-multi-thread

Since we already run the glib main loop on our main thread, we don't want to run the tokio runtime there. For this reason, we avoid using the #[tokio::main] macro or using a top-level block_on call. Doing this will block one of the runtime's threads with the GLib main loop, which is a waste of resources and a potential source of strange bugs.

Instead, we bind tokio::runtime::Runtime to a static variable.

use std::sync::OnceLock;

use glib::clone;
use gtk::glib;
use gtk::prelude::*;
use gtk::{Application, ApplicationWindow, Button};
use tokio::runtime::Runtime;

const APP_ID: &str = "org.gtk_rs.MainEventLoop0";

// DOES NOT COMPILE!
static RUNTIME: Runtime =
    Runtime::new().expect("Setting up tokio runtime needs to succeed.");

fn main() -> glib::ExitCode {
   // Create a new application
   let app = Application::builder().application_id(APP_ID).build();

   // Connect to "activate" signal of `app`
   app.connect_activate(build_ui);

   // Run the application
   app.run()
}

fn build_ui(app: &Application) {
   // Create a button
   let button = Button::builder()
       .label("Press me!")
       .margin_top(12)
       .margin_bottom(12)
       .margin_start(12)
       .margin_end(12)
       .build();

   // ANCHOR: callback
   let (sender, receiver) = async_channel::bounded(1);
   // Connect to "clicked" signal of `button`
   button.connect_clicked(move |_| {
       RUNTIME.spawn(clone!(#[strong] sender, async move {
           let response = reqwest::get("https://www.gtk-rs.org").await;
           sender.send(response).await.expect("The channel needs to be open.");
       }));
   });

   // The main loop executes the asynchronous block
   glib::spawn_future_local(async move {
       while let Ok(response) = receiver.recv().await {
           if let Ok(response) = response {
               println!("Status: {}", response.status());
           } else {
               println!("Could not make a `GET` request.");
           }
       }
   });
   // ANCHOR_END: callback

   // Create a window
   let window = ApplicationWindow::builder()
       .application(app)
       .title("My GTK App")
       .child(&button)
       .build();

   // Present window
   window.present();
}

Unfortunately, this doesn't compile. As usual, Rust's error messages are really helpful.

cannot call non-const fn `tokio::runtime::Runtime::new` in statics
calls in statics are limited to constant functions, tuple structs and tuple variants
consider wrapping this expression in `Lazy::new(|| ...)` from the `once_cell` crate

We could follow the advice directly, but the standard library also provides solutions for that. With std::sync::OnceLock we can initialize the static with the const function OnceLock::new() and initialize it the first time our function runtime is called.

Filename: listings/main_event_loop/9/main.rs

use std::sync::OnceLock;

use glib::clone;
use gtk::glib;
use gtk::prelude::*;
use gtk::{Application, ApplicationWindow, Button};
use tokio::runtime::Runtime;

const APP_ID: &str = "org.gtk_rs.MainEventLoop9";

fn runtime() -> &'static Runtime {
    static RUNTIME: OnceLock<Runtime> = OnceLock::new();
    RUNTIME.get_or_init(|| {
        Runtime::new().expect("Setting up tokio runtime needs to succeed.")
    })
}

fn main() -> glib::ExitCode {
    // Create a new application
    let app = Application::builder().application_id(APP_ID).build();

    // Connect to "activate" signal of `app`
    app.connect_activate(build_ui);

    // Run the application
    app.run()
}

fn build_ui(app: &Application) {
    // Create a button
    let button = Button::builder()
        .label("Press me!")
        .margin_top(12)
        .margin_bottom(12)
        .margin_start(12)
        .margin_end(12)
        .build();

    let (sender, receiver) = async_channel::bounded(1);
    // Connect to "clicked" signal of `button`
    button.connect_clicked(move |_| {
        runtime().spawn(clone!(
            #[strong]
            sender,
            async move {
                let response = reqwest::get("https://www.gtk-rs.org").await;
                sender
                    .send(response)
                    .await
                    .expect("The channel needs to be open.");
            }
        ));
    });

    // The main loop executes the asynchronous block
    glib::spawn_future_local(async move {
        while let Ok(response) = receiver.recv().await {
            if let Ok(response) = response {
                println!("Status: {}", response.status());
            } else {
                println!("Could not make a `GET` request.");
            }
        }
    });

    // Create a window
    let window = ApplicationWindow::builder()
        .application(app)
        .title("My GTK App")
        .child(&button)
        .build();

    // Present window
    window.present();
}

In the button callback we can now spawn the reqwest async block with tokio rather than with glib.

Filename: listings/main_event_loop/9/main.rs

use std::sync::OnceLock;

use glib::clone;
use gtk::glib;
use gtk::prelude::*;
use gtk::{Application, ApplicationWindow, Button};
use tokio::runtime::Runtime;

const APP_ID: &str = "org.gtk_rs.MainEventLoop9";

fn runtime() -> &'static Runtime {
    static RUNTIME: OnceLock<Runtime> = OnceLock::new();
    RUNTIME.get_or_init(|| {
        Runtime::new().expect("Setting up tokio runtime needs to succeed.")
    })
}

fn main() -> glib::ExitCode {
    // Create a new application
    let app = Application::builder().application_id(APP_ID).build();

    // Connect to "activate" signal of `app`
    app.connect_activate(build_ui);

    // Run the application
    app.run()
}

fn build_ui(app: &Application) {
    // Create a button
    let button = Button::builder()
        .label("Press me!")
        .margin_top(12)
        .margin_bottom(12)
        .margin_start(12)
        .margin_end(12)
        .build();

    let (sender, receiver) = async_channel::bounded(1);
    // Connect to "clicked" signal of `button`
    button.connect_clicked(move |_| {
        runtime().spawn(clone!(
            #[strong]
            sender,
            async move {
                let response = reqwest::get("https://www.gtk-rs.org").await;
                sender
                    .send(response)
                    .await
                    .expect("The channel needs to be open.");
            }
        ));
    });

    // The main loop executes the asynchronous block
    glib::spawn_future_local(async move {
        while let Ok(response) = receiver.recv().await {
            if let Ok(response) = response {
                println!("Status: {}", response.status());
            } else {
                println!("Could not make a `GET` request.");
            }
        }
    });

    // Create a window
    let window = ApplicationWindow::builder()
        .application(app)
        .title("My GTK App")
        .child(&button)
        .build();

    // Present window
    window.present();
}

If we now press the button, we should find the following message in our console:

Status: 200 OK

We will not need tokio, reqwest or ashpd in the following chapters, so let's remove them again by executing:

cargo remove tokio reqwest ashpd

How to find out whether you can spawn an async task on the glib main loop? glib should be able to spawn the task when the called functions come from libraries that either:

  • come from the glib ecosystem,
  • don't depend on a runtime but only on the futures family of crates (futures-io, futures-core etc),
  • depend on the async-std or smol runtimes, or
  • have cargo features that let them depend on async-std/smol instead of tokio.

Conclusion

You don't want to block the main thread long enough that it is noticeable by the user. But when should you spawn an async task, instead of spawning a task in a separate thread? Let's go again through the different scenarios.

If the task spends its time calculating rather than waiting for a web response, it is CPU-bound. That means you have to run the task in a separate thread and let it send results back via a channel.

If your task is IO bound, the answer depends on the crates at your disposal and the type of work to be done.

  • Light I/O work with functions from crates using glib, smol, async-std or the futures trait family can be spawned on the main loop. This way, you can often avoid synchronization via channels.
  • Heavy I/O work might still benefit from running in a separate thread / an async executor to avoid saturating the main loop. If you are unsure, benchmarking is advised.

If the best crate for the job relies on tokio, you will have to spawn it with the tokio runtime and communicate via channels.