Idea: Add borrows_(begin/end) to enable guard-less concurrency

I have an idea and I'd like to see what people think.

The idea is to add a Drop/Borrow like trait that provides hooks that allow you to inject code whenever a struct starts/stops being borrowed (only the first/last of overlapping borrows would trigger the call).

trait Borrows {
    fn borrows_begin(&mut self) {}
    fn borrows_end(&mut self) {}
}

The borrow checker should already have this information and I think it would be nice to be able to take advantage of it. My primary use case is for signaling to an underlying concurrent data structure that it's safe to modify its data without the need for the consumers to deal with an explicit guard.

Here's an "always" up-to-date lossy mpsc channel as an example:

See here .

In this example the borrows_begin and borrows_end in main would be inserted by the compiler. This allows the consumer to never have to worry about asking for updates (though they would have to be watchful for extended borrows). The example itself is a bit silly and comes with no guarantees of correctness but hopefully serves for illustrative purposes. Making the lock implicit is generally an anti-pattern, but it is convenient and in a non-blocking case where there are lots of short borrows, I think it could provide a significant ergonomic win. This could be particularly relevant for garbage collection or caching schemes.

There are some other obvious downsides:

  • It's easy to trigger calls inadvertently or more frequently than necessary, especially if you dereference to something that is Copy.
  • It promotes a spooky action at distance that could be hard to understand and it does so in a much more prominent way than Drop does.
  • As currently described, the &mut arguments require instances that implement it to always be declared mut, or be implicitly mut. &mut is the most flexible choice for the methods, but it does make it more annoying or more magic.
  • There is a ton of code relying on & doing nothing but giving you a reference, so there would have to be an effort to be more explicit about what is actually guaranteed and any implementer would have be respect those guarantees and not do anything surprising (like panicking).

I'm still debating whether it would be a worthwhile addition and I'm sure there are many more tradeoffs (and hopefully other benefitting use cases!), but I haven't seen this idea explored before and given how uniquely Rusty it is, I thought I would at least put it out there to see what other people think.

It might be more useful to create an example that is 100% correct/safe, non-trivial, and actually needed as opposed to an example that you acknowledge is not a very good example. Without a true motivating example it is difficult for anyone to really judge the proposal.

4 Likes

Sure. Thanks for the feedback. I agree that would be more useful and expose more of the tradeoffs. I'll see if I can find/make some more compelling/realistic examples.

In the meantime, maybe I can make my strawman example more clear. Guards are pretty ubiquitous in Rust and I think the Borrows trait would give a way to bypass them in some cases. For example, here's how you'd have to interact with my "always" up-to-date lossy mpsc channel in current Rust:

fn main() {
    let (mut reader, writer) = Writer::new(0);
    
    let _ = std::thread::spawn(move || {
        let mut writer = writer;
        writer.update_value(5);
    });

    let guard =  reader.lock();
    println!("First call {:?}", guard);
    drop(guard);

    std::thread::sleep(Duration::from_millis(10));

    let guard =  reader.lock();
    println!("Second call {:?}", guard);
    drop(guard);
}

The caller has to contend with both the reader and the guard and manage both their lifetimes. I found this a bit burdensome when I first ran into it, but I have grown to appreciate it's explicitness.

However, if we had the Borrows trait this interaction could be simplified to:

fn main() {
    let (mut reader, writer) = Writer::new(0);
    let _ = std::thread::spawn(move || {
        let mut writer = writer;
        writer.update_value(5);
    });
    // borrow begins here
    println!("First call {:?}", reader);
    // borrow ends here
    
    std::thread::sleep(Duration::from_millis(10));
    
    // borrow begins here
    println!("Second call {:?}", reader);
    // borrow ends here
}

Here, the caller is able to express their desire to have the access to the data by directly asking the reader. The library author is able to trade-off explicitness about the lock in favor of a more direct interaction. This allows the caller to be able to think in terms of borrows instead having to manage guards.

For one-off situations I think an explicit guard is likely the preferred route. But for a data structure that draws significant usage within an application, eliminating the noise and overhead of guards may end up being a significant win.

Again, this is just an idea I've been noodling on, so if you want to ignore it until I flesh it out more, I'm totally fine with that. Thanks!

Hmm. I see what you are accomplishing, but I'm not sure it merits a new trait. I could instead define a "New-Type" called SelfLockingReader that would wrap "Reader" and perform the necessary locks/unlocks on each call before forwarding to the wrapped reader. So, what you want to accomplish is doable without the new trait.

Not trying to shoot it down so much a provide some counter arguments to help clarify the usefulness.

One thing that jumps to mind here is that part of why it's ok for NLL to end borrows at arbitrary places is because no code is run when that happens. NLL explicitly kept Drop destructors running at the lexical end of blocks, in part to keep it clearer where that code would run (and in part because it would be a breaking change to change it, which I acknowledge wouldn't be the case for the proposal here).

I also worry that it might make things like reborrowing particularly surprising. One generally wouldn't want v.push(1); v.push(2) to run the borrow-and-unborrow code twice, but it feels like that'd commonly be written with an invisible lock.

So I think this needs more justification for why it's worth a language feature, vs just using a lock guard or doing the locking internally to the type.

2 Likes

Happy to clarify!

SelfLockingReader is closer to what I have in mind, but I think it only gets me halfway there. You're right that borrows_begin can basically be mimicked by your wrapper, a bool, and runtime writes. The trait would be slightly more efficient, but not noticeably so. Unfortunately, as far as I know, borrows_end can not be mimicked. Let's look at how this plays out for at SelfLockingReader.

The key differentiating question for SelfLockingReader is when does it unlock?

You could intercept borrows/derefs, unlock at the beginning of the call and then... immediately lock again to gain access to the data. But that essentially means your always locked.

The best way that I can think of to make SelfLockingReader work is to add a method like pub fn unlock(&mut self){...} that user must call whenever they no longer need access. And it has to be &mut to guarantee there aren't references floating around to get invalidated once we unlock.

So with SelfLockingReader we do get to avoid the guard, but we do still place the is still a burden on the caller to call the unlock, which would be avoid with the Borrows.

Looking at the example that you've given, it feels like what you want are what async/await was intended for. While it is an external crate smol seems to have all of the parts you need...

The interaction with the borrow checker would definitely take some serious consideration. I'm not sure if ending a borrow at an arbitrary location within the legal borrows would really be problem in this scheme, but it would definitely have to be carefully evaluated.

Using something that implements this trait would generally take a mindset shift. Reborrowing, is a great example of where that shift would be needed. If you don't care about the performance, you can just let the lock/unlock/lock/unlock happen. If you do care, you can create a critical section by doing something like this...

let v_ref = &mut v;
v_ref.push(1);
v_ref.push(2);
drop(v_ref);

If everything is a critical section... use a guarded version.

This is definitely not a trivial feature, so more justification is a more than reasonable ask.

I think this could be used in conjunction with async/await, but it is a bit different. The use case for my lossy mpsc channel is that the Reader only wants the best answer the Writers can provide at the time. It's sort of like how a monitor only cares about pixel values every 1/120 of a second. The program is free to change those pixels to whatever they like between those intervals, but whatever state those pixel are in at the end of interval is what's getting displayed. For my use case, Readers don't want to await changes from the Writers, they will simply take whatever values the Writers have supplied at the time. The Borrows trait allows them to take and release a lock needed for reading those values without any of the ceremony.

On the Writers side, especially if the Reader is slow, it could totally make sense for Writers to take advantage of async/await instead of just spinning while the Reader is active. For the full implementation that's definitely something I'll be looking at!

This is part of why I mentioned reborrowing in the earlier post. If you look at the MIR output for that code you'll see that those push lines are actually still making new borrows (the &mut (*_2)s): Compiler Explorer

When there's no code associated with that it's fine, but suggests that it would be an awkward place to actually run stuff.

1 Like

Ohh! Interesting. Yes, that is surprising. That's a bit unfortunate for what I want to do... drat. Great example!

1 Like

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.