Sitemap
Python in Plain English

New Python content every day. Follow to join our 3.5M+ monthly readers.

Python Multithreading: Threads, Locks, the GIL, and Parallelism

7 min readSep 24, 2026

--

An easy-to-follow guide to concurrency, parallel computing, and the Python concepts worth knowing for technical interviews.

Multithreading sounds simple: create more threads, run more tasks, and finish faster. But after looking more closely, the important lesson is that more threads do not always mean better performance.

A thread can help your application stay responsive while downloading files or waiting for an API. But give several threads a CPU-heavy calculation, and the result may be completely different.

To understand why, we need to look at how threads work, how they share memory, what happens when they interfere with each other, and how Python’s Global Interpreter Lock (GIL) affects execution.

Press enter or click to view image in full size
Multithreading

1. Process, thread, concurrency, and parallelism

A process is a running instance of a program with its own address space. A thread is a unit of execution inside a process. Multiple threads belonging to the same process share resources and memory, but each thread has its own execution stack.

Imagine an ETL pipeline downloading five files. Instead of waiting for each download to finish before starting the next, we can use several threads to overlap the waiting time.

That is concurrency. But is it parallelism?

Not necessarily.

Concurrency means making progress on multiple tasks during overlapping periods. On a single CPU core, the operating system can switch between runnable threads using time slicing.

Parallelism means executing tasks simultaneously, for example on different CPU cores. It can improve computational performance when a problem can be divided into independent pieces.

The distinction matters because running several tasks concurrently does not guarantee that your program will finish faster.

2. Creating threads in Python

Python provides the threading module. The three methods to remember are:

  • start() begins a thread's execution.
  • join() waits for that thread to finish.
  • is_alive() checks whether a thread is still running.

For a function that accepts arguments, pass the function itself as target and its parameters through args.

import threading
import time


def download(report_id):
time.sleep(1) # Simulate waiting for a server
print(f"Report {report_id} downloaded")


t1 = threading.Thread(target=download, args=(1,))
t2 = threading.Thread(target=download, args=(2,))

t1.start()
t2.start()

t1.join()
t2.join()

print("All downloads completed")

Notice the difference between target=download and target=download(1). The second expression calls the function immediately instead of giving the thread a function to execute.

You can also create a thread by subclassing threading.Thread and overriding its run() method, although passing a function is simpler for many everyday tasks.

One more detail: a daemon thread is a background helper that does not keep Python running when only daemon threads remain. Because it may be stopped abruptly during interpreter shutdown, it should not be responsible for critical writes or cleanup.

3. Shared memory: where threading becomes tricky

Threads are lightweight partly because they share the same process memory. That makes communication convenient, but it also creates opportunities for bugs.

Consider two threads updating the same bank balance. Both read 100, both add 50, and both write 150. We expected 200, but one update was lost.

This is a race condition: the result depends on the order in which concurrent operations occur.

A Lock protects the critical section—the part of the code that accesses or modifies shared state.

import threading

lock = threading.Lock()
balance = 100


def deposit(amount):
global balance

with lock:
balance += amount

The with statement acquires the lock and releases it when the block exits, even if an exception occurs.

The GIL does not eliminate the need for application-level synchronization.

Lock, RLock, Semaphore, Event, and Queue

These are closely related tools, but they solve different problems.

Lock: Allows one thread at a time into a protected section. Use it to protect shared counters, balances, or mutable state.

RLock: A reentrant lock. The same thread can acquire it repeatedly, which is useful with nested or recursive synchronized calls. It must be released as many times as it was acquired.

Semaphore: Allows a limited number of threads to access a resource simultaneously. For example, Semaphore(5) can restrict an API client to five concurrent requests.

Event: Communicates a signal between threads. One thread calls event.set(), and waiting threads can continue when event.wait() returns. An event signals a condition; it does not carry the data itself.

Queue: A practical choice for producer-consumer applications. One thread adds work using put(), while workers retrieve it with get(). A synchronized queue avoids manually coordinating every operation on a shared list.

4. Deadlock, livelock, and starvation

These are three problems that can arise when threads compete for resources.

Get Aliakbar Hosseinzadeh’s stories in your inbox

Join Medium for free to get updates from this writer.

A deadlock happens when threads wait for one another indefinitely. Thread A holds Lock 1 and waits for Lock 2, while Thread B holds Lock 2 and waits for Lock 1. Neither can proceed.

A livelock happens when threads are still active but keep reacting to each other without making useful progress.

Starvation means a thread repeatedly fails to obtain the resources or execution time it needs because other threads keep getting them first.

The Dining Philosophers problem is a classic way to understand these issues.

Five philosophers sit around a table with five chopsticks. Each philosopher needs two chopsticks to eat. If everyone picks up their left chopstick and waits for the right one, nobody can eat.

One solution is to establish a consistent order for acquiring locks. Another is to use timeouts and release any chopstick already held when the second cannot be acquired. Timeouts can prevent indefinite blocking, but they do not, by themselves, guarantee that every philosopher will eventually eat.

The same problem appears in real systems when workers acquire database rows or other shared resources in inconsistent orders.

Press enter or click to view image in full size
Deadlock, livelock, and starvation

5. Thread pools: managing concurrency without the extra work

Creating threads individually is fine for a small example. But imagine processing hundreds of API requests or files.

Creating and managing a separate thread for every task introduces unnecessary complexity and resource overhead.

Python’s ThreadPoolExecutor manages a limited number of reusable worker threads.


from concurrent.futures import ThreadPoolExecutor
import time


def fetch(report_id):
time.sleep(1) # Simulate an I/O operation
return f"report_{report_id}.csv"


with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(fetch, range(4)))

print(results)

Four tasks can overlap their waiting time.

Two executor methods are useful to remember: map() applies a function across an iterable and yields results in input order. submit() schedules an individual task and returns a Future, from which you can obtain the result or exception.

Thread pools are convenient, but worker counts still need limits. Too much concurrency can exhaust memory, overwhelm an API, or make debugging harder.

6. The GIL: the Python interview question that has changed

The Global Interpreter Lock (GIL) has traditionally been one of Python’s biggest constraints for CPU-bound multithreading.

In standard CPython, the GIL allows only one thread at a time to execute Python bytecode within an interpreter. Threads can still overlap I/O operations, and some native libraries release the GIL during computational work.

That explains why multithreading is often useful for downloading files but may not accelerate a CPU-heavy, pure-Python calculation.

However, the statement that Python cannot execute CPU-bound threads in parallel is no longer universally true.

What changed in modern Python?

As of September 2026, Python 3.14 is the latest stable feature release, and Python 3.15 is in its release-candidate stage, with the final release scheduled for October 1, 2026.

Python 3.13 introduced experimental free-threaded builds that can disable the GIL. Python 3.14 made free-threaded Python officially supported, although it remains optional rather than the default.

Free-threaded Python can run Python threads on multiple CPU cores simultaneously. But performance still depends on the workload, synchronization, and third-party library compatibility. Some extensions can also re-enable the GIL.

Python 3.14 also introduced InterpreterPoolExecutor, which can achieve multicore parallelism using isolated interpreters, each with its own GIL.

This is why understanding your Python version and runtime matters more than simply memorizing that Python has a GIL.

7. Which approach should you use?

For I/O-bound work, such as downloading reports, reading network data, or making API calls, start by considering ThreadPoolExecutor. For applications with many cooperative asynchronous I/O operations, asyncio is another option.

For CPU-bound work, such as pure-Python calculations or data transformations, consider ProcessPoolExecutor. Processes can execute on separate CPU cores, although transferring data between them introduces overhead. Free-threaded Python and isolated interpreters provide additional options when their requirements fit the application.

There is no universal worker count or concurrency model that fits every task. Measure the workload, account for memory and synchronization, and avoid creating more workers than the system can use effectively.

A quick interview review

Before your next Python interview, make sure you can explain these distinctions in your own words.

  • Does the GIL make Python code thread-safe? No. Shared application state can still require explicit synchronization.
  • What is the difference between start() and join()? start() begins execution; join() waits for completion.
  • When do you use Lock versus RLock? Use Lock for ordinary mutual exclusion. Use RLock when the same thread must acquire the lock recursively or through nested calls.
  • Can Python execute code in parallel? Yes. Multiprocessing provides multicore execution, and free-threaded CPython supports parallel threads. The traditional GIL still constrains CPU-bound threads in standard CPython.
  • Why might adding more workers make an application slower? Thread scheduling, process startup, communication, synchronization, and resource contention all introduce overhead.
Better is better than best.

--

--

Python in Plain English
Python in Plain English