Background Computation

Relevant official examples: async_compute, external_source_external_thread.


Sometimes you need to perform long-running background computations. You want to do that in a way that does not hold up Bevy's main frame update loop, so that your game can keep refreshing and feeling responsive with no lag spikes.

To do this, Bevy offers a special AsyncComputeTaskPool. You can spawn tasks there, and Bevy will run them on special CPU threads dedicated for the purpose of running background computations.

When you initiate the task, you get a Task handle, which you can use to check for completion.

It is common to write two separate systems, one for initiating tasks and storing the handles, and one for handling the finished work when the tasks complete.

use bevy::tasks::futures_lite::future;
use bevy::tasks::{block_on, AsyncComputeTaskPool, Task};

#[derive(Resource)]
struct MyMapGenTasks {
    generating_chunks: HashMap<UVec2, Task<MyMapChunkData>>,
}

fn begin_generating_map_chunks(
    mut my_tasks: ResMut<MyMapGenTasks>,
) {
    let task_pool = AsyncComputeTaskPool::get();
    for chunk_coord in decide_what_chunks_to_generate(/* ... */) {
        // we might have already spawned a task for this `chunk_coord`
        if my_tasks.generating_chunks.contains_key(&chunk_coord) {
            continue;
        }
        let task = task_pool.spawn(async move {
            // TODO: do whatever you want here!
            generate_map_chunk(chunk_coord)
        });
        my_tasks.generating_chunks.insert(chunk_coord, task);
    }
}

fn receive_generated_map_chunks(
    mut my_tasks: ResMut<MyMapGenTasks>
) {
    my_tasks.generating_chunks.retain(|chunk_coord, task| {
        // check on our task to see how it's doing :)
        let status = block_on(future::poll_once(task));

        // keep the entry in our HashMap only if the task is not done yet
        let retain = status.is_none();

        // if this task is done, handle the data it returned!
        if let Some(mut chunk_data) = status {
            // TODO: do something with the returned `chunk_data`
        }

        retain
    });
}
// every frame, we might have some new chunks that are ready,
// or the need to start generating some new ones. :)
app.add_systems(Update, (
    begin_generating_map_chunks, receive_generated_map_chunks
));

Internal Parallelism

Your tasks can also spawn additional independent tasks themselves, for extra parallelism, using the same API as shown above, from within the closure.

If you'd like your background computation tasks to process data in parallel, you can use scoped tasks. This allows you to create tasks that borrow data from the function that spawns them.

Using the scoped API can also be easier, even if you don't need to borrow data, because you don't have to worry about storing and awaiting the Task handles.

A common pattern is to have your main task (the one you initiate from your systems, as shown earlier) act as a "dispacher", spawning a bunch of scoped tasks to do the actual work.

I/O-heavy Workloads

If your intention is to do background I/O (such as networking or accessing files) instead of heavy CPU work, you can use IoTaskPool instead of AsyncComputeTaskPool. The APIs are the same as shown above. The choice of task pool just helps Bevy schedule and manage your tasks appropriately.

For example, you could spawn tasks to run your game's multiplayer netcode, save/load game save files, etc. Bevy's asset loading infrastructure also makes use of the IoTaskPool.

Passing Data Around

The previous examples showcased a "spawn-join" programming pattern, where you start tasks to perform some work and then consume the values they return after they complete.

If you'd like to have some long-running tasks that send values back to you, instead of returning, you can use channels (from the async-channel crate). Channels can also be used to send data to your long-running background tasks.

Set up some channels and put the side you want to access from Bevy in a resource. To receive data from Bevy systems, you should poll the channels using a non-blocking method, like try_recv, to check if data is available.

use bevy::tasks::IoTaskPool;
use async_channel::{Sender, Receiver};

/// Messages we send to our netcode task
enum MyNetControlMsg {
    DoSomething,
    // ...
}

/// Messages we receive from our netcode task
enum MyNetUpdateMsg {
    SomethingHappened,
    // ...
}

/// Channels used for communicating with our game's netcode task.
/// (The side used from our Bevy systems)
#[derive(Resource)]
struct MyNetChannels {
    tx_control: Sender<MyNetControlMsg>,
    rx_updates: Receiver<MyNetUpdateMsg>,
}

fn setup_net_session(
    mut commands: Commands,
) {
    // create our channels:
    let (tx_control, rx_control) = async_channel::unbounded();
    let (tx_updates, rx_updates) = async_channel::unbounded();

    // spawn our background i/o task for networking
    // and give it its side of the channels:
    IoTaskPool::get().spawn(async move {
        my_netcode(rx_control, tx_updates).await
    }).detach();

    // NOTE: `.detach()` to let the task run
    // without us storing the `Task` handle.
    // Otherwise, the task will get canceled!

    // (though in a real application, you probably want to
    // store the `Task` handle and have a system to monitor
    // your task and recreate it if necessary)

    // put our side of the channels in a resource for later
    commands.insert_resource(MyNetChannels {
        tx_control, rx_updates,
    });
}

fn handle_net_updates(
    my_channels: Res<MyNetChannels>,
) {
    // Non-blocking check for any new messages on the channel
    while let Ok(msg) = my_channels.rx_updates.try_recv() {
        // TODO: do something with `msg`
    }
}

fn tell_the_net_task_what_to_do(
    my_channels: Res<MyNetChannels>,
) {
    if let Err(e) = my_channels.tx_control.try_send(MyNetControlMsg::DoSomething) {
        // TODO: handle errors. Maybe our task has
        // returned or panicked, and closed the channel?
    }
}

/// This runs in the background I/O task
async fn my_netcode(
    rx_control: Receiver<MyNetControlMsg>,
    tx_updates: Sender<MyNetUpdateMsg>,
) {
    // TODO: Here we can connect and talk to our multiplayer server,
    // handle incoming `MyNetControlMsg`s, send `MyNetUpdateMsg`s, etc.

    while let Ok(msg) = rx_control.recv().await {
        // TODO: do something with `msg`

        // Send data back, to be handled from Bevy systems:
        tx_updates.send(MyNetUpdateMsg::SomethingHappened).await
            .expect("Error sending updates over channel");

        // We can also spawn additional parallel tasks
        IoTaskPool::get().spawn(async move {
            // ... some other I/O work ...
        }).detach();
        AsyncComputeTaskPool::get().spawn(async move {
            // ... some heavy CPU work ...
        }).detach();
    }
}
app.add_systems(Startup, setup_net_session);
app.add_systems(FixedUpdate, (
    tell_the_net_task_what_to_do,
    handle_net_updates,
));

Make sure to add async_channel to your Cargo.toml:

[dependencies]
async-channel = "2.3.1"

Wider Async Ecosystem

Bevy's task pools are built on top of the smol runtime.

Feel free to use anything from its ecosystem of compatible crates:

  • async-channel - Multi-producer multi-consumer channels
  • async-fs - Async filesystem primitives
  • async-net - Async networking primitives (TCP/UDP/Unix)
  • async-process - Async interface for working with processes
  • async-lock - Async locks (barrier, mutex, reader-writer lock, semaphore)
  • async-io - Async adapter for I/O types, also timers
  • futures-lite - Misc helper and extension APIs
  • futures - More helper and extension APIs (notably the powerful select! and join! macros)
  • Any Rust async library that supports smol.

Using Your Own Threads

While not typically recommended, sometimes you might want to manage an actual dedicated CPU thread of your own. For example, if you also want to run another framework's runtime (such as tokio) in parallel with Bevy. You might have to do this if you have to use crates built for another async ecosystem, that are not compatible with smol.

To interoperate with your non-Bevy thread, you can move data between it and Bevy using channels. Do the equivalent of what was shown in the example earlier on this page, but instead of async-channel, use the channel types provided by your alternative runtime (such as tokio), or std/crossbeam for raw OS threads.