Back to Blog
Python

Python Threading: A Practical Guide

Use Python threading for I/O-bound tasks: create and start threads, share state safely with locks, and understand where the GIL matters.

threadingconcurrencyGILmultithreadingthread-safety
A visual metaphor for Python threading: multiple threads weaving through a shared resource, with a lock icon representing synchronization.

Python threading lets you run multiple tasks concurrently within a single process. It is especially useful for I/O-bound workloads where waiting on network calls or file reads would otherwise stall the program. Threads share memory, so they are lighter than processes, but that shared state requires locks. This guide covers the threading module, synchronization primitives, the GIL, and how to decide between threading, multiprocessing, and asyncio.

Why Use Threads in Python

Threads are lightweight execution units that share the same memory space within a process. In Python, the threading module provides a high-level API for creating and managing threads. The main benefit is that while one thread waits for an I/O operation to complete, the interpreter can switch to another thread and continue doing useful work. This makes threading a natural fit for applications that spend significant time waiting on external resources, such as web scraping, network servers, or database queries.

A simple example:

import threading import time def print_numbers(): for i in range(5): print(i) time.sleep(0.5) thread = threading.Thread(target=print_numbers) thread.start() thread.join() # wait for the thread to finish

The Thread object takes a callable target, and start() begins execution. join() blocks until the thread completes. This basic pattern is the foundation of most threading code.

Creating and Starting Threads

You can pass arguments to the thread's target function using the args and kwargs parameters:

import threading def fetch_data(url, timeout=10): # Simulate a network request... pass thread = threading.Thread( target=fetch_data, args=('https://example.com',), kwargs={'timeout': 5}, ) thread.start() thread.join()

If you do not need the main program to wait, you can let a thread run in the background by setting daemon=True when creating it. Non-daemon threads keep the interpreter alive until they finish. Daemon threads may be stopped abruptly, so use them only for work that can be safely interrupted.

Sharing State Between Threads

Because threads share the same process memory, multiple threads can mutate the same object. This can produce race conditions:

import threading counter = 0 def increment(): global counter for _ in range(100000): counter += 1 threads = [threading.Thread(target=increment) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print(counter) # may be less than 1000000

counter += 1 is not atomic: the interpreter reads the current value, adds 1, and writes it back. If threads interleave, two threads can read the same old value and write back the same result.

Use a Lock to protect the critical section:

import threading lock = threading.Lock() counter = 0 def increment(): global counter for _ in range(100000): with lock: counter += 1

The with block acquires the lock before entering and releases it after. This prevents two threads from updating counter at the same time.

Other Synchronization Primitives

The threading module includes several useful primitives:

  • RLock is a reentrant lock. The same thread can acquire it multiple times without deadlocking.
  • Semaphore limits how many threads can enter a section. It is useful for controlling access to a fixed pool of resources.
  • Event lets one thread signal others. A worker can call event.wait() until the controlling thread calls event.set().
  • queue.Queue is often simpler than protecting a list with a lock. It is designed for moving data between threads safely.

The GIL and Performance

CPython's global interpreter lock (GIL) prevents multiple threads from executing Python bytecode simultaneously. The GIL does not stop threading from being useful for I/O-bound work: calls such as time.sleep(), network reads, and file reads release the GIL, so other threads can run while one thread is blocked. But for CPU-heavy calculations in pure Python, the GIL keeps threads from using multiple cores. For that case, consider multiprocessing or compiled libraries that can release the GIL during native work, as many NumPy operations do.

Threading remains a good fit when:

  • The program spends most of its time waiting for I/O.
  • You need to maintain shared state and prefer a design where all threads share memory.
  • The number of concurrent tasks is modest, so thread overhead is not the bottleneck.

Threading vs. Multiprocessing vs. Asyncio

  • threading: best for I/O-bound work that needs to share memory and can use blocking APIs.
  • multiprocessing: best for CPU-bound work because each process has its own Python interpreter and can run on parallel cores.
  • asyncio: a single-threaded, cooperative alternative. It is great for I/O-bound code that can use async/await, but it does not bypass blocking calls automatically.

Threads are not always the right tool. If your workload is CPU-bound, multiprocessing is usually clearer than trying to work around the GIL. If you are building a highly concurrent network service, asyncio or a dedicated worker model may scale better.

Safe Threading Practices

  • Keep locked regions as short as possible.
  • Avoid acquiring multiple locks in different orders, or you may create a deadlock.
  • Prefer queue.Queue for passing messages between threads.
  • Do not assume that a Python object's methods are thread-safe by default.
  • Stress-test with threading and measure the results rather than assuming behavior.

Summary

Python threading is a practical way to improve I/O-bound programs. Create threads with threading.Thread, protect shared mutable state with Lock, and remember that CPython's GIL changes what threading can do. For CPU-heavy work, choose multiprocessing; for highly concurrent I/O with modern async code, consider asyncio.

Python Threading: Practical Usage, Locks, and the GIL | RYUSLOG DEV