2 releases
0.1.1 | Aug 19, 2024 |
---|---|
0.1.0 | Jun 1, 2024 |
#339 in Asynchronous
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.