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.

# (C) Copyright 2018-2021 Enthought, Inc., Austin, TX
# All rights reserved.
#
# This software is provided without warranty under the terms of the BSD
# license included in LICENSE.txt and may be redistributed only under
# the conditions described in the aforementioned license. The license
# is also available online at http://www.enthought.com/licenses/BSD.txt
#
# Thanks for using Enthought open source!

"""
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 (
    CANCELLED,
    COMPLETED,
    FAILED,
    IFuture,
    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.
    executor = Instance(TraitsExecutor, ())

    #: The future object returned on task submission.
    future = Instance(IFuture)

    #: 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(), depends_on="future")

    #: Button to cancel, plus its enabled state.
    cancel = Button()
    can_cancel = Property(Bool(), depends_on="future.cancellable")

    @observe("calculate")
    def _submit_calculation(self, event):
        self.message = "Calculating π"
        self.future = submit_call(
            self.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.
            raise RuntimeError(f"Unexpected 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__":
    NonInterruptibleTaskExample().configure_traits()

However, with two simple changes, we can allow the approximate_pi function to cancel mid-calculation. Those two changes are:

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.

# (C) Copyright 2018-2021 Enthought, Inc., Austin, TX
# All rights reserved.
#
# This software is provided without warranty under the terms of the BSD
# license included in LICENSE.txt and may be redistributed only under
# the conditions described in the aforementioned license. The license
# is also available online at http://www.enthought.com/licenses/BSD.txt
#
# Thanks for using Enthought open source!

"""
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,
    IFuture,
    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.
    executor = Instance(TraitsExecutor, ())

    #: The future object returned on task submission.
    future = Instance(IFuture)

    #: 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(), depends_on="future")

    #: Button to cancel, plus its enabled state.
    cancel = Button()
    can_cancel = Property(Bool(), depends_on="future.cancellable")

    @observe("calculate")
    def _submit_calculation(self, event):
        self.message = "Calculating π"
        self.future = submit_iteration(
            self.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.
            raise RuntimeError(f"Unexpected 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__":
    InterruptibleTaskExample().configure_traits()