Threading & the GIL
Reviewed & published by Brayan K
A thread is a separate flow of execution inside one program, letting your code do several things at once — and Python's threading module lets you start, coordinate, and join those threads.
Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
The catch is the GIL — the lock that stops Python threads from running CPU code truly in parallel. Understanding when threads help (I/O) and when they don't (heavy computation) is the single most important idea in this lesson.
What You'll Learn in This Lesson
- Creating threads with threading.Thread
- Starting work with start() and waiting with join()
- What the GIL is and why it exists
- Why threads help I/O-bound but not CPU-bound work
- Protecting shared state with a Lock
- Using ThreadPoolExecutor for clean pooled concurrency
1 Your First Threads: start() and join()
A threading.Thread runs a function on a separate thread. You give it a target function and its args, call start() to launch it, then call join() to wait for it to finish. Here four threads each compute a square and store it in a shared dict:
import threading
def worker(name, results):
results[name] = name * name
results = {}
threads = []
for n in [1, 2, 3, 4]:
t = threading.Thread(target=worker, args=(n, results))
threads.append(t)
t.start() # launch — runs concurrently
for t in threads:
t.join() # wait for each to finish
print(dict(sorted(results.items())))
# {1: 1, 2: 4, 3: 9, 4: 16}import threading
def worker(name, results):
results[name] = name * name
results = {}
threads = []
for n in [1, 2, 3, 4]:
t = threading.Thread(target=worker, args=(n, results))
threads.append(t)
t.start()
for t in threads:
t.join()
# sort so the printed order is deterministic
print(dict(sorted(results.items())))
# Expected output:
# {1: 1, 2: 4, 3: 9, 4: 16}2 The GIL: Why Threads Help I/O, Not CPU
CPython's Global Interpreter Lock (GIL) allows only one thread to run Python bytecode at any instant. That means heavy number-crunching across threads won't use multiple cores — the threads just take turns. But there's a crucial exception: while a thread waits on I/O (network, disk, a sleep), it releases the GIL so another thread can run.
So threads overlap all that waiting. This demo simulates four slow network calls — they run concurrently and the whole batch finishes in roughly the time of one call, not four:
from concurrent.futures import ThreadPoolExecutor
import time
def fetch(url):
time.sleep(0.05) # simulate waiting on the network
return f"{url} -> 200 OK"
urls = ["api/users", "api/posts", "api/comments", "api/likes"]
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(fetch, urls))
for r in results:
print(r)
print("Done (all ran concurrently)")- • I/O-bound (waiting on data) → threads help a lot.
- • CPU-bound (pure computation) → threads don't; use multiprocessing.
from concurrent.futures import ThreadPoolExecutor
import time
def fetch(url):
time.sleep(0.05) # pretend we're waiting on a server
return f"{url} -> 200 OK"
urls = ["api/users", "api/posts", "api/comments", "api/likes"]
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(fetch, urls))
for r in results:
print(r)
print("Done (all ran concurrently)")
# Expected output:
# api/users -> 200 OK
# api/posts -> 200 OK
# api/comments -> 200 OK
# api/likes -> 200 OK
# Done (all ran concurrently)3 Protecting Shared State with a Lock
When several threads update the same variable, you have a race condition. Even counter += 1 is not atomic — it's a read, an add, and a write, and a thread can be paused between them, losing other threads' updates. A threading.Lock fixes this: only one thread can hold the lock at a time, so the update is safe.
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100000):
with lock: # only one thread in here at a time
counter += 1
threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print("Final counter:", counter)
# Final counter: 400000import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100000):
with lock:
counter += 1
threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print("Final counter:", counter)
# Expected output:
# Final counter: 400000
# (Remove 'with lock:' and you'll often see a smaller, wrong number!)🎯 Your Turn: Fill in the Blanks
Replace each ___ so five threads safely append squares to a shared list. You'll need to start(), then join(), and use the lock.
import threading
# 🎯 YOUR TURN: replace each ___
results = []
lock = threading.Lock()
def add_square(n):
with ___: # hint: the lock
results.append(n * n)
threads = [threading.Thread(target=add_square, args=(n,)) for n in range(1, 6)]
for t in threads:
t.___() # hint: launch the thread
for t in threads:
t.___() # hint: wait for it
print(sorted(results))
# Expected output:
# [1, 4, 9, 16, 25]⚠️ Common Errors
❌ Forgetting the parentheses on the target
t = threading.Thread(target=worker()) # WRONG: calls worker NOW✅ Pass the function itself, plus args separately
t = threading.Thread(target=worker, args=(n, results))❌ Reading results before joining
t.start()
print(results) # may be empty — thread isn't done yet✅ Join first, then read
t.start()
t.join()
print(results) # safe now❌ Expecting threads to speed up heavy math
Spawning threads to do pure CPU work (parsing, math loops) gives you no speedup — the GIL serializes it. Reach for multiprocessing instead, which is the very next lesson.
🎯 Mini Challenge: Concurrent Downloads
Use a ThreadPoolExecutor to "download" five files concurrently, build a name→size dictionary from the results, and print the total size.
from concurrent.futures import ThreadPoolExecutor
import time
def download(file_id):
time.sleep(0.02) # simulate network wait
return (file_id, len(f"data-for-{file_id}"))
file_ids = ["a", "b", "c", "d", "e"]
with ThreadPoolExecutor(max_workers=3) as pool:
sizes = dict(pool.map(download, file_ids))
print(sizes)
print("Total bytes:", sum(sizes.values()))
# Expected output:
# {'a': 10, 'b': 10, 'c': 10, 'd': 10, 'e': 10}
# Total bytes: 50📚 Keep Learning
- • threading — Thread-based parallelism
- • concurrent.futures — ThreadPoolExecutor & ProcessPoolExecutor
- • Glossary: Global Interpreter Lock (GIL)
Frequently Asked Questions
Lesson complete — you can run concurrent threads!
You can launch threads with start() and wait with join(), you understand that the GIL makes threads great for I/O-bound work but useless for CPU-bound work, you guard shared state with a Lock, and you reach for ThreadPoolExecutor for clean pooled concurrency.
Practice quiz
What is the GIL (Global Interpreter Lock) in CPython?
- A setting that disables threading entirely
- A lock you must acquire manually before starting any thread
- A lock that lets only one thread execute Python bytecode at a time
- A tool that runs threads on separate CPU cores in parallel
Answer: A lock that lets only one thread execute Python bytecode at a time. The GIL is a mutex that allows only one thread to run Python bytecode at any instant, which is why pure-Python CPU work is not sped up by threads.
For which kind of work do threads help the most in CPython?
- I/O-bound work, like waiting on the network or disk
- CPU-bound number crunching
- Pure mathematical loops
- Nothing — threads never help in Python
Answer: I/O-bound work, like waiting on the network or disk. While a thread waits on I/O it releases the GIL, so other threads run. Threads overlap that waiting, making I/O-bound work much faster.
Which method launches a thread so its target runs concurrently?
- run()
- join()
- begin()
- start()
Answer: start(). t.start() launches the thread and runs its target concurrently. Calling run() directly would execute the target in the current thread instead.
What does t.join() do?
- Starts the thread running
- Blocks the caller until that thread finishes
- Combines two threads into one
- Cancels the thread immediately
Answer: Blocks the caller until that thread finishes. join() makes the calling thread wait until the joined thread completes — essential before reading results the thread produced.
How do you pass a function and its arguments to a Thread correctly?
- threading.Thread(target=worker, args=(n, results))
- threading.Thread(target=worker(n, results))
- threading.Thread(worker, n, results)
- threading.Thread(run=worker, params=[n, results])
Answer: threading.Thread(target=worker, args=(n, results)). Pass the function itself as target and its arguments separately as a tuple in args. Writing target=worker(...) calls the function immediately instead.
Why can `counter += 1` cause a race condition across threads?
- Integers are not thread-safe to read
- The += operator is forbidden inside threads
- It is several bytecode steps (read, add, write), so a thread can be paused mid-update
- Python copies the counter for each thread automatically
Answer: It is several bytecode steps (read, add, write), so a thread can be paused mid-update. The increment is not atomic — it reads, adds, and writes. A thread paused between those steps can lose other threads' updates, corrupting the count.
What is the recommended way to use a threading.Lock?
- lock.acquire() with no release
- with lock: (the context-manager form)
- Calling lock() like a function
- Locks are never needed for shared data
Answer: with lock: (the context-manager form). The 'with lock:' form releases the lock automatically even if an exception is raised inside the block, avoiding a permanently stuck (deadlocked) lock.
For CPU-bound parallelism that uses multiple cores, what should you reach for instead of threads?
- more threading.Lock objects
- a bigger ThreadPoolExecutor
- asyncio
- multiprocessing
Answer: multiprocessing. The GIL serializes CPU-bound Python code across threads. multiprocessing runs separate processes (each with its own interpreter) for true multi-core parallelism.
What is the modern, recommended way to run many concurrent tasks in a reusable pool?
- Creating one raw Thread per task and never joining
- concurrent.futures.ThreadPoolExecutor
- A single global Thread reused by every task
- threading.Lock with max_workers
Answer: concurrent.futures.ThreadPoolExecutor. ThreadPoolExecutor manages a reusable pool of worker threads, hands back results via map()/submit(), propagates exceptions, and cleans up when its 'with' block ends.
What happens if you read a thread's results before calling join()?
- Python raises a RuntimeError every time
- The results are always correct because reads block automatically
- You may read incomplete results because the thread might not be finished
- The thread restarts from the beginning
Answer: You may read incomplete results because the thread might not be finished. Without join(), the main thread can race ahead and read shared data before the worker has finished writing it, giving incomplete or empty results.
Continue this course
- Previous: Checkpoint: OOP & Standard Library
- Next: multiprocessing: True Parallelism — Use multiple processes to bypass the GIL for CPU-bound work
- Quick reference: Python cheat sheet
Frequently asked questions
What is the GIL and why does it matter?
The GIL (Global Interpreter Lock) is a mutex inside CPython that lets only one thread execute Python bytecode at a time. It exists to make memory management safe and simple. The practical consequence is that pure-Python CPU-bound code can't run on multiple cores with threads — the threads take turns. For CPU-bound parallelism you reach for multiprocessing instead.
If the GIL blocks parallelism, when are threads useful at all?
Threads shine for I/O-bound work: waiting on network requests, disk reads, or database queries. While one thread waits for data to arrive, the GIL is released and another thread runs. So you overlap all that waiting and finish far sooner. Downloading 50 web pages, for example, is dramatically faster with threads even with the GIL.
Do I need a Lock for shared data?
Yes, whenever multiple threads read-and-write the same object. Operations that look atomic in source code (like counter += 1) are actually several bytecode steps, and a thread can be paused in the middle, producing lost updates (a race condition). Wrapping the update in 'with lock:' makes it atomic so only one thread touches the data at a time.
Should I create Thread objects directly or use ThreadPoolExecutor?
For anything beyond a one-off, prefer concurrent.futures.ThreadPoolExecutor. It manages a reusable pool of worker threads, hands you results via map() or submit(), propagates exceptions cleanly, and cleans up when the 'with' block ends. Raw Thread objects are great for learning and for long-lived background tasks, but the executor is the modern default.