Tokio Stream & Borrow checker: argument requires that `context` is borrowed for `'static`

Hi,

I'm working on an async message client and the overall idea is two spawn two background tasks:

  1. For handling errors from an error channel
  2. For handling data from a data channel.

The client in question is implemented in a relatively simple way meaning in only provides a connection and some methods to login or request data.

Client code as reference on Github.

When the data / error handler does not contain any reference as an argument, things work as expected.

Here is a simple working example:

  1. The data handler code
  2. The main method code

However, when the data handler contains an argument with a reference, the borrow checker correctly points out that the reference must be borrowed against static to comply with requirement set by the Tokio runtime. And that is were I am stuck in the mud.

In the main method, the compiler still throws an error that the reference must be borrowed against static.

Main code (Link to Github):

    let node_capacity = 50;
    let context = build_context::build_time_data_context(&data, &TimeScale::Month, node_capacity)
        .expect("[main]:  to build context");

    // Borrow checker problem due to lifeline:
    //
    //  ^^^^^^^^ borrowed value does not live long enough
    let causaloid = model::build_main_causaloid(&context);

    // argument requires that `context` is borrowed for `'static`
    let model = model::build_causal_model(&context, causaloid);

All three builder functions take a lifetime parameter and return an object with a lifetime parameter because context, causaloid, and model are defined in that way in an external crate. For example, the build model function has the following signature:
(Link to Github)

pub fn build_causal_model<'l>(
//CustomContext and CustomCausaloid are type aliases to more comprehensive generic types. 
    context: &'l CustomContext<'l>,
    causaloid: CustomCausaloid<'l>,
) -> CustomModel<'l> {
// Impl
}

Furthere down, in main, I try to spwan the background task:
(Link to GitHub)

    // Model is wrapped in an Arc<RwLock<CustomModel>> to allow multiple threads to access it.
    // However, this does not solve the borrow checker problem from above.
    let model =   Arc::new(RwLock::new(model)) as  Arc<RwLock<CustomModel<'_>>>;

    println!("{FN_NAME}: Start the data handlers",);
    let data_topic = client_config.data_channel();
    tokio::spawn(async move {
        if let Err(e) = channel_handler::handle_data_channel_with_inference(
            &data_topic,
            data_handler::handle_data_message_inference,
            model,
        )
        .await
        {
            eprintln!("[QDClient/new]: Consumer connection error: {}", e);
        }
    });

I thought wrapping the message handler and the model argument in a Arc/RwLock would do that trick but that's not the case. Instead, I still get the lifetime error:

error[E0597]: `context` does not live long enough
   --> flv_qd_client_examples/causal_data_inference/src/bin/main.rs:79:47
    |
70  |     let context = build_context::build_time_data_context(&data, &...
    |         ------- binding `context` declared here
...
79  |     let causaloid = model::get_main_causaloid(&context);
    |                                               ^^^^^^^^ borrowed value does not live long enough
80  |     let model = model::build_causal_model(&context, causaloid);
    |                 ---------------------------------------------- argument requires that `context` is borrowed for `'static`
...
136 | }
    | - `context` dropped here while still borrowed

Apparently, this does not work. However, when I uncomment the Tokio block that spwans the background task for the data handler, everything compiles fine.

However, I did read somewhere that for Tokio Async Tasks, in general, you would have to wrap the entire processor in an Arc/RwLock to make internal lifelines work. However, for my message processing, I could not figure this out. I did searched the Tokio documentation, but when it comes to stream processing the section is rather short and brief.

Therefore, my question:

How do I construct a message processor that encapsulates the internal lifelines in such a way that it is safe to use on the Tokio runtime?

Any help on this is sincely appriciated.

Thank you

A spawned task needs to own, not borrow, all the data it is spawned with.

All three builder functions take a lifetime parameter and return an object with a lifetime parameter because context, causaloid, and model are defined in that way in an external crate.

I consider this a design flaw in the library, presuming that the returned structures are not just cheap-to-construct temporary data. This happens on a regular basis — library authors think “I'll use references and this means nobody has to copy any data! Using references is Rusty!” But this breaks down as soon as you want to do something with the library that isn't shaped like a single function of this sort:

fn do_something() {
    let a = A::new();
    let b = B::new(&a);
    // ...use b...
    // a and b are dropped at the end of the function
}

It works great for simple fn main() { use A and B } examples, so it can be hard to notice the problem.

What you can do is have these kind of borrows inside your spawned task — ordinary let but inside the async {} block. You can even start with Arc for all of the parts that don't contain references, but are referenced:

use std::sync::Arc;
fn do_spawn() {
    let a1 = Arc::new(A::new());
    let a2 = a1.clone();
    tokio::spawn(async move {
        let b = B::new(&a);
        // ...use b...
    });
    tokio::spawn(async move {
        let b = B::new(&a);
        // ...use b...
    });
}

If you can't structure your code to keep the borrows tidy like this, you could consider using Box::leak() to create references that are valid forever. This is only appropriate if the borrowed data is created once and not replaced over the entire lifespan of the process, since otherwise it would be a memory leak.

The last resort is to use ouroboros, which lets you write a struct that contains a field that borrows another field. Such things are not really an intended use of Rust references, and there have been many failed attempts — they were discovered to be unsound. ouroboros currently has no known, unfixed soundness bugs, but still it would be wise not to design code that requires it, to reduce risk.

1 Like

@kpreid

Thank you. Moving these borrows inside the spawned tasks did the trick. That worked out meaning the code compiles and runs.

I am actually the library author and I haggled with this decision quite a bit for all the reasons you have spelled out. The Dilemma I faced came from the reality that those generated contexts can grow really large. I had a couple of test cases were a generated context exceeded 20GB in memory and passing that much memory by value would make the library quite a memory hog so passing by references does reduce a fair amount of memory usage whenever you avoid an expensive clone, but I knew that concurrency will be a lot harder than it has to be.

Also, I don't really like the idea to tell people to just buy a bigger computer when a bit of optimization can cut down memory usage significantly. I actually trimmed memory usage by about 50% when I optimized the backing data structure of the hypergraph that stores the context. There is still some potential left, but writing a new sparse matrix implementation that beats the status quo is clearly outside my current expertise.

There are two scenarios when this matters:

  1. Static context as in the example code. This is the easier one as you front-load the complex initialization so whether you pass by value or by reference does not change the reality that this structure remains immutable and invariant.

  2. Dynamic context. Here you pre-construct only a structure, but context values get updated during runtime. That is actually where the above dillema hurts the most because you do not only deal with shared mutable state in Tokio, but also lifetimes and references and you get it all at once.

Background is, when I wrote the DeepCausality crate last year, I didn't had the experience in Rust that I have now, async & concurrency was not really a concern back then because I still had to figure out how to implement generic recursive causal data structures efficiently, and lastly, this is the very first crate I ever wrote and published. Obviously, you cannot get everything right on the first run.

I am very open for suggestions how to solve this better because I acknowledge the current API is clearly not the best design, but I am mindful that these contexts do grow large in memory and that needs to be considered carefully.

Sounds like you could use a Box to me. ( But I may not have understood your problem correctly ).

Sounds like a valid option. I remember, the book actually cited big memory usage as one reason to use a Box. I give it a try and see how it plays along with Tokio. Thanks for the hint.

Yes, use Box for data which is "too big to move". However, that's rarely needed, because most large data is dynamically-sized data, which will be inside a Vec or some such container already, which is a heap allocation already.

The other thing to consider is using Arc, as I sort of already mentioned; this is appropriate when the large data is to be shared between multiple independent tasks, because multiple cloned Arc<T> can point at one T, just like &T can.

You can even use Arc<[T]> in place of Vec<T> and Arc<str> in place of String — this is generally appropriate in the case where you find yourself writing Arc<Vec<T>> or similar, because it keeps one pointer and one allocation instead of two. (I'm not saying you should replace every vector and string this way.)

Thank you @kpreid

Arc<[T]> sounds sensible in this particular case.

I've opened an issue with to track the removal of all lifelines from the public API. Should made it into the next release.

Thanks for the help