In a prior post I had illustrated the
use of the Tcl promise
package for asynchronous computing with
some examples. There we had ignored the possibility of errors and
exceptions and how they are handled in promise-based code. In
this post, we build on the examples in that post to illustrate how
promises greatly simplify handling of errors in async code.
One of the most useful features of the promise abstraction is the ability to funnel errors and exceptions into a common error handling path. This point cannot be stressed enough. Writing async code is hard enough as it is and when you add in error handling and recovery into a event or callback based implementation, it quickly becomes (even more) unmanageable.
Consider an extension of the simple example in our previous post - given a list of integers, we want to generate the list of the corresponding Fibonacci numbers. That example did not deal with errors at all. We will now add a few simple checks and handle errors appropriately.
First, here is the sequential version of the modified code with error handling. First, some utility procedures,
proc display {numbers} {
tk_messageBox -message "Requested Fibos are [join $numbers ,]"
}
proc display_error {error_message error_dictionary} {
tk_messageBox -message "Error message: $error_message\nError code: [dict get \
$error_dictionary -errorcode]" -icon error
}
proc check {n} {
if {![string is integer -strict $n] ||
$n < 1 || $n > 1000} {
throw {FIBONACCI INVALIDARG} "Invalid argument $n: must be an integer in \
the range 1:1000."
}
}
and then the code itself
if {[catch {
display [lmap n $numbers {
check $n
math::fibonacci $n
}]
} error_message error_dictionary]} {
display_error $error_message $error_dictionary
}
As before, we would like to rewrite this to run concurrently so as to not block our user interface while the computation is ongoing. We see how to do this with promises.
Before we do that though, we will take a small detour. In the
previous post we made use of the ptask
command to run the
computation for each element of the list in a separate
thread. This is wasteful at best and potentially disastrous in
terms of system resources at worst. Moreover, each new thread has
the overhead of creating a Tcl interpreter, loading the math
package and so on. Fortunately, the Thread package implements a
thread pool capability which resolves this issue. It permits a
limited number of worker threads to be configured within a pool,
and allows jobs to be queued which will be serviced by a free
worker thread. By a happy coincidence, the promise
package
supports it with the pworker
command.
Our promise-based async version therefore starts off creating a thread pool and initializing it by loading the required packages and defining the utility procedures.
package require Thread
set tpool [tpool::create -initcmd {
package require math
proc check {n} {
if {![string is integer -strict $n] ||
$n < 1 || $n > 1000} {
throw {FIBONACCI INVALIDARG} "Invalid argument $n: must be an \
integer in the range 1:1000."
}
}
}]
tpool::preserve $tpool
See the documentation for the Thread
package for any questions
with regards to the above.
We kick off the computation in almost identical fashion as in
our previous post except for a small change related to the use
of pworker
command instead of ptask
.
If you are unsure about what the code below does, please see
the prior post.
set computation [all [lmap n $numbers {
pworker $tpool [lambda n {
check $n
math::fibonacci $n
} $n]
}]]
Now we just use the done
method to wait for the computation to
complete and display the result.
$computation done display display_error
The first argument to the done
method is the command prefix to invoke
on fulfillment (successful completion) of the promise. An additional
argument, the result of the computation, is appended to the command
prefix before it is called. The second argument to done
, which is
optional, specifies a command prefix to be called when the promise is
rejected. When this is called, it is passed two additional arguments -
the error message and the Tcl error dictionary in the same format as
returned by the catch
command in case of errors.
Thus in our example, if all the worker threads complete sucessfully,
the display
command will be called and passed the list of computed
Fibonacci numbers. In case one or more threads raise an error, the
display_error
command will be called and passed the error message
and error dictionary from the thread.
As a point of comparison, here's the sequential and promise-based code listed together. First the synchronous version,
if {[catch {
display [lmap n $numbers {
check $n
math::fibonacci $n
}]
} error_message error_dictionary]} {
display_error $error_message $error_dictionary
}
and the async version
set computation [all [lmap n $numbers {
pworker $tpool [lambda n {
check $n
math::fibonacci $n
} $n]
}]]
$computation done display display_error
There really isn't a huge difference in complexity and you get all the benefits of async computation. At the same time, consider how much more complex it would be writing an async version without promises and handling all the failure cases.
To summarize, the basic form of promise based computation has the pattern
Create a promise that initiates an async computation
Call its done
method passing it a fulfillment reaction
and a rejection reaction
Now we said the second argument to done
, the rejection reaction,
was optional. So what happens if it is not specified? To answer that
we have to first describe how errors are handled when promises are
chained.
Again, if you are not familiar with promise chaining via the then
method, please read the Promises by Example
post.
Assume we want to run a task on the successful completion of which we run a second task. Moreover, we want to handle any errors that may occur in either task. Here is a procedure defined to do that.
proc run2 {script1 script2} {
set prom1 [ptask $script1]
set prom2 [$prom1 then [lambda {script val} {
puts $val
then_chain [ptask $script]
} $script2]]
$prom2 done puts [lambda {reason error_dictionary} {
puts "Oops! $reason"
}]
}
Let us try it out.
% run2 {return "Task 1 completed"} {return "Task 2 completed"}
→ Task 1 completed
Task 2 completed
The first task completes successfully and fulfills the value of the first
promise whose fulfill reaction runs and initiates the second task. Since
that also completes successfully, its fulfill reaction (the puts
)
is invoked.
What happens if the first task completes but the second one fails?
% run2 {return "Task 1 completed"} {error "Task 2 failed!"}
→ Task 1 completed
Oops! Task 2 failed!
Again, the first task completes, but this time the script passed as the second task raises an error. As a result, the reject reaction of the second promise (the anonymous procedure) is invoked.
Finally what if the first task itself fails?
% run2 {error "Task 1 failed"} {return "Task 2 completed"}
→ Oops! Task 1 failed
We see that the failure of the first task is funnelled through to the reject reaction of the second promise. Here is the crucial statement.
set prom2 [$prom1 then [lambda {script val} {
puts $val
then_chain [ptask $script]
} $script2]]
The then
method call on the first promise registers a fulfillment
reaction to be called when it is fulfilled. However, the second
argument, the rejection reaction, is unspecified. As a consequence,
when the promise is rejected, there is no rejection reaction
registered and the new promise returned by then
is chained
to the first promise and reflects its status which in this case
is the rejection and its associated error value.
Thus all errors, no matter which
async operation they occurred in, are all handled in a single place.
The convenience of this cannot be overstated. This feature is
what allows common error handling for a sequence of async operations
with almost the same clarity as afforded by catch
or try
in
the sequential world. In traditional async programming,
error handling for our simple example would not be too hard but
in anything more complex with multiple parallel operation
fan-ins and fan-outs, it quickly becomes unmanageable. No so
with promises.
What happens to errors if an application has not registered any
relevant rejection reactions, i.e. either directly or chained. In this
case the error is passed on to the Tcl/Tk background error handler
for the interpreter. This is the command returned by the
interp bgerror
command.
Note that error are backgrounded only if at least one applicable fulfillment reaction is registered but not any rejection reactions. If there are no applicable reactions (fulfillment or rejection) then the promise assumes the application is still to register its interest in the outcome and does not invoke the background error handler.
As we saw above, errors in promise-based async code result in the applicable rejection reactions being invoked. It is important to note that this applies to errors in the promise constructor as well. This, as noted in the ECMA specification, is required so that all errors can be treated uniformly no matter at which point they occur.
For example, here we call the ptimer
command to create a promise
based timer passing an invalid value.
% proc print_error {reason edict} {puts $reason}
% set timer [ptimer notaninteger]
→ ::oo::Obj62
% $timer done puts print_error
→ Invalid timeout value "notaninteger".
Notice that the ptimer
call does not raise a Tcl exception. The
errors are funnelled back using the standard promise reaction
mechanisms.
As a more explicit example, here is a raw promise that errors out in its constructor.
% set prom [Promise new [lambda {prom} {
error "You shall never construct me!"
}]]
→ ::oo::Obj63
% $prom done puts print_error
→ You shall never construct me!
The same error trapping also happens for the then
method
(which returns a promise) but not for done
which does not return a
promise.
In effect, you can construct and chain promises without worrying about having to trap errors at each stage.
Note that when a rejection reaction is invoked in response to an error, the error is not automatically propagated. It is up to the reaction to explicitly propagate the error or to handle it itself.
Here is an example where the exception generated by the ptimeout
command is caught and handled.
set prom1 [ptimeout notaninteger]
set prom2 [$prom1 then "" [lambda {reason edict} {
return "Timer expired but so what"
}]]
$prom2 done puts
Notice the reject reaction handled the error and did not propagate the exception. It returned a normal value. On the other hand, below the error is propagated by the reject reaction.
set prom1 [ptimeout 1000]
set prom2 [$prom1 then "" [lambda {reason edict} {
error "$reason Arghh!"
}]]
$prom2 done puts print_error
So far we have looked at how rejections resulting from errors are handled. Now we look at flip side - how promises are rejected.
We have already seen through examples above that errors in async operations invoked through promises are caught and result in the promise being rejected. Promises can also be rejected explicitly as shown in the examples below.
Promises can be explicitly rejected by calling their reject
method.
So the example earlier that generated an error in the constructor could
also have been written as follows:
% set prom [Promise new [lambda {prom} {
$prom reject "You shall never construct me!"
}]]
→ ::oo::Obj63
% $prom done puts print_error
→ You shall never construct me!
In the above example, the constructor script is passed the promise object
being constructed as the prom
parameter and explicitly invokes the
reject
method on it.
In the case of the then
method however, the promise object being
constructed is not directly accessible from the reactions. In this
case the reactions can use the then_reject
procedure call to
do an explicit rejection. See the reference documentation for details
The catch
method for promises is syntactic sugar for the
then
method when no fulfillment reaction is to be specified.
It makes the intent of the method call clearer.
Thus
$prom catch REJECTIONHANDLER
is exactly equivalent to
$prom then "" REJECTIONHANDLER
To summarize the discussion above, async programming is significantly complicated by the need to handle errors. In an event and callback based implementation, the error handling is spread amongst multiple code paths and difficult to follow. Moreover, there is no clear mechanism by which errors in an operation can be propagated when multiple operations are involved.
Much like the use of try
in sequential code, promises simplify
error handling in async code, at the same time reducing clutter and
providing clarity.