Process vs Thread Comparison
# Key differences between processes and threads
# 1. Processes consume significantly more resources than threads
# 2. Processes have isolated data spaces; threads share data within the same process
# 3. Multiple processes don't share data by default
# --> Inter-process communication (IPC) is required
# --> When processes communicate, their threads can also share data
GIL: Global Interpreter Lock
The Global Interpreter Lock (GIL) is not a Python language feature—it was introduced when implementing the CPython interpreter. Think of it like C++ being a language standard that can be compiled by different compilers.
The GIL exists exclusively in CPython and protects interpreter-level data.
Prerequisite Knowledge
In the CPython interpreter, multiple threads with in the same process can only execute one thread at a time. This means multithreaded Python code cannot take advantage of multi-core CPU capabilities.
Python Interpreter Types
# CPython
# Written in C language
# Currently the most widely used interpreter (95% market share)
# GIL exists in CPython
# IPython
# An interactive shell built on top of CPython
# Enhanced interaction methods only; execution functionality is identical to CPython
# Similar to how different browsers share the same IE core
# PyPy
# Another Python interpreter focused on execution speed
# Uses JIT (Just-In-Time) compilation for significantly faster execution
# Jython
# Runs on Java platform
# Compiles Python code directly to Java bytecode
# IronPython
# Similar to Jython but runs on Microsoft .NET platform
# Compiles Python code to .NET bytecode
# Access to Python interpreter is controlled by GIL
# This lock ensures only one thread runs at a time
How GIL Works
GIL is essentially a mutex lock. Like all mutex locks, its fundamental purpose is to convert concurrent execution into serial execution. This prevents multiple tasks from simultaneously modifying shared data, ensuring data integrity.
# Each Python program execution creates an independent process
# Within a Python process, there are:
# - Main thread and its spawned threads
# - Interpreter-level threads (e.g., garbage collection)
# - All threads run within the same process
The problem GIL prevents: multiple threads competing for resources. The solution was to add a lock directly to the interpreter, ensuring only one thread executes at any given moment. Any thread wanting to execute must first acquire this lock, and only releases it after completing its work.
Conclusion: GIL ensures only one thread executes at a time. All threads must acquire the GIL lock before gaining execution rights.
Mutex Locks
Under multithreading, simultaneous access to shared data causes data corruption.
Principles
# Trade-offs:
# - Time for space
# - Space for time
# Consider time complexity
Creating a Mutex Lock
import threading
# Create mutex lock
mutex = threading.Lock()
Locking and Unlocking Resources
# acquire() -- Locks the resource
# Resource becomes locked; other threads cannot modify until released
# release() -- Unlocks the resource
# After unlocking, other threads can access normally
Mutex Lock Example
from threading import Thread
from threading import Lock
import time
counter = 10
def decrement(lock):
lock.acquire()
global counter
temp = counter
time.sleep(0.5) # Simulating processing delay
counter = temp - 1
lock.release()
if __name__ == '__main__':
threads = []
lock = Lock()
for i in range(10):
t = Thread(target=decrement, args=(lock,))
t.start()
threads.append(t)
for thread in threads:
thread.join()
print("Final:", counter)
Important: Always unlock after locking, otherwise resources remain locked indefinitely.
Interview Question: If GIL already exists, why do we still need mutex locks?
Consider two threads executing a = a + 1 where a starts at 0:
- Thread 1 arrives, reads
a = 0, executesa = a + 1, result is 1 - Thread 1 hasn't assigned the result back to
ayet. Thread 2 arrives, readsa = 0, executesa = a + 1, result is still 1 - Mutex locks prevent this race condition in multithreaded environments.
Thread Queues
Why Use Queues in Threads?
Multiple threads within the same process share data. Why do we need queues?
Because a queue is essentially: pipe + lock. Using queues ensures data safety.
Thread queues support:
- FIFO (First In, First Out)
- LIFO (Last In, First Out)
- Priority queue
FIFO Queue
import queue
q = queue.Queue() # Theoretical infinite size
q.put('item_one')
q.put('item_two')
q.put('item_three')
print(q.get()) # item_one
print(q.get()) # item_two
print(q.get()) # item_three
LIFO Queue (queue.LifoQueue())
import queue
q = queue.LifoQueue()
q.put('first')
q.put('second')
q.put('third')
print(q.get()) # third
print(q.get()) # second
print(q.get()) # first
Priority Queue (queue.PriorityQueue())
import queue
q = queue.PriorityQueue()
# Put a tuple: first element is priority (lower number = higher priority)
q.put((20, 'alpha'))
q.put((10, 'beta'))
q.put((30, 'gamma'))
print(q.get()) # (10, 'beta')
print(q.get()) # (20, 'alpha')
print(q.get()) # (30, 'gamma')
Process and Thread Pools
Pool Concept
A pool maintains resources to ensure stable computer operation while maximizing resource utilization. It trades some execution efficiency for hardware safety. A pool represents a sustainable resource limit—exceeding it causes system instability.
Process Pool
Pre-define a pool and add processes to it. Then simply submit tasks to the pool, and any available process executes them.
from concurrent.futures import ProcessPoolExecutor
def compute(a, b):
return a + b
def get_user():
return {'username': 'admin', 'password': 123}
def handle_result(future):
print(f"Result: {future.result()}")
def handle_user_result(future):
result = future.result()
print(f"User: {result.get('username')}")
if __name__ == '__main__':
pool = ProcessPoolExecutor(3) # Create pool with 3 processes
pool.submit(compute, 1, 2).add_done_callback(handle_result)
pool.submit(get_user).add_done_callback(handle_user_result)
pool.shutdown() # Equivalent to join + close
print("Main process continues...")
Thread Pool
Pre-define a pool and add threads to it. Then simply submit tasks, and any available thread executes them.
from concurrent.futures import ThreadPoolExecutor
def compute(a, b):
return a + b
def get_user():
return {'username': 'admin', 'password': 123}
def handle_result(future):
print(f"Result: {future.result()}")
def handle_user_result(future):
result = future.result()
print(f"User: {result.get('username')}")
if __name__ == '__main__':
pool = ThreadPoolExecutor(3) # Create pool with 3 threads
pool.submit(compute, 1, 2).add_done_callback(handle_result)
pool.submit(get_user).add_done_callback(handle_user_result)
pool.shutdown()
print("Main thread continues...")
Web Scraping with Thread Pool
import requests
from concurrent.futures import ThreadPoolExecutor
def fetch_page(url):
response = requests.get(url)
filename = url.split('/')[-1] + '.html'
return {'filename': filename, 'content': response.content}
def save_result(future):
result = future.result()
print(f"Saving: {result['filename']}")
with open(result['filename'], 'wb') as file:
file.write(result['content'])
if __name__ == '__main__':
pool = ThreadPoolExecutor(2)
urls = [
'http://www.example.com',
'http://www.sample.org',
'http://www.test.net'
]
for url in urls:
pool.submit(fetch_page, url).add_done_callback(save_result)
Guiding Principal: Maximize resource utilization while ensuring hardware stability.
Coroutine Theory
Introduction
Coroutines achieve concurrency within a single thread. Since only one CPU core is available, we need to revisit concurrency fundamentals: switching + state preservation.
A coroutine (also called micro-thread or fiber) is lightweight concurrency controlled by user code, not the operating system.
Key Distinctions:
# Python threads are kernel-level: controlled by OS scheduler
# When a thread encounters I/O or runs too long, OS forces context switch
# Coroutines run in a single thread with user-controlled switching
# When encountering I/O, the application (not OS) controls the switch
# This dramatically improves efficiency for I/O-bound operations
Coroutine Characteristics
# Single-threaded concurrency
# Multiple task switching + state preservation within application
# Advantages:
# - Application-level switching is much faster than OS-level
# Disadvantages:
# - If any task blocks, the entire thread blocks
# - Must detect ALL I/O operations in the single thread
# - Missing even one I/O detection causes all other tasks to stall
Coroutine Properties
- Concurrency achieved within a single thread
- No lock needed when modifying shared data
- User program manages multiple context stacks
- Automatically switches on I/O (requires gevent's select mechanism—plain yield or greenlet cannot detect I/O)
Purpose
Achieving concurrency in a single thread:
- Concurrency means multiple tasks appear simultaneous
- Concurrency = Switching + State Preservation
Coroutines are the most resource-efficient, followed by threads, with processes being most resource-intensive. The essence of coroutines is user-controlled task switching when I/O blocks occur, all within a single thread.
Using Coroutines
pip install gevent
import gevent
Gevent is a third-party library enabling concurrent synchronous/asynchronous programming. The primary pattern in gevent uses Greenlets—lightweight coroutines implemented as C extensions. Greenlets run with in the main process but are cooperatively scheduled.
Basic Usage
# Create coroutine: gevent.spawn(function, args...)
g1 = gevent.spawn(func, arg1, arg2, kwarg=value)
g2 = gevent.spawn(another_func)
# Wait for coroutines to complete
g1.join()
g2.join()
# Or: gevent.joinall([g1, g2])
# Retrieve return value
g1.value
Example
from gevent import monkey
monkey.patch_all()
import gevent
import time
def eat():
print('Eating food - phase 1')
time.sleep(2)
print('Eating food - phase 2')
def play():
print('Playing - phase 1')
time.sleep(1)
print('Playing - phase 2')
start_time = time.time()
g1 = gevent.spawn(eat)
g2 = gevent.spawn(play)
g1.join()
g2.join()
print(f'Total time: {time.time() - start_time}')
High-Concurrency Server with Coroutines
Server Implementation
from gevent import monkey
monkey.patch_all()
import gevent
from socket import socket
def handle_client(conn):
while True:
try:
data = conn.recv(1024)
if not data:
break
print(data)
conn.send(data.upper())
except Exception as e:
print(f"Error: {e}")
break
conn.close()
def run_server(ip, port):
server = socket()
server.bind((ip, port))
server.listen(5)
while True:
conn, addr = server.accept()
gevent.spawn(handle_client, conn)
if __name__ == '__main__':
server_task = gevent.spawn(run_server, '127.0.0.1', 8080)
server_task.join()
Client Implementation
import socket
from threading import current_thread
from threading import Thread
def client_session():
client = socket.socket()
client.connect(('127.0.0.1', 8080))
while True:
message = f'{current_thread().name} sends greeting'
client.send(message.encode('utf-8'))
data = client.recv(1024)
print(data)
# Launch 5000 concurrent clients
for _ in range(5000):
t = Thread(target=client_session)
t.start()