Multithreading in Python

mainpynerds

Main

Posted on March 27, 2024

Multithreading in Python

Normally, instructions in a computer program are executed in a sequential way, the execution of one task has to be complete before the next one begins. In some cases, however, we may want some tasks to be executed simultaneously or in overlapping periods of time,  multi-threading is one way that we can achieve this quickly and efficiently. In computer programming terms, handling multiple tasks at the same time is referred to as concurrency, and therefore, multi-threading is  just one way that a program can achieve concurrency.

Before we dive deeper onto how threads and multithreading works, you first need to understand two terms i.e process and thread

In Python, a process refers to an instance of a program which is being executed. The operating system can handle multiple processes at the same time while ensuring data-integrity so that one process cannot access another process's data. Each process is allocated its own memory space where it stores and manipulates its data.

A thread, also known as a sub-process, refers to the smallest set of instructions that can be scheduled to be run by the processor. Threads are lightweight compared to processes,  a single process can contain multiple threads. Each process always starts with just one thread which is referred to as the main thread, new threads can then be added to handle sub-tasks. All threads running in a single process shares the same resources/memory this makes communication between them easier and straightforward.

Multithreading happens when two or more threads appearing in a single process gets executed in overlapping periods of time. However, one thing that should be clear is that only a single thread can actually be executed at any given time in a process. Thus, in reality,  multi-threading is not entirely simultaneous, instead, concurrency is achieved by switching very quickly between the threads, the switching is so fast that one might be forgiven for thinking that the threads are been executed in parallel.

The main purpose of multithreading is to improve program efficiency and performance as it makes it possible to execute multiple tasks simultaneously. 

The threading module

In Python, multithreading is implemented using the threading module which is available in the standard library. The module offers the necessary tools for managing and working with threads.

The Thread class in the module, is used create, run and generally manage threads. Its syntax is as follows:

threading.Thread(target = None, name = None, args = None, kwargs = None, deamon = None)
target A function that will be called when the thread starts. It defaults to None, if no function is to given.
name The thread name. If no name is given, a unique one is auto-generated such as Thread-1. Multiple threads can have a common name.
daemon A boolean value indicating whether the thread should run in the background. It defaults to False meaning that the thread does not run in the background. 
args and kwargs Optional argument that will be passed to the target function when the thread starts.

Consider the following example.

import threading
import time

def square(num):
    for i in range(num):
        print(f"square({i}) = ", i ** 2)
        time.sleep(0.1)

def cube(num):
    for i in range(num):
        print(f"cube({i}) = ", i ** 3, end = '\n\n')
        time.sleep(0.1)


if __name__ == "__main__":
    # Create two thread for each function/task
    thread1 = threading.Thread(target=square, args = (6,))
    thread2 = threading.Thread(target=cube, args = (6,))

    #Start the threads
    thread1.start()
    thread2.start()

    # The main thread waits for both threads to finish
    thread1.join()
    thread2.join()

Output:

square(0) =  0
cube(0) =  0

square(1) =  1
cube(1) =  1

square(2) =  4
cube(2) =  8

square(3) =  9
cube(3) =  27

square(4) =  16
cube(4) =  64

square(5) =  25
cube(5) =  125

Normally, we would have expected the first function i.e square to be run until complete before the other function(cube)  is started. But  as you can see in the above example, the two functions,  square and cube, are been run concurrently

As shown from the previous example, implementing multithreading involves the following basic steps:

  1. Creating the thread 

    This involves creating an instance of the threading.Thread() class. We pass the necessary arguments such as the target function, name of the thread, arguments to the target function if necessary, etc. 

    thread1 = threading.Thread(target=square, args = (6,))
    thread2 = threading.Thread(target=cube, args = (6,))
  2. Starting the thread

    To start the execution of the thread we call the start() method of thread objects .

    thread1.start()
    thread2.start()
  3. Wait for the thread

    When the thread starts, the main thread keeps on running as well. Calling the join() method of the new thread makes the main thread(or the calling thread to be precise) to pause and wait until the execution of the new thread is complete.

     thread1.join()
     thread2.join()

Daemon threads

Daemon threads are threads that runs indefinitely until the program quits. They are mostly suitable for handling regular and repeating tasks that needs to run in the background.

To create a daemon thread, we can set the daemon parameter to True when instantiating a Thread object or we can call the setDaemon() function after we have already instantiated the  thread.

#import the Thread class
from threading import Thread

import time


def task():
    for i in range(20):
         print(i)
         time.sleep(0.1)

#a background task that runs at regular intervals
def background_task(main_task):
     while main_task.is_alive():
        print("daemon task")
        time.sleep(0.5)

if __name__ == "__main__":
     #regular thread
     T1 = Thread(target = task)
     #daemon thread
     T2 = Thread(target = background_task, kwargs = {"main_task": T1}, daemon = True)

     #start the threads
     T1.start()
     T2.start()
   
     T1.join()
     T2.join()

Output:

daemon task
0
1
2
3
4
daemon task
5
6
7
8
9
daemon task
10
11
12
13
14
daemon task
15
16
17
18
19
daemon task

Thread objects also contains the isDaemon() method which can be used to check whether a thread is a Daemon thread.

import threading

t1 = threading.Thread(daemon = True)
t2 = threading.Thread()

#check whether thread is a daemon
print(t1.daemon)
print(t2.daemon)

Output:

True
False 

Thread states 

Threads can be in any of the following states:

  • New - The thread has only been instantiated but has not been started, no resources has yet been allocated for it.
  • Runnable - In this state, the thread is waiting to run, it has all the necessary resources, only that the scheduler has not yet scheduled it to run.
  • Live - In this state, the thread is executing its task .
  • Paused - The thread has been paused in some way. For example, when it is waiting for another thread to complete  the execution.
  • Dead - The  thread is not runnable.  A thread can can reach this state naturally when its task is complete or if it has been  killed unnaturally

The Thread class includes the is_live() method which can be used to tell whether a thread is currently running.

Related articles


💖 💪 🙅 🚩
mainpynerds
Main

Posted on March 27, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related