Idea : Explicit And More Flexible Lifetime Annotation And Thread's At Exit Hook

Hello, I have this idea that can make memory management more flexible and still zero cost

Here is the specs and example :

fn a() -> (String, String) {
    (
        String::from("Hello"),
        String::from("Hello 2")
    ) until MyLifetime
}

fn a2() -> (String, String) {
    (
        String::from("Hello"),
        String::from("Hello 2")
    ) until MyLifetime2
}

fn b() {

    // the heap above can be freed together from any place
    // if close for MyLifetime is not called yet or called 2 times, it will return compile time error
    close MyLifetime;
    close MyLifetime2;
}

It also supports struct

struct MyData {
    val1: String until Mylifetime1
    val2: String until Mylifetime2
}

fn a() {
    let data = MyData { .... };

    close MyLifetime1;

    // atempt to use the already closed val1 returns compile time error
    println!("{:?}", data.val1); 
}

// can override lifetime
fn b() {
    
    // all the individual lifetime is overriden to MyNewLifetime
    let data = MyData {
        ....
    } until MyNewLifetime;
}

fn c() {
    close MyNewLifetime;
}

The name for the lifetime can be any name. The name is unique, if duplicated name is detected it will return compile time error. If a panoc happen, the close is automatically called. This feels more flexible than scope based lifetime

At first glance it looks like Arena, yeah the lifetime is like Arena, but can be used to any allocator not tied to Arena allocator. So it can use Global allocator that is suitable for general purpose like individual deallocation

And then Thread's at exit hook

let mut val = String::new() until MyLifetime3;
let handle = thread::spawn(|| {
    val.push("Hello");
});

// the heap is automatically dropped after the thread finish
handle.at_exit(|| {
    close MyLifetime3;
});

handle.join();

So no Arc is needed, it is all zero cost and has guarantee. If stack reference is used, the compiler already returns compile time error. The different with std::thread::scope is, it does not force to wait the thread. So the thread can run in background without blocking the main thread, and still has safety guarantee that all the heap it uses is cleaned after it finish

And how exactly will the compiler handle this? Are you aware that in general (as in, without more restrictions than the zero you've put here) this is undecidable?

1 Like

I don't have knowledge at that level, so it will be the team and other more knowledgeable to find way how to achieve this. I share the end goal/idea

How is anyone to know that MyLifetime3 is to be moved into the at_exit closure and not kept in the calling scope (yet is still associated with it)? Why is it not moved into the spawn closure? You really need more structuring here. How is any kind of checker to know that spawn and at_exit have this "at_exit can end lifetimes for things in spawn because…it is required to be called after spawn is done with its caller" property?

I'll quote myself here:

5 Likes

There will no move. The core idea is it is not forced to be moved, because if it is moved then no difference with the standart spawn(move || { .... })

The goal is able to borrow heap reference without Arc aka zero cost, and without moving the value. So it will deactivate impl Drop. Let's the hook clean up the memory

That is the initial idea. But just now I realize there is problem, it is impossible to make the compiler know when the thread will finish. If there is other code that use the same value, but then the thread finish ealier than said other code, then it is dangling reference. If it is forced to be moved, then it is no different than what is currently available, the normal thread spawn with move. So I think placing the close inside at_exit hook is not safe :<

So there will some restriction. The use case become :

fn a() -> (JoinHandle<()>, String) {
    let val = String::from("Hello") until MyLifetime;
    let handle = thread::spawn(|| {

    });

    handle.at_exit(|| {
        close MyLifetime;
    });

    (handle, val)
}

fn b() {

    // simulating using the value in other code because it is not moved
    
    let (handle, val) = a();
    my_processing(&val);
    
    // waiting the thread here in other code block
    // automatically dropping the value after finish
    handle.join();
}

Currently to do that need raw pointer if want zero cost, which is unsafe. The available method to use value without moving, std::thread::scope force to wait the thread in place, as soon as it is called then the main thread is blocked, can not let it run in background first then waiting in other code

Me myself also not sure how to implement this technically in compiler level, but I share my idea anyway in case others know how to, is it reliable, etc. If it is really possible and achieved, it makes the code really more flexible and easier

That's fine. The problem is that there are (I'm sure) many open technical questions that have implications for the user-facing behavior that really should be considered and answered from your expectations before implementation is started (as you discovered with the at_exit call here). That's the kind of thinking that the RFC template is designed to help encourage. Leaving "unknown technical detail" notes in it is fine; that can be fleshed out during discussion with those who do know. But the RFC template shows that you're willing to put time in before asking others to dedicate their same (or even more!) time in the same way.

Previously I didn't have any problem that I can catch in that design. But, I find new one

If the lifetime is used inside branching, then it becomes footgun

Eg :

let val = String::from("Hello") until MyLifetime;

if my_condition {
    // processing
    close MyLifetime;
}

println!("{}", val);

The compiler can not know what will happen in runtime, will my_condition true or false. Because if it is true, the println causes UB. If it is false, the println is safe

The solution is

All possible branch does the same thing

Similar how the return value of match { .... } must has the same type, it can also be adapted that if the true branch call close, then the else branch have also to call close, otherwise it will return compile time error

Example :

let val = String::from("Hello") until MyLifetime;

if my_condition {
    close MyLifetime;
} else {
    // forgot to equalize the close MyLifetime here
    // the compiler returns compile error

    // then fixing the error, simply calling close here so all branch is equal
    
    close MyLifetime;
}

// what if the condition without else, always true, and there is other code that call the val

if my_condition {
    close MyLifetime;
}

println!("{}", val);    // the compiler returns compile error, use after close

Standard response:

You should start with the problem you're solving.

Describe what you're doing today, and why it's hard or doesn't work. Describe the things that exist in Rust today, and why they're not enough.

Do a good job on that and you'll get everyone saying "yeah, it would be nice to fix that" first.

Then you can describe your solution, but also if there are problems with the solution that's fine, because people can propose other solutions that would also solve your original problem.

10 Likes

The problem I am trying to solve is the complexity of the current lifetime annotation, that makes everyone will just avoid it so Rust in reality is not like Rust in paper

For example :

struct DatabaseConfig<'a, 'b> {
    host: &'a str,
    query_cache: &'b [u8],
}

struct NetworkConfig<'c> {
    bind_address: &'c str,
}

struct Server<'a, 'b, 'c> {
    db: DatabaseConfig<'a, 'b>,
    net: NetworkConfig<'c>,
}

struct App<'a, 'b, 'c> {
    server: Server<'a, 'b, 'c>,
}

impl<'a, 'b, 'c> App<'a, 'b, 'c> {
    fn run(&self) {
        println!("{}", self.server.db.host);
    }
}

That code is scary and confusing, all the requirement 'a, 'b, 'c will keep spread to the other code that call them

Then, it become like this :

struct DatabaseConfig {
    host: &str,
    query_cache: &[u8],
} 

struct NetworkConfig {
    bind_address: &str,
} 

struct Server {
    db: DatabaseConfig,
    net: NetworkConfig,
}

struct App {
    server: Server,
}

impl App {
    fn run(&self) {
        println!("DB Host: {}", self.server.db.host);
        println!("Net Bind: {}", self.server.net.bind_address);
    }
}

// simulating the use of borrow
fn main() {
    let host_data = String::from("localhost") until MyLifetime;
    let cache_data = vec![1, 2, 3] until MyLifetime;
    let bind_data = String::from("0.0.0.0") until MyLifetime;

    let app = App {
        server: Server {
            db: DatabaseConfig { 
                host: &host_data, 
                query_cache: &cache_data 
            },
            net: NetworkConfig { 
                bind_address: &bind_data 
            },
        }
    } until MyLifetime;

    app.run();

    close MyLifetime;
}

For the Thread at_exit hook, the problem it solves is the limitation of std::thread::scope that force to wait in place where it is written, with the cons can't use reference to stack data, but only using reference to heap data. It removes the usual need of Arc if want to share heap data to other 1 thread

If that is the problem you want to solve, then suggesting an (at least vague) implementation plan is almost a requirement. Many smart minds have worked on designing lifetimes for a long time. If it was possible to make them easy (in all cases, like you suggest) while still implementable, they would have chosen that. Of course it is possible they missed something, but without an implementation plan, most likely your idea just isn't implementable.

This applies more generally to any language feature: if you have some case where you think it's possible to do better than current language features it's one thing, but if you think it's possible to always do better than a language feature, that requires strong justification.

Can I do the implementation demonstration in custom new language? Like creating new language that has that feature for simple implementation demonstration. Because I don't know how to demonstrate in Rust compiler case

Even just a description of the implementation, without any code, will be something. Having an actual implementation even for different language will be even better, but of course the language need to be similar enough to Rust to share the same constraints.

The problem I see is that you can't call a function multiple times, because the label is globally unique. If you call close you can't call until again. And reusing an until label to spawn objects in a loop just causes unbounded memory usage.

But for most programs where we would only need to call drop once, the cost of an Arc is basically zero. And the alternative of leaking the value also exists. Or using a OnceLock.

For example if you clone an Arc for every thread you create, then the cost of creating the thread is orders of magnitude slower than the atomic increment of the Arc

1 Like

I just finished the prototype in custom new language

Note : The language has many bugs because I don't have prior knowledge in thks field. I use AI to help me developing it, otherwise it will not finish, even as prototype. Now importantly, it can already demonstrate how the lifetime is. The compile time double close and forgot to close is already working but not free from bug

The code is here : GitHub - fuji-184/F_Lang · GitHub

To try how is the lifetime :

  1. Clone the repo
  2. Compile the compiler RUSTFLAGS="-Ctarget-cpu=native" cargo build --release
  3. Create new project with command new project_name or simply cd to the folder tes. It contains the code that I tried before. Or copy the code below :

I use prefix rust for syntax highlight :v

error Error {
    TooSmall
    Unknown
}

struct Data {
    i32? val
}

Data can {
    mut .new: i32 val -> Self {
        self.val = val
    }

    mut .multiply: i32 input -> void! {
        input < 10 | return err TooSmall
        self.val = self.val * input
    }

    .print {
        println("val: @self.val")
    }

    .many: i32 num -> i32! {
        num < 2 | return err TooSmall
        
        mut data: i32 vec = vec[] until lifetime_1
        for 0-10 as i {
            data.push(i)
        }

        mut total: i32 = 0
        for data as val {
            total = total + val
        }

        close lifetime_1
        
        return total
    }
}

.create_fn: @str name, @ty tipe, @i32 mul -> token {
    return token:
    .math: @tipe input -> i32 {
        return input * mul
    }
}

@create_fn("tes", i32, 10)

.vector: @str lifetime, i32 val -> i32 vec {
    const val: i32 vec = vec[val] until @lifetime
    return val
}

.main {
    handle {
        mut data: Data = Data:new(10)
        data.multiply(20)?
        data.print()

        const val: i32 = data.many(10)?

        const val2: i32 = data.many(10)?

        const val3: i32 = math(30)

        println("val: @val, val 2: @val2, val 3: @val3")

        const val4: i32 vec = vector("lifetime_2", 10)
        
        close lifetime_2

        const val5: i32 vec = vec[1, 2, 3] until lifetime_3
        const val6: i32 vec = vec[1, 2, 3] until lifetime_3

        close lifetime_3

        for 0-4 as i {
            const val7: i32 vec = vec[1, 2, 3] until lifetime_@i

            close lifetime_@i
        }             

    } err {
    
        TooSmall | println("Error code: @err, Input must be > 10")
        _        | println("Unknown error")
  
    }
}
  1. Then run it ../target/release/f_lang run

So the label is both static literal and from parameter, where both have to be known at compile time so that it is zero cost, we don't need to save anything at runtime

The label is freed from the list after it is closed, not banned permanently, so it can be reused just fine. Each function call can also has different label by passing label through parameter

If the label used inside the loop is same, it reuses the same memory, cam be done in stack or heap. Only if a close to said label is called, then it will allocate new memory for the label, the new alloc can be made faster by preallocating memory then retain 4kb when clearing