Summary
Provide an asynchronous-enabled Drop implementation that functions by explicitly marking the let-bindings of variables that it affects.
Motivation
Some tasks started within coroutines manage resources in ways similar to
synchronous types, that is they are expected to release some of their claims.
Currently, they must be able to do so synchronously via Drop
or be documented
to require an explicit asynchronous release on scope exit.
Guide-level explanation
We introduce an async version of the Drop
trait, AsyncDrop
, is introduced
(via HRTB or a poll
-method, discussions for the exact form of this trait are
somewhat bikeshedding). The trait is not automatic and structural in the same
manner as Drop
is, except for the negative rule for Copy
. The proposed form
is:
trait AsyncDrop {
fn poll(_: Pin<&mut Self>, ctx: &mut Context) -> Poll<()>;
}
We add a new binding mode for variable declarations (such as let
) which can
only be used in an async
blocks such as async functions.
async fn example(http: &HttpSocket) {
// Makes sure to properly finish the request, usually. Failing to do so
// would corrupt the shared stream state, a potential security issue if
// this socket is shared between parties (reverse proxy).
let async req = good_http.get("/example").await;
req
.body_stream()
.for_each(|fragment| {
/* Some work. If this panics, it would not cleanup `req` with `let`.
* `let async` fixes it and allows depleting the body.
* This is necessary for proper framing of responses (in HTTP/1.1).
*/
})
.await;
}
Types bound in this binding mode are treated special when the variables would
exit scope (and thus be dropped). They are instead pinned (only virtually, they
are no longer movable here) and then the type's Drop coroutine is await before
the usually invoked Drop
.
Existing Drop
semantics are not modified. There is no effect when the
value is not bound to a variable with the new binding mode. There are no
effects when async fn
itself is Drop
before being polled to completion.
Instead, the types of coroutines naturally generate the appropriate AsyncDrop
implementation which does the correct cleanup of all exiting variables with
their own AsyncDrop
coroutines.
We don't aim to make it impossible to forget the release of resources, just
syntactically easy to do so. Compare this to Drop
which doesn't make it
impossible to leak but just makes is structurally simple to write code that
does not.
Reference-level explanation
A new modifier for the identifier pattern (async
) is added. The pattern is an
alternative to the ref
qualifier in identifier patterns. It can only be used
in the body of a coroutine (async fn
). As a result, the resumption method
of the coroutine is modified to insert the await
expansion of
AsyncDrop::drop
at those locations determined by the drop-scope and its
unwinding edges. These polls will be called with the context passed to the
coroutine resumption (derived from its resumption argument). The Drop
glue of
the coroutine is not modified.
There are no further semantics attached to the variable, otherwise. It can be moved from in the usual sense. Consequently, the asynchronous drop then depends on the qualifier of the target place.
Drawbacks
This complicates the syntax and semantics of let
. Depending on the open
questions, it may also complicate the analysis of temporaries.
Rationale and alternatives
Other designs explored modifying Drop
itself. A fundamental problem is the
missing access to any task context. Additionally, coroutines defer control to
an executor which is not easily possible from within a synchronous function
(they don't compose).
For instance: https://github.com/rust-lang/rfcs/pull/2958
The difference to this is:
- Semantics introduced here are explicit. This addresses subtle compatibility issues.
- Only
async fn
is changed and no obligations are put on other contexts. - Fields are only recursed when implemented by the type.
The above RFC also argues that Pin<&mut Self>
is not possible, I'd like to argue the opposite. Pin
itself provides an ordering between the call to Drop::drop
and the invalidation of the memory location that was observed to be pinned. Not more! None of the additional calls modify this ordering. If a type decides to utilize the &mut Self
it is given in its own Drop
then it must not rely on pinning. In particular, if it moves some fields within its destructor then it must not implement pin projection. This statement is true irregardless of AsyncDrop
.
Another alternative is some handle to the executor that spawns a new cleanup which is not awaited. This has the drawback of an extra field, some design problems of making the handle generic, and that cleanup can not borrow any of the existing resources (it must instead take ownership of them).
This design ensures that semantics are only modified for code that has access to such a context. As a scope-based mechanism it also naturally composes much better with advanced lifetime uses.
Prior art
(Edited 2024 to add C# and Typescript, which I wasn't aware of initially).
C#
There's a separate interface type IAsyncDisposable
which can be combined with a scoped statement await using
to asynchronously clean up a resource at the end of a scope. This works directly analogous to using
syntax that implements what Rust would describe as RAII (as opposed to garbage collection). The DisposeAsync
method of the interface returns (and possible constructs) a task to be driven to completion when the object is scheduled to be disposed.
One interesting note: The language designers recognize that 'leaving scopes' is an entirely sequential operation and the original tasked is blocked until the single running dropping is complete. But on some schedulers space is limited and you'll want to queue the task in another context instead, so it does not consume additional resource. I don't think the above design has this problem as its unwinding should occur as part of the normal state machine translation. However, this has the downside that all the context which is required for the asynchronous disposal must already be part of the object bound by let async
. These together might consume surprising amounts of space for the surrounding async fn
making use of such a binding.
Typscript
In 5.2 it introduced using
and await using
, establishing an interface for deterministic disposal in both synchronous and asynchronous contexts. These statements are utilized by creating variable bindings with the respective statement. The behavior is pretty similar to the above proposed one, except their 'panic path' does not need to be defined quite as in-depth.
Python
In Python, async with ctx as v:
is a block statement that uses enters the
asynchrounous context manager ctx
(with v
being an object that is the
result of entering) with an asynchrounous operation (awaiting
ctx.__aenter__()
) . When code steps out of the block statement with or
without an exception it will exit the context manager with another
asynchrounous operation (awaiting ctx.__aexit__(*exc_state)
).
However, the devil is in the details. Especially since the task context is not declarative and effects are not special, the exact semantics of this can be rather surprising.
# <https://docs.aiohttp.org/en/stable/>
async with session.get('http://python.org') as response:
html = await response.text()
Note that Python implements coroutine as pure generators. Internally, it relies
on exceptions as well as an implicit task context to communicate with the task
context. When a coroutine is finalized (i.e. 'garbage collected') it unwinds by
injecting raising of a GeneratorExit
exception in its generator.
Consequently, when a coroutine as above is finalized (i.e. Drop
) it will
call the __aexit__
code with a special exception state corresponding to
GeneratorExit
. The special cleanup can thus run some code which will run as
expected if it is entirely sequential. Importantly, the code may even catch the
special finalizing exception.
class AsyncTestContext:
def __del__(self):
v = getattr(self, 'entered', None)
print(f'Currently entered: {v}')
async def __aenter__(self):
self.entered = True
async def __aexit__(self, *args):
# Pretend to do work, yield once.
await asyncio.sleep(0.0)
self.entered = False
This is also the source of a potential bug: The event loop (executor) may be
gone or no longer accept the submission of further coroutines when finalization
is invoked. Indeed, while __aexit__
is declaratively a coroutine, if it tries
to use its context it may fail and throw other exceptions instead of 'unwinding'.
Exiting with a exception that is not GeneratorExit
is vocally ignored by the
generator (it will print a warning and stacktrace) but for surrounding code, no
longer aware of the true cause, the situation can quickly escalate. They may
retry (an infinite loop of exceptional behavior in a finalizer!) and generally
encounter many unforeseen code paths.
Some Python code to play around with
import asyncio
class Test:
def __del__(self):
v = getattr(self, 'entered', None)
print(f'Currently entered: {v}')
async def __aenter__(self):
self.entered = True
async def __aexit__(self, *args):
await asyncio.sleep(1.0)
self.entered = False
async def acount(*args):
import itertools
for i in itertools.count(*args):
yield i
async def leaky(syncer):
#async for _ in acount():
async with Test():
syncer.set_result(True)
# Yield before exiting!
await asyncio.sleep(1.0)
# _usually_ unwinds before here is reached
assert False
def main():
# A local event loop to ensure GC
l = asyncio.new_event_loop()
f = l.create_future()
l.create_task(leaky(f))
l.run_until_complete(f)
del f,l
print('complete')
# leaky(f) is still running
main()
Unresolved questions
-
The location of temporaries in
async fn
could be specified as also using being qualified asasync
. With the proposed design this needs to be decided before stabilizing theAsyncDrop
trait. -
The exact
AsyncDrop
trait needs to be defined. Allowing it to construct an arbitrary (HRTB) custom future type could be useful for sharing finalizers of different objects. -
Will and should it generalize to other kinds of effects (e.g. control flow)? Some form of Drop where the destructor returns a Result and which
?
-tries on this method would come to mind as a sort-of-natural extension of the concept.
Future possibilities
This design could also motivate further bindings for other effects. It should be carefully monitored that this does not create complexity in understanding the semantics (such as sequencing) of potential other combinations.