It's been a very long time since the first part but I didn't want this series to remain incomplete. The solution presented in part 1 solved the basic problem and it's successfully used in production for 6 months now, but some topics where omitted for simplicity.
- How to deal with
Task<TResult>
? - Exception Handling
- Using Tasks in non UI applications, e.g. AppServer
In this post we will be looking at exception handling and Task<TResult>
, that means async methods which "return" a value.
How to get the result
In part 1 the TaskCompleted
event was only used for signaling, so the compulsory sender
and EventArgs
parameters were not of any interest.
And it's just the same in this part - at least for the beginning. One might think that we need to pass the result to the event handler using a custom EventArgs class. Altough this has advantages, as we will see later, it's not necessary to access the task result. In fact we don't need any changes on the TaskAwaiter
in order to work with Task<TResult>
.
Let's do an example. We will be using the following fictive .NET method throughout the examples.
public Task<string> GetStringAsync()
And here's how to use the TaskAwaiter
to await the result:
DEF VAR v-awaiter AS TaskAwaiter NO-UNDO.
DEF VAR v-task AS "Task<CHAR>" NO-UNDO.
v-awaiter = NEW TaskAwaiter().
v-awaiter:TaskCompleted:SUBSCRIBE("HandleResult").
v-task = GetStringAsync().
v-awaiter:Await(v-task).
PROCEDURE HandleResult:
DEF INPUT PARAM sender AS System.Object.
DEF INPUT PARAM args AS System.EventArgs.
MESSAGE v-task:Result
VIEW-AS ALERT-BOX.
END PROCEDURE.
The important part is to store the return value (the task object) of the GetStringAsync
method to a variable (v-task
), which we can access in the event handling procedure (HandleResult
). Using the await include presented at the end of part 1 would save us from some boilerplate code for the TaskAwaiter
but the explicit version is better suited for illustration.
Behavior of the result property
In the above example, access to the task (and it's Result
property) is not limited to the event handling method. We could as well use the the Result
property before the TaskCompleted
event is raised. Altough this would work, the TaskAwaiter
would become useless because the Result
property blocks until the Task is completed. This has the same effect as calling the Wait
method on a task.
That doesn't meant using the Result
property on a not completed task should never be done, but if you want to await the task, make sure to only access the result after the task is completed.
Exception Handling
If an exception is thrown during task execution which is not caught inside the task, it will not be magically thrown again on the awaiting thread. Instead it will remain unperceived as long as you dont't explicitly check the task's state or access the result property (which will throw an exception on a faulted task).
You can check for unhandeled exceptions using the IsFaulted
property of the task. In case it's true the Exception
is stored in the eponymous property. Beware that the real Exception is always wrapped with an AggregateException by the TPL. It's available via the InnerException
property.
Here's an example displaying the message of the inner exception if the task is faulted:
IF v-task:IsFaulted THEN
MESSAGE v-task:Exception:InnerException:Message
VIEW-AS ALERT-BOX.
However AggregateExceptions can contain mulitple exceptions (Stackoverflow: When can an AggregateException contain more than 1 inner exception?):
DEF VAR v-i AS INT NO-UNDO.
IF v-task:IsFaulted THEN DO:
DO v-i = 0 TO v-task:Exception:InnerExceptions:Count - 1:
MESSAGE v-task:Exception:InnerExceptions[v-i]:Message
VIEW-AS ALERT-BOX.
END.
END.
In case of a Task<TResult>
it's not necessary to check state as above. We can just access the Result
property and handle potential exceptions the usual way.
Old-fashioned:
DEF VAR v-result AS CHAR NO-UNDO.
v-result = v-task:Result NO-ERROR.
IF ERROR-STATUS:ERROR THEN
MESSAGE ERROR-STATUS:GET-MESSAGE(1)
VIEW-AS ALERT-BOX.
Modern:
DEF VAR v-error AS Progress.Lang.Error NO-UNDO.
DEF VAR v-result AS CHAR NO-UNDO.
v-result = v-task:Result.
CATCH v-error AS Progress.Lang.Error:
MESSAGE v-error:GetMessage(1)
VIEW-AS ALERT-BOX.
END CATCH.
But, even in this case the AggregateException is thrown, not the real one.
If you have a non generic task (without Result
) and want to use the normal error handling instead of checking the IsFaulted
property, there is a trick to throw the exception. Just invoke Wait()
on the task after it's completed. It will do nothing if the task is completed successfully, but it will throw the AggregateException if it's faulted:
DEF VAR v-error AS Progress.Lang.Error NO-UNDO.
v-task:Wait().
CATCH v-error AS Progress.Lang.Error:
MESSAGE v-error:GetMessage(1)
VIEW-AS ALERT-BOX.
END CATCH.
Minimizing Scope
The solution presented above works, but we need a global variable to hold the task or an instance variable when used in a class. In most cases it would be sufficient to access the task in the event handling procedure / method (HandleResult
in the example above). To achieve this we can change the EventArgs type of the TaskCompleted
event to a custom type containing a Task
property.
The extended TaskAwaiter including the new EventArgs class (changed lines in bold):
public class TaskAwaiter { public event EventHandler<TaskCompletedEventArgs> TaskCompleted; public void Await(Task task) { task.ContinueWith( t => OnTaskCompleted(t), TaskScheduler.FromCurrentSynchronizationContext()); } private void OnTaskCompleted(Task task) { var handler = TaskCompleted; if (handler != null) TaskCompleted(this, new TaskCompletedEventArgs(task)); } } public class TaskCompletedEventArgs : EventArgs { public Task Task { get; private set; } public TaskCompletedEventArgs(Task task) { Task = task; } }
This allows us to use the task in the event handling procedure as follows:
PROCEDURE HandleResult:
DEF INPUT PARAM sender AS System.Object.
DEF INPUT PARAM args AS TaskCompletedEventArgs.
IF args:Task:IsFaulted THEN
...
To access the Result property of a Task<TResult>
we need to cast:
DEF VAR v-result AS CHAR NO-UNDO.
v-result = CAST(args:Task, "Task<CHAR>"):Result.
Adding a state parameter
When working with events it's not unusual to offer a state parameter which allows the user to transfer an arbitrary object to the event handling code. Otherwise this would lead to global state as seen with the task above.
That's why I added an overload to the Await
method which takes a state parameter:
public class TaskAwaiter { public event EventHandler<TaskCompletedEventArgs> TaskCompleted; public void Await(Task task) { Await(task, null); } public void Await(Task task, object state) { task.ContinueWith( t => OnTaskCompleted(t, state), TaskScheduler.FromCurrentSynchronizationContext()); } private void OnTaskCompleted(Task task, object state) { var handler = TaskCompleted; if (handler != null) TaskCompleted(this, new TaskCompletedEventArgs(task, state)); } } public class TaskCompletedEventArgs : EventArgs { public Task Task { get; private set; } public object State { get; private set; } public TaskCompletedEventArgs(Task task, object state) { Task = task; State = state; } }
Here's an example how it could be used from OpenEdge:
DEF VAR v-awaiter AS TaskAwaiter NO-UNDO. v-awaiter = NEW TaskAwaiter(). v-awaiter:TaskCompleted:SUBSCRIBE("HandleResult"). v-awaiter:Await(GetStringAsync(), "useful object"). PROCEDURE HandleResult: DEF INPUT PARAM sender AS System.Object. DEF INPUT PARAM args AS TaskCompletedEventArgs. MESSAGE args:State VIEW-AS ALERT-BOX. END PROCEDURE.
This may seem like overkill in a simple procedural example, but it's definitely useful when working with classes or in a more complex procedure than this.
However you can only pass simple ABL types like char and int (which have a .NET type mapping) and objects derived from System.Object
. Instances of Progress.Lang.Object
can't be referenced from .NET (...it would be possible with a wrapper class but this is another topic).
Adapting the await include
Using the TaskAwaiter without the await include presented in part 1 introduces a lot of boilerplate code. So we should change it to support the optional state parameter:
&SCOPED-DEFINE id {&SEQUENCE}
DEF VAR v-awaiter{&id} AS TaskAwaiter NO-UNDO.
v-awaiter{&id} = NEW TaskAwaiter().
v-awaiter{&id}:TaskCompleted:Subscribe({2}).
&IF "{3}" = "" &THEN
v-awaiter{&id}:Await({1}).
&ELSE
v-awaiter{&id}:Await({1}, {3}).
&ENDIF
Then we end up with this in OpenEdge:
{await GetStringAsync() 'HandleResult' 'useful value'}
PROCEDURE HandleResult:
DEF INPUT PARAM sender AS System.Object.
DEF INPUT PARAM args AS TaskCompletedEventArgs.
MESSAGE args:State
VIEW-AS ALERT-BOX.
END PROCEDURE.
Links
Consuming Task-based Asynchronous Methods from OpenEdge ABL Part 1