Skip to main content

Python Green Threads vs OS Threads

Complete guide to Python concurrency — OS threads, green threads (asyncio), the GIL, event loop internals, Python 3.13 free-threading, and production patterns.

What are Threads?

Threads are the smallest unit of execution that can be scheduled by an operating system. They allow programs to perform multiple tasks concurrently, sharing the same memory space within a process. However, not all threads are created equal!

OS Threads vs Green Threads

Thread Models Comparison

Inspect scheduling, context switching and memory in an OS-thread model and a single-thread cooperative runtime.

Step 1 / 81

OS Threads (Preemptive)

CPU Cores (4 cores)

Core 0

Idle

Core 1

Idle

Core 2

Idle

Core 3

Idle
T1
ready
T2
ready
T3
ready
T4
ready

The kernel schedules threads across multiple CPU cores. Independent workers can execute in parallel when no runtime lock serializes them.

Green Threads (Cooperative)

Single OS Thread

Ready
G1
ready
G2
ready
G3
ready
G4
ready
G5
ready
G6
ready
G7
ready
G8
ready

The runtime schedules eight green threads on one OS thread. Each yields after a 30-point chunk or on completion. They are concurrent but do not run in parallel here.

OS Threads

  • Can run in parallel across multiple cores
  • Often reserve MB-sized stacks
  • The kernel schedules context switches
  • CPU-bound Python bytecode still needs a GIL-free execution path for parallelism

Green Threads

  • Concurrent on one OS thread in this model
  • Small per-task state or runtime-managed stacks
  • The runtime schedules cooperative handoffs
  • Useful for overlapping I/O waits
Authored illustrations, not measured performance. Parallel OS execution assumes work can run without a shared GIL. Green-thread memory, scheduling and switch costs depend on the runtime; the fixed sizes and timing ranges below are illustrative.

Concurrency Timeline

Compare the same four task identities in an authored two-core schedule and a single-core cooperative schedule.

Step 1 / 17
0

OS threads run on multiple cores with preemptive scheduling. The kernel can interrupt any thread at any time. True parallelism means tasks on different cores execute simultaneously.

T1T2T3T4I/O wait (dashed)Compute (solid)Neutral outline: not yet reached
Context Switches
4
Total Time
16 ticks
Core Utilization
62.5%

In this authored OS schedule, tasks can compute on two cores simultaneously. The displayed I/O waits leave gaps because no replacement runnable work is modeled. Its 20 compute ticks use 62.5% of the two-core, 16-tick capacity.

Two authored schedules illustrate placement and handoffs. Their task durations differ, so total time is not an equal-work speedup result. Utilization is calculated from the displayed compute ticks and core capacity. I/O bars show a task waiting, not CPU execution; other runnable OS work is not modeled.

OS Threads (Native/Kernel Threads)

OS threads, also known as native threads or kernel threads, are managed directly by the operating system’s kernel. Each OS thread corresponds to a kernel-level thread that the OS scheduler manages.

Characteristics of OS Threads

  1. Kernel Management: Created and scheduled by the OS kernel
  2. True Parallelism: Can run simultaneously on multiple CPU cores
  3. Preemptive Scheduling: OS can interrupt and switch threads at any time
  4. Higher Overhead: Context switching involves kernel transitions
  5. System Resources: Each thread consumes kernel resources (stack, registers, etc.)

OS Thread Implementation

python
import threading
import time

def cpu_intensive_task(n):
    """Simulate CPU-intensive work"""
    total = 0
    for i in range(n * 1000000):
        total += i
    return total

# Create OS threads in Python
threads = []
for i in range(4):
    thread = threading.Thread(target=cpu_intensive_task, args=(10,))
    threads.append(thread)
    thread.start()

# Wait for all threads to complete
for thread in threads:
    thread.join()

Advantages of OS Threads

  • True Parallelism: Can utilize multiple CPU cores effectively
  • System Integration: Full access to OS services and system calls
  • Blocking I/O Handling: One thread blocking doesn’t affect others
  • Language Agnostic: Supported by the OS, not language-specific

Disadvantages of OS Threads

  • Resource Intensive: Each thread requires significant memory (typically 1–8 MB for stack)
  • Context Switch Overhead: Kernel-mode transitions are expensive (~1–10 microseconds)
  • Limited Scalability: Creating thousands of threads can exhaust system resources
  • Synchronization Complexity: Requires careful handling of locks and shared state

Green Threads (User-Space Threads)

Green threads are threads that are scheduled by a runtime library or virtual machine instead of the operating system. They run entirely in user space and are invisible to the kernel.

Characteristics of Green Threads

  1. User-Space Management: Scheduled by the language runtime or library
  2. Cooperative or Preemptive: Depends on implementation
  3. Lightweight: Minimal memory overhead (typically KB instead of MB)
  4. No True Parallelism: All green threads run on a single OS thread
  5. Fast Context Switching: No kernel transitions required

Green Thread Implementations

Python’s asyncio (Coroutines)

python
import asyncio

async def io_task(name, duration):
    """Simulate I/O-bound work"""
    print(f"Task {name} starting")
    await asyncio.sleep(duration)  # Cooperative yield point
    print(f"Task {name} completed")
    return f"Result from {name}"

async def main():
    # Create multiple coroutines (green threads)
    tasks = [
        io_task("A", 2),
        io_task("B", 1),
        io_task("C", 3)
    ]

    # Run concurrently on a single OS thread
    results = await asyncio.gather(*tasks)
    print(f"Results: {results}")

# Event loop manages green thread scheduling
asyncio.run(main())

Gevent (Green Thread Library)

python
import gevent
from gevent import monkey
monkey.patch_all()  # Patch standard library for green thread support

def fetch_url(url):
    """Simulate network request"""
    print(f"Fetching {url}")
    gevent.sleep(1)  # Yields control to other green threads
    return f"Content from {url}"

# Create green threads
greenlets = [
    gevent.spawn(fetch_url, f"http://example.com/{i}")
    for i in range(1000)  # Can create thousands easily!
]

# Wait for all to complete
gevent.joinall(greenlets)

Modern Async with Error Handling

python
import asyncio
import aiohttp

async def fetch_data(session, url):
    try:
        async with asyncio.timeout(10):
            async with session.get(url) as response:
                response.raise_for_status()
                return await response.text()
    except (asyncio.TimeoutError, aiohttp.ClientError) as e:
        return None  # or log and retry

async def main():
    async with aiohttp.ClientSession() as session:
        results = await asyncio.gather(*[
            fetch_data(session, f"http://api.example.com/{i}")
            for i in range(100)
        ])

Advantages of Green Threads

  • Lightweight: Very low memory overhead per thread
  • Fast Context Switching: No kernel involvement (~0.1–1 microseconds)
  • High Concurrency: Can create millions of green threads
  • Simplified Synchronization: No true parallelism means fewer race conditions
  • Better for I/O: Excellent for I/O-bound workloads

Disadvantages of Green Threads

  • No True Parallelism: Cannot utilize multiple CPU cores
  • Blocking Issues: A blocking system call can freeze all green threads
  • CPU-Bound Limitations: Poor performance for CPU-intensive tasks
  • Runtime Dependency: Requires specific runtime support
  • Debugging Complexity: Stack traces can be confusing

How the Event Loop Works

The asyncio event loop is a single-threaded scheduler for coroutine progress. It runs ready callbacks, lets each coroutine run until its next await, parks waiting work behind Futures, then asks OS primitives such as epoll or kqueue which file descriptors are ready. When I/O completes, asyncio resolves Futures and schedules task wakeups back onto the ready queue. There is no parallel Python execution in the loop itself: one coroutine makes progress at a time.

asyncio Event Loop

Follow one authored pass through ready callbacks, coroutine execution, Futures and operating system readiness.

Step 1 / 7

Ready callbacks are selected

The loop takes a snapshot of the ready queue for this tick. Callbacks scheduled while that snapshot runs wait for the next tick, so one callback cannot keep extending the current pass forever.

ready
run
await
selector
ready

Ready callbacks

Task A wakeupTask C wakeup

Running coroutine

Loop is idle

Futures

Future B: socket read pending

Selector / OS readiness

fd 18: socket read
event_loop.py
ntodo = len(self._ready)
for i in range(ntodo):
    handle = self._ready.popleft()
    handle._run()

The #1 asyncio Mistake: Blocking the Loop

Blocking work stops every coroutine on the loop

If you call a blocking function inside a coroutine, the entire event loop freezes. No other coroutines can run until the blocking call completes.

python
# BAD: blocks the entire event loop for 5 seconds
async def bad_handler():
    time.sleep(5)           # WRONG — use await asyncio.sleep(5)
    requests.get(url)       # WRONG — use aiohttp
    data = db.query(sql)    # WRONG — use asyncpg or aiomysql

# GOOD: run blocking code in a thread pool
async def good_handler():
    loop = asyncio.get_event_loop()
    # Offload blocking call to thread pool
    result = await loop.run_in_executor(None, requests.get, url)

Python’s GIL: The Plot Twist

Python’s Global Interpreter Lock means OS threads cannot execute Python bytecode in parallel. For CPU-bound work, adding threads makes performance worse — not better — because threads fight over the GIL and add context-switch overhead with zero parallelism gain.

For I/O-bound work, threads still help because the GIL is released during system calls (read, write, recv, send). While one thread waits for a network response, another can run Python code.

GIL Contention

Change the workload and thread count to compare a serialized interpreter with an illustrative free-threading model.

4

Pure Python computation (e.g., loops and object processing). The GIL serializes Python bytecode, so adding threads only adds contention overhead in this model.

Execution Time by Thread Count

1T
1.00sbaseline
2T
1.05s1.1x slower
4T
1.19s1.2x slower, selected thread count
8T
1.52s1.5x slower
16T
2.29s2.3x slower
Baseline (1T)
1.00s
Current (4T)
1.19s
Speedup
1.2x slower

4 threads are 19% SLOWER than 1 thread. The GIL serializes all CPU work, and threads fight over lock acquisition.

All times and speedups are calculated from authored formulas, not benchmarks. The CPU-bound case assumes work holds the GIL; native libraries may release it. The optional Python 3.13t series is a teaching model, not a measured result from that release.

GIL in Action: Threads Slower Than Sequential

python
import threading
import time

# Even with multiple OS threads, the GIL prevents true parallelism
def count(n):
    while n > 0:
        n -= 1

# These threads won't run in parallel due to GIL
t1 = threading.Thread(target=count, args=(100000000,))
t2 = threading.Thread(target=count, args=(100000000,))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
print(f"Time with threads: {time.time() - start}")

# Often slower than sequential due to GIL contention!

Python 3.13: Free Threading (PEP 703)

Python 3.13 introduces an experimental free-threaded build (python3.13t) that removes the GIL entirely. For the first time, Python threads can execute in true parallel on multiple cores.

python
import sys
print(sys._is_gil_enabled())  # False on python3.13t

# CPU-bound threads now actually run in parallel
# 4 threads on 4 cores → ~4x speedup (finally!)

What Changes

  • CPU-bound threading: True parallelism. The GILContentionDemo above shows the dramatic difference.
  • Reference counting: Replaced with biased reference counting + deferred RC for thread safety.
  • C extensions: Must be updated to be thread-safe. Many popular packages (NumPy, etc.) are not yet compatible.

What Doesn’t Change

  • asyncio is still better for I/O: Thousands of coroutines are still lighter than thousands of threads.
  • multiprocessing still works: For CPU parallelism on Python < 3.13.
  • The ecosystem needs time: Free-threading is experimental in 3.13 and opt-in.
bash
# Install free-threaded Python
# On macOS: brew install python@3.13 --with-freethreading
# On Ubuntu: sudo apt install python3.13-nogil

# Run with free threading
python3.13t your_script.py

Key Differences

AspectOS ThreadsGreen Threads
ManagementKernel/OSUser-space runtime
Memory per Thread1–8 MB1–64 KB
Context Switch Time1–10 μs0.1–1 μs
True ParallelismYesNo
Number of Threads100s–1000s10,000s–1,000,000s
CPU Cores UtilizedMultipleSingle
Blocking System CallsThread-localGlobal impact
SchedulingPreemptiveCooperative/Preemptive
Best ForCPU-bound tasksI/O-bound tasks

Hybrid Approaches

Some systems combine both models for optimal performance.

M:N Threading (Erlang/Go Model)

go
// Go example - goroutines are green threads mapped to OS threads
func main() {
    // Create thousands of goroutines (green threads)
    for i := 0; i < 10000; i++ {
        go func(id int) {
            // Go runtime maps these to a pool of OS threads
            fmt.Printf("Goroutine %d\n", id)
        }(i)
    }
}

Python multiprocessing + asyncio

python
import multiprocessing
import asyncio

async def async_worker(data):
    """Green thread worker for I/O"""
    await asyncio.sleep(0.1)
    return data * 2

def process_worker(chunk):
    """OS process for CPU work"""
    # Run event loop in each process
    async def process_chunk():
        tasks = [async_worker(item) for item in chunk]
        return await asyncio.gather(*tasks)

    return asyncio.run(process_chunk())

# Combine multiprocessing (true parallelism) with asyncio (green threads)
if __name__ == "__main__":
    data = range(1000)
    chunks = [data[i:i+100] for i in range(0, len(data), 100)]

    with multiprocessing.Pool() as pool:
        results = pool.map(process_worker, chunks)

Choosing the Right Model

Decision Guide

  • Is your bottleneck CPU or I/O?

    • CPU → multiprocessing (or Python 3.13t threads)
    • I/O → asyncio (or threads for blocking libraries)
  • Do you need >1000 concurrent connections?

    • Yes → asyncio (threads can’t scale to 10K+)
    • No → threading is simpler and fine
  • Are you using blocking libraries (requests, psycopg2)?

    • Yes → threading or loop.run_in_executor()
    • No (aiohttp, asyncpg) → asyncio native
  • Do you need both CPU parallelism and high I/O concurrency?

    • Yes → multiprocessing + asyncio in each process

Production Patterns

Thread Pool Sizing

python
import os

# For I/O-bound thread pools
max_workers = min(32, (os.cpu_count() or 1) + 4)

# For CPU-bound (use multiprocessing instead, but if you must)
max_workers = os.cpu_count() or 1

Asyncio Backpressure with Semaphores

python
import asyncio

# Limit concurrent requests to prevent overwhelming the server
sem = asyncio.Semaphore(50)

async def limited_fetch(session, url):
    async with sem:
        async with session.get(url) as resp:
            return await resp.text()

# Even with 10,000 URLs, only 50 run concurrently
tasks = [limited_fetch(session, url) for url in urls]
results = await asyncio.gather(*tasks)

Structured Concurrency (Python 3.11+)

python
async def main():
    async with asyncio.TaskGroup() as tg:
        task1 = tg.create_task(fetch_users())
        task2 = tg.create_task(fetch_orders())
        task3 = tg.create_task(fetch_inventory())
    # All tasks guaranteed complete (or all cancelled on error)
    users, orders, inventory = task1.result(), task2.result(), task3.result()

Graceful Shutdown

python
import signal

async def shutdown(loop, signal=None):
    tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
    for task in tasks:
        task.cancel()
    await asyncio.gather(*tasks, return_exceptions=True)
    loop.stop()

loop = asyncio.get_event_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
    loop.add_signal_handler(sig, lambda s=sig: asyncio.create_task(shutdown(loop, s)))

Performance Comparison

Context Switch Overhead

python
# Measuring context switch time
import threading
import asyncio
import time

# OS Thread context switch
def thread_switch_test():
    event1 = threading.Event()
    event2 = threading.Event()
    switches = 100000

    def thread1():
        for _ in range(switches):
            event2.set()
            event1.wait()
            event1.clear()

    def thread2():
        for _ in range(switches):
            event2.wait()
            event2.clear()
            event1.set()

    t1 = threading.Thread(target=thread1)
    t2 = threading.Thread(target=thread2)

    start = time.time()
    t1.start()
    t2.start()
    t1.join()
    t2.join()

    total_time = time.time() - start
    return total_time / (switches * 2)

# Green thread (coroutine) context switch
async def coro_switch_test():
    switches = 100000
    counter = 0

    async def coro1():
        nonlocal counter
        for _ in range(switches):
            counter += 1
            await asyncio.sleep(0)

    async def coro2():
        nonlocal counter
        for _ in range(switches):
            counter += 1
            await asyncio.sleep(0)

    start = time.time()
    await asyncio.gather(coro1(), coro2())
    total_time = time.time() - start

    return total_time / (switches * 2)

# Results typically show:
# OS Thread switch: ~5-10 microseconds
# Green thread switch: ~0.1-0.5 microseconds

Real-World Examples

Web Servers

Traditional (OS Threads) — Apache:

  • One thread per connection
  • Limited to ~10,000 concurrent connections
  • High memory usage

Modern (Green Threads) — Node.js/Python asyncio:

  • Single-threaded event loop
  • Can handle 100,000+ concurrent connections
  • Low memory footprint

Database Connection Pools

OS Threads:

python
from concurrent.futures import ThreadPoolExecutor
import psycopg2

def query_database(query):
    conn = psycopg2.connect("postgresql://...")
    cursor = conn.cursor()
    cursor.execute(query)
    result = cursor.fetchall()
    conn.close()
    return result

# Limited by thread overhead
with ThreadPoolExecutor(max_workers=100) as executor:
    futures = [executor.submit(query_database, f"SELECT * FROM table_{i}")
               for i in range(100)]

Green Threads:

python
import asyncio
import asyncpg

async def query_database(pool, query):
    async with pool.acquire() as conn:
        return await conn.fetch(query)

async def main():
    # Can handle thousands of concurrent queries
    pool = await asyncpg.create_pool("postgresql://...")

    tasks = [query_database(pool, f"SELECT * FROM table_{i}")
             for i in range(10000)]

    results = await asyncio.gather(*tasks)
    await pool.close()
    return results

Further Reading

If you found this explanation helpful, consider sharing it with others.

Mastodon