Multithreading lets a program run several pieces of work concurrently within the same process, which is especially useful for I/O-bound tasks — ones that spend most of their time waiting on something external, like a network response, a file read, or a database query. Python's built-in threading module makes starting and managing threads straightforward.

Python Program to Run Tasks with Multiple Threads

import threading
import time


def download_file(file_id, delay):
    print(f"Starting download {file_id}...")
    time.sleep(delay)  # simulates waiting on I/O, like a network request
    print(f"Finished download {file_id}")


threads = []
files = [(1, 2), (2, 1), (3, 3)]  # (file_id, simulated seconds to download)

start = time.perf_counter()

for file_id, delay in files:
    t = threading.Thread(target=download_file, args=(file_id, delay))
    threads.append(t)
    t.start()

for t in threads:
    t.join()  # wait for every thread to finish before continuing

elapsed = time.perf_counter() - start
print(f"All downloads finished in {elapsed:.1f} seconds")

Output:

Starting download 1...
Starting download 2...
Starting download 3...
Finished download 2
Finished download 1
Finished download 3
All downloads finished in 3.0 seconds

(The order "Finished" lines appear in can vary between runs — that's expected with concurrent threads.)

Notice the total time is about 3 seconds — the length of the longest individual delay — not 2 + 1 + 3 = 6 seconds. Each threading.Thread starts running independently as soon as .start() is called, so all three "downloads" are waiting simultaneously instead of one after another. .join() blocks the main program until that specific thread has finished, and calling it in a loop over every thread ensures the final print only happens once all of them are done.

A Note on the GIL

Python's Global Interpreter Lock (GIL) means only one thread executes Python bytecode at any given instant, even on a multi-core machine. For I/O-bound work like the example above, this barely matters — threads spend most of their time blocked on time.sleep() or waiting on a socket, and Python releases the GIL during that wait so other threads can run. But for CPU-bound work, like looping through a huge list doing heavy number-crunching, threading usually won't speed things up at all, since the GIL prevents true parallel execution of Python code — you'd reach for the multiprocessing module instead, which runs separate processes with their own interpreter and memory space, genuinely using multiple CPU cores.