#mutex #async-context #arc-mutex #async #lock #no-alloc #shared-data

no-std borrow_mutex

Async Rust Mutex which does not require wrapping the target structure

2 releases

0.1.1 Aug 19, 2024
0.1.0 Jun 1, 2024

#335 in Asynchronous

MIT license

46KB
679 lines

crates.io libs.rs documentation rust-version license

BorrowMutex

Very initial version! Use with caution

BorrowMutex is an async Mutex which does not require wrapping the target structure. Instead, a &mut T can be lended to the mutex at any given time.

This lets any other side borrow the &mut T. The mutable ref is borrow-able only while the lender awaits, and the lending side can await until someone wants to borrow. The semantics enforce at most one side has a mutable reference at any given time.

This lets us share any mutable object between distinct async contexts without Arc<Mutex> over the object in question and without relying on any kind of internal mutability. It's mostly aimed at single-threaded executors where internal mutability is an unnecessary complication. Still, the BorrowMutex is Send+Sync and can be safely used from any number of threads.

The most common use case is having a state handled entirely in its own async context, but occasionally having to be accessed from the outside - another async context.

Since the shared data doesn't have to be wrapped inside an Arc, it doesn't have to be allocated on the heap. In fact, BorrowMutex does not perform any allocations whatsoever. The tests/borrow_basic.rs presents a simple example where everything is stored on the stack.

Safety

The API is unsound when futures are forgotten ([core::mem::forget()]). For convenience, none of the API is marked unsafe.

See BorrowMutex::lend for details.

Hopefully the unsound code could be prohibited in future rust versions with additional compiler annotations.

Example

use borrow_mutex::BorrowMutex;
use futures::FutureExt;

struct TestObject {
    counter: usize,
}

let mutex = BorrowMutex::<16, TestObject>::new();

let f1 = async {
    // try to borrow, await, and repeat until we get an Err.
    // The Err can be either:
    // - the mutex has too many concurrent borrowers (in this example we
    //   have just 1, and the max was 16)
    // - the mutex was terminated - i.e. because the lending side knows it
    //   won't lend anymore
    // We eventually expect the latter here
    while let Ok(mut test) = mutex.request_borrow().await {
        test.counter += 1; // mutate the object!
        println!("f1: counter: {}", test.counter);
        drop(test);
        // `test` is dropped, and so the mutex.lend().await on the
        // other side returns and can use the object freely again.
        // we'll request another borrow in 100ms
        smol::Timer::after(std::time::Duration::from_millis(100)).await;
    }
};

let f2 = async {
    let mut test = TestObject { counter: 1 };
    // local object we'll be sharing

    loop {
        if test.counter >= 20 {
            break;
        }
        // either sleep 200ms or lend if needed in the meantime
        futures::select! {
            _ = smol::Timer::after(std::time::Duration::from_millis(200)).fuse() => {
                if test.counter < 10 {
                    test.counter += 1;
                }
                println!("f2: counter: {}", test.counter);
            }
            _ = mutex.wait_to_lend().fuse() => {
                // there's someone waiting to borrow, lend
                mutex.lend(&mut test).unwrap().await
            }
        }
    }

    mutex.terminate().await;
};

futures::executor::block_on(async {
    futures::join!(f1, f2);
});

Both futures should print interchangeably. See tests/borrow_basic.rs for a full working example.

What if Drop is not called?

Unfortunately, Undefined Behavior. With [core::mem::forget()] or similar called on LendGuard we can make the borrow checker believe the lended &mut T is no longer used, while in fact, it is:

# use borrow_mutex::BorrowMutex;
# use futures::Future;
# use futures::task::Context;
# use futures::task::Poll;
# use core::pin::pin;
struct TestStruct {
    counter: usize,
}
let mutex = BorrowMutex::<16, TestStruct>::new();
let mut test = TestStruct { counter: 1 };

let mut test_borrow = pin!(mutex.request_borrow());
let _ = test_borrow
    .as_mut()
    .poll(&mut Context::from_waker(&futures::task::noop_waker()));

let mut t1 = Box::pin(async {
    mutex.lend(&mut test).unwrap().await;
});

let _ = t1
    .as_mut()
    .poll(&mut Context::from_waker(&futures::task::noop_waker()));
std::mem::forget(t1);
// the compiler thinks `test` is no longer borrowed, but in fact it is

let Poll::Ready(Ok(mut test_borrow)) = test_borrow
    .as_mut()
    .poll(&mut Context::from_waker(&futures::task::noop_waker()))
else {
    panic!();
};

// now we get two mutable references, this is strictly UB
test_borrow.counter = 2;
test.counter = 6;
assert_eq!(test_borrow.counter, 2); // this fails

Similar Rust libraries make their API unsafe exactly because of this reason - it's the caller's responsibility to not call [core::mem::forget()] or similar (async-scoped)

However, this Undefined Behavior is really difficult to trigger in regular code. It's hardly useful to call [core::mem::forget()] on a future.

No runtime deps