- Feature Name:
forget_marker_trait
- Start Date: (fill me in with today's date, YYYY-MM-DD)
- RFC PR: rust-lang/rfcs#0000
- Rust Issue: rust-lang/rust#0000
Summary
Add a Forget
marker trait indicating whether it is safe to skip the destructor before the value of a type exits the scope and basic utilities to work with !Forget
types. Introduce a seamless migration route for the standard library and ecosystem.
Motivation
Many readers may find the biggest problem with Forget
to be migration.
RFC's confidence is taken from the fact that migration can be done easily. See #migration section for details.
Back in 2015, the decision was made to make mem::forget
safe, making every type implicitly implement Forget
. All APIs in std
could've been preserved after that change, except one. Today is 2025 and some things changed, old reasoning is no longer true. This RFC is not targeted at resource leaks in general but is instead focused on allowing a number of APIs to become safe by providing new unsafe guarantees.
What are RAII guards? [1]
RAII is a useful pattern for ensuring resources are properly deallocated or finalized. We can make use of the borrow checker in Rust to statically prevent errors stemming from using resources after finalization takes place.
use std::ops::Deref;
struct Foo;
struct Mutex<T> {
// `MutexGuard` is borrowing from here
}
struct MutexGuard<'a, T: 'a> {
data: &'a T,
// ...
}
impl<T> Mutex<T> {
fn lock(&self) -> MutexGuard<T> {
// Lock the underlying OS mutex.
// MutexGuard keeps a reference to self
MutexGuard {
data: self
}
}
}
// Destructor for unlocking the mutex.
impl<'a, T> Drop for MutexGuard<'a, T> {
fn drop(&mut self) {
// Unlock the underlying OS mutex.
}
}
impl<'a, T> Deref for MutexGuard<'a, T> {
type Target = T;
fn deref(&self) -> &T {
self.data
}
}
fn baz(x: Mutex<Foo>) {
let xx = x.lock();
xx.foo(); // foo is a method on Foo.
// The borrow checker ensures we can't store a reference to the underlying
// Foo which will outlive the guard xx.
// x is unlocked when we exit this function and xx's destructor is executed.
}
The core aim of the borrow checker is to ensure that references to data do not outlive that data. The RAII guard pattern works because the guard object contains a reference to the underlying resource and only exposes such references. Rust ensures that the guard cannot outlive the underlying resource and that references to the resource mediated by the guard cannot outlive the guard. To see how this works it is helpful to examine the signature of deref without lifetime elision:
fn deref<'a>(&'a self) -> &'a T {
// ...
}
The returned reference to the resource has the same lifetime as self
('a
). The borrow checker, therefore, ensures that the lifetime of the reference to T
is shorter than the lifetime of self
.
What is a proxy RAII guard?
thread::scoped
is special because it uses the RAII guard as a proxy to represent other values, but this proxy is not used to access those values. Instead, we are trusting that the borrow checker will ensure that the guard cannot outlive those values, and therefore that joining the thread in the guard's destructor is enough to ensure that the spawned thread is no longer running. [2]
struct JoinHandle<'a>(/* ... */);
impl Drop for JoinHandle<'_> {
fn drop() {
// Join the thread
}
}
let mut buffer = [0u8; 1024];
// `guard` is now borrowing from `buffer`
let guard = thread::scoped(|| {
for i in 0..1000 {
bufffer[i] = i;
}
});
buffer[3] = 4; // Error: `buffer[_]` is assigned to here but it was already borrowed
As we can see, buffer
is borrowed for a lifetime 'a
, until guard
is live. But buffer
is used inside another thread, not directly inside JoinHandle<'a>
. Thus, JoinHandle
is a proxy RAII guard, and its drop handler is used for then necessary cleanup.
Why is the proxy RAII guard gone?
In 2015, the leakpocalypse happened, and the language faced the question: do we make it safe to skip destructors or not? PPYP allows data structures to provide RAII guards while being resilient to skipping the destructor. The only use case in std that cannot be expressed without destructor always running was JoinGuard
, which later got replaced too.
In sync Rust, necessary cleanup can be achieved by taking a closure/callback instead of returning a guard object:
fn something_with_clean_up(f: impl FnOnce(Foo)) {
// Setup.
f(Foo);
// Cleanup. It is *guaranteed* to run, given the proper handling of unwinding.
}
fn main() {
something_with_clean_up(|foo| {
foo.bar();
});
// rest of the code...
}
As you can see, after calling something_with_clean_up
, the control flow is passed to the library. The rest of the user's code cannot continue executing before something_with_clean_up
performs a cleanup.
Thus, there was no point in redesigning the language and delaying Rust 1.0, practically all APIs and patterns could be safely expressed without destructors always running, so making std::mem::forget
safe was a good decision at the time.
What is different
Edition 2018 introduced async
Rust. But as turned out, nuances in its design conflicted with an earlier decision. All async
calls are essentially constructors for state machines which borrow some resources from outside or directly own them. It is user's responsibility to poll those state machines to completion. !Forget
use cases could've been expressed by other means in sync Rust (like taking a callback instead of returning a guard or PPYP), but with async
, anything turns directly into impl Future + use<'a>
which is equivalent to the RAII guard. This means, that sync pattern of taking a closure cannot be used - everything is transformed into RAII guard by the compiler.
Various OS or C/C++ APIs cannot be made async
without performance or ergonomics costs. PPYP can work for Drain<'a>
, but not for io_uring
. As long as the future is 'static
or directly owns all data it is accessing, Pin
guarantees are sufficient. Otherwise, there is no way to make a sound API.
Let's try to translate the previous example, a widely used pattern, to async
Rust.
async fn something_with_clean_up(f: impl AsyncFnOnce(Foo)) {
// setup
f(Foo).await;
// cleanup
}
async fn main() {
something_with_clean_up(async |foo| {
foo.bar().await;
}).await;
// rest of the code...
}
In this code snippet we added async
modifiers to our functions, as well as await
. You may think that cleanup will be done, but it is not guaranteed. All async
calls are turned into structs - like the RAII guards we talked about earlier:
async fn something_with_clean_up(f: impl AsyncFnOnce(Foo)) {
// setup
f(Foo).await;
// cleanup
}
async fn main() {
let fut = something_with_clean_up(async |foo| {
foo.bar().await;
});
{
// Pin the future.
let pinned = Box::pin(fut);
// Poll the future once.
poll_fn(|cx| Poll::Ready(_ = pinned.poll(cx))).await;
forget(pinned); // or `_ = Box::leak(pinned);`
}
// rest of the code...
}
The library is only taking control flow in between await
points. Here, future is pinned and Pin
's drop guarantee is met (boxed future remains allocated for 'static
), but cleanup cannot run - Drop
handler of fut
is skipped. Thus, APIs that require any cleanup for safety can be expressed in sync
Rust, but not in async
Rust, making async
less attractive, as the operating system APIs and C/C++ libraries cannot be used efficiently, ergonomically, and safely.
Another important observation that we can make is that Pin
's drop guarantee only applies to the memory of the Future
itself. But if Future
borrows a buffer, that buffer can be deallocated or re-used before the drop
of the Future
is called. See #connection-to-pin.
Examples of unsafe async APIs that can be allowed in sync Rust
Async spawn
Example from the ecosystem: spawn_unchecked
With the Forget
trait we can make that API safe:
struct TaskHandler<'a>(u64, PhantomNonForget, PhantomData<&'a ()>);
// Or `struct TaskHandler<'a>(u64, PhantomNonForget<&'a ()>)`;
impl Drop for TaskHandler<'_> {
fn drop(&mut self) {
if let Some(mut mutex) = GLOBAL.get(self.0) {
// We can block in async context as this mutex is held
// during the `poll` which should return in a timely manner.
let fut = mutex.lock();
// cancel the future and call its drop handler
drop(fut.take())
}
}
}
// Note that this is basically equivalent to async `scope`, as async `scope`
// would be transformed into the `Future` struct, just like `TaskHandler`.
fn spawn<'a>(fut: impl IntoFuture + 'a) -> TaskHandler<'a> {
GLOBAL.spawn(fut)
}
Async DMA
DMA stands for Direct Memory Access and it’s a peripheral used for transferring data between two memory locations in parallel to the operation of the core processor. For the purposes of this example, it can be thought of as memcpy
in parallel to any other code.
Let's say that Serial::read_exact
triggers a DMA transfer and returns a future that will resolve on completion. It would be safe if we were to block on this future (basically passing control flow to the future itself), but we may instead trigger undefined behavior with forget
:
fn start(serial: &mut Serial) {
let mut buf = [0; 16];
mem::forget(serial.read_exact(&mut buf));
}
fn corrupted() {
let mut x = 0;
let y = 0;
// do stuff with `x` and `y`
}
start(&mut serial);
// `DMA` keeps writing to `buf`, which is on the stack. `x` and `y` live on the stack too,
// so they will be corrupted.
corrupted();
See blog.japaric.io/safe-dma for more.
GPU
async-cuda
, an ergonomic library for interacting with the GPU asynchronously. GPU is just another I/O device (from the point of view of the program), the async model fits surprisingly well. But, this library enforces !Forget
via documentation requirements.
Internally, the
Future
type in this crate schedules a CUDA call on a separate runtime thread. To make the API as ergonomic as possible, the lifetime bounds of the closure (that is sent to the runtime) are tied to the future object. To enforce this bound, the future will block and wait if it is dropped. This mechanism relies on the future being driven to completion, and not forgotten. This is not necessarily guaranteed. Unsafety may arise if either the runtime gives up on or forgets the future, or the caller manually polls the future, and then forgets it.
io_uring
io_uring
is another API that needs !Forget
to function properly. There are attempts at making safe wrappers like ringbahn
, which introduces an internal buffer, or tokio_uring
, that requires passing ownership of the target buffer.
rio
took an approach like async-cuda
, implicitly making its futures !Forget
via documentation.
rio
aims to leverage Rust's compile-time checks to be misuse-resistant compared to io_uring interfaces in other languages, but users should beware that use-after-free bugs are still possible withoutunsafe
when usingrio
.Completion
borrows the buffers involved in a request and its destructor blocks to delay the freeing of those buffers until the corresponding request has been completed, but it is considered safe in Rust for an object's lifetime and borrows to end without its destructor running, and this can happen in various ways, including throughstd::mem::forget
. Be careful not to let completions leak in this way, and if Rust's soundness guarantees are important to you, you may want to avoid this crate.
take_mut
The async version of take_mut
cannot be created as it relies on cleanup code to abort the program.
C/C++ bindings + async do not work well together
It is common for C/C++ APIs to require some cleanup. It is not an issue for sync
rust, as wrappers can just take a closure/callback and ensure that cleanup. But all async
calls are transformed into impl Future + use<'a>
, not passing control flow to the wrapper. io_uring
and async-cuda
fall into that category too. For embedded/kernel development this issue is even worse, as you often cannot afford an allocation due to the lack of resources or complex locking, making borrows your only option and making Pin
's drop guarantee not useful for you.
Guide-level explanation
The core goal of Forget
trait, as proposed in that RFC, is to bring back the "Proxy Guard" idiom for non-static types, with async
being the primary motivation.
What does !Forget
mean?
If any resources are borrowed by some type T: !Forget
, they will remain borrowed until T
is dropped. See a more precise description in #reference-level-explanation.
let mut resource = [0u8; 1024];
let borrower: Borrower<'_> = Borrower::new(&mut resource);
// Violation of the unsafe contract - `resource` is no longer borrowed,
// so repurposing protected memory is safe.
unsafe { std::mem::forget_unchecked(borrower) };
let first_byte = resource[0]; // Potential UB
How is Forget
related to Pin
?
Both Forget
and Pin
concepts serve a similar purpose - guaranteeing that some memory is not moved or repurposed. How Forget
does it? If any resource is borrowed, you cannot take &mut
reference to it, as it would be aliased by !Forget
type that is borrowing from it. Before !Forget
type goes out of scope, removing the borrow, its drop handler must be executed, just like Pin
's drop guarantee. So !Unpin
protects directly owned memory, while !Forget
protects borrowed memory. It is important to note that Forget
is not defined around memory, but around values - see #reference-level-explanation.
With Forget
, some authors may have the option of borrowing data rather than owning it, making their futures Unpin
, but !Forget
.
Core problem
Consider that example
fn spawn<F: IntoFuture>(fut: F) -> JoinHandle<F> {
// store the future in global storage
}
fn main() {
let mut buf = [0u8; 64];
let fut = async {
let mut i = 0;
loop {
// `&mut` to `buf`.
buf[i] = i;
i = (i + 1) % 64;
yield_now().await;
}
};
let handle = spawn(fut);
std::mem::forget(handle);
// `fut` might still be running in the background, but `buf` is no longer protected by the borrow checker.
// Undefined Behavior - aliasing a mutable reference.
let fourth = buf[4];
}
In this case, handle
borrows from buf
, but the code that accessing buf
is not directly tied to handle
, it runs independently of it. Because of this, even if we pin handle
, we still can remove the borrow (by ending the lifetime of handle
) on buf
while JoinHandle
's memory remains available (forget(Box::pin(handle))
).
Functions having signatures with weakening can remove a type from the scope without running its destructor. The following function is an example of a weakening function - after it is called, the borrow checker assumes that the lifetime of T
has ended, as well as all borrows held by T
.
fn weakener<T>(foo: T) -> i32 {
std::mem::forget(T);
0
}
Currently, many APIs are forced into using 'static
bounds, which is one of the pain points users are reporting about async
Rust, together with Send
issues.
Not only forgets
There exists a way to exploit the old thread::scoped
API without any memory leaks! We can move JoinHandle
inside the thread it is meant to protect, creating a kind of cycle:
use std::{
hint::black_box,
marker::PhantomData,
sync::{Arc, Mutex},
};
struct JoinHandle<'a>(PhantomData<&'a ()>);
impl Drop for JoinHandle<'_> {
fn drop(&mut self) {}
}
fn scoped<'a, F>(_f: F) -> JoinHandle<'a>
where
F: FnOnce() -> (),
F: Send + 'a,
{
todo!()
}
fn main() {
let arc1 = Arc::new(Mutex::new(None));
let arc2 = arc1.clone();
let mut buf = [0; 1024];
let buf_ref = &mut buf;
let handle = scoped(move || {
let _handle = arc2.lock().unwrap().take();
for _ in 0..100000 {
black_box(&mut *buf_ref);
}
drop(arc2);
});
arc1.lock().unwrap().replace(handle);
drop(arc1);
// aliased `&mut`
buf[0] = 1;
}
In this code, no memory is leaked, and JoinHandle
's destructor is not skipped. However, many types of channels - including rendezvous channels — can also be vulnerable to this issue if their signatures allow an equivalent implementation using reference counting.
fn main() {
let (tx, rx) = std::sync::mpsc::channel();
let mut buf = [0; 1024];
let buf_ref = &mut buf;
let handle = scoped(move || {
let _handle = rx.recv().unwrap();
for _ in 0..100000 {
black_box(&mut *buf_ref);
}
drop(_handle);
});
tx.send(handle);
drop(tx);
buf[0] = 1;
}
Solution for message passing of !Forget
types.
One might speculate and try to fix some holes, for example by making JoinHandle: !Send
, but this can only count as a workaround. If we look at the problem in depth, we can see that Forget
is generally incompatible with Rc
, as well as other APIs that can be expressed with its signature, because it creates a hidden self-reference. In the example earlier, the borrow checker cannot see a connection between rx
and tx
- when tx
is dropped, buf
is no longer borrowed. What if retained such a connection?
fn main() {
let mutex = Mutex::new(None);
let mutex_ref = &mutex;
let mut buf = [0; 1024];
let buf_ref = &mut buf;
let handle = scoped(move || {
let _handle = mutex_ref.lock().unwrap().take();
for _ in 0..100000 {
black_box(&mut *buf_ref);
}
});
mutex.lock().unwrap().replace(handle);
drop(mutex);
buf[0] = 1;
}
And we got a compiler error preventing the unsoundness:
error[E0597]: `mutex` does not live long enough
--> src/main.rs:23:21
|
22 | let mutex = Mutex::new(None);
| ----- binding `mutex` declared here
23 | let mutex_ref = &mutex;
| ^^^^^^ borrowed value does not live long enough
...
40 | }
| -
| |
| `mutex` dropped here while still borrowed
| borrow might be used here, when `mutex` is dropped and runs the destructor for type `Mutex<Option<JoinHandle<'_>>>`
error[E0505]: cannot move out of `mutex` because it is borrowed
--> src/main.rs:37:10
|
22 | let mutex = Mutex::new(None);
| ----- binding `mutex` declared here
23 | let mutex_ref = &mutex;
| ------ borrow of `mutex` occurs here
...
37 | drop(mutex);
| ^^^^^
| |
| move out of `mutex` occurs here
| borrow later used here
This example is exactly like the first one with Arc
, but uses references instead - we are allowed to pass them with JoinHandle: !Forget
. But what with channels? There are not so many examples in the ecosystem that follow this approach in the signature, as it is not 'static
, but there are some:
fn main() {
let mut queue = heapless::spsc::Queue::<_, 2>::new();
let (mut tx, mut rx) = queue.split();
let mut buf = [0; 1024];
let buf_ref = &mut buf;
let handle = scoped(move || {
let _handle = rx.dequeue();
for _ in 0..100000 {
black_box(&mut *buf_ref);
}
});
// Moving `handle` into `queue`, causing a self-referential borrow (`handle` -> `rx` -> `queue` -> `handle`).
tx.enqueue(handle);
drop(tx);
buf[0] = 1;
}
This code fails to compile too. Why? Because borrow checker detects a self-reference! handle
borrows queue
, but we are moving handle
into queue
, thus queue
borrows queue
. This means we cannot call drop
on queue or take a reference to it, but since drop
is inserted by the compiler, we have an error. If there was a loop {}
and borrow cheker considered diverging during analysis, it would compile and would be sound.
This means that to use message-passing with !Forget
types, API authors must rely on lifetimes more - because Forget
types fundamentally involve lifetime management. Looking at the example above, rx
cannot be passed to the traditional spawn
, because of the F: 'static
requirement. But thread::scope
allows it - as well as async scope
does, with the future itself being !Forget
. Note that rendezvous channels can be soundly expressed using that API and PhantomData
.
Traditional combinators and patterns
Async combinators with join
, race
, or merge
semantics will continue to work as they do. If some future passed into them is !Forget
, their future becomes !Forget
too. Arc
cannot be used with !Forget
types, but the need for Arc
, which is quite a pain point, will decrease, as users will be able to spawn with references directly.
Reference-level explanation
This new auto trait is added to the core::marker
and std::marker
modules:
pub unsafe auto trait Forget { }
Let T
be T: !Forget
and value
be a value borrowed by value of type T
. Unsafe code is given the following guarantees:
- If
value
is borrowed byT
as&mut
,value
cannot be moved/invalidated/borrowed untilT
is dropped. - If
value
is borrowed byT
as&
,value
cannot be moved/invalidated/exclusively borrowed untilT
is dropped.
In practice, we disallow skipping the destructor of !Forget
types before they exit the scope. Violation is not an immediate undefined behavior, but other code can rely on the destructor running, which can lead to undefined behavior down the road. Unsafe code authors can freely violate this rule, if responsibility is taken.
Several observations can be made about this guarantee. For T: 'static
we don't have to run the destructor to fulfill it, as T: 'static
can only have 'static
borrows, which are assumed to be valid indefinite borrows (like with Pin::static_ref
). Another one is, memory borrowed by T: !Forget
type cannot be reused or invalidated, as safe code needs to move/take a reference to value
, similar to the drop guarantee. value
cannot be dropped, as it requires moving.
struct Foo<T>(PhantomNonForget, T);
struct Baz;
impl<T> Drop for Foo<T> { fn drop(&mut self) { } }
let ref_buf = [0u8; 64];
let mut_buf = [0u8; 64];
// We have a guarantee, that no `&mut` can be taken to `ref_buf` until `Foo`'s `drop`.
let foo_ref = Foo(PhantomNonForget, &ref_buf);
// We have a guarantee, that no `&/&mut` can be taken to `mut_buf` until `Foo`'s `drop`.
let foo_mut = Foo(PhantomNonForget, &mut mut_buf);
drop(ref_buf); // error[E0505]: cannot move out of `ref_buf` because it is borrowed
drop(mut_buf); // error[E0505]: cannot move out of `mut_buf` because it is borrowed
let ref_first_byte = ref_buf.0[0]; // Allowed
let mut_first_byte = mut_buf.0[0]; // error[E0503]: cannot use `mut_buf.0[_]` because it was mutably borrowed
drop(foo_ref);
drop(foo_mut);
let mut_first_byte = mut_buf.0[0]; // Allowed
drop(foo_ref); // Allowed
drop(foo_mut); // Allowed
// `Baz` cannot be moved or exclusively borrowed until `Foo` is dropped.
fn phantom<'a>(baz: &'a Baz) -> Foo<PhantomData<&'a ()>> {
Foo(PhantomNonForget, PhantomData)
}
Previous version: Unsafe code authors can rely on the fact that memory borrowed by !Forget
types is not reused or invalidated until the drop (just like Pin
's drop guarantee, but with indirection). Note that for T: 'static
we don't have to run the destructor to fulfill this guarantee, as 'static
borrows can be assumed to be valid indefinitely (like with Pin::static_ref
).
Type becomes !Forget
if it directly contains !Forget
member.
We should either allow !Forget
types in statics or make all 'static
types Forget
because it fulfills the unsafe guarantee and we can't enforce any code running before the program's abortion.
let mut resource = [0u8; 64];
let _unforget = Subsystem::execute(&mut resource);
std::process::abort(); // `resource` is (forcefully) borrowed for `'static`
resource[0] = 42; // unreachable
Standard Library
All APIs in the standard library should be migrated at once. With available migration strategies, there is no benefit in gradual migration, it will greatly reduce the productivity of rustc developers by adding boilerplate and noise into the codebase. An audit must be performed to ensure which APIs must remain Forget
. See #migration for more details.
No types in std will be changed to !Forget
.
Copy
All types that implement Copy
must implement Forget
too.
Unions
Unions are always Forget
. All members of union
must be Forget
, but it is already covered by other rules and does not need to be enforced.
API changes
Rc
/Arc
- all APIs for construction, except the newRc::new_unchecked
method, only exist forT: Forget
types. If we decide to not haveimpl<T: 'static> Forget for T {}
, in the future we may allow safe constructors forT: ?Forget + 'static
(resources are borrowed for'static
, it fulfills the guarantee we are giving to the unsafe code) and something along the lines ofT: ?Forget + Freeze
(to forbid cycles), author of the RFC is not familiar enough with interior mutability questions.ManuallyDrop<T>
always implementsForget
, regardless of theT
.ManuallyDrop::new
is available for types withT: Forget
. New unsafe methodManuallyDrop::new_unchecked
, available forT: ?Forget
, is introduced. We may add a safe constructor withT: ?Forget + 'static
, as we allow forgetting in statics.Box::<T>::into_ptr
is available only forT: Forget
. As forT: !Forget
users shouldManuallyDrop::new_unchecked
and take the pointer via&raw mut
. It will still be allowed to pass this pointer toBox::from_ptr
.Box::<T>::forget
is available only forT: Forget
.forget_unchecked
, a new unsafe function, is added to forgetT: ?Forget
types. It is a wrapper aroundManuallyDrop::new_unchecked
, just asforget
is a wrapper aroundManuallyDrop::new
.PhantomNonForget
is a!Forget
ZST for types to become!Forget
.Vec::drain
is available only forT: Forget
types. A new method might be added to work withT: ?Forget
.- APIs like
std::sync::mpsc::Sender::send
are available only forT: Forget
. - Possibly new channels should be introduced, that are compatible with
T: !Forget
types too. Vec::set_len
is available only forT: Forget
types, to not create a footgun forunsafe
code in the wild. Maybe a new method should be added to supportT: ?Forget
.
Migration
Migration using local_default_bounds
RFC
The local_default_bounds RFC facilitates a smoother migration without requiring an edition change. In essence, it allows users to override default trait bounds, such as removing the Sized
default or adding a custom trait like MyFavouriteTrait
.
We will provide an opt-in mechanism for crates to modify default bounds in function signatures.
// Crate that has migrated
mod migrated {
#![default_generic_bounds(?Forget)]
fn foo<T>(value: T) { /* ... */ } // T: ?Forget
}
// Crate that has not migrated
mod migrated {
fn foo<T>(value: T) { /* ... */ } // T: Forget
}
In the context of the local_default_bounds
RFC, along with introducing the Forget
trait, Bounds for Self
and associated types should default to ?Forget
rather than Forget
. This change is not observable for code that does not explicitly opt into using Forget
, as default_generic_bounds
will continue to default to Forget
. A more detailed explanation will follow later.
As discussed in #semver-and-ecosystem, libraries adopting ?Forget
in their signatures will, at most, require a minor semver change. Consequently, migrating to ?Forget
would be equivalent to the now stable const fn
feature. Libraries have already been adopting const fn
without causing ecosystem fragmentation, as pull requests continue to be merged, progressively making more functions const
.
Not interested in migration crates
Some crates may refuse to migrate due to being unmaintained, the only difference is that for downstream crates their signatures would be filled with T: Forget
. This is only natural, as those crates were written with that assumption as if they manually put T: Forget
on their signatures. Some automatic methods to determine that function can accept Forget
types are not feasible. We are already not doing it for const fn
, it would be a semver hazard and only safe code can touch T
, as analysing unsafe
code is against the design of the language.
If the crate is maintained, however, migration should not be difficult.
#![forbid(unsafe)]
crates
- Set the appropriate bounds:
// can be with `cfg_attr`
#![default_generic_bounds(?Forget)]
-
Resolve any compilation errors by explicitly adding
+ Forget
where needed. -
Optionally: Recurse into your dependencies, applying the same changes as needed. Most probably you will use
!Forget
types with well-maintained crates providing combinators or containers.
For crates with unsafe
code (like libcore
)
- Set the appropriate bounds:
#![default_generic_bounds(?Forget)]
-
Audite your codebase to work properly with
!Forget
types. -
Resolve any compilation errors by explicitly adding
+ Forget
where needed. -
Optionally: Recurse into your dependencies, applying the same changes as needed.
Semver and compatibility, ecosystem splitting
This approach is targeted at minimizing problems between different crates in the ecosystem. For any library, opting into using Forget
and accepting those types will be a minor semver change.
Earlier it was stated that Bounds for Self
and associated types should default to ?Forget
instead of Forget
. This is due to an important case. If a user of the library updated earlier than the library, then without that change it will observe that associated types of traits are Forget
, so it would be a breaking change for the library to lift that constraint in the future. But now, the user will observe ?Forget
, thus it cannot rely on them being Forget
. But for users that did not migrate, as well as the library itself, it will not be observable due to default_generic_bounds
still being Forget
.
// This indicates that user explicity opted in
#![default_generic_bounds(?Forget)]
// After opting in, user needs to add `T::baz(..): Forget` to silence the error - quite easy.
async fn foo<T: other_crate::Trait>(bar: T) {
let fut = bar.baz();
// Compiler will emit an error, as `fut` maybe `!Forget`, because we set `default_foreign_bounds`
// to `?Forget`, and default for associated types in `other_crate` is already `?Forget`. Otherwise it
// would have been a breaking change for `other_crate` to make future provided by `baz` `!Forget`,
// as this code would've compiled now but not in the future.
core::mem::forget(fut);
}
// `other_crate` that did not migrate yet. `Trait::bar(..)` is not locked into `Forget`, but
// this `other_crate` and other crates can only observe `Trait::bar(..): Forget` cases.
mod other_crate {
trait Trait {
async fn baz();
}
}
Changing default
It is not required, but in next editions we may swap the default for default_generic_bounds
. Crates that want to continue using old default in next editions will set #![default_generic_bounds(Forget)]
.
Migration over the edition, with a mask
If local_default_bounds
would not be accepted, we can have a satisfactory migration by having editions <= 2024 have Forget
as default, and editions after 2024 have ?Forget
as default.
While it will not split the ecosystem, it will require everyone to make a migration just as in the local_default_bounds
solution. It can be automated for #![forbid(unsafe)]
crates.
Drawbacks
Migration
If local_default_bounds
is accepted, migration would be practically seamless, as described in #migration. Even if it's not accepted, less seamless but still acceptable solution would be a change over edition.
Message Passing
A traditional approach to message-passing cannot be applied to !Forget
types - slightly different APIs should be developed, preserving a
lifetime connection between tx
and rx
handles.
Rationale and alternatives
All types were assumed to be !Forget
in Rust's early days, and then it was changed in hurry. They flow naturally out of Rust's type system, do not clash with any preexisting concepts that do not directly involve forgetting and are used pleasantly and intuitively, modulo migration. With the async
built around Future
trait it became apparent that language directly lacks this feature. Being simple and non-disturbing, it's hard to find something that would fit that purpose better.
We can do nothing, but use cases just keep piling up.
The author of Unforgettable types - zetanumbers book is working on another approach to that problem, but it is not public yet.
Prior Art
- RFC: Scoped threads, take 2 by aturon · Pull Request #1084 · rust-lang/rfcs · GitHub
- Guaranteed non-static destructors by rkjnsn · Pull Request #1094 · rust-lang/rfcs · GitHub
- Forgetting futures with borrowed data
- rfcs/text/0000-scoped-take-2.md at scoped-take-2 · aturon/rfcs · GitHub
- The Scoped Task trilemma
- Asynchronous clean-up
- https://zetanumbers.github.io/book/myosotis.html: an independent exploration of the same problem space with similar, but subtly different, conclusions.
- https://hackmd.io/@wg-async/S1Q6Leam0: a design meeting regarding the previous post
Leakpocalypse
- RFC: Alter mem::forget to be safe by alexcrichton · Pull Request #1066 · rust-lang/rfcs · GitHub
- std::thread::JoinGuard (and scoped) are unsound because of reference cycles · Issue #24292 · rust-lang/rust · GitHub
- Pre-Pooping Your Pants With Rust
- Leak and Destructor Guarantees by reem · Pull Request #1085 · rust-lang/rfcs · GitHub
- scope in std::thread - Rust
- On reference-counting and leaks · baby steps
Usage of the pattern
- Builder in std::thread - Rust
- spawn_unchecked in async_task - Rust
- Memory safe DMA transfers | Embedded in Rust
- async_nursery - Rust
- The Scoped Task trilemma
- Scope in async_scoped - Rust
Miscellaneous
- Allow types with destructors in static variables · Issue #1111 · rust-lang/rfcs · GitHub
- A formulation for scoped tasks in Rust - Tyler Mandry
MustMove types
Unresolved questions
- Maybe the name
Forget
is misleading, as its core is around theunsafe
guarantee of borrowed resources and the destructor.IndirectPin
? - Maybe force
impl Forget for T where T: 'static {}
and add a generic to thePhantomNonForget
? Use cases and unsafe guarantee are fine with it, and we already allow!Forget
instatic
. - Maybe add
StaticForget<T: ?Forget + 'static>: Forget
. - How does it interact with
&own
? - Maybe make
Vec::set_len
available forT: ?Forget
, but with a new unsafe precondition. Crates with unsafe code that are manually migrating to support!Forget
would need to be aware of that change and verify/modify their unsafe code to work correctly with!Forget
types, or manually restrain them toForget
. - Which approach to migration should be followed?
- How should it interact with
async Drop
?
Future possibilities
This RFC will allow async
Rust to come closer to sync ergonomics, but some code will not be able to reach this end goal and insert "abort bombs" into mandatory destructors. This is strictly better than today's status quo: unsafe
in application code, you can work with it, but this is not ideal. A more robust approach would be the Linear
/MustMove
/!Drop
types. This RFC makes a step towards more liveness guarantees, making them closer. As for the biggest problem - unwinding - with async
, we have more choice over our behavior during unwinds. Even if we do not succeed with effects forbidding unwinding, the future containing linear type may catch any unwind during the poll and return Poll::Pending
, potentially recovering - async Drop
looks promising too.
Maybe if !Forget
type borrows itself, it would be equivalent to the pinning?