๐Ÿงต Pattern 1: Thread Basics

Create a thread, start it, and wait for it. The four verbs you'll use every single time.

The Problem

Scenario: You have three slow, independent jobs (each is mostly waiting on I/O). Run them one after another and you pay the sum of all their waits.

Goal: run them at the same time and pay roughly the cost of the slowest one.

The Sequential Baseline (โŒ Slow)

import time

def job(name):
    time.sleep(1)          # simulate I/O wait
    print(f"{name} done")

job("A"); job("B"); job("C")   # 3 seconds total โ€” each waits for the last

The Four Verbs

Every thread you ever use follows the same lifecycle:

flowchart LR C["Thread(target=fn)
construct"] --> S["t.start()
runs fn in background"] S --> R["running
(shares memory)"] R --> D["fn returns
thread ends"] D --> J["t.join()
main waits here"]
Never confuse start() with run(): t.run() executes the function in the current thread (no concurrency!). Always call start().

The Solution: Start Many, Join All

Pattern: create all threads, start() them all, then join() them all. Starting inside the first loop and joining in a second loop is what makes them overlap.
import threading, time

def job(name):
    print(f"{name} start")
    time.sleep(1)              # I/O wait โ€” GIL released here
    print(f"{name} done")

threads = [threading.Thread(target=job, args=(n,)) for n in "ABC"]

for t in threads:
    t.start()                  # kick them all off first

for t in threads:
    t.join()                   # THEN wait for all โ€” total โ‰ˆ 1s

print("all finished")
Common bug: starting and joining in the same loop โ€” t.start(); t.join() โ€” runs them sequentially again, because you wait for each one before starting the next. Split the loops.

Getting a Return Value

Thread gives you no built-in way to read what target returned. You either write into a shared container, or (much better) use a ThreadPoolExecutor, which hands back a Future.

results = {}

def fetch(name):
    time.sleep(1)
    results[name] = f"data-for-{name}"   # write into shared dict

threads = [threading.Thread(target=fetch, args=(n,)) for n in "ABC"]
for t in threads: t.start()
for t in threads: t.join()
print(results)   # {'A': 'data-for-A', 'B': ..., 'C': ...}
Why a dict is OK here: each thread writes a different key, so there's no contention. If they all incremented the same value, you'd need a Lock.

Subclassing (the older style)

You'll see this in older code: subclass Thread and override run(). Prefer target= or the executor for new code โ€” but you should recognize it.

class Worker(threading.Thread):
    def __init__(self, name):
        super().__init__()
        self.name = name
        self.result = None

    def run(self):                 # override run(), but still call .start()
        time.sleep(1)
        self.result = f"done-{self.name}"

w = Worker("A")
w.start()
w.join()
print(w.result)   # done-A

Key Takeaways

VerbWhat it doesBlocks?
Thread(target=..., args=...)Builds the thread objectNo โ€” nothing runs
.start()Runs target in a new threadNo โ€” returns instantly
.join()Wait for the thread to finishYes โ€” blocks the caller
.is_alive()Check if still runningNo
Real-world: raw threads are great for a handful of fire-and-wait background jobs (send an email, warm a cache, tail a log). The moment you have many tasks or need results back, graduate to ThreadPoolExecutor (Pattern 4).