Making tasks interruptible¶
All background tasks are cancellable in some form: calling the cancel
method
on the future for the task requests cancellation of the background task.
However, for a basic callable submitted using submit_call
, the ability to
cancel is rather crude. If cancellation is requested before the background
task starts executing, then as expected, the callable will not be executed.
However, once the background task starts executing, there’s no safe way to
unilaterally interrupt it. So if cancellation occurs after execution starts,
the callable still runs to completion, and only once it’s completed does the
state of the corresponding future change from CANCELLING
to CANCELLED
.
To allow cancellation to interrupt a background task mid-calculation, the background task must cooperate, meaning that the code for that task must be modified. Fortunately, that modification can be very simple.
This section describes how to modify the callable for a background task to make
it possible to interrupt mid-calculation. In brief, you turn your callable into
a generator function by inserting yield statements representing possible
interruption points, and then execute that callable using submit_iteration
instead of submit_call
. In addition, each yield statement can be used to
provide progress information to the future. The following goes into this in
more detail.
Example: approximating π¶
We use a simplistic example to illustrate. The following code uses a Monte Carlo algorithm to compute (slowly and inefficiently) an approximation to π.
import random
def approximate_pi(sample_count=10 ** 8):
# approximate pi/4 by throwing points at a unit square and
# counting the proportion that land in the quarter circle.
inside = total = 0
for _ in range(sample_count):
x, y = random.random(), random.random()
inside += x * x + y * y < 1
total += 1
return 4 * inside / total
On a typical laptop or desktop machine, one would expect this function call to
take on the order of several seconds to execute. If this callable is submitted
to an executor via submit_call
in a TraitsUI GUI, and then cancelled by the
user after execution has started, the future may not move from CANCELLING
to
CANCELLED
state until several seconds after cancellation is requested. Note,
however, that the GUI will remain responsive and usable during those seconds.
Here’s a complete TraitsUI application
that demonstrates this behaviour.
"""
Use Traits Futures to approximate π in the background.
Compare with the code in interruptible_task.py.
In this version of the code, an already executing computation can't be
interrupted: when the user cancels, the future doesn't move from CANCELLING to
CANCELLED state until the background computation is complete.
"""
import random
from traits.api import (
Bool,
Button,
HasStrictTraits,
Instance,
Int,
observe,
Property,
Str,
)
from traits_futures.api import (
CallFuture,
CANCELLED,
COMPLETED,
FAILED,
submit_call,
TraitsExecutor,
)
from traitsui.api import HGroup, Item, UItem, View
def approximate_pi(sample_count=10 ** 8):
# approximate pi/4 by throwing points at a unit square and
# counting the proportion that land in the quarter circle.
inside = total = 0
for i in range(sample_count):
x, y = random.random(), random.random()
inside += x * x + y * y < 1
total += 1
return 4 * inside / total
class NonInterruptibleTaskExample(HasStrictTraits):
#: The executor to submit tasks to.
traits_executor = Instance(TraitsExecutor)
#: The future object returned on task submission.
future = Instance(CallFuture)
#: Number of points to use.
sample_count = Int(10 ** 8)
#: Message about state of calculation.
message = Str("No previous calculation runs")
#: Button to calculate, plus its enabled state.
calculate = Button()
can_calculate = Property(Bool(), observe="future")
#: Button to cancel, plus its enabled state.
cancel = Button()
can_cancel = Property(Bool(), observe="future.cancellable")
@observe("calculate")
def _submit_calculation(self, event):
self.message = "Calculating π"
self.future = submit_call(
self.traits_executor, approximate_pi, self.sample_count
)
@observe("cancel")
def _request_cancellation(self, event):
self.future.cancel()
self.message = "Cancelling"
@observe("future:done")
def _report_result(self, event):
if self.future.state == CANCELLED:
self.message = "Cancelled"
elif self.future.state == FAILED:
self.message = f"Unexpected error: {self.future.exception[1]}"
elif self.future.state == COMPLETED:
self.message = f"Complete: π ≈ {self.future.result:.6f}"
else:
# Shouldn't ever get here: CANCELLED, FAILED and COMPLETED
# are the only possible final states of a future.
assert False, f"Impossible state: {self.future.state}"
self.future = None
def _get_can_calculate(self):
return self.future is None
def _get_can_cancel(self):
return self.future is not None and self.future.cancellable
traits_view = View(
Item("sample_count"),
UItem("message", style="readonly"),
HGroup(
UItem("calculate", enabled_when="can_calculate"),
UItem("cancel", enabled_when="can_cancel"),
),
resizable=True,
)
if __name__ == "__main__":
traits_executor = TraitsExecutor()
try:
NonInterruptibleTaskExample(
traits_executor=traits_executor
).configure_traits()
finally:
traits_executor.shutdown()
However, with two simple changes, we can allow the approximate_pi
function
to cancel mid-calculation. Those two changes are:
insert a yield statement at possible interruption points
submit the background task via
submit_iteration
instead ofsubmit_call
.
The implementation of submit_iteration
not only checks for cancellation, but
also sends a message to the future at every yield point. For that reason, you
don’t want to yield too often - as a guide, sending a message more than 100
times per second is likely be inefficient. But conversely, if you yield too
rarely, then the checks for cancellation will be spaced further apart, so you
increase the latency for a response to a cancellation request.
Making the approximation cancellable¶
Here’s a modification of the above function that checks for cancellation every 100 thousand iterations (which, for reference, worked out to around every 50th of a second when tested on a high-end 2018 laptop). It adds just two lines to the original function.
def approximate_pi(sample_count=10 ** 8):
# approximate pi/4 by throwing points at a unit square and
# counting the proportion that land in the quarter circle.
inside = total = 0
for i in range(sample_count):
if i % 10 ** 5 == 0:
yield # <- allow interruption here
x, y = random.random(), random.random()
inside += x * x + y * y < 1
total += 1
return 4 * inside / total
Adding the yield changes the function type: it’s now a Python generator function, returning a generator when called. So we need to use
submit_iteration
instead of submit_call
to send this function to the
executor, and we get an IterationFuture
instead of a CallFuture
in return.
Just as with the CallFuture
, the eventual result of the approximate_pi
call is supplied to the future on completion via the result
attribute.
Sending partial results¶
As we mentioned above, submit_iteration
also sends a message to the
IterationFuture
whenever it encounters a yield. That message carries
whatever was yielded as a payload. That means that we can replace the plain
yield to yield an expression, providing information to the future. That
information could contain progress information, partial results, log messages,
or any useful information you want to provide (though ideally, whatever Python
object you yield should be both immutable and pickleable). Every time you do a
yield something
in the iteration, that something
is assigned to the
result_event
trait on the IterationFuture
object, making it easy to listen
for those results.
Here’s a version of the approximation code that yields partial results at each yield point.
def approximate_pi(sample_count=10 ** 8):
# approximate pi/4 by throwing points at a unit square and
# counting the proportion that land in the quarter circle.
inside = total = 0
for i in range(sample_count):
if i > 0 and i % 10 ** 5 == 0:
yield 4 * inside / total # <- partial result
x, y = random.random(), random.random()
inside += x * x + y * y < 1
total += 1
return 4 * inside / total
Here’s a complete TraitsUI example
making use of the above.
"""
Use Traits Futures to approximate π in the background.
Compare with the code in non_interruptible_task.py.
In this version of the code, pressing the "Cancel" button interrupts the
background task. In addition, the background task provides ongoing progress
information to the UI.
"""
import random
from traits.api import (
Bool,
Button,
HasStrictTraits,
Instance,
Int,
observe,
Property,
Str,
)
from traits_futures.api import (
CANCELLED,
COMPLETED,
FAILED,
IterationFuture,
submit_iteration,
TraitsExecutor,
)
from traitsui.api import HGroup, Item, UItem, View
def approximate_pi(sample_count=10 ** 8):
# approximate pi/4 by throwing points at a unit square and
# counting the proportion that land in the quarter circle.
inside = total = 0
for i in range(sample_count):
if i > 0 and i % 10 ** 5 == 0:
yield 4 * inside / total # <- partial result
x, y = random.random(), random.random()
inside += x * x + y * y < 1
total += 1
return 4 * inside / total
class InterruptibleTaskExample(HasStrictTraits):
#: The executor to submit tasks to.
traits_executor = Instance(TraitsExecutor)
#: The future object returned on task submission.
future = Instance(IterationFuture)
#: Number of points to use.
sample_count = Int(10 ** 8)
#: Message about state of calculation.
message = Str("No previous calculation runs")
#: Button to calculate, plus its enabled state.
calculate = Button()
can_calculate = Property(Bool(), observe="future")
#: Button to cancel, plus its enabled state.
cancel = Button()
can_cancel = Property(Bool(), observe="future.cancellable")
@observe("calculate")
def _submit_calculation(self, event):
self.message = "Calculating π"
self.future = submit_iteration(
self.traits_executor, approximate_pi, self.sample_count
)
@observe("cancel")
def _request_cancellation(self, event):
self.future.cancel()
self.message = "Cancelling"
@observe("future:done")
def _report_result(self, event):
if self.future.state == CANCELLED:
self.message = "Cancelled"
elif self.future.state == FAILED:
self.message = f"Unexpected error: {self.future.exception[1]}"
elif self.future.state == COMPLETED:
self.message = f"Complete: π ≈ {self.future.result:.6f}"
else:
# Shouldn't ever get here: CANCELLED, FAILED and COMPLETED
# are the only possible final states of a future.
assert False, f"Impossible state: {self.future.state}"
self.future = None
@observe("future:result_event")
def _report_partial_result(self, event):
self.message = f"Running: π ≈ {event.new:.6f}"
def _get_can_calculate(self):
return self.future is None
def _get_can_cancel(self):
return self.future is not None and self.future.cancellable
traits_view = View(
Item("sample_count"),
UItem("message", style="readonly"),
HGroup(
UItem("calculate", enabled_when="can_calculate"),
UItem("cancel", enabled_when="can_cancel"),
),
resizable=True,
)
if __name__ == "__main__":
traits_executor = TraitsExecutor()
try:
InterruptibleTaskExample(
traits_executor=traits_executor
).configure_traits()
finally:
traits_executor.shutdown()