I have a class that is used to kick off asynchronous
work.
When it comes to disposing of this class, I need to make sure all the tasks are complete first.
It seems like there are a few patterns for this.
IAsyncDisposable
Complete()
with separateCompletion
taskComplete()
with separateCompletion
task and then alsoDispose()
?
What are the pitfalls of using something like the following?
public class AsyncWorkHelper
{
private readonly List<Task> _tasks = new();
private CancellationTokenSource? _cancellationTokenSource;
private SemaphoreSlim _semaphore = new SemaphoreSlim(1);
private bool _isComplete;
public async Task PushWorkAsync()
{
if (_isComplete)
return;
// ...
}
public async Task CompleteAsync(bool cancel)
{
_isComplete = true;
if (cancel)
_cancellationTokenSource?.Cancel();
// wait for all tasks to finish
await Task.WhenAll(_tasks.ToArray());
// now safe to clean up
_cancellationTokenSource?.Dispose();
_semaphore.Dispose();
}
}
As a secondary question, I am also wondering if I should implement IDispose
with a blocking wait for situations where the calling code is in a synchronous context?
I have a class that is used to kick off asynchronous
work.
When it comes to disposing of this class, I need to make sure all the tasks are complete first.
It seems like there are a few patterns for this.
IAsyncDisposable
Complete()
with separateCompletion
taskComplete()
with separateCompletion
task and then alsoDispose()
?
What are the pitfalls of using something like the following?
public class AsyncWorkHelper
{
private readonly List<Task> _tasks = new();
private CancellationTokenSource? _cancellationTokenSource;
private SemaphoreSlim _semaphore = new SemaphoreSlim(1);
private bool _isComplete;
public async Task PushWorkAsync()
{
if (_isComplete)
return;
// ...
}
public async Task CompleteAsync(bool cancel)
{
_isComplete = true;
if (cancel)
_cancellationTokenSource?.Cancel();
// wait for all tasks to finish
await Task.WhenAll(_tasks.ToArray());
// now safe to clean up
_cancellationTokenSource?.Dispose();
_semaphore.Dispose();
}
}
As a secondary question, I am also wondering if I should implement IDispose
with a blocking wait for situations where the calling code is in a synchronous context?
1 Answer
Reset to default 0I see some fundamental issues in the AsyncWorkHelper:
The task list should be thread-safe. Considering that a task can switch from PushWorkAsync to PushWorkAsync right after the _isComplete check, a new task might be added to the task list while we are still waiting for its completion. possible issues: Race Conditions, CollectionModifiedException, InvalidOperationException, Lost Tasks (Task Might Not Be Awaited)
By using _isComplete, you are essentially trying to mimic a general task that represents the completion of all pushed tasks. For this, we can use TaskCompletionSource, which is a common use case for such scenarios.
You start and fire a new task each time PushWorkAsync is triggered. This means tasks start running, and then in CompleteAsync, you wait for all of them to complete. Dispose should be used when we need to free resources (such as physical resources, memory, handlers, etc.), but your definition of disposal is entirely aligned with completion, which is different from disposing.
Let's say we need to implement disposal because of some additional requirements you haven't mentioned. In that case, we must terminate all tasks, free resources, and manage the cancellation process using a CancellationTokenSource. Then, you need to combine the use of IAsyncDisposable with CancellationTokenSource.CancelAsync().