Python Advance 📂 Threads · 3 of 4 100 min read

Python GIL & Threading with Queue

Master Python's Global Interpreter Lock and thread-safe queues in one deep-dive. Learn why the GIL exists, when it hurts (CPU-bound) and when it doesn't (I/O-bound), and how to build production-grade producer-consumer systems with queue.Queue, PriorityQueue, and ThreadPoolExecutor. Includes benchmarks, real-world web scraper, chained pipelines, poison-pill shutdown, and a golden-rules cheat sheet.

Section 01

The Story That Explains The GIL

The Single Microphone at a Debate
Imagine a debate hall with 8 speakers on stage, but there is only one microphone. Only the person holding the microphone can speak — everyone else must wait, no matter how eager they are. The mic is passed around, but at any given instant, exactly one voice is heard.

Now imagine the audience thinks all 8 speakers are talking at once because the mic moves so fast. In reality, only one is ever speaking. Adding more speakers does not help — the microphone is the bottleneck.

That single microphone is Python's Global Interpreter Lock (GIL). The 8 speakers are your threads. No matter how many CPU cores you have, only one Python thread executes Python bytecode at a time.

The GIL is a mutex (mutual exclusion lock) inside CPython that protects access to Python objects. It prevents multiple native threads from executing Python bytecode simultaneously. This design keeps CPython simple and fast for single-threaded workloads, but it fundamentally limits CPU-bound multithreaded performance.

🎬
The Core Insight

Python threads are real OS threads, but the GIL serialises their execution of Python code. Threads are still fantastic for I/O-bound work (network, disk, database) because the GIL is released during blocking calls. They are poor for CPU-bound work — for that, use processes.


Section 02

What Exactly Is The GIL?

The Global Interpreter Lock is a single, process-wide mutex in the CPython interpreter. Every Python thread must acquire the GIL before it can execute a single bytecode instruction. When one thread holds it, all others are blocked at the Python level.

🔑 Diagram — The GIL Bottleneck: Many Threads, One Lock, One CPU
Thread 1 Thread 2 Thread 3 Thread 4 🔒 GIL Only 1 pass at a time Python Interpreter (bytecode VM) CPU 3 threads block, wait, do nothing 1 thread makes progress

Every Python thread must funnel through the single GIL to reach the interpreter. No matter how many cores exist, only one thread ever runs Python bytecode at a time.

🔑 The Life of a Thread Under the GIL
Step 1
Thread requests the GIL. If another thread holds it, this thread blocks.
Step 2
Once acquired, the thread runs Python bytecode until it hits a switch interval (default 5 ms in Python 3.2+) or a blocking I/O call.
Step 3
The interpreter releases the GIL and signals waiting threads.
Step 4
Another thread acquires the GIL, runs for its slice, then releases. Cycle repeats.
Result
Threads interleave execution — they do not run in true parallel on multiple CPU cores.
⚠️
Common Misconception

The GIL does not make Python "single-threaded". You can absolutely create many threads and they will run concurrently. The GIL simply prevents them from running Python bytecode in parallel. During I/O, sleeps, or calls into C extensions that release the GIL (NumPy, requests, etc.), other threads can make real progress.


Section 03

Why Does The GIL Exist?

The GIL was introduced in the early 1990s to solve one problem: memory management safety. CPython uses reference counting for garbage collection. Without a global lock, two threads could increment or decrement a refcount at the same time, corrupting memory and crashing the interpreter.

🧠
Reason 1 — Memory Safety
Reference Counting
Every Python object has a refcount. When it hits zero, the object is freed. A single global lock is a simple, fast way to make every refcount increment and decrement atomic, without needing per-object locks.
Reason 2 — Speed for Single Threads
One Lock is Fast
Acquiring one uncontended lock is faster than acquiring many fine-grained locks. Since most Python programs are single-threaded, the GIL makes them faster than a lock-per-object design would.
📚
Reason 3 — C Extension Simplicity
Ecosystem Compatibility
Thousands of C extensions (NumPy, Pillow, database drivers) assume the GIL protects them. Removing it would break the ecosystem. Efforts like PEP 703 (No-GIL Python) are addressing this in Python 3.13+ as an opt-in build.
💡
The 2026 Reality

Python 3.13 introduced an experimental free-threaded (no-GIL) build via PEP 703. It is available today but still opt-in and slightly slower for single-threaded workloads. For production code, assume the GIL exists and design accordingly — it will remain the default for years to come.


Section 04

Behind the Scenes — Threads & PyThreadState

When you call threading.Thread(target=…).start(), Python does surprisingly little. It creates a small C data structure called PyThreadState, launches a native OS thread (a POSIX pthread on Linux/macOS, a Windows thread on Windows), and hands it off. Thread scheduling itself is done entirely by the operating system — Python has no thread scheduler of its own.

🛠️ What Happens on thread.start()
Step 1
Python allocates a small PyThreadState struct (< 100 bytes) with per-thread interpreter state.
Step 2
A new native OS thread is spawned (pthread_create on Unix).
Step 3
The thread calls PyEval_CallObject — a plain C function that runs your Python callable.
Step 4
The OS kernel schedules the thread onto a CPU core. Python has no say in which core or when.

The PyThreadState Structure

Each Python thread carries its own state — a stack frame, recursion depth, exception info, tick counter, and tracing hooks. A global variable _PyThreadState_Current always points to the state of whichever thread is currently running.

/* Python/pystate.h — simplified */
typedef struct _ts {
    struct _ts       *next;
    PyInterpreterState *interp;
    struct _frame   *frame;          // current stack frame
    int                 recursion_depth;
    int                 tracing;
    Py_tracefunc        c_profilefunc;
    Py_tracefunc        c_tracefunc;
    PyObject           *curexc_type;
    PyObject           *curexc_value;
    PyObject           *curexc_traceback;
    PyObject           *dict;            // thread-local storage
    int                 tick_counter;    // used by the check interval
    int                 gilstate_counter;
    PyObject           *async_exc;
    long                thread_id;
} PyThreadState;

/* Points to currently executing thread's state */
PyThreadState *_PyThreadState_Current = NULL;
🔑
Why This Matters

Because Python threads are real OS threads, they carry all the OS overhead (context switches, scheduling latency, cache misses). But because only one can hold the GIL, most of that parallelism is wasted. You pay for OS-level threads without getting OS-level parallelism.


Section 05

Ticks, Check Intervals & The Modern Switch

How does Python decide when to release the GIL from a CPU-bound thread that never does I/O? It periodically performs a "check". The scheme changed dramatically between Python 2 and Python 3.2.

🔴 Python 2.x — Tick-Based (Old)
PropertyValue
TriggerEvery 100 bytecode ticks
Time-based?No — count-based
Settingsys.setcheckinterval()
Problem1 tick can take 6+ seconds if it's a C call like -1 in big_list
MulticoreCatastrophic — GIL battle across cores
🟢 Python 3.2+ — Time-Based (Modern)
PropertyValue
TriggerEvery 5 ms of wall-clock time
Time-based?Yes — real milliseconds
Settingsys.setswitchinterval(0.005)
ProblemMuch smoother — but GIL battle still exists
MulticoreBetter fairness, still no true parallelism
⏲ Diagram — Old (Tick) vs Modern (Time) GIL Switching
Python 2.x — TICK-BASED check every 100 bytecodes 100 tick chk 100 tick 1 tick = 6+ seconds! 🔴 A slow C call = one tick = blocks everything (Ctrl-C ignored) Python 3.2+ — TIME-BASED check every 5 ms 5 ms 5 ms 5 ms 5 ms ✅ Predictable switches — no thread can hog forever Result on multiple threads (Python 3.2+): T1 T2 T3 ... waits ... Time →

Python 2 counted bytecodes and could hang for seconds. Python 3.2+ uses wall-clock time, giving predictable 5 ms interleaving — but the multicore GIL battle remains.

What Is a "Tick"?

A tick loosely maps to one Python virtual-machine instruction. You can see them by disassembling any function with dis:

>>> import dis
>>> def countdown(n):
...     while n > 0:
...         print(n)
...         n -= 1
>>> dis.dis(countdown)
  0 SETUP_LOOP           33 (to 36)
  3 LOAD_FAST             0 (n)             # <- Tick 1
  6 LOAD_CONST            1 (0)
  9 COMPARE_OP            4 (>)
 12 JUMP_IF_FALSE        19 (to 34)
 15 POP_TOP
 16 LOAD_FAST             0 (n)
 19 PRINT_ITEM
 20 PRINT_NEWLINE                          # <- Tick 2
 21 LOAD_FAST             0 (n)
 24 LOAD_CONST            2 (1)                # <- Tick 3
 27 INPLACE_SUBTRACT
 28 STORE_FAST            0 (n)                # <- Tick 4
 31 JUMP_ABSOLUTE         3
⚠️
Ticks Are Not Time-Based (Python 2)

A single tick can hide a huge amount of work if it calls a C function. In Python 2, -1 in range(100_000_000) counts as ONE tick and can take 6+ seconds — during which no other thread runs and even Ctrl-C is ignored. Python 3.2's time-based switch interval largely fixed this specific issue.

The Check Itself — Simplified From ceval.c

/* Python/ceval.c — the heart of the interpreter loop */
if (--_Py_Ticker < 0) {
    _Py_Ticker = _Py_CheckInterval;

    if (things_to_do) {
        if (Py_MakePendingCalls() < 0) { ... }
    }

    if (interpreter_lock) {
        /* Give another thread a chance */
        PyThread_release_lock(interpreter_lock);

        /* Other threads may run now */

        PyThread_acquire_lock(interpreter_lock, 1);
    }
}

Section 06

GIL Behavior — Cooperative I/O Release

For I/O-bound threads, the GIL is beautifully simple: a thread holds the GIL while running Python code, but releases it whenever it blocks for I/O. Any other ready thread immediately grabs the lock and runs.

⏱️ Diagram — GIL Timeline During I/O (Thread T1 vs Thread T2)
Time → T1 T2 RUN holds GIL I/O WAIT (GIL released) RUN I/O WAIT RUN blocked RUN grabs GIL while T1 waits blocked RUN blocked release release Running (holds GIL) Blocked / I/O wait (no GIL)

While T1 waits on I/O, it releases the GIL and T2 gets to run its Python code. This "cooperative" pattern is why threads are excellent for I/O-heavy workloads.

A
Thread runs Python code (holds GIL)
The thread executes bytecodes — arithmetic, list ops, function calls — while holding the interpreter lock. All other threads sit blocked.
B
Thread hits I/O — releases GIL
A call like socket.recv(), file.read(), or time.sleep() explicitly releases the GIL just before blocking. The kernel takes over.
C
Another thread acquires the GIL and runs
A ready thread grabs the lock instantly, runs its own Python code, then possibly hits I/O and releases again.
D
I/O completes — thread reacquires GIL
When the socket call returns, the thread must line up and wait for the GIL again before it can process the result. This is "cooperative multitasking".
🎯
The I/O Sweet Spot

Because network and disk operations are 1000× slower than CPU work, the GIL is released far more than it's held during I/O-heavy workloads. This is why threads + Queue is still the best pattern for scraping, file processing, and network fanout in Python.


Section 07

CPU-Bound vs I/O-Bound — When The GIL Bites

📈 Diagram — Wall-Clock Time: Threads vs Sequential vs Processes
CPU-BOUND WORKLOAD Sequential (1 thread) 2.4s 2 threads 2.6s SLOWER! 2 processes 1.3s (2x) Threads ❌   Processes ✅ GIL serialises Python bytecode. More threads = more contention. I/O-BOUND WORKLOAD Sequential (1 thread) 10s 10 threads 1.2s (~9x faster!) 10 processes 1.4s (works, but heavier) Threads ✅  Cheap & Fast GIL released during network wait. All 10 requests happen in parallel.

Same threading code, opposite results. The workload's nature — CPU vs I/O — decides whether threads help or hurt.

🔴 CPU-Bound (GIL Hurts)
WorkloadThreads Help?
Prime number sieveNo
Image processing (pure Python)No
Matrix multiply (pure Python)No
Sorting large in-memory listsNo
JSON parsing millions of recordsNo
Solutionmultiprocessing
🟢 I/O-Bound (GIL Doesn't Matter)
WorkloadThreads Help?
HTTP requests to 100 URLsYes — 10-50x
Reading many filesYes
Database queriesYes
Waiting for user inputYes
Copying data over networkYes
Solutionthreading + Queue
🎯
The Golden Rule

If your bottleneck is waiting (network, disk, database) → threads win.
If your bottleneck is computing (Python bytecode running hot) → processes win.
When in doubt, profile first with time.perf_counter() and check CPU vs wall time.


Section 08

The Beazley Experiment — When 2 Threads Are SLOWER Than 1

David Beazley's 2009 Bombshell
In 2009, David Beazley ran a trivial CPU-bound benchmark on a dual-core MacBook. Sequential execution finished in 24.6 seconds. Splitting the same work across 2 threads took 45.5 seconds — nearly 1.8× SLOWER. Then he disabled one CPU core and the threaded version dropped to 38 seconds. Faster on one core than two. Bloody hell, indeed.

This became one of the most influential Python performance talks ever given, and it exposed a truth most Python programmers still miss: the GIL is not just slow on multicore — it's catastrophically slow.
📉 Diagram — The Beazley Numbers Visualised
Sequential (1 core, 1 thread) 24.6 s ✅ 2 threads, 2 CPU cores 45.5 s ❌ (1.8× slower!) 2 threads, 1 CPU core (other disabled) 38.0 s — faster with FEWER cores! baseline

The famous inversion: adding a second core makes threaded Python slower, because two cores create constant GIL contention.

Recreating The Experiment

import time
from threading import Thread

def count(n):
    while n > 0:
        n -= 1

COUNT = 100_000_000

# --- Sequential ---
start = time.perf_counter()
count(COUNT)
count(COUNT)
print(f"Sequential : {time.perf_counter() - start:.1f}s")

# --- Two threads (each does half? no — each does full!) ---
start = time.perf_counter()
t1 = Thread(target=count, args=(COUNT,))
t2 = Thread(target=count, args=(COUNT,))
t1.start(); t2.start()
t1.join();  t2.join()
print(f"Threaded   : {time.perf_counter() - start:.1f}s")
OUTPUT — Beazley's original numbers
Sequential : 24.6s Threaded : 45.5s <- 1.8x SLOWER on 2 cores! With one CPU core disabled: Threaded : 38.0s <- FASTER with fewer cores
🔥
Why Fewer Cores = Faster?

This counter-intuitive result is the smoking gun for multicore GIL contention. With one core, only one thread can ever run — no wasted signaling. With two cores, the OS schedules both threads simultaneously and they enter a constant GIL battle: acquire, release, retry, acquire, release, retry — burning tens of thousands of system calls per second on lock contention.


Section 09

The Multicore GIL Battle

On a single core, the OS runs one thread at a time — the GIL is barely contested. On multicore, the OS gleefully schedules multiple threads on different cores at the same time, and they immediately start fighting over the one interpreter lock.

⚔️ Diagram — Two Cores, One GIL: The Battle
CORE 1 → Thread T1 (CPU-bound) CORE 2 → Thread T2 (waiting for GIL) Time (5 ms slices) → RUN (holds GIL) R RUN (re-grabs GIL) R RUN (again) RUN RUN T2 wins! T1 releases (R) → instantly reacquires the GIL because it's already running on Core 1 BUSY RETRY RETRY RETRY RETRY RETRY ... (100s of failed attempts) ... RETRY FINALLY! Each RETRY = full OS wake-up + system call + failed lock attempt (thousands of Mach calls) R = release GIL

T1 keeps winning because it's already scheduled and awake on Core 1. T2 wastes CPU cycles waking up on Core 2 only to find the lock is gone before it can grab it.

01
Thread 1 (Core 1) releases the GIL every 5 ms
After its time-slice, T1 releases the GIL and sends a signal to any waiting thread.
02
Thread 2 (Core 2) wakes up on signal
T2 was sleeping, waiting on the semaphore. The signal wakes it up on the second core.
03
Thread 1 IMMEDIATELY reacquires the GIL
Because T1 is still running on Core 1, it beats T2 to the lock. T2 tried but "Acquire GIL fails".
04
T2 sleeps again — pointless wakeup
T2 goes back to sleep. Another signal arrives 5 ms later. It wakes, fails again, sleeps again.
05
100s of failed acquisitions per second
Every failed acquisition is a full OS context switch + system call. On 2 cores this generated ~9.5 million Mach system calls in Beazley's trace.

Beazley's Instrumented Trace — The GIL Battle Live

# t1, t2 = thread ids. Middle number = ticks remaining. Right = total checks.
t2 100 5392 ENTRY
t2 100 5392 ACQUIRE
t2 100 5393 RELEASE
t1 100 5393 ACQUIRE     # thread switch — t1 wins
t2 100 5393 ENTRY
t2  27 5393 BUSY        # t2 tries to run but GIL taken by t1
t1 100 5394 RELEASE
t1 100 5394 ENTRY       # t1 releases and IMMEDIATELY re-enters
t1 100 5394 ACQUIRE     # t1 reacquires before t2 can wake
t2  74 5394 RETRY       # t2 wakes up, fails, goes back to sleep
t1 100 5395 RELEASE
t1 100 5395 ENTRY
t1 100 5395 ACQUIRE     # t1 wins AGAIN
t2  83 5395 RETRY       # t2 fails AGAIN
...  # hundreds of failed retries before t2 gets a turn
🚫
The Root Cause — A Scheduler Conflict

Python wants to run single-threaded but delegates scheduling to the OS. The OS wants to use every available core and freely schedules threads simultaneously. These two goals are fundamentally incompatible. The result is the "GIL battle" — a fight the OS keeps starting and Python keeps losing.


Section 10

Signaling Overhead — Where All The Time Goes

The GIL is not a simple mutex. On Unix it's implemented as either a POSIX unnamed semaphore or a pthreads condition variable. Every acquire/release involves signaling — and signaling costs system calls.

ScenarioUnix syscallsMach syscallsComment
Sequential (OS-X, 1 CPU)736117Baseline — no threading overhead
2 CPU-bound threads (1 CPU)1,149~3,300,0003.3 million lock signals!
2 CPU-bound threads (2 CPUs)1,149~9,500,000Nearly 3× worse on multicore
💩
The Hidden Cost

Every 5 ms the interpreter locks a mutex, signals a condition variable where another thread is always waiting, triggers a pthreads/kernel round-trip to deliver the signal, and then reacquires the lock. Multiplied by 200 checks per second, per core, this is where your CPU cycles vanish.


Section 11

Priority Inversion — When CPU Threads Starve I/O Threads

Here's the truly ugly one. Imagine a mixed workload: one CPU-bound worker crunching numbers, one I/O-bound worker sitting on a socket waiting for a packet. The packet arrives — the I/O thread wants to run right now — but it can't get the GIL.

🚨 Diagram — Priority Inversion: High-Priority Thread Starves
CPU thread (low priority) — hogs GIL I/O thread (HIGH priority) — can't get in! RUN • RUN • RUN • RUN • RUN • RUN (holds GIL non-stop, ~16,000 ticks) Releases every 5 ms but instantly reacquires — the I/O thread never wins the race 📨 packet arrives sleep FAIL FAIL FAIL FAIL FAIL ... hundreds of failed acquires ... FAIL RUN (3 ticks) Response latency: hundreds of milliseconds — even though CPU work was "just background"

The OS thinks the I/O thread is urgent. The GIL doesn't care about OS priority. A low-priority CPU thread blocks a high-priority I/O thread — the textbook definition of priority inversion.

01
CPU-bound Thread runs on Core 1
Grinding numbers, holding the GIL, releasing every 5 ms and immediately reacquiring — the same battle we just saw.
02
Network packet arrives — I/O thread on Core 2 wakes
The OS gives I/O-bound threads HIGH priority. The thread wakes on Core 2 and tries to acquire the GIL to process the packet.
03
Acquire GIL — FAILS
The CPU thread already grabbed it before the I/O thread could react. I/O thread sleeps.
04
Wakes → fails → sleeps × 16,000 ticks
Beazley's trace showed an I/O-bound thread waiting through ~16,000 ticks — potentially hundreds of milliseconds — before ever getting to run 3 ticks of work.
05
Response latency destroyed
Your web server, socket handler, or GUI event loop appears to freeze even though the CPU worker is theoretically "just" grinding a background task.
⚠️
The "Priority Inversion" Problem

The OS thinks the I/O thread is high priority. The GIL doesn't care about OS priority. A low-priority CPU thread ends up blocking a high-priority I/O thread — a textbook priority inversion caused entirely by the interpreter lock. This is why mixing heavy CPU work with I/O in the same Python process is a bad idea.


Section 12

Signals, Ctrl-C & Frozen Threaded Programs

Why Ctrl-C Doesn't Kill Your Threaded Python Program
You launch a threaded Python program. It hangs. You mash Ctrl-C. Nothing happens. You mash it again. Still nothing. Eventually you give up and open another terminal to kill -9 it. Extremely annoying — and it's a direct consequence of how signals interact with the GIL.
👥 Why Signals Get Weird
Fact 1
Python signal handlers (like the Ctrl-C handler) can only run in the main thread.
Fact 2
When you press Ctrl-C, the interpreter marks a pending signal and starts trying to thread-switch after every single tick until the main thread gets scheduled.
Fact 3
If the main thread is blocked on thread.join() or a Lock, it will never get scheduled to handle the signal.
Result
Ctrl-C is silently queued, but never runs. Meanwhile the interpreter is thread-switching frantically after every tick, making the program run even slower.

Safe Pattern — Interruptible Threaded Program

import threading
import time

stop_event = threading.Event()

def worker():
    while not stop_event.is_set():
        # do real work, but check stop_event periodically
        time.sleep(0.1)
    print("Worker exiting cleanly.")

t = threading.Thread(target=worker)
t.start()

try:
    # Main thread stays responsive to signals
    while t.is_alive():
        t.join(timeout=0.5)     # timeout lets main thread wake to check signals
except KeyboardInterrupt:
    print("\nCtrl-C received — asking worker to stop")
    stop_event.set()
    t.join()
💡
The Fix

Never call t.join() with no timeout in the main thread if you want Ctrl-C to work. Use a short timeout so the main thread periodically wakes and can process pending signals. Pair it with a threading.Event to signal graceful shutdown to workers.


Section 13

Proving The GIL — A CPU-Bound Benchmark

import time
import threading
from multiprocessing import Process

# A pure Python CPU-bound task
def count_down(n):
    while n > 0:
        n -= 1

COUNT = 50_000_000

# --- 1. Single thread (baseline) ---
start = time.perf_counter()
count_down(COUNT)
print(f"Single thread:  {time.perf_counter() - start:.2f}s")

# --- 2. Two threads (splitting the work) ---
start = time.perf_counter()
t1 = threading.Thread(target=count_down, args=(COUNT // 2,))
t2 = threading.Thread(target=count_down, args=(COUNT // 2,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Two threads:    {time.perf_counter() - start:.2f}s")

# --- 3. Two processes (bypasses the GIL) ---
start = time.perf_counter()
p1 = Process(target=count_down, args=(COUNT // 2,))
p2 = Process(target=count_down, args=(COUNT // 2,))
p1.start(); p2.start()
p1.join(); p2.join()
print(f"Two processes:  {time.perf_counter() - start:.2f}s")
OUTPUT (typical 8-core machine)
Single thread: 2.43s Two threads: 2.61s <- SLOWER due to GIL contention overhead Two processes: 1.28s <- Nearly 2x faster — true parallelism

Section 14

Threads Shine For I/O — Same Benchmark, Different Workload

import time
import threading
import requests

URLS = ["https://httpbin.org/delay/1"] * 10

def fetch(url):
    requests.get(url)

# --- Sequential ---
start = time.perf_counter()
for url in URLS:
    fetch(url)
print(f"Sequential:  {time.perf_counter() - start:.2f}s")

# --- 10 threads in parallel ---
start = time.perf_counter()
threads = [threading.Thread(target=fetch, args=(u,)) for u in URLS]
for t in threads: t.start()
for t in threads: t.join()
print(f"10 threads:  {time.perf_counter() - start:.2f}s")
OUTPUT
Sequential: 10.34s 10 threads: 1.18s <- ~9x faster — GIL released during network wait

During requests.get(), the underlying socket call releases the GIL. Other threads immediately grab it and fire their requests. All 10 network calls happen in parallel, and the total time collapses to roughly the time of one call. This is exactly why threads paired with a Queue are perfect for I/O work.


Section 15

Enter The Queue — Thread-Safe Communication

📨 Diagram — Producer-Consumer Flow With queue.Queue
PRODUCERS P1 (producer) P2 (producer) P3 (producer) put() put() put() queue.Queue (FIFO) i1 i2 i3 i4 thread-safe buffer get() get() get() CONSUMERS C1 (worker) C2 (worker) C3 (worker) No shared locks, no race conditions — the queue owns synchronisation

Producers drop items into the queue with put(); consumers pull with get(). All locking is internal to the queue.

The Restaurant Kitchen
In a busy restaurant, waiters (producers) don't hand orders directly to chefs. They pin tickets to a rotating rail (the queue). Chefs (consumers) pull the next ticket when they are free. Nobody steps on anyone's toes, nobody has to shout across the kitchen, and if the kitchen gets overloaded the queue naturally builds up so waiters can slow down.

Python's queue.Queue is that rail — a thread-safe, FIFO buffer designed to hand work between producer and consumer threads without you needing to write a single Lock statement.
📨
queue.Queue
FIFO — First In, First Out
The default. Items come out in the order they went in. Perfect for work queues where fairness matters — every task gets processed in submission order.
📚
queue.LifoQueue
LIFO — Last In, First Out
A stack. Useful when the newest task is most important — e.g. a recent-first undo history or newest-message-first notification processor.
🏆
queue.PriorityQueue
Priority-Ordered
Items are tuples (priority, data). Lowest priority number is served first. Perfect for task schedulers where urgent items should jump the line.
🔑
Why Not Just Use a List?

Python lists are not thread-safe for all operations. Two threads calling list.append() may work today but list.pop(0) plus an index-based read from another thread is a race condition waiting to happen. queue.Queue uses internal locks and condition variables so put(), get(), and task_done() are always atomic. You never need to write your own locking around it.


Section 16

Queue API — The Essential Methods

MethodWhat It DoesBlocking Behaviour
q.put(item)Add item to the queueBlocks if queue is full (bounded queue)
q.get()Remove and return next itemBlocks forever if queue is empty
q.put(item, timeout=5)Try to add with a deadlineRaises queue.Full after 5s
q.get(timeout=5)Try to fetch with a deadlineRaises queue.Empty after 5s
q.put_nowait(item)Non-blocking addRaises queue.Full immediately
q.get_nowait()Non-blocking fetchRaises queue.Empty immediately
q.task_done()Signal that a fetched item is fully processedPairs with q.join()
q.join()Wait until every put item has had task_done calledBlocks until queue is fully drained
q.qsize()Approximate current sizeOnly an estimate — do not rely on it for logic

Section 17

Your First Producer-Consumer With Queue

import threading
import queue
import time
import random

# A thread-safe, bounded FIFO queue holding at most 5 items
q = queue.Queue(maxsize=5)

def producer(name):
    for i in range(10):
        item = f"{name}-item-{i}"
        q.put(item)                          # blocks if queue is full
        print(f"[P {name}] produced {item} (queue size={q.qsize()})")
        time.sleep(random.uniform(0.05, 0.2))

def consumer(name):
    while True:
        item = q.get()                        # blocks if queue is empty
        if item is None:                      # poison pill = shutdown signal
            q.task_done()
            break
        print(f"    [C {name}] consumed {item}")
        time.sleep(random.uniform(0.1, 0.3))
        q.task_done()                          # signal completion

# Start 2 producers and 3 consumers
producers = [threading.Thread(target=producer, args=(f"P{i}",)) for i in range(2)]
consumers = [threading.Thread(target=consumer, args=(f"C{i}",)) for i in range(3)]

for t in producers + consumers:
    t.start()

# Wait for producers to finish
for t in producers: t.join()

# Wait until all produced items are processed
q.join()

# Send poison pills to shut consumers down cleanly
for _ in consumers:
    q.put(None)

for t in consumers: t.join()

print("All work complete.")
OUTPUT (order will vary)
[P P0] produced P0-item-0 (queue size=1) [P P1] produced P1-item-0 (queue size=2) [C C0] consumed P0-item-0 [C C1] consumed P1-item-0 [P P0] produced P0-item-1 (queue size=1) [C C2] consumed P0-item-1 ... All work complete.
🔥
The Poison Pill Pattern

Consumers loop forever on q.get(). To stop them cleanly, put one None per consumer into the queue after the real work is done. When a consumer sees None, it exits. Simpler and safer than forcefully killing threads.


Section 18

Real-World Example — Concurrent Web Scraper

Let's build something practical: a scraper that downloads 50 URLs using 10 worker threads. A queue holds the URLs, workers pull and process them, and results go into a second queue.

import threading
import queue
import requests
import time

URL_LIST = [f"https://httpbin.org/delay/1?id={i}" for i in range(50)]
NUM_WORKERS = 10

task_queue   = queue.Queue()
result_queue = queue.Queue()

def worker(worker_id):
    while True:
        url = task_queue.get()
        if url is None:                     # shutdown
            task_queue.task_done()
            break
        try:
            r = requests.get(url, timeout=10)
            result_queue.put((url, r.status_code, len(r.content)))
            print(f"[W{worker_id}] {url} -> {r.status_code}")
        except Exception as e:
            result_queue.put((url, "ERROR", str(e)))
        finally:
            task_queue.task_done()

# Feed the queue
for url in URL_LIST:
    task_queue.put(url)

# Start workers
start = time.perf_counter()
workers = [threading.Thread(target=worker, args=(i,), daemon=True)
           for i in range(NUM_WORKERS)]
for w in workers: w.start()

# Wait for all URLs to be processed
task_queue.join()

# Shutdown workers
for _ in workers:
    task_queue.put(None)
for w in workers: w.join()

elapsed = time.perf_counter() - start

# Drain results
results = []
while not result_queue.empty():
    results.append(result_queue.get())

print(f"\nFetched {len(results)} URLs in {elapsed:.2f}s using {NUM_WORKERS} threads")
print(f"Sequential would take ~{len(URL_LIST)}s")
OUTPUT
[W0] https://httpbin.org/delay/1?id=0 -> 200 [W3] https://httpbin.org/delay/1?id=1 -> 200 [W7] https://httpbin.org/delay/1?id=2 -> 200 ... (all 50 URLs) Fetched 50 URLs in 5.42s using 10 threads Sequential would take ~50s

Section 19

PriorityQueue — When Some Tasks Matter More

A support-ticket system where paying customers jump the queue. The priority number goes first in the tuple — lowest number wins.

🏆 Diagram — PriorityQueue Reorders On Insert
INSERT ORDER P3 • "Fix typo" P1 • "PROD DOWN!" P5 • "Update year" P2 • "Login broken" P1 • "Breach!" Priority Queue (min-heap) 🔁 auto-sorts GET ORDER (sorted) P1 • "PROD DOWN!" P1 • "Breach!" P2 • "Login broken" P3 • "Fix typo" P5 • "Update year" Lowest priority number always comes out first — regardless of insert order

Insert in any order; the queue always yields the highest-priority item next. Perfect for task schedulers, alerts, and tiered SLA processing.

import threading
import queue
import time
from dataclasses import dataclass, field
from typing import Any

# Wrap payload so equal-priority items don't try to compare data
@dataclass(order=True)
class Ticket:
    priority: int
    data: Any = field(compare=False)

pq = queue.PriorityQueue()

# Producers add tickets with varied priority (1=urgent, 5=low)
pq.put(Ticket(3, "Fix typo on homepage"))
pq.put(Ticket(1, "PROD DOWN — payment gateway failing!"))
pq.put(Ticket(5, "Update the copyright year"))
pq.put(Ticket(2, "Enterprise customer login broken"))
pq.put(Ticket(1, "Data breach alert!"))

def handler():
    while not pq.empty():
        ticket = pq.get()
        print(f"[P{ticket.priority}] {ticket.data}")
        pq.task_done()
        time.sleep(0.2)

threading.Thread(target=handler).start()
OUTPUT — sorted by priority, not insertion order
[P1] PROD DOWN — payment gateway failing! [P1] Data breach alert! [P2] Enterprise customer login broken [P3] Fix typo on homepage [P5] Update the copyright year
⚠️
Watch Out — Priority Tie-Breaking

If two items share a priority, PriorityQueue tries to compare the next tuple element (usually your data). If your data is an unhashable/unorderable object, you get a TypeError. Use a @dataclass(order=True) with field(compare=False), or include a monotonic counter as the tie-breaker: (priority, counter, data).


Section 20

The Modern Way — ThreadPoolExecutor

concurrent.futures.ThreadPoolExecutor gives you a queue-backed thread pool with a clean API. It manages the queue, worker threads, and result collection for you. For most new code, this is what you should reach for.

🛡️ Diagram — ThreadPoolExecutor Internal Architecture
YOUR CODE pool.submit(fn, a) pool.submit(fn, b) pool.submit(fn, c) pool.submit(fn, d) pool.submit(fn, e) pool.submit(fn, f) ThreadPoolExecutor(max_workers=4) Work Queue (internal FIFO) task d task e task f pending Worker 1 (task a) Worker 2 (task b) Worker 3 (task c) Worker 4 (idle) Futures f1 ✓ f2 ✓ f3 ⌛ f4 ⌛ as_completed You submit tasks, executor manages queue + workers + result collection

Under the hood, ThreadPoolExecutor is exactly a queue plus a worker pool — you just don't have to wire it up yourself.

from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
import time

urls = [f"https://httpbin.org/delay/1?id={i}" for i in range(20)]

def fetch(url):
    r = requests.get(url, timeout=10)
    return url, r.status_code, len(r.content)

start = time.perf_counter()

# The executor manages an internal queue + worker threads
with ThreadPoolExecutor(max_workers=10) as pool:
    # submit() returns a Future — put a task on the queue
    futures = {pool.submit(fetch, u): u for u in urls}

    # as_completed yields futures as they finish
    for future in as_completed(futures):
        url, code, size = future.result()
        print(f"{code} {size:6d}B  {url}")

print(f"\nDone in {time.perf_counter() - start:.2f}s")
OUTPUT
200 308B https://httpbin.org/delay/1?id=0 200 308B https://httpbin.org/delay/1?id=1 ... Done in 2.31s
🏆
Executor vs Raw Queue — When to Use Which

Use ThreadPoolExecutor for one-shot batch jobs: submit N tasks, collect N results, done. Use Queue directly when you have long-running streaming pipelines, priority handling, or complex producer-consumer topologies (e.g. a scraper that queues newly-discovered URLs while workers process them).


Section 21

Pipeline Pattern — Chained Queues

Real systems often have multiple stages. Example: download → parse → save. Chain queues between stages and each stage runs in its own thread pool.

🔁 Diagram — Three-Stage Pipeline With Chained Queues
URLs list download_q DOWNLOADERS ×4 D1 D2 D3 D4 parse_q PARSERS ×2 P1 P2 save_q SAVER ×1 S1 Slow: I/O 4 workers Medium: CPU parse 2 workers Serialised writes 1 worker Each stage's worker count matches its bottleneck — producers upstream, consumers downstream

Data flows left → right through queues. Slower stages get more workers; serial stages get exactly one to preserve order.

import threading
import queue
import time

download_q = queue.Queue()
parse_q    = queue.Queue()
save_q     = queue.Queue()

def downloader():
    while True:
        url = download_q.get()
        if url is None: break
        time.sleep(0.3)                     # simulated network
        html = f"<html>from {url}</html>"
        parse_q.put((url, html))
        download_q.task_done()

def parser():
    while True:
        item = parse_q.get()
        if item is None: break
        url, html = item
        time.sleep(0.1)                     # simulated parsing
        record = {"url": url, "len": len(html)}
        save_q.put(record)
        parse_q.task_done()

def saver():
    while True:
        rec = save_q.get()
        if rec is None: break
        print(f"SAVED {rec}")
        save_q.task_done()

# Start each stage as a thread pool
threads = []
for _ in range(4): threads.append(threading.Thread(target=downloader))
for _ in range(2): threads.append(threading.Thread(target=parser))
for _ in range(1): threads.append(threading.Thread(target=saver))
for t in threads: t.start()

# Feed URLs
for i in range(20):
    download_q.put(f"https://example.com/page{i}")

# Wait for each stage to fully drain, then send poison pills
download_q.join()
for _ in range(4): download_q.put(None)

parse_q.join()
for _ in range(2): parse_q.put(None)

save_q.join()
save_q.put(None)

for t in threads: t.join()
print("Pipeline complete.")
🛠️
Tuning Worker Counts Per Stage

Notice we used 4 downloaders, 2 parsers, and 1 saver. The slow I/O stage (downloading) gets the most workers. The saver is a single thread so writes to a file or database happen in order without contention. Match worker count to the bottleneck of each stage.


Section 22

Bypassing The GIL — Your Options

📡
multiprocessing
Each process has its own Python interpreter and its own GIL. True parallelism on multiple cores. Use multiprocessing.Queue or Pool.map for CPU-bound work.
CPU-bound → number crunching, ML training
😷
asyncio
Cooperative multitasking on a single thread. Massively concurrent for I/O without the overhead of OS threads. Use for 1000+ concurrent connections (e.g. websocket servers).
I/O-bound at very high concurrency
🔥
C Extensions (NumPy, Cython)
C extensions can explicitly release the GIL during heavy computation. NumPy operations already do this — np.dot() on large arrays uses all cores.
Vectorised numeric code
🧠
Free-Threaded Python (3.13+)
Experimental no-GIL build (PEP 703). Enable with a special interpreter build. Threads run in true parallel. Still maturing — not the default yet in 2026.
Cutting-edge, opt-in only
🏗️
Subinterpreters (PEP 684)
Python 3.12+ allows multiple isolated interpreters in one process, each with its own GIL. Good middle ground between threads and full multiprocessing.
Advanced — small ecosystem so far
🚀
Alternative Runtimes
Jython and IronPython have no GIL (they use their host runtime's threading). PyPy has a GIL but is much faster overall. Rarely used in production Python today.
Niche — check ecosystem compatibility

Section 23

Common Pitfalls And Fixes

⚠️ Threading + Queue — Bugs You Will Hit
Bug 1
Forgetting task_done()q.join() hangs forever. Every get() must be paired with a task_done(), even on exceptions. Use try/finally.
Bug 2
Deadlock on shutdown → Consumers wait for the queue, but you never sent them a shutdown signal. Always send one poison pill (None) per consumer thread.
Bug 3
Unbounded queue exhaustion → Producers outrun consumers, RAM fills up. Set Queue(maxsize=1000) so producers block naturally under backpressure.
Bug 4
Shared mutable state → Threads mutating the same dict/list without a Lock. Use the queue for communication, not shared globals.
Bug 5
Zombie threads → Main thread exits, workers keep running. Use daemon=True for background workers OR call join() on all threads.
Bug 6
Exceptions swallowed silently → An exception in a worker just kills that thread with no message. Wrap the worker body in try/except and log or push errors to a dedicated error queue.
Bug 7
Frozen Ctrl-C → Main thread stuck on t.join() with no timeout. Always use t.join(timeout=0.5) in a loop so signals can be processed.
Safe Worker Template

Every production worker should look like this: fetch from queue, check for poison pill, wrap real work in try/except, always call task_done() in a finally block. This eliminates 90% of threading bugs.

def safe_worker(work_q, error_q):
    while True:
        item = work_q.get()
        try:
            if item is None:                # poison pill
                break
            process(item)                    # your real work
        except Exception as e:
            error_q.put((item, repr(e)))    # never let exceptions vanish
        finally:
            work_q.task_done()               # ALWAYS signal completion

Section 24

Threads vs Processes vs Asyncio — Decision Table

Property threading + Queue multiprocessing asyncio
Best for I/O-bound (up to ~100 workers) CPU-bound I/O-bound at massive scale (1000+)
Bypasses GIL? No — but releases during I/O Yes — each process has its own No — single-threaded
Memory sharing Free — shared address space Expensive — pickling required Free — same event loop
Overhead per unit ~8 MB per thread ~30 MB per process ~few KB per coroutine
Startup cost Fast (~ms) Slow (~100ms fork/spawn) Instant
Existing sync code works Yes — unchanged Yes — unchanged No — needs async rewrite
Debugging difficulty Medium (race conditions) Medium (IPC issues) High (cooperative scheduling)

Section 25

Golden Rules

🔑 GIL & Threading With Queue — Non-Negotiable Rules
1
Never use threads for CPU-bound Python code. The GIL will serialise them and you'll get zero speedup — often a slowdown, as Beazley proved (2 threads ran 1.8× SLOWER than sequential). Reach for multiprocessing or a C-extension (NumPy) instead.
2
Do use threads for I/O-bound work — network calls, disk reads, database queries. The GIL is released during blocking I/O so threads genuinely overlap.
3
Never mix heavy CPU and latency-sensitive I/O in the same process. Priority inversion will make the I/O thread wait through thousands of ticks. Offload the CPU work to a separate process.
4
Always communicate between threads through queue.Queue, never through shared mutable objects. The queue's internal locks eliminate almost every race condition.
5
Pair every q.get() with a q.task_done() in a finally block. Without it, q.join() hangs forever if any worker throws an exception.
6
Set maxsize on queues used in long-running pipelines. An unbounded queue is a memory leak waiting to happen when producers outrun consumers.
7
Shut consumers down with poison pills — one None per worker after your real work is done. Never rely on daemon=True alone for clean shutdown of stateful workers.
8
Keep the main thread responsive to signals. Always use t.join(timeout=0.5) in a loop instead of a bare t.join(), so Ctrl-C actually works.
9
For most batch jobs, prefer concurrent.futures.ThreadPoolExecutor over hand-rolled thread+queue code. It manages the queue, the pool, and result collection in a few lines.
10
Never let exceptions vanish inside a worker. Wrap the worker body in try/except and route errors to a dedicated error queue or logger. Silent thread deaths are the hardest bugs to find.
11
Match worker count to your workload. Rule of thumb: for I/O-bound work, start with min(32, os.cpu_count() * 4). More is not always better — connection pool limits and remote rate limits usually cap the useful concurrency.