Instance disposal, like instance construction, poses an interesting problem when designing
async-ready types. The question that usually comes up is: how should I handle disposal if my type has asynchronous operations in progress? There are two common options, either (or both) of which may be appropriate for different situations.
Option 1: Dispose Means Cancel
Historically, disposing a resource on Windows while there are outstanding operations on that resource causes those operations to complete in a canceled state. Translating this pattern into the .NET
async world means that each
IDisposable type should have a private
CancellationTokenSource that is canceled when
Dispose is called. Internally, every asynchronous operation would use that private
CancellationToken while also allowing the user to supply their own
CancellationToken (this is easy to do using CancellationTokenHelpers.Normalize or CancellationTokenSource.CreateLinkedTokenSource).
This approach has a synchronous
IDisposable.Dispose method that can return before the asynchronous operations complete. The asynchronous operations then encounter a race condition: if they were about to complete, then they may complete successfully, faulted, or canceled. This race condition is almost always benign, so you usually don’t have to worry about it.
For some situations this works out just fine (e.g.,
HttpClient uses this approach). But some situations cannot accept the fact that
Dispose returns with operations still in flight; sometimes you need to know when the resource is free, and tracking all the operations all the time would be too messy.
Option 2: Asynchronous Disposal (Completion)
You can use this approach when you need asynchronous disposal; “dispose” becomes an asynchronous operation. You can’t use
IDisposable.Dispose for this, since
Dispose must be a synchronous method (OK, technically you could block in
Dispose, but that’s a hack that will cause other problems). Once again, you can turn to Microsoft to see how they handled this situation. As usual, Stephen Toub has been there, done that, and designed the T-shirt.
Terminology note: I use the term “asynchronous disposal” for what should really be called “completion” (an asynchronous operation). “Asynchronous disposal” has nothing to do with
IDisposable. However, I’m sticking with the “asynchronous disposal” term to reduce confusion. E.g., I can say “when the disposal completes” rather than “when the completion completes”.
Consider ConcurrentExclusiveSchedulerPair. This is a pair of
Task schedulers; it’s responsible for queueing tasks to run. The scheduler pair instance itself supports asynchronous “disposal”; the semantics are that once “disposal” is requested, no new tasks are accepted by the scheduler pair. However, the already-queued tasks are not canceled; the “disposal” is considered complete once those already-queued tasks have been run.
This permits a nice, clean,
async-friendly shutdown. The “asynchronous dispose” API for
ConcurrentExclusiveSchedulerPair looks like this:
This pattern is similar to the asynchronous initialization pattern covered earlier in this series. With asynchronous initialization, the operation is started in the constructor and the
Initialization property is used to detect the completion of (and get the results of) the asynchronous initialization. With asynchronous disposal, the operation is started by invoking
Complete and the
Completion property is used to detect the completion of (and get the results of) the asynchronous disposal. In both cases, the tasks are properties on the instance because they pertain strongly to the instance; this is a better design than returning a
Task from the
The next example takes this pattern a bit further. Blocks in a TPL Dataflow mesh support a clean, cooperative, asynchronous shutdown. Similar to the task scheduler example, dataflow blocks stop receiving input when completion is requested, and will (eventually) complete once all queued data has been processed. Dataflow blocks have an additional twist: as well as completing normally, they can complete in a faulted state. In this case, the dataflow block will stop receiving input and drop all its queued data; however, if there’s a piece of data currently being processed, it will gracefully wait for that processing to complete.
Dataflow blocks have this kind of API:
Aside: Interestingly, these are the only members of
IDataflowBlock. I asked Stephen Toub about this, and he said that they did consider naming this interface something like
IAsyncCompletable, but that the
Fault members don’t always make sense for all types.
So, if you need to support “asynchronous disposal”, I recommend following this same pattern. Just like the asynchronous initialization pattern, you may want to define your own marker interface if you have implementations that might need asynchronous disposal.
Types with asynchronous disposal should have at least the
Completion member, and most likely the
Complete member. Only add the
Fault member if it really makes sense for your type. As a final note, instance-level cancellation is handled by passing a
CancellationToken into the constructor; there’s no existing examples of a
Cancel member for asynchronous disposal. If you do need “external” cancellation like this, consider implementing
IDisposable with cancellation semantics (as described above) in addition to the standard asynchronous disposal API.
Update (2014-12-01): For more details, see Recipe 10.6 in my Concurrency Cookbook.