What are Futures in the ThreadPoolExecutor

November 21, 2021 Python ThreadPoolExecutor

Future objects are a promise for a result from an asynchronous task executed by the ThreadPoolExecutor.

In this tutorial, you will discover Future objects used by Python thread pools.

Let's get started.

ThreadPoolExecutor Returns Futures

The ThreadPoolExecutor in Python provides a pool of reusable threads for executing ad hoc tasks.

You can submit tasks to the thread pool by calling the submit() function and passing in the name of the function you wish to execute on another thread.

Calling the submit() function will return a Future object.

What are Future objects returned by the ThreadPoolExecutor?

Future Is a Handle on an Asynchronous Task

A Future object represents the asynchronous execution of a task.

Get a Future Object

You will not create a Future object yourself.

Instead, you will receive a Future object when calling the submit() on your ThreadPoolExecutor.

...
# submit a task to the thread pool
future = executor.submit(work)

The idea is that you hang on to the Future object and query it to check on the status of your task.

If you do not need to query the status of your asynchronous task or retrieve a result, you do not need to keep the Future object returned from a call on submit.

Future Object Status

You can check on the status of your asynchronous task via its Future object.

For example, you may want to check on the status of your task, such as whether it is currently running, is done, or perhaps has been cancelled.

This can be achieved by calling functions on the Future object; for example:

...
# check if the task is running
if future.running():
	# do something...

The three functions you can use to check the status of your task are running(), done(), and cancelled().

Future Object Results

You can also use the Future objects to get the result from the task or the exception if one was raised during the execution of the task.

This can be achieved by calling the result() function for the result and the exception() function to retrieve the exception.

The result() and exception() functions only return once the task has completed, e.g. done() returns True.

This means that the calls to result() and exception will block until the task is completed. That is, the call will automatically wait until the task is complete before returning a value.

...
# get a result from the task once it is complete
result = future.result() # blocks

Future Object Timeouts

It is a good practice to limit how long you are willing to wait for a result or an exception.

As such, you can set a timeout when calling these functions via the timeout argument and specify a number of seconds.

If the timeout elapses before a result or exception is returned, then a TimeoutError is raised that you may choose to handle.

...
# handle any timeout
try:
	# get a result from the task once it is complete
	result = future.result(timeout=60) # blocks
	# do something...
except TimeoutError:
	# handle timeout

Future Object Exception Handling

If an exception is raised during the execution of the task, it will be raised again automatically when you attempt to retrieve the result from the Future.

As such, if an exception can reasonably be raised within the task, then you can handle it when retrieving the result.

…
# handle exception raised by the task
try:
	# get a result from the task once it is complete
	result = future.result() # blocks
	# do something...
except:
	# handle exception raised when executing the task

Future Object Callbacks

The Future allows us to register a callback function to be called once the task has completed.

This can be achieved by calling the add_done_callback() function in the future and specifying the name of our custom callback function.

...
# register a callback function
future.add_done_callback(custom_callback)

Our custom callback function must take a single argument, which is the future object on which it is registered.

def custom_callback(future):
	# do something

The callback is only called once the task has completed. You can register multiple callback functions for a given Future object and they will be called in the order that they were registered.

An exception in one callback function will not impact the calls to subsequent callback functions.

Multiple Future Objects

If you call submit multiple times for different tasks or different arguments to the same task, you can do so in a loop and store all of the Future objects in a collection for later use.

For example, it is common to use a list comprehension.

...
# create many tasks and store the future objects in a list
futures = [executor.submit(work) for _ in range(100)]

The collection of Future objects can then be handed off to utility functions provided by the concurrent.futures module, such as wait() and as_completed().

Now that we are familiar with how to use Future objects, let's take a closer look at the life-cycle of Futures.

Life-cycle of Future Objects

A Future object is created when we call submit() for a task on a ThreadPoolExecutor.

A Future object can exist in one of three states:

  1. Scheduled (pre-running)
  2. Running
  3. Done (post-running)

The figure below summarizes the life-cycle of a Future object.

Scheduled Future Object

After the Future object is created, it is queued in the thread pool for execution until a worker thread becomes available to execute it.

At this point, it is not "running." It is "scheduled."

A scheduled task can be "cancelled."

Running Future Object

A worker thread will take a task off the internal queue and start executing it.

Once a task has started being executed, the status of the Future object is now "running."

A running task cannot be cancelled.

Done Future Object

When the task for a Future object completes, it has the status "done," and if the target function returns a value, it can be retrieved.

A "done" task will not be "running."

While a task is running, it can raise an uncaught exception, causing the execution of the task to stop. The exception will be stored and can be retrieved directly or will be re-raised if the result is attempted to be retrieved.

A "cancelled" task will always be in the "done" state.

Takeaways

You now know how to use Future objects returned from the ThreadPoolExecutor.



If you enjoyed this tutorial, you will love my book: Python ThreadPoolExecutor Jump-Start. It covers everything you need to master the topic with hands-on examples and clear explanations.