Understanding Multithreading, GIL, and Coroutines in Python

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:

  1. Thread 1 arrives, reads a = 0, executes a = a + 1, result is 1
  2. Thread 1 hasn't assigned the result back to a yet. Thread 2 arrives, reads a = 0, executes a = a + 1, result is still 1
  3. 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:

  1. FIFO (First In, First Out)
  2. LIFO (Last In, First Out)
  3. 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

  1. Concurrency achieved within a single thread
  2. No lock needed when modifying shared data
  3. User program manages multiple context stacks
  4. 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()

Tags: python multithreading GIL mutex Thread Queue

Posted on Thu, 06 Aug 2026 16:45:37 +0000 by CreativityLost