I made a thread-safe class that binds a CancellationTokenSource to a Task, and guarantees that the CancellationTokenSource will be disposed when its associated Task completes. It uses locks to ensure that the CancellationTokenSource will not be canceled during or after it has been disposed. This happens for compliance with the documentation, that states:
The Dispose method must only be used when all other operations on the CancellationTokenSource object have completed.
And also:
The Dispose method leaves the CancellationTokenSource in an unusable state.
Here is the class:
public class CancelableExecution
{
private readonly bool _allowConcurrency;
private Operation _activeOperation;
// Represents a cancelable operation that signals its completion when disposed
private class Operation : IDisposable
{
private readonly CancellationTokenSource _cts;
private readonly TaskCompletionSource<bool> _completionSource;
private bool _disposed;
public Task Completion => _completionSource.Task; // Never fails
public Operation(CancellationTokenSource cts)
{
_cts = cts;
_completionSource = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
}
public void Cancel() { lock (this) if (!_disposed) _cts.Cancel(); }
void IDisposable.Dispose() // It is disposed once and only once
{
try { lock (this) { _cts.Dispose(); _disposed = true; } }
finally { _completionSource.SetResult(true); }
}
}
public CancelableExecution(bool allowConcurrency)
{
_allowConcurrency = allowConcurrency;
}
public CancelableExecution() : this(false) { }
public bool IsRunning => Volatile.Read(ref _activeOperation) != null;
public async Task<TResult> RunAsync<TResult>(
Func<CancellationToken, Task<TResult>> action,
CancellationToken extraToken = default)
{
if (action == null) throw new ArgumentNullException(nameof(action));
var cts = CancellationTokenSource.CreateLinkedTokenSource(extraToken);
using (var operation = new Operation(cts))
{
// Set this as the active operation
var oldOperation = Interlocked.Exchange(ref _activeOperation, operation);
try
{
if (oldOperation != null && !_allowConcurrency)
{
oldOperation.Cancel();
await oldOperation.Completion; // Continue on captured context
// The Completion never fails
}
cts.Token.ThrowIfCancellationRequested();
var task = action(cts.Token); // Invoke on the initial context
return await task.ConfigureAwait(false);
}
finally
{
// If this is still the active operation, set it back to null
Interlocked.CompareExchange(ref _activeOperation, null, operation);
}
}
// The cts is disposed along with the operation
}
public Task RunAsync(Func<CancellationToken, Task> action,
CancellationToken extraToken = default)
{
if (action == null) throw new ArgumentNullException(nameof(action));
return RunAsync<object>(async ct =>
{
await action(ct).ConfigureAwait(false);
return null;
}, extraToken);
}
public Task CancelAsync()
{
var operation = Volatile.Read(ref _activeOperation);
if (operation == null) return Task.CompletedTask;
operation.Cancel();
return operation.Completion;
}
public bool Cancel() => CancelAsync() != Task.CompletedTask;
}
The primary methods of the CancelableExecution class are the RunAsync and the Cancel. By default concurrent operations are not allowed, meaning that calling RunAsync a second time will silently cancel and await the completion of the previous operation (if it's still running), before starting the new operation.
This class can be used in applications of any kind. Its primary usage though is in UI applications, inside forms with buttons for starting and canceling an asynchronous operation, or with a listbox that cancels and restarts an operation every time its selected item is changed. Here is an example of the first case:
private readonly CancelableExecution _cancelableExecution = new CancelableExecution();
private async void btnExecute_Click(object sender, EventArgs e)
{
string result;
try
{
Cursor = Cursors.WaitCursor;
btnExecute.Enabled = false;
btnCancel.Enabled = true;
result = await _cancelableExecution.RunAsync(async ct =>
{
await Task.Delay(3000, ct); // Simulate some cancelable I/O operation
return "Hello!";
});
}
catch (OperationCanceledException)
{
return;
}
finally
{
btnExecute.Enabled = true;
btnCancel.Enabled = false;
Cursor = Cursors.Default;
}
this.Text += result;
}
private void btnCancel_Click(object sender, EventArgs e)
{
_cancelableExecution.Cancel();
}
The RunAsync method accepts an extra CancellationToken as argument, that is linked to the internally created CancellationTokenSource. Supplying this optional token may be useful in advanced scenarios.