# Topic covered
Concurrency & Parallelism
Multithreading & Multiprocessing
Global Interpreter Lock (GIL)

Concurrency

  • Definition:
    • Structuring a program so that multiple tasks make progress by taking turns.
    • Handling multiple tasks at the same time, making progress on all of them, but not necessarily executing them simultaneously.
    • Focus is on managing multiple things at once.
  • In Python:
    • Achieved with multithreading (I/O-bound tasks, limited by GIL).
    • Achieved with asyncio (single-threaded cooperative multitasking).

Concurrency = A single waiter handling many tables by switching attention.

Parallelism

  • Definition:
    • Running multiple tasks at the same time on different CPU cores.
    • True simultaneous execution.
    • Focus is on speeding up computations.
  • In Python:
    • Achieved with multiprocessing (each process has its own Python interpreter, bypassing the GIL).
    • Example: Splitting a large matrix multiplication across 8 CPU cores.

Parallelism = Many waiters serving tables at the same time.

Multithreading

Multithreading in Python achieves concurrency by using a single process and a single CPU core.

Due to the Global Interpreter Lock (GIL), only one thread can execute Python bytecode at a time, so it’s a form of interleaved execution, not true parallelism.

import threading
import time

def take_orders():
    for i in range(1, 4):
        print(f"Taking order for #{i}")
        time.sleep(2)

def brew_chai():
    for i in range(1, 4):
        print(f"Brewing chai for #{i}")
        time.sleep(3)
        
# create threads
order_thread = threading.Thread(target=take_orders)
brew_thread = threading.Thread(target=brew_chai)

order_thread.start()
brew_thread.start()

# wait for both to finish
order_thread.join()
brew_thread.join()

print(f"All orders taken and chai brewed")

# Taking order for #1
# Brewing chai for #1
# Taking order for #2
# Brewing chai for #2
# Taking order for #3
# Brewing chai for #3
# All orders taken and chai brewed

Multiprocessing

Multiprocessing achieves parallelism, which is a more specific form of concurrency where multiple tasks run at the exact same time on different CPU cores. This bypasses the GIL and is ideal for CPU-bound tasks.

from multiprocessing import Process
import time

def brew_chai(name):
    print(f"Start of {name} chai brewing")
    time.sleep(3)
    print(f"End of {name} chai brewing")

if __name__ == "__main__":
    chai_makers = [
        Process(target=brew_chai, args=(f"Chai Maker #{i+1}", ))
        for i in range(3)
    ]

    # Start all process
    for p in chai_makers:
        p.start()

    # wait for all to complete
    for p in chai_makers:
        p.join()

    print("All chai served")


# Start of Chai Maker #1 chai brewing
# Start of Chai Maker #2 chai brewing
# Start of Chai Maker #3 chai brewing
# End of Chai Maker #1 chai brewing
# End of Chai Maker #2 chai brewing
# End of Chai Maker #3 chai brewing
# All chai served

Global Interpreter Lock (GIL)

GIL = Global Interpreter Lock

It’s a mutex (mutual exclusion lock) in the CPython interpreter that ensures only one thread executes Python bytecode at a time, even on multi-core processors.

Why Does GIL Exist?

CPython’s memory management (especially garbage collection using reference counting) is not thread-safe.

Instead of making all internal operations thread-safe (which would be slower and more complex), Python designers added the GIL to keep things simpler.

Effects of GIL

  • Multithreading for I/O-bound tasks works fine
    • Threads release the GIL while waiting on I/O (file, network).
    • Example: web servers, network calls, file reads.
  • Multithreading for CPU-bound tasks does NOT scale
    • Only one thread can run Python code at a time.
    • So, on a 4-core CPU, threads won’t run in true parallel for heavy computations.
  • Multiprocessing bypasses GIL
    • Each process has its own Python interpreter + GIL.
    • Allows true parallel CPU execution.

Overcome GIL using multiprocessing

Multithreading - Execution time is high and its increases in additional thread is added

import threading
import time

def brew_chai():
    print(f"{threading.current_thread().name} started brewing...")
    count = 0
    for _ in range(100_000_000):
        count += 1
    print(f"{threading.current_thread().name} finished brewing...")

thread1 =threading.Thread(target=brew_chai, name="Barista-1")
thread2 = threading.Thread(target=brew_chai, name="Barista-2")
# thread3 = threading.Thread(target=brew_chai, name="Barista-3")

start = time.time()
thread1.start()
thread2.start()
# thread3.start()
thread1.join()
thread2.join()
# thread3.join()
end = time.time()

print(f"total time taken: {end - start:.2f} seconds")


# Barista-1 started brewing...
# Barista-2 started brewing...
# Barista-1 finished brewing...
# Barista-2 finished brewing...
# total time taken: 10.83 seconds

Multiprocessing - Takes less time & remains constant even if processed increases

from multiprocessing import Process
import time

def crunch_number():
    print(f"Started the count process...")
    count = 0
    for _ in range(100_000_000):
        count += 1
    print(f"Ended the count process...")

if __name__ == "__main__":
    start = time.time()

    p1 = Process(target=crunch_number)
    p2 = Process(target=crunch_number)
    p3 = Process(target=crunch_number)

    p1.start()
    p2.start()
    p3.start()
    p1.join()
    p2.join()
    p3.join()

    end = time.time()

    print(f"Total time with multi-processing is {end - start:.2f} seconds")

# Started the count process...
# Started the count process...
# Ended the count process...
# Ended the count process...
# Total time with multi-processing is 2.63 seconds