-
Notifications
You must be signed in to change notification settings - Fork 5.3k
Description
Related to #27547.
Now that we’re adding System.IAsyncDisposable to the core libraries, we also want to implement the interface on a variety of types that currently do or have the potential to do asynchronous work as part of their disposal. This is primarily focused on System.IO types that might flush or otherwise perform I/O as part of their clean up (e.g. flushing a buffer), though it is not limited to such types.
The following types would all implement IAsyncDisposable, and all gain a public member:
public ValueTask DisposeAsync();with it being virtual on all of the non-sealed classes:
public virtual ValueTask DisposeAsync();- System.IO.Stream. Its
DisposeAsyncwill do the equivalent of Task.Run(Dispose). Then, our variousStream-derived types will provide a more specialized implementation where appropriate. For example,MemoryStream’sDisposeis a nop, so we’ll make itsDisposeAsynca nop as well (unless the instance is actually of a type derived fromMemoryStream, in which case we’ll delegate to the base implementation). Conversely,FileStream’sDisposedoes a flush, so itsDisposeAsyncwill do an asynchronous flush. - System.IO.BinaryWriter. Its implementation will check to see whether the instance is a concrete
BinaryWriteror something derived from it. If concrete, theDisposeAsyncwill effectively be a copy of the synchronousDispose, except using async equivalents on the underlyingStream, e.g. where the synchronous implementation callsFlushorDispose/Close, the asynchronous implementation would useFlushAsync/DisposeAsync. If instead the type is derived fromBinaryWriter, the implementation will simply do the equivalent ofTask.Run(Dispose), so as to pick up whateverDisposeimplementation the derived class is providing, and the derived class may then choose to overrideDisposeAsyncto provide a better implementation if applicable (the core libraries don’t provide any derived types). - System.IO.TextWriter. Its implementations will do the equivalent of
Task.Run(Dispose). Derived implementations can then do something better if appropriate. For example, we’ll override onStreamWriterto asynchronously flush. - System.Threading.Timer.
Timercurrently provides twoDisposemethods,Dispose()andDispose(WaitHandle), the latter of which not only stops the timer, but also signals the providedWaitHandlewhen the timer guarantees that no more callbacks associated with that timer will be invoked. A caller can then block on thisWaitHandleto know when it’s safe to progress with any state thatTimermay have interacted with. As such, we’ll provide an equivalentDisposeAsync, where the returnedValueTaskwill complete when the timer appropriately guarantees the same thing, allowing a caller to wait asynchronously instead of synchronously. - System.Threading.CancellationTokenRegistration.
CancellationTokenRegistration.Disposedoes two things: it unregisters the callback, and then it blocks until the callback has completed if the callback is currently running.DisposeAsyncwill do the same thing, but allow for that waiting to be done asynchronously rather than synchronously.
Open issues:
BinaryReader/TextReader. I listed BinaryWriter and TextWriter, but not BinaryReader and TextReader. Do we want to implement IAsyncDisposable on those as well, as with their writing counterparts? It’s rare for an implementation to actually need to do asynchronous work as part of closing a reader, as they generally don't need to flush. We could add them now for completeness, or we could wait until there's a demonstrated need.
Stream.DisposeAsync. There are some unfortunate issues here. I see three main options:
- Implement IAsyncDisposable as a public virtual.
- Don't implement the interface on Stream, but implement it on derived types.
- Don't implement it on any Stream, and just have callers use
FlushAsync+Dispose.
(2) isn't a very good for a few reasons:
- To use
usingwith an object, the compiler needs to be able to see statically that it implementsIDisposable; similarly forIAsyncDisposablewithawait using. It's very common to just have aStreamreference that you get handed from somewhere, and so even if the derived implementation implements the interface, you wouldn't be able to useawait usingwith it as a Stream. - If a
BaseStreamimplements the interface explicitly, then there's no good way for aDerivedStream : BaseStreamto invoke the base stream's implementation in order to clean up any resources the base stream owned. - Call sites need to type test for
IAsyncDisposableto know whether they can useDisposeAsyncand then fall back to usingDisposeifDisposeAsyncisn't available.
(1) is what I've implemented, but it has its own problems. First, when we add DisposeAsync to Stream, it has to invoke the existing Dispose, as otherwise when code started using await using with a stream instead of using, it wouldn't actually clean anything up until the owner of that stream released a new version that overrode the new method. Second, since Dispose could be doing anything, including I/O, we don't want to synchronously block the caller who just asked to do work asynchronously, so DisposeAsync really needs to queue the call to Dispose. Now, it's very common for a derived Stream's Dispose(bool) override to do some cleanup and then call base.Dispose(disposing) in order to do whatever further cleanup work its base has (which may be an intermediary stream rather than Stream itself). However, if you do that with DisposeAsync and get down to the base Stream.DisposeAsync method, you'll end up queueing a work item to invoke Dispose... this shouldn't hurt anything functionally, as Dispose is meant to be idempotent, but it's unnecessary work. We could say "if you derive directly from Stream, don't bother calling to base.Dispose(disposing)", but that doesn't work, either. Consider a type like FileStream. FileStreamhas its own cleanup to do, so it needs to overrideDisposeAsync. However, what about an existing MySpecializedFileStreamthat derives fromFileStreamand overridesDisposeto cleanup additional stuff and then call toFileStream's implementation. That FileStream-derived type won't have overridden DisposeAsyncyet, which meansFileStream's DisposeAsyncactually needs to type test whether the instance is a concreteFileStreamor something derived, and if derived, it should just usebase.DisposeAsync()instead of its async logic. That then makes thebaseinvocation problem transitive, as whenMySpecializedFileStreamgoes to overrideDisposeAsync, if it calls base.DisposeAsync(), it'll end up going all the way down to Stream.DisposeAsync, which will queue a call to Dispose`.
This makes me wonder whether we should just do (3). But not implementing IAsyncDisposable on a type like Stream makes me question IAsyncDisposable.
Similar problems apply to TextWriter as well.
A few notes:
- Shouldn’t every type that implements
IDisposablealso implementIAsyncDisposable? No. The vast majority of IDisposable types do not perform asynchronous work as part of their disposal, with most dispose routines primarily focused on releasing native resources (often via calling Dispose on SafeHandles) and other such synchronous operations. We only want to implement IAsyncDisposable when we know that the type has the strong potential to do asynchronous I/O that would otherwise force its synchronous Dispose to block or spin waiting for those operations to complete. - Don’t we need a DisposeAsync(bool disposing)? No. The synchronous pattern has this so that both Dispose and a finalizer can share a dispose implementation, with the former passing true and the latter passing false, and then the implementation generally not disposing other managed state in the case of it being a finalizer. With DisposeAsync, the benefit of the method is to the caller being able to await for the disposal to complete rather than being blocked implicitly; this is not relevant to a finalizer, which will not wait for any such work, synchronously or asynchronously.