Making brighter Futures with Qt

Like many developers in Vfx and Games, I’ve been spending a lot of time looking at Python3 and what it new concepts it brings to the table. One that caught my eye is the new futures module.

The basic concept is that smaller self-contain tasks can be offloaded to a thread and later on the main code will ask for the results of that task, resulting in the main thread blocking until the task is finished, or just being able to give the result back if it’s completed.

with ThreadPoolExecutor(max_workers=1) as executor:
    future = executor.submit(pow, 323, 1235)
    print(future.result())

I love it! It opens up so many possibilities for pushing work to threads as well as being easy to integrate into existing code.

Sadly this is only in Python 3.5+, which is still not my go-to python version of choice, as well as PySide not really having any equivalent exposed in PySide (very happy to be corrected if I’m wrong).

So I started thinking about how this could be implemented. The obvious choice was to use the QRunnable class as the thread management is taken care of, but the drawback is that its one of the few Qt objects that cannot emit signals. Whilst the QRunnable is incapable of having signals, we can get around that by adding a QObject instance to it which can have signals.
Combining this design with the QRunnables and allowing for the main thread to wait until a signal is emitted (which I talked about in detail in my last post: http://geoffsamuel.com/2020/05/11/holding-for-the-applause-signal/) the code pretty much writes itself.

One design choice I wanted to add was use class and static methods to generate the jobs, rather than use context managers as I feel this leaves the code looking more readable as well as taking up less lines of code on the page.

import sys
import time

from PySide import QtCore


class _QFuturesStates(object):
    """
    The States of the QFutures
    """
    PENDING = 0
    PROCESSING = 1
    FINISHED = 2


class _QFuturesSignals(QtCore.QObject):
    """
    Class to emit signals
    """
    finishedSignal = QtCore.Signal()


class QFutures(QtCore.QRunnable):
    """
    QFutures Class
    """
    def __init__(self, function, *args, **kwargs):
        """
        Constructor
        
        args:
            function (method): The method to run
            *args + **kwargs: Arguments to be passed into the function to be run 
        """
        super(QFutures, self).__init__()
        self._signals = _QFuturesSignals()
        
        self._state = _QFuturesStates.PENDING
        self._function = function
        self._args = args
        self._kwargs = kwargs
        self._result = None
    
    @staticmethod
    def submitToPool(pool, function, *args, **kwargs):
        """
        Static Method
        
        Submit the function to be run on a given QThreadPool instance
        
        args:
            pool (QThreadPool): The thread pool to run this function
            function (method): The method to run
            *args + **kwargs: Arguments to be passed into the function to be run 
        """
        newFutures = QFutures(function, *args, **kwargs)
        pool.start(newFutures)
        return newFutures

    @staticmethod
    def submit(function, *args, **kwargs):
        """
        Static Method
        
        Submit the function to be run on the global threading pool
        
        args:
            function (method): The method to run
            *args + **kwargs: Arguments to be passed into the function to be run 
        """
        newFutures = QFutures(function, *args, **kwargs)
        pool = QtCore.QThreadPool.globalInstance()
        pool.start(newFutures)
        return newFutures

    def run(self):
        """
        Re-Implemented Method
        
        The run logic of the QRunnable, call the function and store the results 
        """
        self._state = _QFuturesStates.PROCESSING
        self._result = self._function(*self._args, **self._kwargs)
        self._state = _QFuturesStates.FINISHED
        self._signals.finishedSignal.emit()
    
    def result(self):
        """
        Gets the results of the function, will wait if the process isnt finished 
        """
        if self._state != _QFuturesStates.FINISHED:
            self._wait()
        return self._result

    def _wait(self):
        """
        Blocking method to wait until the process is finished 
        """
        if self._state == _QFuturesStates.FINISHED:
            return
        loop = QtCore.QEventLoop()
        self._signals.finishedSignal.connect(loop.quit)
        loop.exec_()

An easy way to test that everything is working as expected is to have a small method which will sleep for a set amount of time, and return a given value.

def sitAndWait(timeToWait, valueToReturn):
    """
    Demo method to wait for a given time, and return the given value
    """
    print "{} starting".format(valueToReturn)
    time.sleep(timeToWait)
    print "{} finished".format(valueToReturn)
    return valueToReturn


print "Start - Global Pool"
app = QtCore.QCoreApplication(sys.argv)
 
data1 = QFutures.submit(sitAndWait, 10, 2)
data2 = QFutures.submit(sitAndWait, 15, 3)
data3 = QFutures.submit(sitAndWait, 8, 4)
 
print "results for {}".format(sitAndWait(5, 1))
 
print "results for {}".format(data1.result())
print "results for {}".format(data2.result())
print "results for {}".format(data3.result())
 
print "Done"

Which outputs:

Start - Global Pool
1 starting
2 starting
3 starting
4 starting
1 finished
results for 1
4 finished
2 finished
results for 2
3 finished
results for 3
results for 4
Done

And that shows that it’s working as expected, the main thread was blocking until task 2 was finished, but when it came to wanting the results of task 4, they were ready.

We can modify the test to use a QThreadPool as well, using the QFutures.submitToPool method, giving us the ability to pre-allocate the pool to use for the task load, in this example, a more limited amount of threads.

print "Start - Thread Pool"
pool = QtCore.QThreadPool()
pool.setMaxThreadCount(2)
data1 = QFutures.submitToPool(pool, sitAndWait, 10, 2)
data2 = QFutures.submitToPool(pool, sitAndWait, 15, 3)
data3 = QFutures.submitToPool(pool, sitAndWait, 8, 4)

print "results for {}".format(sitAndWait(5, 1))

print "results for {}".format(data1.result())
print "results for {}".format(data2.result())
print "results for {}".format(data3.result())

print "Done"

Which outputs:

Start - Thread Pool
1 starting
2 starting
3 starting
1 finished
results for 1
2 finished
4 starting
results for 2
3 finished
results for 3
4 finished
results for 4
Done

So by combining some simple Qt tricks, we can make a Qt based Futures class to greatly speed up and offload smaller tasks, which a very minimal API change to existing code. It’s something I wished I knew about years ago and will be using a lot more in the futures!

– Geoff Samuel