# 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 tasksat the same time, making progress on all of them, butnot necessarily executingthem 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).
- Achieved with
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.
- Achieved with
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