Search code examples
pythonmultithreadinggil

Executing non-GUI threads in a separate GIL


My team develops a GTK-Python application that dynamically spawns threads to handle longer running tasks. Recently, we added a feature for requesting some batch computation on demand and this has made the GUI unresponsive. My research and analysis shows that the problem seems to be contention for GIL that starves the GUI thread. The cheapest solution that we're considering is to move our custom threads to another process/GIL so that the threads spawned by the GUI framework run in one process but our dynamically spawned threads run in the other process.

I've been reading the multiprocessing docs and nothing jumps out at me that supports such a feature out of the box. Which options should I explore?


Solution

  • Here is one possibility. You create a child process thread_creator whose purpose is to create child threads. This is achieved by passing to thread_creator a multiprocessing.Queue instance that will contain tuples specifying the arguments for a new child thread to be created. A special sentinel value of None is a signal for the thread_creator process to terminate. When all non-daemon threads it has created terminate, then the child process will terminate and along with it any daemon threads that it has created.

    In the code below we create a non-daemon thread sample_threaded_worker that will terminate after .5 seconds and a daemon thread another_threaded_worker that continuously loops printing a message every .1 seconds. After the thread_creator_process is asked to terminate, it will do so as soon as sample_threaded_worker completes. When that occurs, another_threaded_worker will automatically be terminated. Thus another_threaded_worker will be executing for approximately .5 seconds and will print out approximately 5 messages:

    from multiprocessing import Process, Queue
    from threading import Thread
    import time
    
    def sample_threaded_worker(x=1, y=2, debug=False):
        time.sleep(.5)
        if debug:
            print('sample_threaded_worker', x, y)
    
    def another_threaded_worker():
        while True:
            print('another_threaded_worker', time.time())
            time.sleep(.1)
    
    def thread_creator(queue):
        while True:
            request = queue.get()
            if request is None:
                break
            # Unpack
            target, args, kwargs, daemon = request
            Thread(target=target, args=args, kwargs=kwargs, daemon=daemon).start()
    
    def create_thread(queue, target=None, args=(), kwargs={}, daemon=None):
        """Helper function to create a child thread in the child process."""
    
        queue.put((target, args, kwargs, daemon))
    
    def main():
        queue = Queue()
        p = Process(target=thread_creator, args=(queue,))
        p.start()
        create_thread(queue, target=sample_threaded_worker, args=(5, 9), kwargs={'debug': True})
        create_thread(queue, target=another_threaded_worker, daemon=True)
        # Terminate process. All daemon threads that have been created will be killed
        # when all non-daemon threads have completed.
        queue.put(None)
        p.join()
    
    if __name__ == '__main__':
        main()
    

    Prints:

    another_threaded_worker 1718880689.7697933
    another_threaded_worker 1718880689.87115
    another_threaded_worker 1718880689.9717884
    another_threaded_worker 1718880690.0727093
    another_threaded_worker 1718880690.1730008
    sample_threaded_worker 5 9