This article explains how to utilize Tasks for managing asynchronous events in the .NET Framework, including returning Tasks from control-based events, managing Task-based events in the ViewModel, canceling Tasks returned from events, and an example application demonstrating these concepts.
Introduction
When you have void
returned from an event handler, there is not a lot you can do with it, but, if you get a Task
back, you have access to one of the most powerful types for managing asynchronous events in the .NET Framework. This article shows ways to encapsulate events within Tasks
and how these Tasks
can be used to control program flow in a manner that's both logical and easy to maintain.
Returning Tasks from Controls
The technique for returning Tasks
from control-based events is quite straightforward. In this WPF
example, an extension method is added to the ButtonControl
.
public static async Task ClickRaisedAsync(this Button button)
{
var tcs = new TaskCompletionSource<bool>();
RoutedEventHandler clickHandler = (s, e) => tcs.SetResult(true);
button.Click += clickHandler;
await tcs.Task;
button.Click -= clickHandler;
}
The extension method instantiates a TaskCompletionSource
and attaches a handler to the event. The handler simply sets the TaskCompletionSource
result. The TaskCompletionSource
's Task
is then awaited. Finally, the continuation after the await
statement removes the handler.
Managing Task-based Events in the ViewModel
The arrangement shown above is fine for dealing with events that are only concerned with visual presentation such as Storyboard
events, but, for other events, it's better to abstract the event from the control and manage it within the ViewModel
. The Prism Framework has a DelegateCommand that's used to bind events in the view to handler methods in the ViewModel
. But the method that's called when an event is raised returns void
. In order to be able to await
the event, the returned object needs to be a Task
.
AwaitableCommand
The AwaitableCommand
class, shown below, has a CommandRaisedAsync()
method that returns a Task
and can be awaited. The class has a private
DelegateCommand
instance and most of the class' methods simply call the corresponding method of that instance.
public class AwaitableCommand<T> : ICommand
{
private readonly DelegateCommand<T> delegateCommand;
public AwaitableCommand(Func<T, bool> canExecuteMethod)
{
delegateCommand =
new DelegateCommand<T>((t) => Execute(t), canExecuteMethod);
}
public event EventHandler<T> CommandRaisedEvent;
public event EventHandler CanExecuteChanged
{
add { delegateCommand.CanExecuteChanged += value; }
remove { delegateCommand.CanExecuteChanged -= value; }
}
public bool CanExecute(object parameter)
{
return delegateCommand.CanExecute((T)parameter);
}
public void Execute(object parameter)
{
CommandRaisedEvent?.Invoke(this, (T)parameter);
}
public void RaiseCanExecuteChanged()
{
delegateCommand.RaiseCanExecuteChanged();
}
public async Task<T> CommandRaisedAsync()
{
var tcs = new TaskCompletionSource<T>();
EventHandler<T> handler = (s, p) => tcs.SetResult(p);
CommandRaisedEvent += handler;
var parameter = await tcs.Task;
CommandRaisedEvent -= handler;
return parameter;
}
}
Cancelling Tasks Returned from Events
One of the advantages of returning a Task
from an event is that Tasks
can be cancelled. Returning a cancellable Task
from CommandRaisedAsync
would enable the event to be timed out and the Task
could be used effectively with other Tasks
in calls to Task.WhenAny
. The Task.WhenAny
method returns the first Task
of a series of Tasks
to complete and it's sometimes necessary, when using this method, to cancel any uncompleted Tasks
. The technique for cancelling a Task
is to instantiate a CancellationTokenSource
and pass the CancellationToken
that it generates to an async
method that returns a Task
. Cancellation is effected by calling CancellationTokenSource.Cancel
. Cancellation is not automatic, it needs to be implemented within the async
method by monitoring the state of the CancellationToken
and taking action to cancel the method when the token's IsCancellationRequested
property changes to true
. Here's an implementation of CommandRaisedAsync
method that allows the method to be cancelled.
public async Task<T> CommandRaisedAsync(CancellationToken token)
{
var commandRaisedTcs = new TaskCompletionSource<T>();
using (var cts = CancellationTokenSource.CreateLinkedTokenSource(token))
{
var localToken = cts.Token;
EventHandler<T> handler = (s, p) => commandRaisedTcs.SetResult(p);
CommandRaisedEvent += handler;
var commandRaisedTask = commandRaisedTcs.Task;
var delayUntilCancelledTask = Task.Delay(-1, localToken);
var awaitedTask = await Task.WhenAny
(commandRaisedTask, delayUntilCancelledTask);
CommandRaisedEvent -= handler;
if (awaitedTask.Status == TaskStatus.Canceled)
{
commandRaisedTcs.SetCanceled();
throw new OperationCanceledException("The Event was cancelled");
}
cts.Cancel();
return commandRaisedTask.Result;
}
}
The trick here is to use the Task
returned from a call to Task.Delay(-1,localToken)
to monitor the state of the CancellationToken
and use a second Task
to represent the command raised event. The call to await Task.WhenAny(commandRaisedTask, delayUntilCancelledTask)
asynchronously waits for the first Task
of the two to complete. If the delayUntilCancelled Task
completes first, an OperationCanceledException
is thrown and the commandRasedTask
is cancelled. If the commandRaisedTask
completes first, the delayUntilCancelledTask
is cancelled and the commandRaisedTask.Result
is returned.
Cancellation using Linked CancellationTokenSources
In the example above, the CancellationToken
passed to the Task.Delay
method. is linked to the token passed into CommandRaisedAsync
. The effect of this linkage is that the token generated by the linked source will be set to cancelled when the token passed into CommandRaisedAsync
is cancelled. So the delayUntilCancelledTask
can be cancelled by two sources, the local source and an external source. The using
statement ensures that the linked CompletionTokenSource's Dispose
method is called when the class
goes out of scope. This precaution is not necessary with unlinked CompletionTokenSources
.
Example Application
In this somewhat contrived WPF
example, a behaviour is implemented where, when a mouse button is clicked and held down within a Rectangle
, a counter is updated and records the period of time that the button is held in that position. Releasing the button or moving the mouse outside the Rectangle
stops the count. The behaviour is timed out after 6 seconds but it can be cancelled before then by clicking a cancel Button
. Apart from the start and cancel button events, four other events need to be managed. They are, the MouseButtonDown
, MouseButtonUp
, MouseLeave
events and a timer event that's used to update the counter. In the code, the ICommand
instances are initialised in the ViewModel
's constructor and bound to their events in the XAML.
public TestCounterVM()
{
MouseDownCommand = new AwaitableCommand<object>(o => true);
MouseUpCommand = new AwaitableCommand<object>(o => true);
MouseLeaveCommand = new AwaitableCommand<object>(o => true);
CancelCommand = new DelegateCommand<object>(OnCancel, o => IsStarted);
StartCommand = new DelegateCommand<object>(OnStartAsync, o => !IsStarted);
}
<Rectangle Width="60" Height="60" Fill="Green" Margin="5" >
<i:Interaction.Triggers>
<i:EventTrigger EventName="MouseDown">
<cmd:InvokeCommandAction Command="{Binding MouseDownCommand}"/>
</i:EventTrigger>
<i:EventTrigger EventName="MouseUp">
<i:InvokeCommandAction Command="{Binding MouseUpCommand}"/>
</i:EventTrigger>
<i:EventTrigger EventName="MouseLeave">
<i:InvokeCommandAction Command="{Binding MouseLeaveCommand}"/>
</i:EventTrigger>
</i:Interaction.Triggers>
</Rectangle>
The ViewModel
has a ManageCounterAsync
method that awaits the Task
returned from MouseDownCommand.CommandRaisedAsync
. If the Task
completes in a cancelled state, an OperationCanceledException
is thrown and the method exits. If the Task
ran to completion, the continuation is run and StartCountingAsync
is called. This method uses Task.Delay
to update the counter periodically. An instance of Task.WhenAny
is then awaited. It returns the first Task
to complete from the Tasks
generated by calling MouseUpCommand.CommandRaisedAsync, MouseLeaveCommand.CommandRaisedAsync
and StartCountingAsync
. The continuation from this await
statement cancels the other two Tasks
by calling localCts.Cancel()
. The next iteration of the while
loop starts the process off again.
private async Task ManageCounterAsync(CancellationToken cancelDemoToken)
{
while (true)
{
using (var localCts =
CancellationTokenSource.CreateLinkedTokenSource(cancelDemoToken))
{
var token = localCts.Token;
await MouseDownCommand.CommandRaisedAsync(cancelDemoToken);
Task startCountingTask = StartCountingAsync(token);
var whenAnyTask = await Task.WhenAny
(MouseUpCommand.CommandRaisedAsync(token),
MouseLeaveCommand.CommandRaisedAsync(token), startCountingTask);
localCts.Cancel();
}
}
}
private async Task StartCountingAsync(CancellationToken token)
{
while (true)
{
await Task.Delay(300, token);
Counter += 1;
}
}
OnStartAsync
is called when a start Button
is clicked. A catch
block catches an exception thrown by cancelling the ManageCounterAsync
method and updates the enabled property of the start and cancel Buttons
. The timeout is activated by instructing the CancellationTokenSource
to cancel the token after 6 seconds.
private async void OnStartAsync(object arg)
{
Counter = 0;
IsStarted = true;
cts = new CancellationTokenSource();
cts.CancelAfter(6000);
var token = cts.Token;
try
{
await ManageCounterAsync(token);
}
catch (OperationCanceledException)
{
IsStarted = false;
}
}
The OnCancel
method simply cancels the ManageCounterAsync
method.
private void OnCancel(object arg)
{
cts.Cancel();
}
Conclusion
Managing events in the conventional manner can be a tedious process involving the 'wiring up' of numerous event handlers scattered throughout the ViewModel
and connected by multiple if then
statements. By getting events to return a Task
, their management can be greatly simplified. In the example above, four events are effectively managed within one method, there's little wiring and no if
statements.
References
History
- 5th June, 2018: Initial version