Threading & the GIL

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.

Learn Threading & the GIL in our free Python course — an interactive lesson with runnable examples, a practice exercise and a quick reference.

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.

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:

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:

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.

Replace each ___ so five threads safely append squares to a shared list. You'll need to start() , then join() , and use the lock .

✅ Pass the function itself, plus args separately

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.

Use a ThreadPoolExecutor to "download" five files concurrently, build a name→size dictionary from the results, and print the total size.

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.

🚀 Up next: multiprocessing — sidestep the GIL and get true parallelism across CPU cores.

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)

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 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.