-
Notifications
You must be signed in to change notification settings - Fork 1.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Drop::poll_drop_ready for asynchronous destructors #2958
Conversation
b22bdf7
to
c003e9f
Compare
This is my biggest issue with the concept of async destructors. You mention that destructors can already panic: however, I would consider this bad practice (unless you intend to abort the program) as double-panics cause an abort, and destructors are invoked during unwinding. Personally, I would prefer if all panics inside destructors caused an abort, as it would remove several exception-safety related footguns from the language. Even the other things you mention doing in a destructor (mutating global state, taking locks, etc.) are not indicative of "good code". Sure: there may be times where you need to do those things, but it would be with great reluctance... The motivations presented in the RFC apply equally well to destructors that are fallible. Currently, you would achieve fallible destruction by having some kind of explicit "close" method which returns a result, and you can do the same thing with async destructors (have an async close method) especially when "drop" needs to work in sync contexts anyway. I would prefer that Rust moves in a different direction to the one proposed here: one where the role of destructors is reduced rather than expanded. I think that RAII is not the right tool for the job for these more complex cases, and we should instead look towards ideas like linear type systems to solve these cases. |
1. If they would already have to call `drop_in_place` to ensure that normal | ||
destructors are called (as in data structures like `Vec`). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would there be an async_drop_in_place
, or does the user need to manually perform the poll?
unsafe {
futures_util::future::join_all([
futures_util::future::poll_fn(|cx| poll_drop_ready(&mut *ptr1, cx)),
futures_util::future::poll_fn(|cx| poll_drop_ready(&mut *ptr2, cx)),
futures_util::future::poll_fn(|cx| poll_drop_ready(&mut *ptr3, cx)),
]).await;
drop_in_place(ptr1);
drop_in_place(ptr2);
drop_in_place(ptr3);
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure what the best approach here is, but possibly this would be helpful. Then we would probably want to change the drop glue description from being as described to instead being described as calling async_drop_in_place in an async context. This would also save a virtual call in the case of trait objects, as they would not need to call poll_drop_ready and then drop_in_place.
Note that for types like vec, for example, the right approach will be to call poll_drop_ready
on the slice in Vec's poll_drop_ready method. Slices will have to have drop glue implemented to join a poll_drop_ready on all of their members.
Co-authored-by: kennytm <[email protected]>
I don't know if it is suitable for discussion here. but vale has some interesting points
If we can have this, then we can make |
`poll_drop_ready`, it would be able to perform an asynchronous flush as a part | ||
of its destructor code. | ||
|
||
## Types can which close asynchronously |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
s/types can which/types which can/
?
`poll_drop_ready` called, and we even guarantee the drop order between | ||
variables and between fields of a type. When the user sees a value go out of | ||
scope *in an async context*, they know that `poll_drop_ready` is called. And we | ||
do guarantee that the destructors of all fields of that type will be called. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this need to hold up in presence of panics? Double panics? If one of the asynchronous destructors panics when polled, do we attempt to drive to completion the other destructors? Do we call the non-async drop for the fields which have had their async destructor panic? Do we call non-async portion of the drop glue at all after a panic in an async destructor?
will occur in a loop, at the level of the type being dropped, until all calls | ||
to `poll_drop_ready` for that value have returned `Ready`, so they will be | ||
"interleaved" and concurrent in practice. The program will *not* wait for each | ||
field to return ready before beginning to process the subsequent field. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider referencing https://github.com/rust-lang/rfcs/blob/master/text/1857-stabilize-drop-order.md here.
Also consider mentioning how built-in structures like Vec
ought to behave and if there are or aren’t any special considerations in presence of panics, especially during structure construction.
be noted. | ||
|
||
In particular, users cannot safely assume that their values will be dropped in | ||
an async context, regardless of how they are intended to be used. It's this |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is an async context
? The body of an async function? An async block? A Future
? Anything running in an executor?
} | ||
``` | ||
|
||
However, we do guarantee that values dropped in an async context *will* have |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How is this guaranteed if the async function is in I guess what you describe as a non-async context - which could be a "plain old future combinator"? If there is anything around the async function which isn't aware about the async function, I think it wouldn't be called
### The necessity of fused semantics | ||
|
||
Calls to `poll_drop_ready` occur in a loop, until every recursive subcall | ||
returns `Ready`. These calls are (by necessity) stateless: we would be required |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wouldn't call this stateless, since fields are clearly stateful - their state is modified by polling. I guess you mean the there is no state to record which fields are already done and that calling the function needs to be idempotent - but this is not what I first think about when reading stateless
possible, even if it makes `poll_drop_ready` less straightforward to implement. | ||
|
||
We believe this decision is well-motivated for several reasons. First, it | ||
pushes complexity to the implementor of the async destructor, rather than |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately this approach puts a very heavy burdon on the application developer. They need to be sure that all components they choose for their application are supporting async drop. E.g. in my application I might run a function which intends to perform a database transaction. If my function is cancelled (dropped), I want to rollback the transaction, but need to wait until the rollback completes.
Now I start with a framework which supports async drop. My application works. Then later on the framework gets updated and someone uses a new future combinator. Or I switch out the framework. Then suddenly my remaining code still works, but the rollback will no longer happen - without any code directly related to the rollback having been changed.
That scenario actually horrifies me, and I don't see any other language feature in either rust or any other language which could make that happen.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I replied to this point in the out-of-line comment, but to reiterate: this is exactly the behavior of synchronous destructors, which are not guaranteed to run, but are generally relied on by all users. Leaking data without documenting should be considered a serious bug, and beginning to leak data should be considered a breaking change. This is already our community standard around synchronous destructors, and the same process would work fine for asynchronous destructors.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps something that is unclear is what the default behavior is of some type that contains other values that implemented poll_drop_ready
. Is the default that when any type drops in an async context, it will automatically check poll_drop_ready()
of all its fields? Or does it only do so if I remember to forward poll_drop_ready
on every type that.
Put another way, with code:
struct Envelope<T>(T);
async fn foo() {}
async fn bar() {
let _e = Envelope(foo());
// does `Envelope::poll_drop_ready` forward, or did I accidentally "leak"?
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a specific section about this; yes, the glue is called automatically, recursively, on fields of types, just like destructors. https://github.com/withoutboats/rfcs/blob/poll-drop-ready/text/0000-poll-drop-ready.md#drop-glue-in-mempoll_drop_ready
This is the same behavior of non-asynchronous destructors.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh ok, thanks! I missed that somehow.
an extension of the normal behavior of destructors, which already can block, | ||
panic, take locks, park the thread, mutate global state, and so on (as they are | ||
intended to do). | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMHO one big drawback of this approach is that it doesn't solve the requirement for implementing synchronous drop as a fallback, since there are no guarantees about async drop being called.
That kind of raises the question for application developers on "when to actually implement it", or "why to implement it at all".
the state of the future the async call returns, and the layout of each future | ||
for each concrete type would be different and unknown. It would be functionally | ||
equivalent to having a trait object with an unknown associated type, which is | ||
not allowed for exactly this reason. It would not be possible to support async |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's definitely a drawback. But async fn
in traits and trait objects is in general a problem that still requires a solution - I'm not sure whether drop is special in that regard.
On the flip side we want people to write asychronous functions and not manual state-machines - because the latter are hard! That's why invented async/await. Inside drop we might want to call functions that are defined as async fn, e.g. for performing the async database rollback, which might lock an async semaphore, and then uses an async socket. Some of those things have pinning requirements, which makes things even harder to get right manually.
for clean up of external resources in the "RAII" sense that they are used in | ||
Rust and C++. | ||
|
||
C++ has a coroutine proposal similar to async/await. It has not yet been |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is a wrong description of it. It doesn't need async destructors because C++ coroutines which resemble async/await tasks run to completion. You can just write your normal function, with a try/finally
block if you desire some cleanup.
See my old question to Lewis Baker about this: lewissbaker/cppcoro#102
The same applies e.g. to async/await in Javascript, Kotlin, C#, etc.
The Rust situation is a homemade issue, since it's the only language which represents async functions as futures which are cancellable at any suspension point.
and, if necessary, reconstruct a pin of those fields. [The pin documentation | ||
contains relevant information.][pin-docs] In other words, if your drop | ||
implementation is implemented correctly, you can construct a `Pin` of self | ||
inside your `poll_drop_ready`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this now mean we can or can not call async fn
s inside drop? I think in generally the answer is "no":
- We can not directly await since
poll_drop_ready
is not an async fn - The workaround is: Create a
Future
through the async fn one intends to call, store it somewhere in a pinned location, and poll it on every call from insidepoll_drop_ready
- The latter approach doesn't seem to work, since there is no
Pin
argument
If this is true this would be major limitation, since most of the ecosystem is now moving towards first class support for async functions. And one might e.g. not even be possible to write to an async channel anymore without support for that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We provide specific guarantees about how poll_drop_ready will be called, which one can use to prove the safety of calls Pin::new_unchecked
in order to poll those futures.
Thanks for taking the time to write this up! Performing async cleanup work is definitely something that I am currently missing from both an application developer as well as a library developer perspective. On the application I currently have a project which would in some places simply being broken if someone would cancel Futures at any point in time, since some code might not assume the possibility of not being executed, and synchronous cleanup being hard. That's at the moment tolerable, because the application is small enough to know exactly that nothing would be cancelled - but it's far from an ideal solution. I expect those issues to become even bigger and more prevalent once more developers with a lesser knowledge of Rust and async/await semantics . On the library side the missing ability to perform async/cleanup work and the ability to defer destruction of tasks prevented so far both nice APIs for completion based IO, as well as scoped tasks with graceful cancellation semantics (tokio-rs/tokio#1879). Therefore I'm super interested on improving on this. However I'm afraid the problem I've encountered so far are not really solved by this RFC, and therefore I don't see this improving the situation for projects I've been working on vastly. The main concern I have is the lack of guarantees. It doesn't seem to be guaranteed that the
As long as there are no guarantees, it doesn't make sense to use it for those scenarios. Even if I'm taking memory safety aside, there are some application level requirements which on a first look do not look different from where we are now with this proposal. E.g. if I have the requirement to rollback a transaction on a client disconnect, I would need to perform a call using an async/await database API. As far as I understand this proposal so far, this wouldn't be possible, and I would need to somehow still work around this by delegating the work to a new spawned task. I would like to get rid of the latter workarounds this these break structured concurrency concepts (which is the nice theoretical term), but practically also have an impact on system reliability and resilience: By doing work outside of the original function/task we lose accounting (e.g. limiting/throttling/load-shedding) for that work and risk being overloaded due to invisible background cleanup tasks. That can again be solved using workarounds, but I would rather prefer a solution for async/await itself which does not make those workarounds necessary. I had an idea a while about solving the issue by having a separate kind of async function which has the same guarantees about running to completion as regular threads. This has a different set of drawbacks, but so far seems to me the most promising compromise. I might try to dusting off the draft RFC I started a while ago and share it. |
This is already true of course for all destructors. Without this line of code a Rust Vec would not call the destructors of its members when its dropped. And yet, no one is very worried that their data structures do not run destructors properly, because failing to run destructors without clearly communicating that in the documentation is considered a serious, show-stopping bug in the library. We will need to introduce the same culture shift around
It's interesting you cite this as impossible since its listed as a motivation in the RFC. The pattern of just using an owned guard's destructor to guard scopes is totally unsound, but the sound alternative in which a callback takes a reference to a guard still requires a destructor to be implemented properly, so that the scope will be guarded in the case of panic. If this guard would like to yield the thread and park this task during that period, it needs something analogous to this RFC.
The type implementing drop would need to be able to store the state of the future of that database API somehow. Right now, this may require boxing. The realities of state & their impact on the ergonomics of using this feature are acknowledged in the RFC. |
Any chance this helps with issues around destructors in thread local storage ala rust-random/rand#996 rust-random/rand#968 ? |
@burdges from what I can tell not at all. AFAIK accounting for the inherent lack of ordering in TLS destructors can only really be done in library/application specific manner. |
This "prepares" the object to be destroyed by calling its destructor, calling | ||
`poll_drop_ready` on the object and all of its fields. This is called before | ||
any destructors are run on that object. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would the reference counted pointers have a forwarding implementation of poll_drop_ready
to the inner type? Is it possible to provide such implementation?
The proposed drop order makes it challenging if not impossible. Drop::poll_drop_ready
on the fields happens before Drop::drop
of the struct, so on the one hand, the async drop process of the inner type might have already started, on the other hand, the shared pointer themselves must still remain fully functional.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a fair question & we need to be careful about shared ownership constructs, which I never investigated very thoroughly. I think there are specifically concerns about cyclic shared ownership constructs which might clone the cyclic Rc in their poll_drop_ready
implementation. Do you have specific scenarios in mind we could explore?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- The
Arc<T>
recovers unique ownership overT
insideArc<T>::drop
implementation (when it decrements counter to zero). - Forwarding the
Arc<T>::poll_drop_ready
toT::poll_drop_ready
requires a unique access, but at this point ownership is still shared.
Additionally decrementing the counter in Arc<T>::poll_drop_ready
is impossible, because Arc<T>
is accessible and must remain valid (according to the proposed drop order).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This may be a reason we should return from poll_drop_ready
to poll_drop
, implemented by default by forwarding to the normal drop.
Rc/Arc would need to be changed to this basic algorithm:
if strong_count > 0:
decrement strong_count
if strong_count == 0:
ready!(inner.poll_drop)
deallocate
This may be possible to implement correctly with poll_drop_ready, too.
Arc/Rc would exist during dropping in a state of having 0 count, but afaict we know we have exclusive access because we decremented the count to 0.
It should also be noted that it introduces silent "yield points" into async | ||
contexts, whereas previously all yield points were visible awaits. This is just |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does that mean, that in an asynchronous context, a drop of non-copy temporaries at the end of statement / scope always introduces a yield point? Can it be sometimes avoided, if so on what basis? What about code that drops generic T
temporary?
The presence or absence of yield points shapes the overall size of the generator, the determination which lifetimes and types will be included in it, how long the borrows extend, whether resulting future type will by Sync or Send, etc. Introduction of additional yield points to an existing async code base is unlikely to be backward compatible change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Optimizing no-op yield points away is specifically mentioned in the RFC as desirable, and should usually be achievable, maybe benefiting from MIR optimization passes. The biggest concern will be trait objects, because the call will be virtual. We may need specific representational optimizations, such as making the poll_drop_ready pointer null, to prevent that from being a problem.
However, you raise a very good point: this optimization would be necessary to influence the auto trait implementation of async functions, since we must only store state for types which have a meaningful poll_drop_ready in order to avoid a backward compatibility hazard. We probably need to figure out how we could solve that hazard.
It's also an interesting and important point that adding a meaningful poll_drop_ready
to a type would be a potentially breaking change, because other non-send types may have to be persisted across that yield point, whereas they weren't before.
Possibly this pushes us to making poll_drop_ready
implemented by a separate trait, so that the compiler can more readily track which values have meaningful glue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, it may be that trait objects scupper this plan:
async fn foo(obj: Box<dyn Send>) {
let rc = Rc::new(0);
let obj = obj;
}
Can't see a way that this doesn't become a future that no longer implements Send
. At very least, implementing this may require crater, lints, and a serious migration effort.
@withoutboats Would an extension to
@Diggsey |
Sure: async fn write_to_file() {
let f = open_the_file().await;
f.write_all("bleh").await;
f.close().await;
} A linear type system would mean that failing to call If this function was fallible, it would also fail to compile: async fn write_to_file() -> Result<(), Error> {
let f = open_the_file().await?;
f.write_all("bleh").await?; // Error: `f` is implicitly dropped
f.close().await;
} Using today's syntax, you'd need to explicitly handle that, ensuring that async fn write_to_file() -> Result<(), Error> {
let f = open_the_file().await?;
let res = f.write_all("bleh").await;
f.close().await;
res
} If this becomes too verbose, we could look to improve the syntax (for example, the This could be introduced using a new auto marker trait ( This RFC doesn't touch much on unwinding, other than to state that the RFC would allow asynchronous cleanup whilst unwinding is occurring. This seems complicated and dangerous to me, as it would allow a panic to be "in-flight" on a suspended task! Some questions about that:
In my proposal, in the case of unwinding due to a panic, |
// an empty async function | ||
async fn drop_async<T>(to_drop: T) { } | ||
|
||
fn poll_drop_ready<T>(&mut self, cx: &mut Context<'_>) -> Poll<()> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this a freestanding function which takes &mut self
?
``` | ||
|
||
However, we do guarantee that values dropped in an async context *will* have | ||
`poll_drop_ready` called, and we even guarantee the drop order between |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be possible to explicitly add that poll_drop_ready
may not be called, but when it's called it's in a loop at least until it returns Poll::Ready(())
?
(Yes it's been already said above, but I feel it should also be in the "guarantees" section)
Linear types do not play well with generic parameters. Consider your linear file type, what happens when I pass it to Even if it weren't a huge backwards compatibility hazard, in order for types to return different finalized outcomes (ie different results or futures), they'd all need different associated types, which a generic function would not be able to handle. For drop, this is simple enough: // all type parameters are implicitly bound `Nonlinear`, just as they are implicitly bound `Sized`
trait Nonlinear { }
// types which explicitly implement Linear do not implement Nonlinear
trait Linear {
type Outcome;
fn finalize(&mut self) -> Self::Outcome;
}
// new alternative to drop which must be explicitly called on types that dont implement Nonlinear
fn finalize<T: ?Nonlinear>(arg: T) -> T::Outcome { /* intrinsic */ } But obviously for more complex functions which take multiple generic parameters by value, they now have to return a tuple of their real return value and the outcome of the finalizers of all of their generic parameters, so that their callers can process them. Because of these issues, I see little chance of this progressing further in Rust in any near or medium term time horizon. The limitations on drop that make it potentially error prone exist because they are necessary to make it tractable.
Agreed, the behavior of unwinding needs to be thought through more carefully. |
Yep, but this is no different than many other features we add (const fn, nostd, etc.) and migration can be done on-demand as need for it becomes apparant. The reality is that most existing code is not going to work with any form of async drop, because existing code expects to be able to drop types synchronously: this RFC side-steps the issue by providing a "default behaviour" when there's no obvious way for async drop to be called, but that default beheviour will be wrong, or at least not ideal in many cases, and so will still result in an ecosystem-wide migration to "support" async destructors properly in many libraries. What's worse to me is that there's no way of knowing, as a user, whether a library does "do the right thing" (whatever that means for the particular library in question) because the code will compile fine either way.
This is not what I described: there's no need for a "finalize" trait, or to modify the bounds on "drop" because linear types cannot be dropped (except through unwinding).
IMO, this is quite an important aspect of the RFC to omit, and "scope guards" should not be listed as a motivation for this solution unless that is addressed. |
gonna close this, now the file is uploaded and the async WG could take this over after streams if they want. i dont want to deal with this rfc thread. im not getting paid to do this and it makes me very unhappy |
Not sure where this discussion should move to, so I'll continue here, hoping boats unsubscribed to not make them feel worse. If the conversation moves elsewhere, please just poke here so people interested in this RFC can know where it's moving towards? FWIW, I don't see what backwards compatibility hazard would be brought by a That said, indeed it's something that'd probably be not-very-well-supported through the ecosystem (like On the other hand, async destructors would certainly involve quite a few corner cases (at the very least around unwinding and cancelling the task mid-drop), that I feel would have a worse impact on the overall ecosystem (by creating bugs in previously correct code) than a forced addition of Side-note: if going the linear type way, we may want to add a |
This is the design I worked on last year to allow asynchrony in destructors. It is a relatively small addition, a new provided method on
Drop
and adjustment to the drop the glue used in async contexts.Rendered