Multithreading

Multithreading is technique of executing multiple threads concurrently.

A thread is an independent sequence of instructions within a process. It is the smallest unit of execution that the operating system (OS) can schedule. Multiple threads within a process share the same memory space but execute independently.

Table of contents

More on Threads

A thread is the smallest sequence of instructions that can be executed independently. Each thread has its own stack and registers, but it shares the same memory with other threads in the same process. This means threads in a process can access the same data, but they cannot modify each other’s register value without proper synchronization.

In short, each thread operates independently, unaware of the others unless explicitly designed to interact.

Advantages and Disadvantages

Memory / Communication efficiency

  • Efficient Memory Usage: Threads require significantly less memory overhead compared to creating new processes with fork()/spawn().

  • Fast Communication: Since threads within the same process share memory, they can exchange data efficiently without requiring OS managed inter-process communication (IPC).

  • Use Case: Multithreading is particularly useful in high-performance applications such as graphics rendering and deep learning.

Unmanaged Synchronization.

  • Lack of Isolation: Unlike processes, threads do not have full isolation which automatically controlled by OS. If one thread crashes or corrupts shared data, it can affect all threads within the processes. Improper synchronization can lead to race conditions, deadlocks, and performance bottlenecks.

Example of Multithreading

Below are basic implementations of multithreading in Python and C++.

Multithreading in Python

Python’s Global Interpreter Lock (GIL) prevents true parallel execution for CPU-bound tasks but still allows efficient multithreading for I/O-bound operations.

Note - GIL
In CPython, the Global Interpreter Lock (GIL) is a mutex that ensures only one thread executes Python bytecode at a time. This design simplifies memory management and prevents race conditions, but it also means that multi-threaded Python programs may not fully utilize multi-core processors for CPU-bound tasks. However, for I/O-bound operations, threading can still be effective. (ref: realpython.com)

Python MultiThreading Example

import os
import time
import threading
from threading import Thread

def run_sub_thread(idx: int, sleep_time:int=2) -> None:
    sub_thread_name = threading.current_thread().name
    print(
        f"{sub_thread_name} in PID({os.getpid()}) "
        f"is doing {idx} work"
    )
    time.sleep(sleep_time)

def display_threads() -> None:
    print("================")
    print(f"Active threads : {threading.active_count()}")
    for thread_ in threading.enumerate():
        print(thread_)

def main(
    num_threads : int = 5,
    sleep_time  : int = 2    
) -> None:
    display_threads()

    threads = [
        Thread(
            target=run_sub_thread, args=(idx, sleep_time)
        )
        for idx in range(num_threads)
    ]
    
    for thread_ in threads:
        thread_.start()

    display_threads()

    for thread_ in threads:
        thread_.join()

    display_threads() # to check only one thread is available now

if "__main__" == __name__:
    num_threads : int = 5
    main(num_threads)

MultiThreading in C++

C++ provides robust multithreading support, we must take care of managing synchronization effectively.

Basic C++ MultiThreading Example

#include <iostream>
#include <chrono>
#include <thread>
#include <unistd.h>
#include <string>
#include <sys/types.h>

#include "Timer.h"

void ExecuteThread(int idx, int sleepSeconds)
{
    pid_t threadPID = getpid();
    std::thread::id threadID = std::this_thread::get_id();
    std::cout << "Thread - "<< idx << " ("<< threadID << ") in PID (" << threadPID << ")" << "\n";
    std::this_thread::sleep_for(std::chrono::seconds(sleepSeconds));
}

int main()
{
    Timer timer;

    const int NUM_THREAD = 5;
    const int SLEEP_SECONDS = 2;

    std::thread threads[NUM_THREAD];

    for (int i=0; i < NUM_THREAD; i++)
    {
        threads[i] = std::thread(ExecuteThread, i, SLEEP_SECONDS);
    }

    for ( int i=0; i < NUM_THREAD; i++)
    {
        threads[i].join();
    }

}

Then the output would be:

Thread - 0 (Thread - 0x16d2a7000) in PID (91237)
Thread - Thread - 4 (0x16d4d7000) in PID (91237)
3 (0x16d44b000) in PID (91237)
1 (0x16d333000) in PID (91237)
Thread - 2 (0x16d3bf000) in PID (91237)
Process Ended in 2.00527 [s].

The output of the above code might be scrambled, demonstrating a race condition. Because std::cout is a shared resource, multiple threads trying to write to it simultaneously can interleave their output. This is a classic example of why proper synchronization is essential.

Note - Race condition
A race condition occurs in concurrent programming when the behavior of a program depends on the relative timing of events, such as the order in which threads or processes are scheduled to run. This can lead to unpredictable and erroneous results because the outcome depends on the interleaving of operations performed by multiple threads or processes.

To solve this, I put the following print class represented as ThreadSafePrinter class using the concept of mutex (mutual exclusion, we will discuss in the future).

Thread-Safe Printing Utility

// ThreadSafeUtils.h
#pragma once
#include <mutex>
#include <string>

class ThreadSafePrinter
{
public:
    void Print(const std::string& str);
private:
    std::mutex m_Mutex;
};
// ThreadSafeUtils.cpp
#include <iostream>
#include "ThreadSafeUtils.h"

void ThreadSafePrinter::Print(const std::string& str)
{
    std::lock_guard<std::mutex> lock(m_Mutex);
    std::cout << str ;
}

Updated Multithreading Code with Thread-Safe Printing

#include <chrono>
#include <iostream>
#include <mutex>
#include <string>
#include <sstream>
#include <thread>

#include <unistd.h>
#include <sys/types.h>

#include "Timer.h"
#include "ThreadSafeUtils.h"

void ExecuteThread(int idx, int sleepSeconds, ThreadSafePrinter& printer)
{
    pid_t threadPID = getpid();
    std::thread::id threadID = std::this_thread::get_id();

    std::ostringstream ss;
    ss << "Thread - "<< idx << " ("<< threadID << ") in PID (" << threadPID << ")" << "\n";

    printer.Print(ss.str());

    std::this_thread::sleep_for(std::chrono::seconds(sleepSeconds));
}

int main()
{
    Timer timer;
    ThreadSafePrinter safePrinter;

    const int NUM_THREAD = 5;
    const int SLEEP_SECONDS = 2;

    std::thread threads[NUM_THREAD];

    for (int i=0; i < NUM_THREAD; i++)
    {
        threads[i] = std::thread(ExecuteThread, i, SLEEP_SECONDS, std::ref(safePrinter));
    }

    for ( int i=0; i < NUM_THREAD; i++)
    {
        threads[i].join();
    }

}

Then the expected output will be looked like as follows:

Thread - 0 (0x16bb77000) in PID (92800)
Thread - 3 (0x16bd1b000) in PID (92800)
Thread - 4 (0x16bda7000) in PID (92800)
Thread - 1 (0x16bc03000) in PID (92800)
Thread - 2 (0x16bc8f000) in PID (92800)
Process Ended in 2.00515 [s].

This updated version ensures synchronized output using a mutex, preventing race conditions in multi-threaded environments.

Conclusion

Multithreading is powerful, but it comes with challenges like synchronization. Python and C++ handle threads differently, but both require careful management to avoid race conditions and performance issues.