DEV Community

loading...
Cover image for Python | Master Multiple Threads

Python | Master Multiple Threads

Vitto Rivabella
πŸ’» CGI Research and Development 🧠 Lazy dev that loves automation πŸ”§ Developing tools and posting things I know
・6 min read

There are situations where sequentially processing different logics in python becomes a time-consuming task with no apparent reason other than waiting for your single thread to wait or perform an operation.

Here's where parallel computing and multithreading comes in handy.

Before digging too deep into the multithreading world, let's give a bit of context on how we differentiate different computational parallelisms and operations:

I/O Bound vs CPU Bound

Briefly, I/O Bound concerns things such as:

  • Downloading / Uploading data from the internet.
  • Importing a file.
  • Loading something.

Tasks that have very little to no effect on the CPU

On the other side we have operations with an average or higher effect on the CPU:

  • Resizing files.
  • Expressions and Complex math logics.

If you want to read more about I/O bound and CPU Bound actions:
*CPU Vs I/O bound thread on Stackoverflow
*Understanding CPU and I/O bound for asynchronous operations

Now that we know the main differences between:

  • I/O Bound.
  • CPU Bound.

Let's talk about the two main ways of running parallel computational efforts and how Python has been designed to perform such a task.

Multiprocessing and MultiThreading in Python

When we talk about: "Running parallel CPU Bound operations", we're talking about MultiPocessing.
When we're talking about "Running parallel I/O Bound operations", we're talking about MultiThreading

  • CPU Bound -> Multiprocessing
  • I/O Bound -> MultiThreading

Those are two different things, but why?

Python Global Interpreter Lock

Python is a linear language, hence by default uses a single thread ran under a GIL.

The Python Global Interpreter Lock, or GIL, is a mutex (a lock) that allows only one thread to hold the control of the Python interpreter. Hence, only one thread can be in a state of execution at any point in time, even on a multi-thread (multiple CPU cores) architecture.

Now, I won't go too deep on why Larry Hasting and his team, while creating Python, went for such a design choice, you should just know that it has been made to protect us from:

  • Deadlocking
  • Reference count variable (variables that store reference count to objects) corruption due to multiple concurrent modifications.

And works by creating a single lock on the interpreter which adds a rule that states "The execution of any Python bytecode requires acquiring the interpreter lock."

By doing that we're solving deadlocks while locking the different reference count variables.

At the same time, unfortunately, we're effectively making any CPU bound logic single-threaded, because: While I/O bound operations commonly spend the greatest part of their computational time waiting, giving them the ability to share the Global Lock "interactively", CPU-bound operation, are performing actions, preventing them from doing the same.

*That's why the GIL has also gained a reputation as an "infamous" feature of Python. *

Here's a cool video you might want to watch on the subject, featuring Larry himself

And some further resources about the GIL in Python

In this post, we won't dig too deep into Multiprocessing, but we will explore different ways to implement MultiThreading.

Implementing MultiThreading in Python

Is pretty simple, but is important to understand how the resolution queue works, and that there are different methods to implement such logic.

Let's now demonstrate MultiThreading creating a simple thread that executes a simple sleep function, to then print the total execution time of our code:

import threading
import time

start_time = time.perf_counter()

def do_something():
    print("Sleeping for 2 second(s)...")
    time.sleep(2)


temp_thread = threading.Thread(target=do_something)
temp_thread.start()


finish_time = time.perf_counter()

print(f"CODE_EXECUTION_TIME: {round(finish_time - start_time, 2)}s")

Enter fullscreen mode Exit fullscreen mode

Output:

CODE_EXECUTION_TIME: 0.0s
Finished sleeping for 2 second(s)

Enter fullscreen mode Exit fullscreen mode

As we can see the print output resolves before the do_something function, because the main thread ends before the secondary thread, running the print function before the do_something() function awakes.

By importing the threading library and creating a new thread we're essentially forking the main thread, resolving two parallel efforts at the same time.

Fork_join

When created we also need to bind the thread to logic, in this case: do_something(). To do so, we pass the target argument.

Another thing to notice is that we would maybe want to print the total execution time of our code and not only the one of the main thread, essentially joining both threads. To do so we can add temp_thread.join()

import threading
import time

start_time = time.perf_counter()

def do_something(seconds):
    print(f"Sleeping for {seconds} second(s)...")
    time.sleep(seconds)



temp_thread = threading.Thread(target=do_something, args = [2])
temp_thread.start()
temp_thread.join()

finish_time = time.perf_counter()

print(f"CODE_EXECUTION_TIME: {round(finish_time - start_time, 2)}s")

Enter fullscreen mode Exit fullscreen mode

Output:

Sleeping for 2 second(s)...
CODE_EXECUTION_TIME: 2.0s
Enter fullscreen mode Exit fullscreen mode

By adding the join() statement to the thread, we're waiting for the thread to resolve before proceeding with resolving the main thread.

To further explore the resolution queue of different threads let's create a number of them passing to do_something() a "seconds" argument:

import threading
import time

start_time = time.perf_counter()

def do_something(seconds):
    print(f"Sleeping for {seconds} second(s)...")
    time.sleep(seconds)


seconds = [3,2,1]
threads = []

for second in seconds:
    temp_thread = threading.Thread(target=do_something, args = [second])
    temp_thread.start()
    threads.append(temp_thread)

for thread in threads:
    thread.join()   

finish_time = time.perf_counter()

print(f"CODE_EXECUTION_TIME: {round(finish_time - start_time, 2)}s")

Enter fullscreen mode Exit fullscreen mode

Output:

Sleeping for 3 second(s)...
Sleeping for 2 second(s)...
Sleeping for 1 second(s)...
CODE_EXECUTION_TIME: 3.0s
Enter fullscreen mode Exit fullscreen mode

As you can see, in the above code, we:

  • Created a list called seconds
  • Created a list of threads
  • Added the argument "seconds" to the thread constructor
  • Looped through the list passing second in seconds and appending each thread to the threads list
  • Joined every thread to the main one in a secondary loop

We have created a threads list because we couldn't join them directly in the first loop. Doing so would have caused the code to wait for the resolution of the just created thread resulting in a synchronous behavior, hence we create a secondary loop that joins all the threads altogether.

As you can see the total time execution on a single-threaded python program would have resulted in a waiting time of 6 seconds, using multithreading we needed just 3s.

One problem raises here, how can we use the return value of a function bound to a secondary thread?

Using the concurrent.futures module to handle multithreading

Using the thread library is not the only way to handle python multithreading.
A more handy way is the concurrent.futures module, which is usually used along with a context manager.

Let's create a secondary thread:

import concurrent.futures

with concurrent.futures.ThreadPoolExecutor() as executor:
    temp_thread = executor.submit(do_something, 1)
print(temp_thread.result())
Enter fullscreen mode Exit fullscreen mode

Output:

Sleeping for 1 second(s)
Finished sleeping for 1 second(s) 
Enter fullscreen mode Exit fullscreen mode

Simple as that.
We create a context manager to handle the ThreadPoolExecutor() and given the do_something() function that we declared before, this code will create a new thread (executor) binding a function and the argument(s).

Also, the return statement of the executor.submit() method is an object called "future" that contains the returned value of the bound function and is accessed via the .result() method.

As we've seen before, it's rare that we just want to create a single secondary thread, commonly we create multiple of them to resolve as many I/O bound tasks as possible.

To do so we can use a list comprehension or a simple for loop to create a list of futures returned by the resolved executors.

import concurrent.futures

seconds = [3,2,1]
with concurrent.futures.ThreadPoolExecutor() as executor:
    results = [executor.submit(do_something, sec) for sec in seconds]

for f in concurrent.futures.as_completed(results):
    print(f.result())
Enter fullscreen mode Exit fullscreen mode

Output:

Sleeping for 3 second(s)
Sleeping for 2 second(s)
Sleeping for 1 second(s)
Finished sleeping for 1 second(s) 
Finished sleeping for 2 second(s) 
Finished sleeping for 3 second(s)
Enter fullscreen mode Exit fullscreen mode

Having a list of futures gives us the ability to use another concurrent.futures method:

  • as_completed(arg)

That listens to the completion of the different list items and explains why start and end outputs are reversed.

One last thing that is important to know is that by using the concurrent.futures module we can map an executor over an iterable to perform a given logic.
This will create as many threads as the length of the given iterable, returning a list of result values from the performed bound function.

import concurrent.futures

seconds = [3,2,1]
with concurrent.futures.ThreadPoolExecutor() as executor:
    results = executor.map(do_something, seconds)


for r in results:
    print(r)

Enter fullscreen mode Exit fullscreen mode

Output:

Sleeping for 3 second(s)
Sleeping for 2 second(s)
Sleeping for 1 second(s)
Finished sleeping for 3 second(s) 
Finished sleeping for 2 second(s) 
Finished sleeping for 1 second(s)
Enter fullscreen mode Exit fullscreen mode

Notice that the return values order reflects the thread creation order because map returns the result values in the order the threads were started.
Note: map returns the result value and not a future object.

This is all for this Master Threading in Python post!

What would you want to read next?
Drop me your feedback, questions, comments down here, and let me know your thoughts πŸ‘‡

And don't forget to follow me on Twitter for a chat or some useful tweets!πŸ₯‘

Discussion (2)

Collapse
dsasse07 profile image
Daniel Sasse

Really interesting article, thank you for sharing!

Collapse
vittorivabella profile image
Vitto Rivabella Author

Thank you Daniel! Glad you found it interesting. If there's something you would want to explore further, just drop me your suggestions!