2 releases
0.1.1 | Aug 19, 2024 |
---|---|
0.1.0 | Jun 1, 2024 |
#334 in Asynchronous
133 downloads per month
46KB
679 lines
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.